Skip to content

fix: fall back to unconverted textures when the ktx native decoder cannot load - #9801

Draft
alejandro-jimenez-dcl wants to merge 4 commits into
mainfrom
bugsweep/ktx-unity-dll-load
Draft

fix: fall back to unconverted textures when the ktx native decoder cannot load#9801
alejandro-jimenez-dcl wants to merge 4 commits into
mainfrom
bugsweep/ktx-unity-dll-load

Conversation

@alejandro-jimenez-dcl

@alejandro-jimenez-dcl alejandro-jimenez-dcl commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Problem

DllNotFoundException: Unable to load DLL 'ktx_unity' from
KtxNativeInstance.CertifySupportedPlatform whenever a texture arrives from the
media-converter as image/ktx2 on a machine where the native plugin (or a transitive DLL
dependency) cannot be opened - AV quarantine, missing VC++ runtime, corrupted install.
Sentry UNITY-EXPLORER-NQS: 235 events past week, ongoing. Every KTX2 texture load on that
machine fails for the whole session, session after session, with no fallback.

Root cause

The KTX2-conversion pipeline is enabled based solely on the remote ktx2-conversion
feature flag, with no verification that the native decoder is actually loadable on this
machine and no fallback when it is not - a missing local capability probe for a
remotely-toggled native-dependent feature. The DLL load failure itself is environmental and
unfixable client-side; the permanent per-machine breakage is not.

Fix (~60 production LOC)

  • New KtxNativeSupport: cached static capability probe (KtxTexture.Open on garbage
    bytes - returns ErrorCode.LoadingFailed on a healthy lib, throws on a broken one);
    catches DllNotFoundException | EntryPointNotFoundException | NotSupportedException;
    one-time warning when unsupported; internal probe-override test seam +
    MarkUnsupported().
  • RequestHub.SetKTXEnabled: enabled && KtxNativeSupport.IsSupported - probe runs lazily,
    only when the flag is on.
  • GetTextureWebRequest: same gate in Initialize; a mid-session DllNotFoundException
    in ExecuteKtxAsync trips MarkUnsupported() and rethrows - subsequent requests
    self-heal onto the direct-URL path.
  • NFTShapePlugin: same gate, keeping the NFT content-info fetch consistent.
  • PerformanceBenchmark pins the probe true per-test (review finding: the real probe's
    debug-only error log would trip UTF's unexpected-error check in the perf lane).

Result: affected machines degrade gracefully to unconverted originals (more bandwidth, zero
user-visible breakage) instead of failing outright.

Test

New EditMode KtxNativeSupportShould (5 tests): routing to the original URL when the probe
reports unavailable (the repro), routing to the converter when available (over-disable
guard), probe caching, DllNotFound-in-probe handling, and the MarkUnsupported runtime trip.

Validation

Windows Unity 6000.4 EditMode lane at the pin: RED FAIL 1/5 as intended (request routed to
the converter URL at pin despite the failing probe; 4 by-construction guards passed) /
GREEN PASS 5/5.

Fixes #7832
Related: #5150 (autoclosed original of this signature, still occurring), #7634 (closed
duplicate), #9064 (context: registry-resolved com.unity.cloud.ktx 3.6.3)

Fixes #5150

…nnot load

## Problem

`DllNotFoundException: Unable to load DLL 'ktx_unity'` from
`KtxNativeInstance.CertifySupportedPlatform` whenever a texture arrives from the
media-converter as `image/ktx2` on a machine where the native plugin (or a transitive DLL
dependency) cannot be opened — AV quarantine, missing VC++ runtime, corrupted install.
Sentry UNITY-EXPLORER-NQS: 235 events past week, ongoing. Every KTX2 texture load on that
machine fails for the whole session, session after session, with no fallback.

## Root cause

The KTX2-conversion pipeline is enabled based solely on the remote `ktx2-conversion`
feature flag, with no verification that the native decoder is actually loadable on this
machine and no fallback when it is not — a missing local capability probe for a
remotely-toggled native-dependent feature. The DLL load failure itself is environmental and
unfixable client-side; the permanent per-machine breakage is not.

## Fix (~60 production LOC)

- New `KtxNativeSupport`: cached static capability probe (`KtxTexture.Open` on garbage
  bytes — returns `ErrorCode.LoadingFailed` on a healthy lib, throws on a broken one);
  catches `DllNotFoundException | EntryPointNotFoundException | NotSupportedException`;
  one-time warning when unsupported; internal probe-override test seam +
  `MarkUnsupported()`.
- `RequestHub.SetKTXEnabled`: `enabled && KtxNativeSupport.IsSupported` — probe runs lazily,
  only when the flag is on.
- `GetTextureWebRequest`: same gate in `Initialize`; a mid-session `DllNotFoundException`
  in `ExecuteKtxAsync` trips `MarkUnsupported()` and rethrows — subsequent requests
  self-heal onto the direct-URL path.
- `NFTShapePlugin`: same gate, keeping the NFT content-info fetch consistent.
- `PerformanceBenchmark` pins the probe true per-test (review finding: the real probe's
  debug-only error log would trip UTF's unexpected-error check in the perf lane).

Result: affected machines degrade gracefully to unconverted originals (more bandwidth, zero
user-visible breakage) instead of failing outright.

## Test

New EditMode `KtxNativeSupportShould` (5 tests): routing to the original URL when the probe
reports unavailable (the repro), routing to the converter when available (over-disable
guard), probe caching, DllNotFound-in-probe handling, and the `MarkUnsupported` runtime trip.

## Validation

Windows Unity 6000.4 EditMode lane at the pin: RED FAIL 1/5 as intended (request routed to
the converter URL at pin despite the failing probe; 4 by-construction guards passed) /
GREEN PASS 5/5.

Fixes #7832
Related: #5150 (autoclosed original of this signature, still occurring), #7634 (closed
duplicate), #9064 (context: registry-resolved com.unity.cloud.ktx 3.6.3)

Includes inspection-warning cleanup in all touched files.
@alejandro-jimenez-dcl
alejandro-jimenez-dcl requested review from a team as code owners August 19, 2026 12:20
@alejandro-jimenez-dcl alejandro-jimenez-dcl self-assigned this Aug 19, 2026
@decentraland-bot
decentraland-bot self-requested a review August 19, 2026 12:36
@alejandro-jimenez-dcl
alejandro-jimenez-dcl marked this pull request as draft August 19, 2026 12:49

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: fix: fall back to unconverted textures when the ktx native decoder cannot load


STEP 2 — Root-cause check

PASS. The PR correctly identifies and fixes the root cause: a missing local capability probe for a remotely-toggled native-dependent feature. The DLL load failure itself is environmental and unfixable client-side; the missing graceful degradation is the real defect. The fix does not swallow exceptions, null-check symptoms, or disable checks — it adds a proper capability probe and wires it into the three relevant decision points.

STEP 3 — Design & integration

PASS. Owner search completed.

New unit: KtxNativeSupport — a public static class with a cached bool? probe.

What it manages: Whether the current machine can load the ktx_unity native plugin — a machine-wide environmental fact, not an entity/scene/connection lifecycle.

Who already creates/destroys this capability: Nobody. There is no existing lifecycle owner for native-DLL availability. The closest pattern in the codebase is FeatureFlagsConfiguration (also static, also uses Initialize/Reset), which manages a similar global-singleton concern.

Could the logic live at an existing creation/destruction point? No. The probe is consumed from three independent call sites (RequestHub.SetKTXEnabled, GetTextureWebRequest.Initialize, NFTShapePlugin.InjectToWorld) across different assemblies. A static utility is the natural home for a cross-cutting, session-scoped, machine-level capability check. DI would add ceremony for something that has no dependencies, no lifecycle, and is genuinely process-global.

Guard layering is justified, not over-engineered:

  • SetKTXEnabled: prevents the flag from being true when the probe fails at boot — keeps the probe lazy (only runs when the feature flag is on).
  • Initialize: belt-and-suspenders per-request check — necessary because MarkUnsupported() can flip isSupported to false at runtime without updating the RequestHub.ktxEnabled field (which was captured at SetKTXEnabled time).
  • ExecuteKtxAsync catch: last-resort runtime trip for the edge case where the probe passes but a later Open() call hits a DLL load failure.

Each guard covers a distinct failure window. Removing the Initialize check would leave a gap between a runtime MarkUnsupported() and the session end.

Teardown/consumption trace:

  • NativeArray<byte> in Probe(): using-scoped, released on all paths. ✅
  • KtxTexture in Probe(): Dispose() called after Open() returns; correctly skipped if Open() throws (no native state exists). ✅
  • probeOverride + isSupported static fields: Reset() clears both in [TearDown] of every test. ✅
  • KtxTexture in ExecuteKtxAsync(): Dispose() in existing finally block (unchanged by this PR). ✅

STEP 4 — Member audit

Member Visibility Consumers Verdict
IsSupported (property) public RequestHub.SetKTXEnabled, GetTextureWebRequest.Initialize, NFTShapePlugin.InjectToWorld, 6 test methods Multi-consumer, appropriate
MarkUnsupported() internal ExecuteKtxAsync catch block, StayUnsupported_AfterRuntimeTrip test 2 consumers — correct scope
Reset() internal 4 test [SetUp]/[TearDown] methods Test-only cleanup — correct
probeOverride internal field 7 test assignments Test seam — correct scope

No single-use intermediates, no absent-≠-false conflation, no redundant guards.

STEP 5 — Line-level review

Findings are posted as inline comments below. Summary:

# Severity File Issue
1 P2 GetTextureWebRequest.cs:111 catch (DllNotFoundException) misses EntryPointNotFoundException — incomplete runtime self-healing
2 P2 NFTShapePlugin.cs:79 Stale isKtxEnabled snapshot — LoadNFTTypeSystem won't self-heal after runtime MarkUnsupported()
3 P2 KtxNativeSupport.cs:65-66 Global unityLogger.logEnabled = false is broad — consider more surgical suppression

STEP 6 — Complexity

COMPLEX — Introduces a new cross-cutting static utility, modifies the web request pipeline's texture routing logic, and touches feature-flag gating across multiple subsystems (RequestHub, GetTextureWebRequest, NFTShapePlugin, PerformanceBenchmark).

STEP 7 — QA assessment

QA_REQUIRED: YES — Changes affect runtime texture loading behavior. Users on machines with broken ktx_unity installations will experience different behavior (graceful fallback to unconverted originals instead of repeated failures).

STEP 8 — Non-blocking warnings

None. Main.unity is not modified.

CI Status

  • ✅ PlayMode tests pass
  • ❌ EditMode tests failed — infrastructure stall (watchdog triggered, xml files: 0, total: 0, passed: 0, failed: 0). No tests ran; this is a CI environment issue, not a code regression.
  • ⏳ Builds (windows64, macOS) and Lint still in progress.

Security review

No security issues found. The probe uses a locally-allocated 16-byte buffer (not attacker-controlled), internal test seams are properly scoped (only DynamicProxyGenAssembly2 and DCL.Editor via InternalsVisibleTo), and the design fails closed on any error.


REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Introduces a cross-cutting native capability probe wired into the texture request pipeline, feature-flag gating, NFT content routing, and performance test infrastructure.
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub

Comment thread Explorer/Assets/DCL/WebRequests/Texture/GetTextureWebRequest.cs Outdated
Comment thread Explorer/Assets/DCL/PluginSystem/World/NFTShapePlugin.cs
Comment thread Explorer/Assets/DCL/WebRequests/Texture/KtxNativeSupport.cs Outdated
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Build skipped — no changes detected under Explorer/.

Lint

No C# files changed — lint ratchet skipped.

Tests

⚠️ EditMode produced no results — the run likely crashed or timed out before finishing. Check the Unity Test / Test (editmode) job.

TESTS SUITE Result Passed Failed Skipped
EditMode ⚠️ No results
PlayMode ✅ Passed 236 0 37

@github-actions

Copy link
Copy Markdown
Contributor

🔍 Jarvis reviewed this PR and found no blocking issues, but assessed it as complex — human DEV review is still required before merging.

@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9801, run #32259906606

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times. The values are the median, and (min–max) is the lowest and highest of those runs — a wide range means the metric is noisy and small differences are not trustworthy.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a real difference: larger than both 3% and the run-to-run range.
  • ⚪ within noise — the difference is smaller than how much the build varies between its own runs, so it cannot be told apart from random variation. Treat it as no change.
  • Exceptions per run — the average number of exceptions in a run's log; more than the baseline is flagged 🔴 even when frame times look fine. The Exception breakdown under each table groups them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2313 (×3) 2200 (×3)
CPU average 38.6 ms (33.4–38.8) 40.5 ms (39.6–41.5) 1.9 ms ⚪ within noise
CPU 1% worst 322.6 ms (57.0–343.5) 468.3 ms (439.3–470.8) 145.8 ms ⚪ within noise
CPU 0.1% worst 344.1 ms (341.1–360.3) 493.1 ms (452.4–496.6) 149.0 ms 🔴 43% slower
GPU average 9.5 ms (9.2–9.6) 9.7 ms (9.3–9.8) 0.2 ms ⚪ within noise
GPU 1% worst 35.6 ms (23.5–37.7) 49.2 ms (47.9–49.6) 13.6 ms ⚪ within noise
GPU 0.1% worst 44.4 ms (39.8–45.0) 55.1 ms (54.1–58.1) 10.7 ms 🔴 24% slower
Exceptions per run 66 2 -64 🟢 fewer errors
Exception breakdown
Exception Baseline (3 runs) Change (3 runs)
[ENGINE] NullReferenceException 3 3
[ENGINE] ObjectDisposedException 3 3
[UI] DllNotFoundException 192 0

Apple M1

Metric Baseline Change Δ Result
Samples 4105 (×3) 4080 (×3)
CPU average 21.8 ms (21.8–22.9) 22.0 ms (21.8–23.1) 0.1 ms ⚪ within noise
CPU 1% worst 215.9 ms (215.7–217.7) 222.0 ms (218.6–231.3) 6.1 ms ⚪ within noise
CPU 0.1% worst 226.3 ms (222.9–228.8) 233.8 ms (233.8–241.3) 7.5 ms 🔴 3% slower
GPU average 2.5 ms (2.0–3.2) 2.5 ms (2.1–8.6) 0.1 ms ⚪ within noise
GPU 1% worst 34.3 ms (34.2–36.2) 34.7 ms (34.0–36.8) 0.4 ms ⚪ within noise
GPU 0.1% worst 36.3 ms (34.8–37.5) 36.0 ms (35.1–37.5) -0.3 ms ⚪ within noise
Exceptions per run 0 0 0 ⚪ none new

The package only logs the expected probe error under #if DEBUG (editor
and development builds), so muting Unity logging for it is unnecessary;
the ReportHub warning remains the only unsupported signal.

@alejandro-jimenez-dcl alejandro-jimenez-dcl left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approve

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.

3 participants