Skip to content

fix: openExplorerUi verdict, request_id correlation and stopEmote's success value - #10096

Merged
popuz merged 11 commits into
devfrom
fix/sdk/explorer-ui-verdict-and-request-id
Sep 22, 2026
Merged

popuz merged 11 commits into
devfrom
fix/sdk/explorer-ui-verdict-and-request-id

Conversation

@popuz

@popuz popuz commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Pull Request Description

What does this PR change?

Three defects in openExplorerUi and the event stream it writes back to scenes, found in a manual pass. One commit each, so any can be dropped.

1. Two calls back to back both got OPENED and the panel was pushed onto the windows stack
twice. The verdict is now decided after the thread hop, behind a private showPending flag —
MVCManager.IsShowing reads controller.State, which flips only after popupCloser.HideAsync, so
MVC cannot answer for a panel that is still landing. The same commit fixes the case where the user
had opened the panel first: the continuation bailed before enqueuing, so the scene was told
OPENED and then received no events at all. No per-call CTS on purpose — SafeRestart would cancel
the first of two overlapping calls, which is the scenario being fixed.

2. request_id was dropped at the JS boundary and never written to the result, so a scene with
two sessions on one panel could not tell which close was whose. It now crosses as a uint and is
echoed on both events of the call that actually got the panel; an absent id collapses onto 0 at
the shim. Separately, timestamp was the tick the queue was drained on — it is now read at
enqueue, and WriteExplorerUiEventsSystem no longer takes ISceneStateProvider at all.

3. stopEmote returned a promise in the boolean success field. An await alone would resolve
to undefined, so bool is threaded through TryStopEmote → wrapper → shim. ⚠️ Behaviour
change:
success was an unresolved promise, i.e. always truthy; it is now false when the scene
is not the current one and the stop was refused.

ExplorerItemPurchaseResult / component 1222 arrive unused on purpose. The protocol repin pulls
them in, IAP is a separate effort, and EU_ITEM_PURCHASE answers REJECTED_FEATURE_DISABLED.
Repinning to @dcl/protocol@next is not an option — experimental carries AvatarEmoteMask and the
comms social-emote fields that main lacks, and dropping them breaks the emote system.

Related: decentraland/protocol#464 · decentraland/protocol#486 · decentraland/js-sdk-toolchain#1543

Test Instructions

Steps (standard run):

metaforge explorer run 10096

Point the build at the test scene — it is already deployed, nothing to build or install. Either
launch with these app params
(how):

--dclenv zone --realm sdk7testscenes.dcl.eth --position 80,-6

…or, if the build already runs on zone, type /goto sdk7testscenes.dcl.eth/80,-6 in chat.

Expected result: you land in front of four rows of labelled cubes, with two report panels drawn
on screen by the scene. Hovering a cube says what it does.

Where the results appear

The scene draws two separate report panels, and every step below says which one to read.

  • RIGHT panel — "Call results". What the scene asked for and was answered.
    🟩 OPENED granted · 🟨 WAS_ALREADY_OPEN refused because a panel is already up · 🟥 rejected.
  • LEFT panel — "ExplorerUiEventsResult". What actually opened and closed, e.g.
    MAP OPENED (tick 1953, req 101). The open and close of one panel share the same req, and the
    close shows a bigger tick.

Both are in-scene UI, not Explorer UI — they sit on top of whatever panel you open, so you can read
them without closing it.

Panels are closed with Esc — there is no button for it anywhere.

Test Steps

# Do Expect
1 Nothing — just arrive RIGHT: 🟥 ON-START(MAP) REJECTED_NO_USER_GESTURE (5)
2 Front row: click any cube, then Esc Panel opens · RIGHT: 🟩 OPENED · LEFT: OPENED, then CLOSED after Esc, same req, bigger tick
3 Row 2: DOUBLE CALL (teal), then Esc One panel opens · RIGHT: 🟩 OPENED and 🟨 WAS_ALREADY_OPEN · LEFT: req 101 on both lines
4 DOUBLE CALL a second time LEFT: the new open/close pair uses different req numbers than the first
5 Row 2: DELAYED (yellow), then touch nothing for 5 s RIGHT: 🟥 REJECTED_NO_USER_GESTURE (5)
6 Row 2: INVALID (99) (dark red) RIGHT: 🟥 REJECTED_FEATURE_DISABLED (4), instantly
7 Row 3: CLOSE(MAP), then Esc RIGHT: CLOSED (0 collected)
8 Row 3: CHAIN(BACKPACK), then Esc RIGHT: CLOSED [opened+closed]
9 Row 3: MATCH-OPENED(PLACES) RIGHT: MATCHED while the panel is still open
10 Row 3: TIMEOUT-3s(SETTINGS), do not press Esc RIGHT: TIMEDOUT after ~3 s, panel stays up
11 Row 3: DOUBLE(EVENTS) RIGHT: NOT_OPENED WAS_ALREADY_OPEN · one panel only
12 Back row: TRIGGER EMOTE, then STOP EMOTE Dance stops · RIGHT: success=true typeof=boolean
13 /goto sdk7testscenes.dcl.eth/80,-4 and use that scene Teleport, move-player, emotes, external URL and NFT dialog all work as usual — they share plumbing with this change

Additional Testing Notes

  • 🟨 is correct whenever any panel is open, not only the same one — panels sharing a slot exclude
    each other by design.
  • The LEFT panel only reports panels the scene opened. Ones you open from the Explorer menu never
    appear there.
  • In rows 7–11, nothing appearing in the RIGHT panel after you close the panel is a failure, not
    slowness.

Quality Checklist

  • Changes have been tested locally — 62 EditMode tests in CrdtEcsBridge.RestrictedActions.Tests
    (from 57); each fix has a regression that fails without it
  • Documentation has been updated (if required)
  • Performance impact has been considered
  • For SDK features: Test scene is included — feat: explorer UI events test scene at 80,-6 sdk7-test-scenes#98, live at
    sdk7testscenes.dcl.eth 80,-6

Brings in request_id on the explorer-ui request and on its result
component, plus the item-purchase panel value. The generated
ExplorerItemPurchaseResult binding lands unused on purpose: neither the
panel nor its result component is implemented.
The verdict was decided on the scene's JS thread and returned before the
work that shows the panel had run. Two calls in quick succession both got
OPENED although one panel opened, and a user opening the panel inside
that window left the scene holding an OPENED that emitted no events at
all — an await on the panel's life cycle never finished.

The restricted action is now asynchronous: it hops to the main thread,
decides there, answers, and shows. The slot a request has handed to MVC
counts as taken, because MVC reports a panel as showing only once the
view's life cycle has begun, which is later than the call to ShowAsync.

The cheap gates stay synchronous, so refusing a misbehaving scene still
costs no frame. There is no per-call cancellation token on purpose: a
second call is a legitimate request owed WasAlreadyOpen, and the house
pattern of restarting a shared token would cancel the first one instead.
…n tick

A scene could not tell which of its own calls a panel lifecycle event
belonged to. The id was dropped at the JS module boundary, which forwarded
only the panel value, and the echo field on the result component was never
written, so two waits live on the same panel settled on one close.

The id now travels from the request across the bridge, through the
restricted actions API and the explorer UI action, into the event queue and
out onto every event the request produces, on the close as well as the open.
A request that carries no id arrives as 0, the value the protocol reserves
for an uncorrelated one.

The tick is captured where the event happens instead of where the queue is
drained, which is a tick or more later and is not what the field is
documented to mean. With the tick riding on the event the writer no longer
reads the scene state at all.

EU_ITEM_PURCHASE stays unimplemented and keeps answering
REJECTED_FEATURE_DISABLED; the default branch of the ui mapping now says
that this is deliberate.
The JS module handed the bridge call's promise straight back as `success`,
so every scene read a truthy object no matter what happened. Awaiting it
alone would have resolved to undefined, since nothing along the chain
produced a value: TryStopEmote returned void and quietly did nothing when
the scene was not the current one. That refusal is the one outcome a scene
can act on, so the bool is carried from the API through the wrapper to the
module, and `success` is now the boolean the protocol's SuccessResponse
promises.
@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac built successfully in Unity Cloud.

Name Links & timing
Build 9c01e5a · Logs · built 2026-09-22T14:42:55Z
Windows GitHub job · Unity Cloud #6 · Unity log · ⏱ 25m 32s build + 8m 4s queue · Download .zip · .zip via S3
Mac GitHub job · Unity Cloud #6 · Unity log · ⏱ 23m 46s build + 4m 1s queue · Download .zip · .zip via S3

Lint

Warnings count reduced: 11875 => 11874

Lint run · full InspectCode report · took 27m 49s

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped Tests time Job time
EditMode ✅ Passed 26000 0 13 4m 15s 15m 10s
PlayMode ✅ Passed 256 0 37 46s 19m 34s

Tests time sums the test cases; Job time is the job's wall clock including checkout, licensing and asset import.

Slowest tests
  • [editmode] 16.5s DCL.AuthenticationScreenFlow.Tests.ProfileFetchingAuthStateShould.CancelStalledFetchOnTimeout
  • [editmode] 13.1s DCL.Tests.Editor.ValidationTests.CheckForDebugUsage
  • [editmode] 11.7s DCL.Tests.Editor.ValidationTests.CheckUnityObjectsForMissingReferences
  • [editmode] 10.0s DCL.Notifications.Tests.NotificationsRequestControllerShould.ReuseSingleListInstanceAcrossPollIterations
  • [editmode] 5.0s DCL.Friends.Tests.FriendsConnectivityStatusTrackerShould.RaiseOnlineEventWhenSameStatusIsRebroadcastAfterReset
  • [editmode] 5.0s CrdtEcsBridge.WorldSynchronizer.Tests.CrdtWorldSynchronizerShould.ThrowIfSyncBufferIsAlreadyRented
  • [editmode] 4.9s DCL.Tests.Editor.ValidationTests.SettingsAreValid
  • [editmode] 4.2s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(20,4000)
  • [editmode] 4.1s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(5,4000)
  • [editmode] 4.1s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(90,4000)
  • [playmode] 5.6s Global.Tests.PlayMode.CubeWaveSceneShould.EmitECSComponents
  • [playmode] 5.6s DCL.AvatarRendering.AvatarShape.Tests.AvatarBaseLegacyAnimationPlayModeShould.ReplaceEmoteAnimation_DoesNotEnableAnimator_WhileLegacyAnimationIsPlaying
  • [playmode] 2.9s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.ContinuousTweensRunIndefinitelyWhenDurationIsZero
  • [playmode] 2.1s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.RotateContinuousCompletesAfterDuration
  • [playmode] 2.1s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithoutLoopCompletesOnce
  • [playmode] 2.1s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.MoveContinuousMovesAndCompletesAfterDuration
  • [playmode] 2.1s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.TextureMoveContinuousOffsetCompletesAndUpdatesMaterial
  • [playmode] 2.0s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TextureMoveSequenceUpdatesMaterial
  • [playmode] 1.6s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithMoveRotateScaleWithOmittedScale_ResolvesScaleFromCurrentTransform
  • [playmode] 1.5s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.RotateContinuousPositiveAndNegativeYDirectionsAreOpposite

Full report: run summary · results + editor logs: editmode · playmode

Performance

🏁 Bare-metal benchmark finished — run #35742327217.

Full report

PR #10096, run #35742327217

Overall: ✅ no significant changes

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

How to read this table
  • Each build is measured 3 times, interleaved with the other build (change, baseline, change, baseline, ...) in the same session, so both see the same world content and machine state. The values are the median, and (min–max) is the lowest and highest of those runs.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a difference that passed every check: the runs are fully separated (every run of one build faster than every run of the other), and the median difference is at least 3% and at least 0.5 ms.
  • ⚪ within noise — the builds' runs overlap, or the difference is tiny; it cannot be told apart from random variation. Treat it as no change.
  • — informational — the 0.1% worst metrics average only the few worst frames of a run, so a single OS hiccup swings them by a lot; they are shown for context and never earn a verdict.
  • ⚠️ no verdict — the two builds' sessions were not comparable (very different sample counts, or too few usable runs), so no conclusion is drawn from them.
  • Exceptions per run — the average number of exceptions in a run's log, not counting teardown ones logged while the app quits. Flagged only on a difference of at least 2 per run and 1.5× the other build; exception kinds the baseline never threw are called out under the table. The Exception breakdown groups all of 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.
  • The Overall line at the top only reacts to a metric that moved on two or more machines, or by 10% or more on one — a single modest 🟢/🔴 cell can still be a statistical fluke.

Apple M1

Metric Baseline Change Δ Result
Samples 4239 (×3) 4126 (×3)
CPU average 21.1 ms (20.5–22.2) 21.6 ms (21.6–22.7) 0.5 ms ⚪ within noise
CPU 1% worst 187.7 ms (84.8–231.4) 229.8 ms (226.1–232.0) 42.1 ms ⚪ within noise
CPU 0.1% worst 240.3 ms (235.4–256.6) 238.2 ms (237.4–239.7) -2.0 ms — informational
GPU average 35.0 ms (34.3–36.6) 34.2 ms (33.8–34.4) -0.8 ms ⚪ within noise
GPU 1% worst 46.7 ms (45.3–46.8) 45.7 ms (45.3–45.9) -1.0 ms ⚪ within noise
GPU 0.1% worst 47.7 ms (46.2–48.0) 46.5 ms (46.5–46.9) -1.2 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Intel Core i5

Metric Baseline Change Δ Result
Samples 4409 (×3) 4324 (×3)
CPU average 20.2 ms (16.8–21.5) 20.8 ms (16.7–21.0) 0.5 ms ⚪ within noise
CPU 1% worst 352.8 ms (122.9–458.9) 413.5 ms (325.5–439.3) 60.7 ms ⚪ within noise
CPU 0.1% worst 545.7 ms (354.1–549.9) 482.9 ms (431.2–547.1) -62.8 ms — informational
GPU average 13.7 ms (10.7–13.9) 12.7 ms (9.2–12.8) -1.0 ms ⚪ within noise
GPU 1% worst 226.9 ms (48.0–258.1) 190.0 ms (127.0–228.1) -36.9 ms ⚪ within noise
GPU 0.1% worst 540.5 ms (291.3–541.6) 473.1 ms (382.6–545.1) -67.4 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Automation

On demand — comment /visual-tests on this PR to run the visual regression suite against its build.

popuz and others added 2 commits September 15, 2026 10:51
A comment-only pass over the openExplorerUi fixes. Every surviving fact now lives
in exactly one place: the meaning of requestId 0 and of the event tick stay on
ExplorerUiEvent, the showPending window stays at the check a cleanup would
"simplify" away, and the reason no SafeRestart token is taken stays next to the
call that breaks without it. The rest was narration, the same fact echoed across
layers, or tests restating their own names.

No logic, naming or formatting changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@popuz popuz self-assigned this Sep 15, 2026
@popuz
popuz marked this pull request as ready for review September 15, 2026 09:03
@popuz
popuz requested review from a team as code owners September 15, 2026 09:03
@decentraland-bot
decentraland-bot self-requested a review September 15, 2026 09:03

@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: openExplorerUi verdict, request_id correlation and stopEmote's success value

STEP 1 — Context & Scope

Inspected at base 06de13e1d490 / head 971a07d94f1f. Changed files span: ExplorerUiActions.cs, IExplorerUiActions.cs, RestrictedActionsAPIImplementation.cs, RestrictedActionsAPIWrapper.cs, IRestrictedActionsAPI.cs, RestrictedActions.js, ExplorerUiEvent.cs, WriteExplorerUiEventsSystem.cs, ECSWorldFactory.cs, SceneInstanceDeps.cs, three test files, five protocol .gen.cs files plus one .meta, and scripts/package.json + lockfile. Protocol gen files are auto-generated protobuf output.

Read surrounding files at the PR head: ExplorerUiActions.cs, WriteExplorerUiEventsSystem.cs, ExplorerUiEvent.cs, IExplorerUiActions.cs, IRestrictedActionsAPI.cs, RestrictedActionsAPIWrapper.cs, RestrictedActions.js. Verified assembly, lifecycle, and threading context.

STEP 2 — Root-cause check: PASS

Three defects, each addressed at its root:

  1. Race condition (verdict decided before state existed): OpenSection decided synchronously on the JS thread, before the main-thread hop, so two rapid calls both got OPENED. The fix moves the decision to after SwitchToMainThread and adds a showPending flag that covers the window between accepting the request and MVC reporting the panel as showing. This fixes the cause — not a workaround.

  2. request_id dropped: The JS bridge (RestrictedActions.js) forwarded only message.ui. The id now crosses the bridge as a uint, echoed on both UiOpened and UiClosed events. Root cause addressed.

  3. stopEmote returning promise as boolean: The JS shim returned the bridge call's promise object without await, so success was always a truthy object. Adding await and returning bool from TryStopEmote fixes the cause.

STEP 3 — Design & integration: PASS

MANDATORY OWNER SEARCH: No new long-lived systems, managers, or controllers are introduced. The showPending flag is a new field on the existing ExplorerUiActions class (created per scene in SceneInstanceDeps.cs). The scene owns its lifecycle; teardown cancels via disposeCts.Token. No new lifecycle owners needed.

TEARDOWN / CONSUMPTION TRACE:

  • showPending — set to true in OpenSectionAsync, cleared in ShowUntilClosedAsync's finally block (guaranteed even on cancellation/exception). No leak path.
  • ShowUntilClosedAsync.Forget() — intentionally detached because ShowAsync resolves when the panel closes, which is seconds/minutes later. The caller must return Opened immediately. Exceptions are caught: OperationCanceledException is swallowed, others logged via ReportHub. Opened is enqueued before the detached call; Closed is in the finally, so every Opened is paired with a Closed regardless of outcome. This matches CLAUDE.md §9 — detached async for work that outlives the caller's scope, with proper exception handling.
  • ISceneStateProvider removed from WriteExplorerUiEventsSystem — the tick now comes from the event itself, eliminating the drain-tick inaccuracy. Both the CRDT APPEND timestamp and the payload Timestamp use the event's tick, consistent with TriggerAreaHandlerSystem.

Threading: OpenSectionAsync hops to the main thread via UniTask.SwitchToMainThread(ct) before accessing showPending or mvcManager. ShowUntilClosedAsync runs entirely on the main thread. Queue.Enqueue is called only from the main thread. No thread-safety concern.

Deliberate absence of per-call CancellationTokenSource: Correct — SafeRestart would cancel the first of two overlapping calls, which is exactly the scenario being fixed. Scene teardown remains the only cancellation.

STEP 4 — Member audit: PASS

Member Consumers Notes
IExplorerUiActions.OpenSectionAsync RestrictedActionsAPIImplementation.TryOpenExplorerUiAsync (1) Replaces OpenSection; interface exists for test substitution — legitimate
IRestrictedActionsAPI.TryOpenExplorerUiAsync RestrictedActionsAPIWrapper.OpenExplorerUi (1) Same pattern as other async methods on the interface
IRestrictedActionsAPI.TryStopEmote (now bool) RestrictedActionsAPIWrapper.StopEmote (1) Return type change only; interface exists for test seam
ExplorerUiEvent.RequestId, .Tick ExplorerUiActions.Enqueue, WriteExplorerUiEventsSystem.Update, tests Consumed correctly

No single-use extraction or derived predicates that re-validate guaranteed state.

STEP 5 — Line-level review

No P0 or P1 issues found. Two P2 observations:

[P2] ExplorerUiEvent struct does not override Equals/GetHashCode — relies on ValueType.Equals (reflection-based). Works correctly for all-value-type fields but a manual override would be faster and more explicit. See inline suggestion.

[P2] EU_ITEM_PURCHASE not in JS enum — intentional (disabled feature); TryMapExplorerUi's default branch returns RejectedFeatureDisabled with a clarifying comment. No action needed.

STEP 6 — Complexity

COMPLEX — touches the scene runtime CRDT bridge, JS module system, shared interfaces, async patterns (new UniTask flow with showPending flag), and protocol bindings across 4+ files.

STEP 7 — QA assessment

YES — runtime code changes affecting scene-to-explorer panel interactions, emote stop behavior, and the JS/C# bridge.

STEP 8 — Non-blocking warnings

None. Main.unity is not modified.

Security review

Credentials: No secrets committed.
Input validation: requestId is a uint from the scene, guarded with != undefined defaulting to 0. ui validated by TryMapExplorerUi. No injection surface.
Authorization: All existing gates preserved (IsCurrent, gesture check, FeaturesRegistry, Communities allowlist).

Supply chain:

Dependency Version Publisher Scope Risk
@dcl/protocol 1.0.0-34848312390.commit-ca008c2 @dcl build (codegen) Low — exact commit pin, first-party
ExplorerItemPurchaseResult.gen.cs new protobuf codegen runtime (unused) Low — auto-generated, RejectedFeatureDisabled for its enum

Pin changed from caret range to exact version — improvement. No new native plugins, assemblies, or execution hooks.

DEPENDENCY_REVIEW: PASS


REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies the scene runtime CRDT bridge, JS module interop, async patterns with new showPending flag, and protocol bindings across multiple assemblies.
QA_REQUIRED: YES


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

@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.

@popuz
popuz requested a review from pravusjif September 15, 2026 09:16
@popuz
popuz requested a review from dalkia September 15, 2026 14:37

@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: openExplorerUi verdict, request_id correlation and stopEmote's success value

Inspected at head 3dcbac41 (base dev). The diff since the prior review (971a07d) is merge-only — no substantive code changes.


STEP 2 — Root-cause check: PASS

Three defects, each fixed at the root:

  1. Verdict race — OpenSection decided synchronously on the JS thread before the main-thread hop, so two rapid calls both saw IsShowing == false and both got OPENED. The fix moves the decision after SwitchToMainThread and adds a showPending flag that covers the window between ShowAsync being called and MVC reporting the view as showing. Root cause addressed.
  2. request_id dropped — the JS bridge forwarded only message.ui; the id now travels as a uint through the bridge, restricted-actions API, event queue, and onto both the open and close events. Root cause addressed.
  3. stopEmote promise-as-boolean — the JS shim returned the bridge promise object (always truthy) without await. TryStopEmote now returns bool, the shim awaits it, and success is the actual boolean the protocol promises. Root cause addressed.

STEP 3 — Design & integration: PASS

Owner search: No new long-lived units introduced. showPending lives on the existing ExplorerUiActions (created per scene in SceneInstanceDeps), which is the natural owner of the panel-show lifecycle for that scene.

Teardown trace:

  • showPending → set in OpenSectionAsync, cleared in ShowUntilClosedAsync's finally (guaranteed on success, cancellation, and exception). No leak path.
  • ShowUntilClosedAsync.Forget() — intentionally detached because ShowAsync resolves only when the panel closes. Exceptions caught: OperationCanceledException swallowed (sanctioned pattern), others logged via ReportHub. Opened enqueued before the detached call; Closed in finally — every open is paired with a close.
  • ISceneStateProvider removed from WriteExplorerUiEventsSystem — tick now travels on the event itself, eliminating drain-tick inaccuracy. Design improvement.

Threading: All reads/writes of showPending and all Queue.Enqueue calls occur after SwitchToMainThread. ShowUntilClosedAsync also runs on the main thread. No concurrent access.

No per-call CTS, intentionally: SafeRestart would cancel the first of two overlapping calls — the exact scenario being fixed. Scene teardown via disposeCts.Token remains the only cancellation vector. Correct.

STEP 4 — Member audit: PASS

No single-use accessors or derived predicates that re-validate guaranteed state. Enqueue helper has two call sites (open + close) and centralizes all four event fields — earns its keep.

STEP 5 — Findings

See inline comment below. One P2 carried forward from the prior review.

Checked and clean

Category Status
R1 hot-path alloc ✅ Static lambda in WriteExplorerUiEventsSystem; ExplorerUiEvent is a stack-allocated struct
R2 LINQ ✅ None
R4 ECS discipline ✅ WriteExplorerUiEventsSystem lost a dependency it didn't need; no new per-frame work
R5 structural changes ✅ No entity structural changes
R6 acquire/release ✅ Teardown traced above
R7 nullability ✅ No new nullable lies or forgiving operators
R8 root cause ✅ All three fixes address the actual defect
R9 logging ✅ ReportHub.LogException with ReportCategory.RESTRICTED_ACTIONS
R10 catch scope ✅ Inner try/finally + outer specific catches
R11 cancellation ✅ Caller's CT threaded through SwitchToMainThread; no per-call CTS by design
R12 abstraction ✅ No new single-impl interfaces
R13 reuse ✅ Enqueue centralizes; TryMapExplorerUi reused
R14 contract types ✅ ExplorerUiEvent is a readonly struct with value-type fields
R15 dead weight ✅ Removed stale ISceneStateProvider dep from writer
R16–R17 naming ✅ OpenSectionAsync, ShowUntilClosedAsync, Enqueue — clear responsibility
R18 magic numbers ✅ 0 for absent request-id is the protocol's documented default
R19 file hygiene ✅
R20 idioms ✅ readonly on all immutable fields
R21 MVC ✅ Panel lifecycle unchanged
R22 tests ✅ 5 new regression tests covering each bug; test scene linked
R23 comments ✅ Comments explain the code's own guarantees, no narration
R24 scope ✅ All files relate to the three stated fixes + protocol repin

Merge gates

  • R25 — Outstanding comments: 1 prior inline comment on ExplorerUiEvent.cs (P2: missing Equals/GetHashCode override) is still unresolved. Refreshed below with an updated suggestion.
  • R26 — QA: Awaiting QA. PR includes detailed test instructions and links sdk7-test-scenes #98.

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies async/UniTask patterns in the restricted-actions pipeline, the JS–C# bridge, ECS event writing, and cross-assembly interfaces.
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by Vitaly Popuzin (<@U03V3D7E0NL>) via Slack

@DafGreco DafGreco left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✔️ PR reviewed and approved by QA on both platforms following instructions playing both happy and un-happy path

Regressions for this ticket had been performed in order to verify that the normal flow is working as expected:

  • [ ✔️] Backpack and wearables in world
  • [ ✔️] Emotes in world and in backpack
  • [ ✔️] Teleport with map/coordinates/Jump In
  • [ ✔️] Chat and multiplayer
  • [ ✔️] Profile card
  • [ ✔️] Camera
  • [ ✔️] Skybox
  • [ ✔️] Settings

Evidence:

Image
20260916-1251-47.3336689.mp4
Image Image Image Image

@pravusjif pravusjif left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM! the stopEmote change is unrelated but I guess it helps with lowering warnings.

Discard the UniTask returned by the NSubstitute Received/DidNotReceive
assertions inside async tests (CS4014), drop the redundant DCL qualifier
in RestrictedActionsAPIWrapper, and give ExplorerUiEvent an explicit
IEquatable implementation instead of the reflection-based ValueType one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
popuz added a commit to decentraland/sdk7-test-scenes that referenced this pull request Sep 22, 2026
Adds `scenes/80,-6-explorer-ui-events`, a 2×2 scene covering the `openExplorerUi` restricted action
and the `ExplorerUiEventsResult` lifecycle stream the explorer writes back to the scene.

It exists to make four things checkable that were previously either broken or unobservable:

1. **The open verdict.** Two calls back to back both used to be granted `OPENED`, and the explore
   panel was pushed onto the windows stack twice.
2. **`request_id` correlation.** The id was dropped at the JS module boundary and never written to
   the result, so a scene with two sessions on the same panel could not tell which one a close
   belonged to.
3. **The per-event tick.** Events were stamped with the tick the queue happened to be *drained* on,
   which flattened distinct events onto one value.
4. **`stopEmote`'s result.** The `success` field held an unresolved promise — always truthy — instead
   of a boolean.

The explorer-side fixes are in decentraland/unity-explorer#10096. Against a build without them,
scenarios 4, 7 and 8 in the scene README fail and every request id in the HUD reads `0`, which is
exactly what makes this scene useful as a regression check.

## What the scene shows

Two HUD feeds report side by side, so nothing needs a log tail (everything is logged too, under the
`[openExplorerUi]`, `[explorerUiWait]`, `[explorerUiEvents]` and `[stopEmote]` prefixes):

- **Right — "Call results":** what each call was *told*. Green `OPENED`, yellow `WAS_ALREADY_OPEN`,
  red rejections.
- **Left — "ExplorerUiEventsResult":** what actually *happened* to the panels — one line per
  lifecycle event with the panel, the kind, the tick and the request id.

The two are deliberately not merged: a verdict says a request was accepted, an event says a panel
really appeared, and a `CLOSED` has no verdict counterpart at all — it arrives unprompted, including
when the player presses Esc.

Four rows of cubes running north from the spawn:

| Row | What it covers |
| --- | --- |
| Panels | One cube per `ExplorerUi` value — the happy path, no `requestId`, so both events report `req 0` |
| Gates | Calls the explorer must refuse: expired gesture window, a second call on a busy panel (ids 101/102), an unknown enum value |
| Wait helper | `openExplorerUiAndWait` across its whole outcome surface: `closed`, `matched`, `timedOut`, `notOpened` |
| stopEmote | Play an emote, stop it, print `typeof success` |

Both consumption styles run side by side on purpose: the raw drain over the grow-only value set and
the SDK helper. Every event the helper consumes must still appear in the left-hand HUD.

The full scenario list with expected results is in the scene's
[README](scenes/80,-6-explorer-ui-events/README.md).

## Testing

The scene deploys to `sdk7testscenes.dcl.eth` on SEPOLIA automatically from this PR — see the bot
comment below for the deep link.

**This needs a custom explorer build.** The fixes are not in any released client, so on a stock
build the DOUBLE CALL cube reports two `OPENED`s, the ids all read `0`, and STOP EMOTE prints
`typeof=object`. Run decentraland/unity-explorer#10096 to see the expected behaviour:

```bash
metaforge explorer run 10096
```

then connect with `--dclenv zone --realm sdk7testscenes.dcl.eth --position 80,-6`.

**Closing a panel is done with Esc** — there is no in-world button for it, and the scene never closes
a panel itself. That is what makes the `CLOSED` events worth watching.

## Before merging

The scene is pinned to a **branch build** of `@dcl/sdk`, `@dcl/js-runtime` and `@dcl/sdk-commands`
(`7.28.1-34832208486.commit-2bfb1a8`), because `openExplorerUiAndWait`, the `requestId` field and the
`ExplorerUiEventsResult` component are not in a published release yet.

The pin must move to a published version once decentraland/js-sdk-toolchain#1543 lands. Merge order:
js-sdk-toolchain#1543 → unity-explorer#10096 → repin here → merge this.

Parcels `80,-6`, `81,-6`, `80,-5`, `81,-5` are free; `npm run check-parcels` reports no collisions.
The scene sits directly south of `80,-4-restricted-actions`, which covers the rest of that API.
@popuz
popuz merged commit 3e69f84 into dev Sep 22, 2026
30 of 31 checks passed
@popuz
popuz deleted the fix/sdk/explorer-ui-verdict-and-request-id branch September 22, 2026 15:02
popuz added a commit to decentraland/js-sdk-toolchain that referenced this pull request Sep 23, 2026
Follow-up to #1511, as announced there. `openExplorerUiAndWait` opens an explorer panel and resolves once the session that call started ends.

This is a rewrite of what this PR originally proposed (`openExplorerUiAndWaitClose`). decentraland/protocol#464 added a second result component rather than new fields, which showed that this surface grows by **adding whole components** — the first version could not absorb that, so the helper was redesigned around correlation instead of guessing. The design notes live in the commit messages of `ae7413b` and `6c60912`.

## What changes

- New `@dcl/sdk/explorer-ui` subpath module (file-layout entry, same mechanism as `platform/`), exporting `openExplorerUiAndWait` plus re-exports of `ExplorerUi` / `OpenExplorerUiResult`, so one import covers the whole flow. Like `platform/`, it is not part of the playground-assets rollup, so none of the helper's own symbols reach `api.md`.
- **Sessions are correlated, not guessed.** Every call mints a `request_id` (a counter starting at 1; the wire's `0` means uncorrelated) and the explorer echoes it on the events that call produced. Listeners are armed *before* the RPC, so an event that lands while it is in flight is already observed. That deletes the anchor, the per-`ui` FIFO, the consumption high-water mark, the pre-RPC timestamp snapshot and the replay loop of the first version.
- **Graceful on an explorer that does not echo yet.** An uncorrelated lifecycle event falls back to matching on `ui`, which is unambiguous while panels cannot coexist (a second call for the same panel is answered `WAS_ALREADY_OPEN`). Where that fallback cannot be trusted — a panel that can coexist with another, or a channel with no `ui` field — the call rejects with an explicit message instead of attributing the event to the wrong session.
- **Channels are the extension point.** `channel(name, component)` wraps any grow-only result component, so a component added later needs no SDK release. `ExplorerUiEvents` and `ItemPurchase` ship ready-made. `collect` names the channels whose events are gathered, each tagged with its channel name; `until` (a channel, or `variant(channel, 'case')`) stops the wait early.
- **The panel closing is the terminal**, and it wins over `until`. `WaitOutcome` is `closed` | `matched` | `notOpened` | `timedOut` — the outcome is the outer axis, so `notOpened`/`timedOut` are written once and never change as channels are added. `$case` says *why the wait stopped*; `events` always holds everything collected, the stopping event included.
- **Non-`OPENED` verdicts resolve immediately** as `notOpened`: event delivery is owner-only, so a scene that did not open the panel never receives its `closed` — waiting would hang deterministically.
- **`timeoutMs` (no default) resolves with `$case: 'timedOut'`.** Implemented as a lazily added engine system (the scene runtime has no timers) that removes itself once no timed session remains. It stays optional because panels legitimately stay open for minutes; what omitting it costs is documented on the option.
- The promise never rejects on lifecycle grounds — only when an event cannot be attributed to any call, or when the RPC itself fails.
- **`ExplorerItemPurchaseResult` (1221) is declared grow-only** in `generateIndex.ts`. Without that allowlist entry it silently generates as LWW and same-tick events overwrite each other.
- **`code-to-composite` mock completed.** Its auto-mock returned an async function for any unknown property, which turned `OpenExplorerUiResult` into a function whose members are `undefined` — a scene using the helper hung forever while the command reported success. The mock now carries the real enum and answers `REJECTED_FEATURE_DISABLED`, matching the file's refuse-gracefully convention. Also fills in `copyToClipboard` and `stopEmote`.

## Protocol bump

`@dcl/protocol` moves to `1.0.0-34643020503.commit-6402953`, which carries decentraland/protocol#464. That is what adds the 69 lines to `api.md` — the new `PBExplorerItemPurchaseResult` message, the `EU_ITEM_PURCHASE` enum member and the `core::ExplorerItemPurchaseResult` entry in `componentDefinitionByName`. The regenerated `.crdt` snapshots in the diff come from the same bump.

## Verification

- `test/sdk/explorer-ui/openExplorerUiAndWait.spec.ts` — 24 tests, all green. Events are driven through the real incoming CRDT path (a transport feeding `APPEND_VALUE`), never `addValue`: the outgoing path hands `onChange` the whole set, which a scene never sees for a renderer-owned component. Tests read the minted id back from the mocked RPC rather than guessing it, and use a synthetic grow-only component to cover channel behaviour independently of 1221.
- Covered: id minting and RPC forwarding; foreign-id events ignored; two concurrent sessions with interleaved events; the uncorrelated `ui` fallback and both rejection paths; every non-`OPENED` verdict; the collected chain including its terminator; `until` by channel and by variant, including on a channel that is not collected; close beating `until`; `timedOut` with partial events and system self-removal; close beating a timeout that expires in the same tick; `timeoutMs: 0`; RPC failure; a re-wrapped channel not double-collecting; the name-conflict throw; events arriving while the RPC is in flight; the `undefined` delivered on `DELETE_ENTITY`.

## Trying it end to end

The helper needs an explorer that echoes `request_id`, which is decentraland/unity-explorer#10096. Against a client without it, the helper still works through the uncorrelated `ui` fallback, and only `EU_ITEM_PURCHASE` rejects.

decentraland/sdk7-test-scenes#98 exercises it live, next to a hand-rolled drain of the same component so both consumption styles stay covered. It is deployed, so no local build is needed: [jump in](http://decentraland.zone/jump/?dclenv=zone&realm=sdk7testscenes.dcl.eth&position=80,-6) and use the **Wait helper** row — the five cubes cover close, a collected chain, an early `until` match, a timeout, and a second call answered `WAS_ALREADY_OPEN`. Each outcome is printed to the scene's own HUD on the **right**; the **left** HUD shows the raw events underneath, and every event the helper consumes must still appear there.
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.

4 participants