feat(scripting): frame generational collector for wasm scripts (#13869) 0613f5b262
* add rive --bench for headless frame timing and a rasc runtime env override

* map the guest sampler compare sentinel to none instead of casting it raw

* stage tier ladder wamrc input before the in-place module load corrupts it

* compile ladder artifacts with sw bounds checks so non guard page runtimes can run them

* carry the execution tier through the shared module cache and let bench wait for the o3 swap

* add an allocation churn kernel to the wamr bench

* recurring leak warnings with a time-to-trap estimate, arena modules watched too

* allocation-free Mat4 Into builders and multiply, reusable render pass wrapper, cached canvas view

* deliver decode completions at the frame boundary and re-take the mark around them and resize

* build the canvas view wrapper eagerly so it cannot land above a frame arena mark

* rive.yaml wasmRuntime choice and finalizer-based handle release under the collecting runtime

* pregrow aot memory to the declared ceiling and cap ladder artifacts at opt level 1

* let tlsf claim pregrown memory before growing so aot pregrow does not read as oom

* per runtime leak wording via a collected marker export and trap context after warnings

* hw bounds artifact species on the tier ladder for guard page builds

* cap hw artifacts at opt level 0, the miscompile bites earlier without inline checks

* bind ASC_RUNTIME so runtime-gated std branches stop compiling as stub

* register the handle finalizer through the runtime, pin its closure, zero renew slack

* finalizer, soak and aot differential coverage plus a no-ceiling aot notice

* frame collector milestone 0: checked arena runtime, design doc and per-visitor generation fix

* frame collector milestone 1: cheney scavenger, moving visitors, remembered set, exact handle reap

* record frame collector m1 status in the design doc

* pass multivalue through bake postOptimize so value struct modules survive wasm-opt

* add RIVE_RASC_UNSAFE bake hatch and inert arena hooks on collecting runtimes

* record box2d and fields comparison results in the frame gc design doc

* frame collector milestone 2: copying major, large objects born old, pin list, debug heap verifier

* spill start function managed locals to the shadow stack so mid-init itcms steps see them

* count majors for telemetry and pin the threshold trigger with a fixture

* design doc: record milestone 2 and the copying major decision

* android armv7 wamr lane and a headless on-device gc bench tool

* calibrate the major threshold at the first boundary instead of collecting init retention

* declare THUMBV7 to the aot loader and print fault addresses in the device bench

* design doc: console validation results and the arm32 aot lane status

* fix wamrc strict fp miscompile: strictfp on generated functions, codegen cap below ir opt, ladder lifts sw to optimized ir

* frame collector milestone 3: sliced major marks and sweeps across boundaries for big heaps

* design doc: record milestone 3

* address review: reentrant decode cancel, bump-based leak watch under pregrow, pin chunk refcounts, stale view wrapper on resize, windows bench build

* check in the churn soak project used for on-device collector validation

* address review round two: carve capacity includes the block gap, unpin marks during majors, gate the bench target, hw species in the ladder test

* address review round three: reject bad bench counts, version the artifact cache, cover late visitor stubs, clear remembered bits at the copying major

* pin the leak warning firing, unpin-during-sweep, and stale remembered bit with tests

* match the pin contract: trap on double pin and foreign unpin

* twin the function env visitor and cover closure survival under the frame collector

* export the major tuning knobs from frame bakes and stop callModule folding traps and missing exports into zero

* consolidate on the frame collector: delete tcms and the arena lane, deprecate incremental, default wasmRuntime to frame

* remove the incremental runtime: itcms, tlsf, and the shadow stack live on archive/rasc-gc-experiments with tcms and the arena

* sweep retained chunks in sliced majors, relocate DataView dataStart, atomic ladder cache writes, review fixes

* gc bench covers every module vm and the tier bench fails when the O3 swap never lands

* objects born in the old region during a sweep only carry a mark while the sweep still covers their chunk

* switch the sweep to the retained list even when the budget dies on the last old chunk

* reject non-positive --bench frame counts

* renumber our wamrc patches past master's 0013 windows patch

Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head
index 14789ee..17d18e7 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-6066a2edc0d6500e26ea83cc7d849ee095f6c790
+0613f5b2624c3aa03e8158a9daf4996864305240
diff --git a/include/rive/wasm/module_tier_ladder.hpp b/include/rive/wasm/module_tier_ladder.hpp
index 7c62567..d353edb 100644
--- a/include/rive/wasm/module_tier_ladder.hpp
+++ b/include/rive/wasm/module_tier_ladder.hpp
@@ -22,7 +22,10 @@
 enum class TierSpecies : uint8_t
 {
     o0 = 0, // wamrc -O0: ~2x interp, sub-second for typical modules
-    o3 = 1, // wamrc -O3: native parity, the bits that ship
+    o3 = 1, // wamrc top opt level with sw bounds, runs on every build
+    // No inline bounds checks; needs the guard-page trap handler, so only
+    // RIVE_WASM_HW_BOUNDS builds produce or load these.
+    hw = 2,
 };
 
 // Drives wamrc subprocesses that turn wasm modules into AOT artifacts and
@@ -60,6 +63,11 @@
                   uint64_t moduleKey,
                   Span<const uint8_t> moduleBytes);
 
+    // Write the module's wamrc input before wasm_runtime_load touches the
+    // buffer: the fast-interp loader rewrites it in place, so bytes staged
+    // at schedule time are no longer valid wasm.
+    void stagePristine(uint64_t moduleKey, Span<const uint8_t> moduleBytes);
+
     // Ready artifact path for a module at the given species, empty if none.
     std::string artifactPath(uint64_t moduleKey, TierSpecies species);
 
diff --git a/include/rive/wasm/wasm_scripting_vm.hpp b/include/rive/wasm/wasm_scripting_vm.hpp
index 58504fb..6ef7b54 100644
--- a/include/rive/wasm/wasm_scripting_vm.hpp
+++ b/include/rive/wasm/wasm_scripting_vm.hpp
@@ -269,31 +269,30 @@
     /// no wasm frames on the stack. Returns true when a swap happened.
     bool maybeUpgradeTier();
 
-    /// Frame-arena contract for stub-runtime modules. A module opts in by
-    /// exporting __riveFrameArena and __riveFrameRewind (rt/frame in rasc's
-    /// std). The host takes the arena mark at the first frame boundary after
-    /// the first advance completes, re-takes it after any later script init
-    /// so init allocations persist, and rewinds to it at every other frame
-    /// boundary. The contract: state that must survive a frame lives in
-    /// allocations made before the mark; module-static caches over per-frame
-    /// data register an __onReset hook and drop them on rewind.
-    ///
-    /// Stub-runtime modules without the opt-in get a leak watch instead:
-    /// once linear memory grows well past its post-first-advance baseline
-    /// the call returns a one-time warning for the host's log channel
-    /// (RIVE_WASM_LEAK_WARN=0 silences it, e.g. for benches).
+    /// The per-frame collection point: frame collector modules scavenge
+    /// here (call once during load after init so init promotion lands in
+    /// load time). All rasc modules also get the leak watch: once the heap
+    /// grows well past its post-first-advance baseline the call returns a
+    /// warning for the host's log channel, re-armed per 8MB of further
+    /// growth and carrying a time-to-trap estimate (RIVE_WASM_LEAK_WARN=0
+    /// silences it, e.g. for benches).
     ///
     /// Call once per host frame with no wasm frames live; returns null when
     /// there is nothing to report.
     const char* frameBoundary();
     const char* handleLeakWarning();
+    const char* heapGrowthWarning();
+
+    /// Current size of the module's linear memory in 64KB wasm pages.
+    uint32_t memoryPages() const;
 
     /// Swap execution onto a compiled artifact of this module, carrying
     /// memory, globals, and tables. No wasm frames may be live. On failure
     /// the current instance keeps running.
     bool applyTierArtifact(Span<const uint8_t> artifactBytes,
                            ExecutionTier tier,
-                           std::string& error);
+                           std::string& error,
+                           bool hwBounds = false);
 
     /// Backend seams: host code reaches module memory and module functions
     /// only through these, so a browser backend can substitute staged
@@ -311,6 +310,19 @@
                                 uint32_t argc,
                                 uint32_t* argv);
 
+    /// The honest variant: distinguishes a missing export from a trap from
+    /// a real zero result, for callers where the difference is a failure.
+    enum class CallOutcome
+    {
+        ok,
+        missing,
+        trapped,
+    };
+    CallOutcome callModuleChecked(const char* name,
+                                  uint32_t argc,
+                                  uint32_t* argv,
+                                  uint32_t* result);
+
     /// Ends module execution like a trap once the current native returns.
     virtual void raiseModuleError(const char* message);
 
@@ -370,17 +382,34 @@
     /// execution, completed or cancelled entries erase themselves.
     std::unordered_map<uint32_t, rcp<WorkTask>> m_pendingDecodes;
     uint64_t m_decodeOwnerId = 0;
-    // Frame-arena contract state; see frameBoundary().
-    bool m_frameArenaOptIn = false;
-    bool m_frameArenaPending = false;
-    bool m_frameArenaAnnounced = false;
-    uint32_t m_frameArenaMark = 0;
+    // Delivery is synchronous, so the pixels borrow the decoder's buffer.
+    struct DecodeResult
+    {
+        bool ok = false;
+        uint32_t token = 0;
+        uint32_t width = 0;
+        uint32_t height = 0;
+        Span<const uint8_t> pixels;
+        std::string error;
+    };
+    void deliverDecodeResult(const DecodeResult& result);
     bool m_advancedOnce = false;
+    bool m_frameMinor = false;
+    bool m_frameMinorAnnounced = false;
+    /// Module exports __riveHeapUsed; the leak watch reads bump bytes
+    /// instead of page counts, which pregrown aot memory freezes.
+    bool m_heapUsedProbe = false;
+    /// Collected runtimes warn only on a second consecutive growth window.
+    bool m_leakArmedCollected = false;
     bool m_leakWatch = false;
-    uint32_t m_leakBaselinePages = 0;
-    uint32_t m_leakFrames = 0;
     std::string m_leakWarning;
-    bool m_reapHandles = false;
+    uint32_t m_leakBaselinePages = 0;
+    uint32_t m_leakFirstBaselinePages = 0;
+    uint32_t m_leakFrames = 0;
+    uint32_t m_leakTotalFrames = 0;
+    uint32_t m_leakWarningCount = 0;
+    bool m_leakTrapContextPrinted = false;
+    bool m_collectedRuntime = false;
     bool m_handleWatch = false;
     uint32_t m_handleBaselineLive = 0;
     uint32_t m_handleFrames = 0;
diff --git a/scripting/premake5_wamr.lua b/scripting/premake5_wamr.lua
index ae2e020..8aa357f 100644
--- a/scripting/premake5_wamr.lua
+++ b/scripting/premake5_wamr.lua
@@ -49,7 +49,11 @@
     table.insert(wamrConfigDefines, 'WASM_API_EXTERN=')
 end
 
-local platformDir = os.target() == 'linux' and 'linux'
+-- os.target() stays the host under --for_android; the option is the
+-- truth for the platform dir.
+local forAndroid = _OPTIONS['for_android'] ~= nil
+local platformDir = forAndroid and 'android'
+    or os.target() == 'linux' and 'linux'
     or os.target() == 'windows' and 'windows'
     or 'darwin'
 
@@ -72,13 +76,15 @@
     buildoptions({ '-fno-lto' })
     -- The tail-dup knobs need llvm 19; older clangs build correct but
     -- slower dispatch, still caught by the dispatch-site guard where it runs.
+    -- The probe runs host clang; cross toolchains (the NDK's) may reject
+    -- the knobs the host accepts, so cross builds keep default dispatch.
     local devNull = os.ishost('windows') and 'NUL' or '/dev/null'
     local _, tailDupProbe = os.outputof(
         'clang -fsyntax-only -x c ' .. devNull ..
             ' -mllvm -tail-dup-pred-size=5000' ..
             ' -mllvm -tail-dup-succ-size=5000 2>&1'
     )
-    if tailDupProbe == 0 then
+    if tailDupProbe == 0 and _OPTIONS['for_android'] == nil then
         buildoptions({
             '-mllvm -tail-dup-pred-size=5000',
             '-mllvm -tail-dup-succ-size=5000',
@@ -89,6 +95,8 @@
     defines({ 'BH_PLATFORM_DARWIN' })
     filter({ 'system:linux' })
     defines({ 'BH_PLATFORM_LINUX' })
+    filter({ 'system:android' })
+    defines({ 'BH_PLATFORM_ANDROID' })
     filter({ 'system:windows' })
     defines({
         'BH_PLATFORM_WINDOWS',
@@ -107,9 +115,20 @@
     else
         isArm64 = machine == 'arm64' or machine == 'aarch64'
     end
+    local isArm32 = forAndroid and archOption == 'arm'
     -- The quoted string define goes through buildoptions pre-escaped so both
     -- the gmake and ninja generators deliver the quotes to the compiler.
-    if isArm64 then
+    if isArm32 then
+        -- NDK armv7 compiles thumb2 with NEON/VFP by default. Upstream's
+        -- thumb reloc does pointer arithmetic clang 18 makes a hard error.
+        -- The THUMBV7 string matters: the aot loader matches it against
+        -- wamrc's thumbv7 artifacts (bare THUMB defaults to thumbv4t).
+        defines({ 'BUILD_TARGET_THUMB_VFP' })
+        buildoptions({
+            '-DBUILD_TARGET=\\"THUMBV7\\"',
+            '-Wno-int-conversion',
+        })
+    elseif isArm64 then
         defines({ 'BUILD_TARGET_AARCH64' })
         buildoptions({ '-DBUILD_TARGET=\\"AARCH64\\"' })
     else
@@ -118,13 +137,17 @@
     end
     -- em64 is WAMR's x86-64 SysV invoke shim. With WASM_ENABLE_SIMD the
     -- invoke marshaling widens float slots to v128, so the shim must be the
-    -- _simd variant or int registers load from the wrong offsets. Windows
-    -- takes the mingw shim: same Win64 ABI as MSVC targets, and GAS syntax
-    -- that clang's integrated assembler handles without ml64.
-    local invokeNative = isArm64 and 'invokeNative_aarch64_simd.s'
+    -- _simd variant or int registers load from the wrong offsets. arm32 has
+    -- no simd invoke variant; its marshaling never widens. Windows takes
+    -- the mingw shim: same Win64 ABI as MSVC targets, and GAS syntax that
+    -- clang's integrated assembler handles without ml64.
+    local invokeNative = isArm32 and 'invokeNative_thumb_vfp.s'
+        or isArm64 and 'invokeNative_aarch64_simd.s'
         or os.target() == 'windows' and 'invokeNative_mingw_x64_simd.s'
         or 'invokeNative_em64_simd.s'
-    local aotReloc = isArm64 and 'aot_reloc_aarch64.c' or 'aot_reloc_x86_64.c'
+    local aotReloc = isArm32 and 'aot_reloc_thumb.c'
+        or isArm64 and 'aot_reloc_aarch64.c'
+        or 'aot_reloc_x86_64.c'
     includedirs({
         wamr .. '/core/iwasm/include',
         wamr .. '/core/iwasm/common',
diff --git a/scripting/wamr_patches/0014-strictfp-on-generated-functions.patch b/scripting/wamr_patches/0014-strictfp-on-generated-functions.patch
new file mode 100644
index 0000000..1548a4b
--- /dev/null
+++ b/scripting/wamr_patches/0014-strictfp-on-generated-functions.patch
@@ -0,0 +1,24 @@
+--- a/core/iwasm/compilation/aot_llvm.c
++++ b/core/iwasm/compilation/aot_llvm.c
+@@ -95,6 +95,21 @@
+         return NULL;
+     }
+ 
++    /* Hard-float lowering emits llvm.experimental.constrained.* calls;
++       LangRef requires strictfp on any function containing them, or the
++       optimizer and ISel are free to assume default FP semantics and
++       miscompile (observed at opt-level >= 1 once mem2reg/sroa expose the
++       dataflow). */
++    {
++        unsigned kind = LLVMGetEnumAttributeKindForName("strictfp", 8);
++        if (kind != 0) {
++            LLVMAddAttributeAtIndex(
++                func, (LLVMAttributeIndex)LLVMAttributeFunctionIndex,
++                LLVMCreateEnumAttribute(LLVMGetModuleContext(module), kind,
++                                        0));
++        }
++    }
++
+     j = 0;
+     local_value = LLVMGetParam(func, j++);
+     LLVMSetValueName(local_value, "exec_env");
diff --git a/scripting/wamr_patches/0015-codegen-opt-level-cap.patch b/scripting/wamr_patches/0015-codegen-opt-level-cap.patch
new file mode 100644
index 0000000..89da6e0
--- /dev/null
+++ b/scripting/wamr_patches/0015-codegen-opt-level-cap.patch
@@ -0,0 +1,61 @@
+--- a/core/iwasm/compilation/aot_llvm.c
++++ b/core/iwasm/compilation/aot_llvm.c
+@@ -3238,9 +3253,17 @@
+         else
+             code_model = LLVMCodeModelSmall;
+ 
+-        /* Create the target machine */
++        /* Create the target machine. The backend level may be pinned below
++           the IR level: LLVM's optimizing AArch64 codegen miscompiles the
++           strict (constrained) FP this compiler emits, so a build can keep
++           IR optimization while selecting instructions conservatively. */
++        uint32 tm_opt_level = opt_level;
++        if (option->codegen_opt_level_plus_one != 0
++            && option->codegen_opt_level_plus_one - 1 < tm_opt_level) {
++            tm_opt_level = option->codegen_opt_level_plus_one - 1;
++        }
+         if (!(comp_ctx->target_machine = LLVMCreateTargetMachineWithOpts(
+-                  target, triple_norm, cpu, features, opt_level,
++                  target, triple_norm, cpu, features, tm_opt_level,
+                   LLVMRelocStatic, code_model, false,
+                   comp_ctx->stack_usage_file))) {
+             aot_set_last_error("create LLVM target machine failed.");
+--- a/core/iwasm/include/aot_comp_option.h
++++ b/core/iwasm/include/aot_comp_option.h
+@@ -83,6 +83,10 @@
+     bool enable_shared_chain;
+     char *use_prof_file;
+     uint32_t opt_level;
++    /* Backend codegen level override, stored as level + 1 so a zeroed
++       struct follows opt_level. Lets a build keep IR optimization while
++       pinning conservative instruction selection. */
++    uint32_t codegen_opt_level_plus_one;
+     uint32_t size_level;
+     uint32_t output_format;
+     uint32_t bounds_checks;
+--- a/wamr-compiler/main.c
++++ b/wamr-compiler/main.c
+@@ -130,6 +130,7 @@
+     printf("                            For example, --cpu-features=+feature1,-feature2\n");
+     printf("                            Use --cpu-features=+help to list all the features supported\n");
+     printf("  --opt-level=n             Set the optimization level (0 to 3, default is 3)\n");
++    printf("  --codegen-opt-level=n     Cap the backend codegen level below the IR opt level\n");
+     printf("  --size-level=n            Set the code size level (0 to 3, default is 3)\n");
+     printf("                              0 - Large code model\n");
+     printf("                              1 - Medium code model\n");
+@@ -472,6 +473,15 @@
+             option.opt_level = (uint32)atoi(argv[0] + 12);
+             if (option.opt_level > 3)
+                 option.opt_level = 3;
++        }
++        else if (!strncmp(argv[0], "--codegen-opt-level=", 20)) {
++            uint32 level;
++            if (argv[0][20] == '\0')
++                PRINT_HELP_AND_EXIT();
++            level = (uint32)atoi(argv[0] + 20);
++            if (level > 3)
++                level = 3;
++            option.codegen_opt_level_plus_one = level + 1;
+         }
+         else if (!strncmp(argv[0], "--size-level=", 13)) {
+             if (argv[0][13] == '\0')
diff --git a/src/wasm/module_tier_ladder.cpp b/src/wasm/module_tier_ladder.cpp
index da0b28c..5318d0f 100644
--- a/src/wasm/module_tier_ladder.cpp
+++ b/src/wasm/module_tier_ladder.cpp
@@ -7,6 +7,7 @@
 #include <algorithm>
 #include <cerrno>
 #include <cstdio>
+#include <filesystem>
 #include <fcntl.h>
 #include <cstdlib>
 #include <cstring>
@@ -71,6 +72,17 @@
     {
         m_wamrcPath = getenv("RIVE_WAMRC");
     }
+    // Self-configure from env so the first module load, which happens
+    // before any host configure call, can stage its pristine bytes.
+    if (m_cacheDir.empty() && !m_wamrcPath.empty())
+    {
+        const char* dirEnv = getenv("RIVE_AOT_CACHE_DIR");
+        m_cacheDir =
+            dirEnv != nullptr
+                ? dirEnv
+                : (std::filesystem::temp_directory_path() / "rive_aot_cache")
+                      .string();
+    }
     return !m_wamrcPath.empty() && !m_cacheDir.empty();
 }
 
@@ -119,7 +131,10 @@
 
 std::string ModuleTierLadder::keyedCacheDir()
 {
-    std::string dir = m_cacheDir + "/" + wamrcVersion();
+    // The revision folds our wamrc flag choices into the cache key; bump
+    // it whenever species flags change or stale artifacts (like the
+    // pre-codegen-cap -O1 ones) get served on a cache hit.
+    std::string dir = m_cacheDir + "/" + wamrcVersion() + "-r3";
     mkdir(m_cacheDir.c_str(), 0755);
     mkdir(dir.c_str(), 0755);
     return dir;
@@ -128,11 +143,17 @@
 std::string ModuleTierLadder::artifactName(uint64_t moduleKey,
                                            TierSpecies species)
 {
+    const char* pattern = "%016llx.aot";
+    if (species == TierSpecies::o0)
+    {
+        pattern = "%016llx.o0.aot";
+    }
+    else if (species == TierSpecies::hw)
+    {
+        pattern = "%016llx.hw.aot";
+    }
     char name[64];
-    snprintf(name,
-             sizeof(name),
-             species == TierSpecies::o0 ? "%016llx.o0.aot" : "%016llx.aot",
-             (unsigned long long)moduleKey);
+    snprintf(name, sizeof(name), pattern, (unsigned long long)moduleKey);
     return name;
 }
 
@@ -153,6 +174,52 @@
     return std::string();
 }
 
+// The cache is shared across processes and a name must never be visible
+// half-written: write a process-unique temp, then atomically rename.
+static bool writeFileAtomic(const std::string& path, Span<const uint8_t> bytes)
+{
+    std::string tmpPath = path + "." + std::to_string(getpid()) + ".tmp";
+    FILE* f = fopen(tmpPath.c_str(), "wb");
+    if (f == nullptr)
+    {
+        return false;
+    }
+    size_t written = fwrite(bytes.data(), 1, bytes.size(), f);
+    if (fclose(f) != 0 || written != bytes.size())
+    {
+        unlink(tmpPath.c_str());
+        return false;
+    }
+    if (rename(tmpPath.c_str(), path.c_str()) != 0)
+    {
+        unlink(tmpPath.c_str());
+        return false;
+    }
+    return true;
+}
+
+void ModuleTierLadder::stagePristine(uint64_t moduleKey,
+                                     Span<const uint8_t> moduleBytes)
+{
+    if (!enabled())
+    {
+        return;
+    }
+    std::unique_lock<std::mutex> lock(m_mutex);
+    char wasmName[64];
+    snprintf(wasmName,
+             sizeof(wasmName),
+             "%016llx.wasm",
+             (unsigned long long)moduleKey);
+    std::string wasmPath = keyedCacheDir() + "/" + wasmName;
+    struct stat st;
+    if (stat(wasmPath.c_str(), &st) == 0)
+    {
+        return;
+    }
+    writeFileAtomic(wasmPath, moduleBytes);
+}
+
 void ModuleTierLadder::schedule(const std::string& laneId,
                                 uint64_t moduleKey,
                                 Span<const uint8_t> moduleBytes)
@@ -184,14 +251,21 @@
              (unsigned long long)moduleKey);
     std::string wasmPath = dir + "/" + wasmName;
 
+    // On guard-page builds the top rung is the hw species: no inline
+    // bounds checks and growth never moves the memory base.
+#ifdef RIVE_WASM_HW_BOUNDS
+    constexpr TierSpecies kTopSpecies = TierSpecies::hw;
+#else
+    constexpr TierSpecies kTopSpecies = TierSpecies::o3;
+#endif
     std::vector<TierSpecies> wanted;
     if (moduleBytes.size() <= kStraightToO3Bytes)
     {
-        wanted = {TierSpecies::o3};
+        wanted = {kTopSpecies};
     }
     else
     {
-        wanted = {TierSpecies::o0, TierSpecies::o3};
+        wanted = {TierSpecies::o0, kTopSpecies};
     }
 
     bool queued = false;
@@ -212,13 +286,14 @@
         }
         if (!queued)
         {
-            FILE* f = fopen(wasmPath.c_str(), "wb");
-            if (f == nullptr)
+            // A pristine stage from before the in-place load wins; the bytes
+            // passed here may already be loader-rewritten.
+            struct stat wasmStat;
+            if (stat(wasmPath.c_str(), &wasmStat) != 0 &&
+                !writeFileAtomic(wasmPath, moduleBytes))
             {
                 return;
             }
-            fwrite(moduleBytes.data(), 1, moduleBytes.size(), f);
-            fclose(f);
             queued = true;
         }
         m_queue.push_back(
@@ -300,10 +375,33 @@
     std::string tmpPath = finalPath + ".tmp";
 
     std::vector<std::string> args = {wamrc};
-    if (job.species == TierSpecies::o0)
+    if (job.species == TierSpecies::hw)
+    {
+        // Guard-page bounds; native stack checks stay sw (the runtime
+        // builds with WASM_DISABLE_STACK_HW_BOUND_CHECK).
+        args.push_back("--bounds-checks=0");
+        args.push_back("--stack-bounds-checks=1");
+    }
+    else
+    {
+        // sw bounds run on every build; wamrc's default hw-bounds output
+        // segfaults on runtimes without the guard-page trap handler.
+        args.push_back("--bounds-checks=1");
+    }
+    if (job.species == TierSpecies::o0 || job.species == TierSpecies::hw)
     {
         args.push_back("--opt-level=0");
     }
+    else
+    {
+        // LLVM's optimizing backend miscompiles the strict (constrained)
+        // FP wamrc emits, probabilistically corrupting float-heavy modules
+        // (box2d was the repro; the town's draco trap was the same class).
+        // Our vendored wamrc pins codegen conservative while the IR still
+        // optimizes; lift only against the box2d strict-probe soak.
+        args.push_back("--opt-level=1");
+        args.push_back("--codegen-opt-level=0");
+    }
     args.push_back("-o");
     args.push_back(tmpPath);
     args.push_back(job.wasmPath);
@@ -314,6 +412,11 @@
     }
     argv.push_back(nullptr);
 
+#ifdef RIVE_ANDROID
+    // No wamrc on device, and posix_spawn needs API 28; the ladder never
+    // schedules compiles here.
+    return false;
+#else
     pid_t pid = -1;
     posix_spawn_file_actions_t actions;
     posix_spawn_file_actions_init(&actions);
@@ -358,6 +461,7 @@
     }
     // Atomic arrival: a partial artifact can never carry the final name.
     return rename(tmpPath.c_str(), finalPath.c_str()) == 0;
+#endif
 }
 
 void ModuleTierLadder::drain()
@@ -387,6 +491,7 @@
                                 uint64_t,
                                 Span<const uint8_t>)
 {}
+void ModuleTierLadder::stagePristine(uint64_t, Span<const uint8_t>) {}
 std::string ModuleTierLadder::artifactPath(uint64_t, TierSpecies)
 {
     return std::string();
diff --git a/src/wasm/wasm_scripting_vm.cpp b/src/wasm/wasm_scripting_vm.cpp
index 4d65756..171a917 100644
--- a/src/wasm/wasm_scripting_vm.cpp
+++ b/src/wasm/wasm_scripting_vm.cpp
@@ -122,10 +122,16 @@
 // instance of the same content reloads identical bytes. Entries live for
 // the process; the byte buffer must outlive the module (wasm_runtime_load
 // keeps referencing it).
+static void pregrowAotMemory(wasm_module_inst_t instance);
+
 struct SharedWasmModule
 {
     std::vector<uint8_t> bytes;
     wasm_module_t module = nullptr;
+    // Artifact-backed entries hand every later VM their real tier; without
+    // this a cache hit reports interp while running compiled code.
+    WasmScriptingVM::ExecutionTier tier =
+        WasmScriptingVM::ExecutionTier::interp;
 };
 
 static std::unordered_map<uint64_t, SharedWasmModule>& sharedModuleCache()
@@ -165,11 +171,24 @@
                                      uint32_t argc,
                                      uint32_t* argv)
 {
+    uint32_t result = 0;
+    callModuleChecked(name, argc, argv, &result);
+    return result;
+}
+
+WasmScriptingVM::CallOutcome WasmScriptingVM::callModuleChecked(
+    const char* name,
+    uint32_t argc,
+    uint32_t* argv,
+    uint32_t* result)
+{
     wasm_module_inst_t inst = m_state->instance;
     wasm_function_inst_t f = wasm_runtime_lookup_function(inst, name);
     if (f == nullptr)
     {
-        return 0;
+        // Callers probing optional exports read the outcome; a plain
+        // callModule folds this to zero, so probe before relying on it.
+        return CallOutcome::missing;
     }
     uint32_t buf[8] = {0};
     for (uint32_t i = 0; i < argc; i++)
@@ -183,12 +202,25 @@
         const char* exception = wasm_runtime_get_exception(inst);
         if (exception != nullptr)
         {
-            fprintf(stderr, "wasm call trapped: %s\n", exception);
+            fprintf(stderr, "wasm call trapped in %s: %s\n", name, exception);
+            if (m_leakWarningCount > 0 && !m_leakTrapContextPrinted)
+            {
+                // A bare trap after leak warnings is almost always the
+                // memory ceiling; say so once for hosts that dropped the
+                // warning strings.
+                m_leakTrapContextPrinted = true;
+                fprintf(stderr,
+                        "wasm call trapped after %u script heap leak "
+                        "warnings; the module likely hit its wasmMaxPages "
+                        "ceiling\n",
+                        m_leakWarningCount);
+            }
             wasm_runtime_clear_exception(inst);
         }
-        return 0;
+        return CallOutcome::trapped;
     }
-    return buf[0];
+    *result = buf[0];
+    return CallOutcome::ok;
 }
 
 void* WasmScriptingVM::resolveModulePtr(uint32_t appAddr, uint32_t size)
@@ -1502,7 +1534,10 @@
     desc.wrapU = (ore::WrapMode)podDesc->wrapU;
     desc.wrapV = (ore::WrapMode)podDesc->wrapV;
     desc.wrapW = (ore::WrapMode)podDesc->wrapW;
-    desc.compare = (ore::CompareFunction)podDesc->compare;
+    // The guest sends ~0 for "no comparison sampler".
+    desc.compare = podDesc->compare == 0xFFFFFFFFu
+                       ? ore::CompareFunction::none
+                       : (ore::CompareFunction)podDesc->compare;
     desc.minLod = podDesc->minLod;
     desc.maxLod = podDesc->maxLod;
     desc.maxAnisotropy = podDesc->maxAnisotropy;
@@ -4736,6 +4771,44 @@
     }
 }
 
+void WasmScriptingVM::deliverDecodeResult(const DecodeResult& result)
+{
+    if (result.ok)
+    {
+        uint32_t byteCount = (uint32_t)result.pixels.size();
+        uint32_t sizeArgs[1] = {byteCount};
+        uint32_t pixelsPtr = callModule("malloc", 1, sizeArgs);
+        if (pixelsPtr == 0)
+        {
+            DecodeResult failure;
+            failure.token = result.token;
+            failure.error = "failed to allocate decoded pixels";
+            deliverDecodeResult(failure);
+            return;
+        }
+        memcpy(resolveModulePtr(pixelsPtr, byteCount),
+               result.pixels.data(),
+               byteCount);
+        uint32_t args[6] = {m_L,
+                            result.token,
+                            result.width,
+                            result.height,
+                            pixelsPtr,
+                            byteCount};
+        callModule("host_image_decoded", 6, args);
+        guestFree(pixelsPtr);
+        return;
+    }
+    uint32_t messagePtr = guestString(result.error.c_str());
+    if (messagePtr == 0)
+    {
+        return;
+    }
+    uint32_t args[3] = {m_L, result.token, messagePtr};
+    callModule("host_image_decode_failed", 3, args);
+    guestFree(messagePtr);
+}
+
 void WasmScriptingVM::resolveImageDecode(uint32_t token,
                                          uint32_t width,
                                          uint32_t height,
@@ -4747,17 +4820,13 @@
     {
         return;
     }
-    uint32_t sizeArgs[1] = {byteCount};
-    uint32_t pixelsPtr = callModule("malloc", 1, sizeArgs);
-    if (pixelsPtr == 0)
-    {
-        rejectImageDecode(token, "failed to allocate decoded pixels");
-        return;
-    }
-    memcpy(resolveModulePtr(pixelsPtr, byteCount), pixels, byteCount);
-    uint32_t args[6] = {m_L, token, width, height, pixelsPtr, byteCount};
-    callModule("host_image_decoded", 6, args);
-    guestFree(pixelsPtr);
+    DecodeResult result;
+    result.ok = true;
+    result.token = token;
+    result.width = width;
+    result.height = height;
+    result.pixels = Span<const uint8_t>(pixels, byteCount);
+    deliverDecodeResult(result);
 }
 
 void WasmScriptingVM::rejectImageDecode(uint32_t token, const char* message)
@@ -4767,14 +4836,10 @@
     {
         return;
     }
-    uint32_t messagePtr = guestString(message);
-    if (messagePtr == 0)
-    {
-        return;
-    }
-    uint32_t args[3] = {m_L, token, messagePtr};
-    callModule("host_image_decode_failed", 3, args);
-    guestFree(messagePtr);
+    DecodeResult result;
+    result.token = token;
+    result.error = message;
+    deliverDecodeResult(result);
 }
 
 void WasmScriptingVM::setTimeoutMs(int ms)
@@ -4895,11 +4960,24 @@
         auto& ladder = ModuleTierLadder::instance();
         if (ladder.enabled())
         {
-            std::string path = ladder.artifactPath(moduleKey, TierSpecies::o3);
-            if (!path.empty() && path.size() < sizeof(aotPath))
+#ifdef RIVE_WASM_HW_BOUNDS
+            std::string hwPath =
+                ladder.artifactPath(moduleKey, TierSpecies::hw);
+            if (!hwPath.empty() && hwPath.size() < sizeof(aotPath))
             {
-                memcpy(aotPath, path.c_str(), path.size() + 1);
-                haveAot = true;
+                memcpy(aotPath, hwPath.c_str(), hwPath.size() + 1);
+                haveHwAot = true;
+            }
+#endif
+            if (!haveHwAot)
+            {
+                std::string path =
+                    ladder.artifactPath(moduleKey, TierSpecies::o3);
+                if (!path.empty() && path.size() < sizeof(aotPath))
+                {
+                    memcpy(aotPath, path.c_str(), path.size() + 1);
+                    haveAot = true;
+                }
             }
         }
     }
@@ -4919,6 +4997,7 @@
     {
         m_state->module = cached->second.module;
         m_state->ownsModule = false;
+        m_tier = cached->second.tier;
         // The VM's own copy is redundant against the cache entry, but the
         // tier ladder still needs the bytes; entries live for the process.
         m_scheduleBytes = Span<const uint8_t>(cached->second.bytes.data(),
@@ -4944,6 +5023,15 @@
                 m_tier = ExecutionTier::aotO3;
             }
         }
+        if (!haveAot && !haveHwAot)
+        {
+            // The load below rewrites the buffer in place; wamrc needs the
+            // module as it is now.
+            ModuleTierLadder::instance().stagePristine(
+                m_moduleKey,
+                Span<const uint8_t>(m_moduleBytes.data(),
+                                    m_moduleBytes.size()));
+        }
         m_state->module = wasm_runtime_load(m_moduleBytes.data(),
                                             (uint32_t)m_moduleBytes.size(),
                                             error,
@@ -4957,6 +5045,7 @@
             SharedWasmModule entry;
             entry.bytes = std::move(m_moduleBytes);
             entry.module = m_state->module;
+            entry.tier = m_tier;
             auto inserted = cache.emplace(moduleKey, std::move(entry));
             m_state->ownsModule = false;
             m_scheduleBytes =
@@ -4995,6 +5084,10 @@
         m_lastError = std::string("module instantiate failed: ") + error;
         return false;
     }
+    if (m_tier != ExecutionTier::interp && !haveHwAot)
+    {
+        pregrowAotMemory(m_state->instance);
+    }
     m_state->execEnv =
         wasm_runtime_create_exec_env(m_state->instance, 512 * 1024);
     if (m_state->execEnv == nullptr)
@@ -5004,31 +5097,27 @@
     }
     wasm_runtime_set_user_data(m_state->execEnv, this);
 
-    // Frame-arena opt-in, else the leak watch for rasc-linked modules
-    // (riveRegister marks one); their stub allocator never frees, so
-    // unbounded linear-memory growth is content leaking per frame.
-    m_frameArenaOptIn =
-        wasm_runtime_lookup_function(m_state->instance, "__riveFrameArena") !=
-            nullptr &&
-        wasm_runtime_lookup_function(m_state->instance, "__riveFrameRewind") !=
-            nullptr;
+    // The leak watch for rasc-linked modules (riveRegister marks one): the
+    // stub baseline never frees, and sustained growth on a collecting
+    // module is references accumulating.
     const char* leakEnv = getenv("RIVE_WASM_LEAK_WARN");
-    m_leakWatch = !m_frameArenaOptIn &&
-                  wasm_runtime_lookup_function(m_state->instance,
+    m_leakWatch = wasm_runtime_lookup_function(m_state->instance,
                                                "riveRegister") != nullptr &&
                   (leakEnv == nullptr || strcmp(leakEnv, "0") != 0);
-    // Frame-scoped handle reaping only composes with the arena: the rewind
-    // is the proof that unreleased per-frame wrappers are unreachable. Both
-    // hooks or neither, else a missing rebase would reap mark-scoped mints.
-    m_reapHandles =
-        m_frameArenaOptIn &&
-        wasm_runtime_lookup_function(m_state->instance, "__riveReapHandles") !=
-            nullptr &&
-        wasm_runtime_lookup_function(m_state->instance,
-                                     "__riveHandlesRebase") != nullptr;
+    m_collectedRuntime =
+        wasm_runtime_lookup_function(m_state->instance, "__riveCollected") !=
+        nullptr;
     m_handleWatch = wasm_runtime_lookup_function(m_state->instance,
                                                  "riveRegister") != nullptr &&
                     (leakEnv == nullptr || strcmp(leakEnv, "0") != 0);
+    // Frame collector modules scavenge at every boundary instead of
+    // rewinding or finalizing.
+    m_frameMinor = wasm_runtime_lookup_function(m_state->instance,
+                                                "__riveFrameMinor") != nullptr;
+    // Stub modules report their bump position; page counts go blind once
+    // the aot lanes pregrow to wasmMaxPages.
+    m_heapUsedProbe = wasm_runtime_lookup_function(m_state->instance,
+                                                   "__riveHeapUsed") != nullptr;
 
     callModule("__wasm_call_ctors", 0, nullptr);
     m_L = callModule("host_newstate", 0, nullptr);
@@ -5082,7 +5171,16 @@
         return false;
     }
     ExecutionTier target = ExecutionTier::aotO3;
-    std::string path = ladder.artifactPath(m_moduleKey, TierSpecies::o3);
+    bool hwBounds = false;
+    std::string path;
+#ifdef RIVE_WASM_HW_BOUNDS
+    path = ladder.artifactPath(m_moduleKey, TierSpecies::hw);
+    hwBounds = !path.empty();
+#endif
+    if (path.empty())
+    {
+        path = ladder.artifactPath(m_moduleKey, TierSpecies::o3);
+    }
     if (path.empty() && m_tier < ExecutionTier::aotO0)
     {
         target = ExecutionTier::aotO0;
@@ -5111,7 +5209,8 @@
     if (!applyTierArtifact(
             Span<const uint8_t>(artifact.data(), artifact.size()),
             target,
-            error))
+            error,
+            hwBounds))
     {
         fprintf(stderr, "wasm tier swap failed: %s\n", error.c_str());
         return false;
@@ -5125,41 +5224,34 @@
     {
         return nullptr;
     }
-    if (m_frameArenaOptIn)
+    if (m_frameMinor)
     {
-        if (m_frameArenaPending)
+        uint32_t promoted = callModule("__riveFrameMinor", 0, nullptr);
+        if (getenv("RIVE_FRAME_GC_DEBUG") != nullptr)
         {
-            m_frameArenaPending = false;
-            if (m_reapHandles)
-            {
-                // Everything minted so far becomes mark-scoped state; drop
-                // it from the reap log without releasing.
-                callModule("__riveHandlesRebase", 0, nullptr);
-            }
-            m_frameArenaMark = callModule("__riveFrameArena", 0, nullptr);
-            if (m_frameArenaMark != 0 && !m_frameArenaAnnounced)
-            {
-                m_frameArenaAnnounced = true;
-                return "script frame arena: mark taken, rewinding per frame";
-            }
+            fprintf(stderr, "frameMinor promoted=%u\n", promoted);
+            callModule("__riveFrameVerify", 0, nullptr);
         }
-        else if (m_frameArenaMark != 0)
+        if (!m_frameMinorAnnounced)
         {
-            if (m_reapHandles)
-            {
-                // Before the rewind erases this frame's wrappers: any handle
-                // they minted and never released is provably garbage.
-                callModule("__riveReapHandles", 0, nullptr);
-            }
-            uint32_t args[1] = {m_frameArenaMark};
-            callModule("__riveFrameRewind", 1, args);
+            m_frameMinorAnnounced = true;
+            return "script frame gc: scavenging per frame";
         }
-        return handleLeakWarning();
+        if (const char* warning = handleLeakWarning())
+        {
+            return warning;
+        }
+        return heapGrowthWarning();
     }
     if (const char* warning = handleLeakWarning())
     {
         return warning;
     }
+    return heapGrowthWarning();
+}
+
+const char* WasmScriptingVM::heapGrowthWarning()
+{
     if (!m_leakWatch || !m_advancedOnce)
     {
         return nullptr;
@@ -5170,16 +5262,25 @@
     {
         return nullptr;
     }
-    uint32_t pages = (uint32_t)wasm_memory_get_cur_page_count(memory);
+    // The stub bump position beats page counts when available: pregrown
+    // aot memory never grows.
+    uint32_t pages =
+        m_heapUsedProbe
+            ? std::max(1u, callModule("__riveHeapUsed", 0, nullptr) >> 16)
+            : (uint32_t)wasm_memory_get_cur_page_count(memory);
     if (m_leakBaselinePages == 0)
     {
         m_leakBaselinePages = pages;
+        m_leakFirstBaselinePages = pages;
         return nullptr;
     }
     m_leakFrames++;
+    m_leakTotalFrames++;
     // 8MB past baseline over at least two seconds of frames: far beyond any
     // one-time warmup we have measured, reached in seconds by a per-frame
-    // leak (box2d hand-optimized leaked ~2.1MB/s).
+    // leak (box2d hand-optimized leaked ~2.1MB/s). The watch re-arms after
+    // each warning, so a leaking session keeps hearing about it every 8MB
+    // instead of dying hours after a single line scrolled away.
     constexpr uint32_t kLeakWarnPages = 128;
     constexpr uint32_t kLeakWarnMinFrames = 120;
     if (m_leakFrames < kLeakWarnMinFrames ||
@@ -5187,16 +5288,61 @@
     {
         return nullptr;
     }
-    m_leakWatch = false;
-    char buffer[256];
-    snprintf(buffer,
-             sizeof(buffer),
-             "script heap grew %uMB over %u frames; the stub runtime never "
-             "frees, so per-frame allocations leak. Opt into the frame arena "
-             "(export const __riveFrameArena = true) or use a collecting "
-             "runtime. RIVE_WASM_LEAK_WARN=0 silences this.",
-             (pages - m_leakBaselinePages) / 16,
-             m_leakFrames);
+    if (m_collectedRuntime && !m_leakArmedCollected)
+    {
+        // Linear memory never shrinks, so a collected runtime's one-time
+        // spike would read as growth forever. Demand a second growing
+        // window before the first warning.
+        m_leakArmedCollected = true;
+        m_leakBaselinePages = pages;
+        m_leakFrames = 0;
+        return nullptr;
+    }
+    uint32_t grownMB = (pages - m_leakBaselinePages) / 16;
+    uint32_t frames = m_leakFrames;
+    m_leakBaselinePages = pages;
+    m_leakFrames = 0;
+    m_leakWarningCount++;
+
+    // Time to the wasmMaxPages trap from the average growth rate at 60fps.
+    // Growth arrives in page-doubling steps, so this is an estimate.
+    char projection[96] = {0};
+    uint32_t maxPages = (uint32_t)wasm_memory_get_max_page_count(memory);
+    double pagesPerFrame =
+        (double)(pages - m_leakFirstBaselinePages) / (double)m_leakTotalFrames;
+    if (maxPages > pages && pagesPerFrame > 0.0)
+    {
+        double minutes =
+            (double)(maxPages - pages) / pagesPerFrame / (60.0 * 60.0);
+        snprintf(projection,
+                 sizeof(projection),
+                 "; at this rate every frame traps in roughly %.0f minutes",
+                 minutes < 1.0 ? 1.0 : minutes);
+    }
+    char buffer[384];
+    if (m_collectedRuntime)
+    {
+        snprintf(buffer,
+                 sizeof(buffer),
+                 "script heap grew %uMB over %u frames%s; the collector is "
+                 "running, so something is accumulating references (a "
+                 "growing array, map, or cache). RIVE_WASM_LEAK_WARN=0 "
+                 "silences this.",
+                 grownMB,
+                 frames,
+                 projection);
+    }
+    else
+    {
+        snprintf(buffer,
+                 sizeof(buffer),
+                 "script heap grew %uMB over %u frames%s; the stub runtime "
+                 "never frees, so per-frame allocations leak. Set "
+                 "wasmRuntime: frame. RIVE_WASM_LEAK_WARN=0 silences this.",
+                 grownMB,
+                 frames,
+                 projection);
+    }
     m_leakWarning = buffer;
     return m_leakWarning.c_str();
 }
@@ -5260,6 +5406,52 @@
     return "unknown";
 }
 
+// Growing linear memory reallocs it, which in-flight AOT frames do not
+// tolerate (fields root cause #3): on an artifact, take the module's whole
+// declared ceiling up front, while no frames are live, so it never grows
+// again. Modules without a declared ceiling keep growth-on-demand.
+static void pregrowAotMemory(wasm_module_inst_t instance)
+{
+    wasm_memory_inst_t memory = wasm_runtime_get_default_memory(instance);
+    if (memory == nullptr)
+    {
+        return;
+    }
+    uint32_t pages = (uint32_t)wasm_memory_get_cur_page_count(memory);
+    uint32_t maxPages = (uint32_t)wasm_memory_get_max_page_count(memory);
+    constexpr uint32_t kUnboundedPages = 65536;
+    if (maxPages >= kUnboundedPages)
+    {
+        fprintf(stderr,
+                "wasm aot: module declares no wasmMaxPages; memory cannot be "
+                "reserved up front, so growth during frames may trap\n");
+        return;
+    }
+    if (maxPages <= pages)
+    {
+        return;
+    }
+    if (!wasm_runtime_enlarge_memory(instance, maxPages - pages))
+    {
+        fprintf(stderr,
+                "wasm aot: pregrow to %u pages failed; growth during frames "
+                "may trap\n",
+                maxPages);
+    }
+}
+
+uint32_t WasmScriptingVM::memoryPages() const
+{
+    if (m_state == nullptr || m_state->instance == nullptr)
+    {
+        return 0;
+    }
+    wasm_memory_inst_t memory =
+        wasm_runtime_get_default_memory(m_state->instance);
+    return memory == nullptr ? 0
+                             : (uint32_t)wasm_memory_get_cur_page_count(memory);
+}
+
 const char* WasmScriptingVM::handleLeakWarning()
 {
     if (!m_handleWatch || !m_advancedOnce)
@@ -5284,7 +5476,11 @@
     {
         return nullptr;
     }
-    m_handleWatch = false;
+    // Re-arm so a leaking session keeps warning every 512 handles.
+    uint32_t grown = live + 1 - m_handleBaselineLive;
+    uint32_t frames = m_handleFrames;
+    m_handleBaselineLive = live + 1;
+    m_handleFrames = 0;
     uint32_t counts[(size_t)HandleTable::Tag::node + 1] = {0};
     for (const HandleTable::Slot& slot : m_handles.slots)
     {
@@ -5307,8 +5503,8 @@
              "script leaked %u host handles over %u frames (most: %u %s); "
              "resources created per frame need release() or finish(). "
              "RIVE_WASM_LEAK_WARN=0 silences this.",
-             live + 1 - m_handleBaselineLive,
-             m_handleFrames,
+             grown,
+             frames,
              counts[top],
              handleTagName((HandleTable::Tag)top));
     m_leakWarning = buffer;
@@ -5317,7 +5513,8 @@
 
 bool WasmScriptingVM::applyTierArtifact(Span<const uint8_t> artifactBytes,
                                         ExecutionTier tier,
-                                        std::string& error)
+                                        std::string& error,
+                                        bool hwBounds)
 {
     auto next = std::make_unique<WamrState>();
     next->artifactBytes.assign(artifactBytes.begin(), artifactBytes.end());
@@ -5331,6 +5528,10 @@
         error = std::string("artifact load failed: ") + loadError;
         return false;
     }
+    if (hwBounds)
+    {
+        wasm_runtime_set_module_hw_bounds(next->module, true);
+    }
     next->instance = wasm_runtime_instantiate(next->module,
                                               512 * 1024,
                                               0,
@@ -5354,6 +5555,12 @@
         return false;
     }
     wasm_runtime_set_user_data(next->execEnv, this);
+    if (!hwBounds)
+    {
+        // Guard-page memory never moves on growth; only the sw lane needs
+        // the ceiling reserved up front.
+        pregrowAotMemory(next->instance);
+    }
     m_state = std::move(next);
     m_tier = tier;
     return true;
@@ -5432,11 +5639,24 @@
            name.size() + 1);
 
     uint32_t requireArgs[2] = {m_L, namePtr};
-    uint32_t status = callModule("host_require", 2, requireArgs);
+    uint32_t status = 0;
+    CallOutcome outcome =
+        callModuleChecked("host_require", 2, requireArgs, &status);
 
     uint32_t freeArgs[1] = {namePtr};
     callModule("free", 1, freeArgs);
 
+    if (outcome != CallOutcome::ok)
+    {
+        // Folding a trap into "status 0" once read as a successful require
+        // with no generator; fail the require instead.
+        m_lastError = outcome == CallOutcome::trapped
+                          ? "module require trapped"
+                          : "module has no host_require export";
+        uint32_t topArgs[2] = {m_L, 0};
+        callModule("host_settop", 2, topArgs);
+        return false;
+    }
     if (status != 0)
     {
         uint32_t strArgs[2] = {m_L, (uint32_t)-1};
@@ -5540,12 +5760,6 @@
     {
         status = buf[0];
     }
-    if (m_frameArenaMark != 0)
-    {
-        // A later init's allocations must persist: re-take the mark at the
-        // next boundary instead of rewinding them away.
-        m_frameArenaPending = true;
-    }
     switch (status)
     {
         case 0:
@@ -5582,10 +5796,7 @@
     }
     if (!m_advancedOnce)
     {
-        // The mark waits for the first full frame so lazy statics and
-        // steady-state warmup growth land below it.
         m_advancedOnce = true;
-        m_frameArenaPending = m_frameArenaOptIn;
     }
     return results[0].of.i32 != 0;
 }
diff --git a/tests/premake5.lua b/tests/premake5.lua
index 0b96e41..c20f6d5 100644
--- a/tests/premake5.lua
+++ b/tests/premake5.lua
@@ -115,6 +115,18 @@
     end
 end
 
+-- Headless collector validation on device targets; a plain executable so it
+-- runs from adb shell without the APK harness. Wasm scripting only: the
+-- source names WasmScriptingVM, which other configurations never declare.
+if _OPTIONS['with_rive_scripting']
+    and (_OPTIONS['scripting_vm'] == 'wasm' or _OPTIONS['scripting_vm'] == 'both')
+then
+    rive_tools_project('wasm_gc_bench', 'ConsoleApp')
+    do
+        files({ 'wasm_gc_bench/wasm_gc_bench.cpp' })
+    end
+end
+
 rive_tools_project('player', 'RiveTool')
 do
     files({ 'player/player.cpp' })
diff --git a/tests/unit_tests/runtime/module_tier_ladder_test.cpp b/tests/unit_tests/runtime/module_tier_ladder_test.cpp
index e2f8a01..807aed1 100644
--- a/tests/unit_tests/runtime/module_tier_ladder_test.cpp
+++ b/tests/unit_tests/runtime/module_tier_ladder_test.cpp
@@ -54,7 +54,13 @@
 
     // Under the straight-to-O3 cutoff, so exactly the one artifact.
     CHECK(arrivals.load() == 1);
-    std::string path = ladder.artifactPath(key, TierSpecies::o3);
+    // Guard-page builds ride the hw species as their top rung.
+#ifdef RIVE_WASM_HW_BOUNDS
+    constexpr TierSpecies kTopSpecies = TierSpecies::hw;
+#else
+    constexpr TierSpecies kTopSpecies = TierSpecies::o3;
+#endif
+    std::string path = ladder.artifactPath(key, kTopSpecies);
     REQUIRE(!path.empty());
     CHECK(ladder.artifactPath(key, TierSpecies::o0).empty());
 
diff --git a/tests/wasm_gc_bench/wasm_gc_bench.cpp b/tests/wasm_gc_bench/wasm_gc_bench.cpp
new file mode 100644
index 0000000..466499b
--- /dev/null
+++ b/tests/wasm_gc_bench/wasm_gc_bench.cpp
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Headless on-device harness for the wasm scripting collector lanes: loads a
+// baked .riv, advances frames through the host frame boundary, and reports
+// timing, wasm memory, and live handle counts. No GPU; recording goes into a
+// deferred session that is never replayed.
+
+#include "rive/animation/state_machine_instance.hpp"
+#include "rive/file.hpp"
+#include "rive/renderer/cmd/deferred_session.hpp"
+#include "rive/wasm/wasm_scripting_vm.hpp"
+#include "utils/no_op_renderer.hpp"
+
+#include <algorithm>
+#include <chrono>
+#include <cstdio>
+#include <cstdlib>
+#include <fstream>
+#include <vector>
+
+#ifndef _WIN32
+#include <csignal>
+
+// SIGILL diagnostics for the device AOT lane: adb shell has no tombstone
+// access, so print the fault address before dying.
+static void onFault(int sig, siginfo_t* info, void*)
+{
+    fprintf(stderr, "gcbench fault: signal %d at %p\n", sig, info->si_addr);
+    fflush(stderr);
+    signal(sig, SIG_DFL);
+    raise(sig);
+}
+
+static void installFaultHandler()
+{
+    struct sigaction action = {};
+    action.sa_sigaction = onFault;
+    action.sa_flags = SA_SIGINFO;
+    sigaction(SIGILL, &action, nullptr);
+    sigaction(SIGSEGV, &action, nullptr);
+    sigaction(SIGBUS, &action, nullptr);
+}
+#else
+static void installFaultHandler() {}
+#endif
+
+using Clock = std::chrono::steady_clock;
+
+static double msSince(Clock::time_point start)
+{
+    return std::chrono::duration<double, std::milli>(Clock::now() - start)
+        .count();
+}
+
+int main(int argc, char** argv)
+{
+    installFaultHandler();
+    if (argc < 2)
+    {
+        fprintf(stderr, "usage: wasm_gc_bench <file.riv> [frames]\n");
+        return 1;
+    }
+    int frames = argc > 2 ? atoi(argv[2]) : 600;
+    if (frames <= 0)
+    {
+        fprintf(stderr, "frame count must be positive\n");
+        return 1;
+    }
+
+    std::ifstream in(argv[1], std::ios::binary);
+    std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)),
+                               std::istreambuf_iterator<char>());
+    if (bytes.empty())
+    {
+        fprintf(stderr, "cannot read %s\n", argv[1]);
+        return 1;
+    }
+
+    rive::cmd::DeferredSession session(rive::ore::ReplayCaps{});
+    rive::ImportResult result;
+    auto importStart = Clock::now();
+    auto file = rive::File::import(
+        rive::Span<const uint8_t>(bytes.data(), (size_t)bytes.size()),
+        &session,
+        &result);
+    double importMs = msSince(importStart);
+    if (result != rive::ImportResult::success || file == nullptr)
+    {
+        fprintf(stderr, "import failed\n");
+        return 1;
+    }
+    rive::WasmScriptingVM* vm = file->wasmScriptingVM();
+    if (vm == nullptr)
+    {
+        fprintf(stderr, "no wasm scripting vm in this file\n");
+        return 1;
+    }
+    // Multi-module files run one VM per module; boundaries and metrics
+    // must cover them all.
+    auto live = [&]() {
+        uint32_t count = 0;
+        for (auto& moduleVm : file->wasmVMs())
+        {
+            count += (uint32_t)(moduleVm->handles().slots.size() -
+                                moduleVm->handles().freeSlots.size());
+        }
+        return count;
+    };
+    auto pages = [&]() {
+        uint32_t count = 0;
+        for (auto& moduleVm : file->wasmVMs())
+        {
+            count += moduleVm->memoryPages();
+        }
+        return count;
+    };
+    printf("gcbench import: %.1fms, %u wasm pages\n", importMs, pages());
+
+    auto instance = file->artboardDefault();
+    if (instance == nullptr)
+    {
+        fprintf(stderr, "no default artboard\n");
+        return 1;
+    }
+    // Scripted content runs through the state machine when one exists.
+    auto machine = instance->defaultStateMachine();
+    printf("gcbench driving: %s\n",
+           machine != nullptr ? "state machine" : "artboard");
+
+    // Load-time boundary: init's survivors promote here, during load, so
+    // the first presented frame never pays for them.
+    auto loadCollect = Clock::now();
+    if (const char* notice = file->frameBoundary())
+    {
+        printf("gcbench notice: %s\n", notice);
+    }
+    printf("gcbench post-init collect: %.1fms\n", msSince(loadCollect));
+
+    std::vector<double> frameMs;
+    frameMs.reserve(frames);
+    uint32_t pagesBefore = pages();
+    uint32_t livePeak = 0;
+    for (int i = 0; i < frames; i++)
+    {
+        auto start = Clock::now();
+        if (machine != nullptr)
+        {
+            machine->advanceAndApply(1.0f / 60);
+        }
+        else
+        {
+            instance->advance(1.0f / 60);
+        }
+        // The draw pass runs the scripted render callbacks; recording goes
+        // nowhere but the script-side work is real.
+        rive::NoOpRenderer renderer;
+        instance->draw(&renderer);
+        if (const char* notice = file->frameBoundary())
+        {
+            printf("gcbench notice: %s\n", notice);
+        }
+        frameMs.push_back(msSince(start));
+        livePeak = std::max(livePeak, live());
+    }
+    std::sort(frameMs.begin(), frameMs.end());
+    double total = 0;
+    for (double ms : frameMs)
+    {
+        total += ms;
+    }
+    printf("gcbench frames: %d, advance+boundary mean %.3fms p50 %.3fms p95 "
+           "%.3fms max %.3fms\n",
+           frames,
+           total / frames,
+           frameMs[frames / 2],
+           frameMs[(size_t)(frames * 0.95)],
+           frameMs.back());
+    printf("gcbench memory: %u -> %u wasm pages\n", pagesBefore, pages());
+    printf("gcbench handles: %u live, %u peak\n", live(), livePeak);
+    return 0;
+}