Skip to content

feat: forced wearables debug tool - #10160

Merged
dalkia merged 9 commits into
devfrom
feat/forced-wearables-debug-tool
Sep 24, 2026
Merged

dalkia merged 9 commits into
devfrom
feat/forced-wearables-debug-tool

Conversation

@dalkia

@dalkia dalkia commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Pull Request Description

What does this PR change?

Renders a wearable on your own avatar without owning it, so an unpublished or locally re-converted wearable can be checked in-world. Came out of verifying an abgen asset-bundle fix on a wearable the tester doesn't own.

ForcedWearables holds the session's faked set. SelfProfile.ProfileAsync applies it to every profile it hands out — which is what the player entity, and therefore the avatar, is built from.

While the set is non-empty, the session does not deploy a profile at all. Backpack saves, name changes and passport edits apply locally and are discarded on relaunch. Same approach --self-preview-wearables already takes for backpack publishing. This also covers forced emotes, which leak into deployed profiles on dev today.

Two ways in, mirroring the existing forced-emotes flags:

--self-force-wearables <csv> next to --self-force-emotes
DebugSettings.wearablesToAddToUserProfile editor-only, same guard as emotesToAddToUserProfile

Fixed at construction — no runtime mutation, no debug widget, no mid-session avatar rebuild.

Docs: self-force-wearables entry in docs/app-arguments.md.

Test Instructions

Steps (standard run):

metaforge explorer run 10160

Expected result: without the flag, nothing changes — every apply is an early return and profiles deploy normally.

Prerequisites

  • An account that does not own the test wearable (any normal account — it's a paid collection item)

Strawberry rabbit lolita dress (upper_body, unmistakable at a glance):

urn:decentraland:matic:collections-v2:0x9251f5c79923bc80e5dd8fc6d0c9fa02953aa622:0

Test Steps

  1. Launch with --self-force-wearables <urn above>. The avatar wears the dress from boot.
  2. Change your profile name, save.
  3. Relaunch without the flag. The dress must be gone — and so must the name change. That is the trade: nothing persists while the flag is on.
  4. Have a second client look at you in step 1: no dress. It was never deployed.

Additional Testing Notes

  • Step 3 is the regression that matters. The name change is the cheapest deploy path to exercise; the Backpack is the hardest.
  • Log line Profile deploy skipped: forced wearables or emotes are active fires on every suppressed save — worth tailing so a silent no-op isn't mistaken for a bug.
  • Do not use on a brand-new account: a first profile must be deployed to exist, and the flag blocks that.
  • Nothing replaces a same-category wearable at this layer, so the dress renders on top of the upper_body already worn. Expected.
  • Extended URN form (…:0:105) is shortened on the way in — behaviour identical.
  • Touches the SelfProfile constructor; ScreenRecorderTester and SelfProfilePlayground updated.

Quality Checklist

  • Changes have been tested locally
  • Documentation has been updated (if required)
  • Performance impact has been considered
  • For SDK features: Test scene is included

🤖 Generated with Claude Code

Renders wearables on the own avatar without owning them, so an unpublished
or locally converted wearable can be checked in-world.

ForcedWearables holds the session's set: SelfProfile applies it to every
profile it hands out (what the player entity, and therefore the avatar, is
built from) and strips it from anything about to be deployed.

Seeded the same way forced emotes are - --self-force-wearables or the
DebugSettings array - plus a "Forced Wearables" debug widget that edits the
set at runtime, with the avatar rebuilt on change.

The strip covers everything forced this session, not just the current set:
un-forcing does not un-equip, and the backpack copies the player entity's
wearables into IEquippedWearables, from where they would otherwise be
deployed. It sits in UpdateProfileAsync, the only path that reaches
IProfileRepository.SetAsync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dalkia
dalkia requested review from a team as code owners September 18, 2026 16:46
@github-actions
github-actions Bot requested a review from anicalbano September 18, 2026 16:46
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac built successfully in Unity Cloud.

Name Links & timing
Build c383102 · Logs · built 2026-09-23T14:29:14Z
Windows GitHub job · Unity Cloud #13 · Unity log · ⏱ 38m 22s build + 9m 8s queue · Download .zip · .zip via S3
Mac GitHub job · Unity Cloud #9 · Unity log · ⏱ 26m 51s build + 2m 1s queue · Download .zip · .zip via S3

Lint

Warnings count reduced: 11870 => 11858

Warnings/errors in files changed by this PR (24)
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:56  CSharpWarnings::CS8601  Possible null reference assignment
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:57  CSharpWarnings::CS8601  Possible null reference assignment
Assets/DCL/Profiles/Self/SelfProfile.cs:157  CSharpWarnings::CS8604  Possible null reference argument for parameter 'id' in 'DCL.Profiles.IProfileCache.Set'
Assets/DCL/Profiles/Self/SelfProfile.cs:179  CSharpWarnings::CS8604  Possible null reference argument for parameter 'id' in 'DCL.Profiles.IProfileCache.Set'
Assets/DCL/Profiles/Self/SelfProfile.cs:189  CSharpWarnings::CS8604  Possible null reference argument for parameter 'id' in 'DCL.Profiles.IProfileCache.Set'
Assets/DCL/Profiles/Self/SelfProfile.cs:206  CSharpWarnings::CS8604  Possible null reference argument for parameter 'id' in 'DCL.Profiles.IProfileCache.Set'
Assets/DCL/Profiles/Self/SelfProfile.cs:220  CSharpWarnings::CS8604  Possible null reference argument for parameter 'id' in 'DCL.Profiles.IProfileCache.Set'
Assets/DCL/Profiles/Self/SelfProfile.cs:172  CSharpWarnings::CS8604  Possible null reference argument for parameter 'id' in 'DCL.Profiles.ProfileRepositoryExtensions.GetAsync'
Assets/DCL/Profiles/Self/SelfProfile.cs:196  CSharpWarnings::CS8604  Possible null reference argument for parameter 'id' in 'DCL.Profiles.ProfileRepositoryExtensions.GetAsync'
Assets/DCL/Infrastructure/Global/Dynamic/ProfileContainer.cs:137  CSharpWarnings::CS8604  Possible null reference argument for parameter 'web3Provider' in 'DCL.PluginSystem.Global.GiftingPlugin.GiftingPlugin'
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:38  CSharpWarnings::CS8618  Non-nullable field 'Texture' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:34  CSharpWarnings::CS8618  Non-nullable field 'canvasRectTransform' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:35  CSharpWarnings::CS8618  Non-nullable field 'hud' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:39  CSharpWarnings::CS8618  Non-nullable field 'metadata' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:65  CSharpWarnings::CS8625  Cannot convert null literal to non-nullable reference type
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:65  CSharpWarnings::CS8625  Cannot convert null literal to non-nullable reference type
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:65  CSharpWarnings::CS8625  Cannot convert null literal to non-nullable reference type
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:65  CSharpWarnings::CS8625  Cannot convert null literal to non-nullable reference type
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:66  CSharpWarnings::CS8625  Cannot convert null literal to non-nullable reference type
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:34  InconsistentNaming  Name 'canvasRectTransform' does not match rule 'members_should_be_pascal_case'. Suggested name is 'CanvasRectTransform'.
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:35  InconsistentNaming  Name 'hud' does not match rule 'members_should_be_pascal_case'. Suggested name is 'Hud'.
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:39  InconsistentNaming  Name 'metadata' does not match rule 'members_should_be_pascal_case'. Suggested name is 'Metadata'.
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:32  InconsistentNaming  Name 'profileUrl' does not match rule 'members_should_be_pascal_case'. Suggested name is 'ProfileUrl'.
Assets/DCL/InWorldCamera/InWorldCamera/Playground/ScreenRecorderTester.cs:43  UnusedMember.Local  Method 'OnDestroy' is never used

Lint run · full InspectCode report · took 26m 14s

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped Tests time Job time
EditMode ✅ Passed 26022 0 13 4m 18s 22m 30s
PlayMode ✅ Passed 256 0 37 44s 14m 33s

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

Slowest tests
  • [editmode] 34.8s DCL.Tests.Editor.ValidationTests.CheckUnityObjectsForMissingReferences
  • [editmode] 16.5s DCL.AuthenticationScreenFlow.Tests.ProfileFetchingAuthStateShould.CancelStalledFetchOnTimeout
  • [editmode] 10.0s DCL.Notifications.Tests.NotificationsRequestControllerShould.ReuseSingleListInstanceAcrossPollIterations
  • [editmode] 9.6s DCL.Tests.Editor.ValidationTests.CheckForDebugUsage
  • [editmode] 5.0s DCL.Friends.Tests.FriendsConnectivityStatusTrackerShould.RaiseOnlineEventWhenSameStatusIsRebroadcastAfterReset
  • [editmode] 5.0s CrdtEcsBridge.WorldSynchronizer.Tests.CrdtWorldSynchronizerShould.ThrowIfSyncBufferIsAlreadyRented
  • [editmode] 4.4s DCL.Tests.Editor.ValidationTests.SettingsAreValid
  • [editmode] 4.1s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(30,4000)
  • [editmode] 4.1s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(20,4000)
  • [editmode] 4.1s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(5,4000)
  • [playmode] 5.3s Global.Tests.PlayMode.CubeWaveSceneShould.EmitECSComponents
  • [playmode] 3.6s DCL.AvatarRendering.AvatarShape.Tests.AvatarBaseLegacyAnimationPlayModeShould.ReplaceEmoteAnimation_DoesNotEnableAnimator_WhileLegacyAnimationIsPlaying
  • [playmode] 2.8s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.ContinuousTweensRunIndefinitelyWhenDurationIsZero
  • [playmode] 2.2s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithoutLoopCompletesOnce
  • [playmode] 2.2s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.RotateContinuousCompletesAfterDuration
  • [playmode] 2.1s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.MoveContinuousMovesAndCompletesAfterDuration
  • [playmode] 2.1s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.TextureMoveContinuousOffsetCompletesAndUpdatesMaterial
  • [playmode] 2.1s 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 #35874600659.

Full report

PR #10160, run #35874600659

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 4298 (×3) 4244 (×3)
CPU average 20.8 ms (20.8–21.1) 21.0 ms (20.3–21.1) 0.3 ms ⚪ within noise
CPU 1% worst 200.2 ms (196.4–202.6) 191.7 ms (64.8–200.2) -8.5 ms ⚪ within noise
CPU 0.1% worst 233.1 ms (232.5–259.3) 233.4 ms (232.8–233.9) 0.3 ms — informational
GPU average 33.2 ms (32.8–34.2) 34.0 ms (33.7–34.4) 0.8 ms ⚪ within noise
GPU 1% worst 43.9 ms (42.9–45.3) 45.4 ms (44.0–45.5) 1.5 ms ⚪ within noise
GPU 0.1% worst 45.3 ms (44.3–46.3) 46.2 ms (44.7–46.7) 1.0 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Intel Core i5

Metric Baseline Change Δ Result
Samples 4842 (×3) 4722 (×3)
CPU average 18.4 ms (15.6–19.5) 18.9 ms (16.6–19.6) 0.6 ms ⚪ within noise
CPU 1% worst 330.7 ms (35.1–392.6) 372.6 ms (271.0–428.7) 41.9 ms ⚪ within noise
CPU 0.1% worst 452.5 ms (88.7–494.2) 478.5 ms (444.4–514.4) 26.0 ms — informational
GPU average 11.7 ms (10.1–12.3) 11.3 ms (9.9–12.6) -0.5 ms ⚪ within noise
GPU 1% worst 240.9 ms (22.7–245.1) 178.4 ms (138.2–309.3) -62.5 ms ⚪ within noise
GPU 0.1% worst 454.0 ms (28.8–487.1) 466.5 ms (441.7–509.5) 12.5 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.

@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 — feat: forced wearables debug tool

Step 2 — Root-cause check

New feature (debug tool for wearing wearables without owning them). Not a fix — no root-cause concern. PASS

Step 3 — Design & integration

Owner search. ForcedWearables manages a session-scoped set of URNs applied to the self-profile. Searched:

  • SelfProfile — owns profile fetch/deploy. Cannot host the data: the debug widget also calls Add/Remove/Clear independently.
  • ProfileContainer.Create — composition root that wires both SelfProfile and the widget. Right place for construction.
  • No existing manager covers "session-only faked wearables" — IOwnedNftFilter, SELF_PREVIEW_WEARABLES, and IEquippedWearables are unrelated concerns.

ForcedWearables earns its existence: two independent callers (SelfProfile and the debug widget), a real invariant (everForced ⊇ wearables), and a Changed event. Inlining would scatter the state, not simplify it.

Teardown trace:

  • forcedWearables.Changed += RebuildAvatarWithForcedWearables (SelfProfile.cs:66) — subscribed in constructor
  • forcedWearables.Changed -= RebuildAvatarWithForcedWearables (SelfProfile.cs:73) — unsubscribed in Dispose
  • ProfileContainer.Dispose() → SelfProfile.Dispose() — symmetric ✅

ProfilePropagated deliberately skipped. RebuildAvatarWithForcedWearables calls UpdateAvatarInWorld but not ProfilePropagated. Correct — ProfilePropagated feeds MultiplayerContainer.OnSelfProfilePropagated, which propagates to other players. Forced wearables are local-only by design.

Security invariant verified. ProfileBuilder.Build() copies URNs element-by-element into a fresh Avatar.wearables set, so ApplyTo/RemoveFrom mutating the profile cannot cross-contaminate other cached/pooled profiles. UpdateProfileAsync strips before every SetAsync, and TryGetBackingSet gates both apply and strip — if the cast fails, neither can execute, so the invariant holds: nothing applied → nothing to strip. The debug widget is excluded from non-debug builds by DebugUtilitiesContainer's allowedCategories gate.

Design: PASS

Step 4 — Member audit

Member Consumers Verdict
ForcedWearables.Wearables (line 29) 0 Unused — see inline comment
ForcedWearables.Add/Remove/Clear Debug widget (ProfileContainer.cs:159-161) OK — single consumer, but exposed for the widget
ForcedWearables.ApplyTo/RemoveFrom SelfProfile (4 call sites) OK
ForcedWearables.Changed SelfProfile (subscribe/unsubscribe) OK — symmetric
RebuildAvatarWithForcedWearables Changed event only OK — private

Step 5 — Line-level findings

See inline comments below. Summary:

# Sev Rule File Finding
1 P1 R22 (repo-wide) No tests for ForcedWearables
2 P2 R13 ProfileContainer.cs Parsing helpers duplicate the emotes ones
3 P2 R15 ForcedWearables.cs:29 Wearables property has zero consumers
4 P2 R23 SelfProfile.cs:135 Comment narrates backpack behavior
5 P2 R7 ForcedWearables.cs:100 null! in TryGetBackingSet out-param — justified (Try-pattern)

Categories checked clean: R1 perf-alloc (all paths are boot-only or user-triggered, not per-frame), R2 LINQ (mirrors existing emotes pattern at boot, not hot path), R4 ECS lifecycle (no ECS systems introduced), R5 entity by-ref (no entity manipulation), R6 teardown (symmetric), R7 nullability (sound), R8 root-cause (n/a — new feature), R9 logging (ReportHub.LogError with ReportCategory.PROFILE — correct), R10 catch-scope (no new catch blocks), R11 async (no new async code), R12 single-impl (no new interfaces), R14 contract honesty (OK), R16-R19 naming/hygiene (OK), R20 idiom (no Concurrent*, readonly on fields — correct), R21 MVC (n/a), R24 scope (all files relate to the stated change).

Step 6 — Complexity

COMPLEX — new class with state management, touches profile lifecycle (SelfProfile constructor + UpdateProfileAsync), modifies the profile deploy path, adds debug widget integration.

Step 7 — QA assessment

QA_REQUIRED: YES — runtime code under Explorer/, affects avatar rendering and profile deploy paths.

Step 8 — Non-blocking warnings

No Main.unity modifications. No warnings.

Merge gates

R25 — Outstanding comments: This is the first review. 5 inline findings posted below (1× P1, 4× P2).

R26 — QA sign-off: Awaiting QA. PR does not carry no QA needed. Test instructions are present and followable.

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: New stateful class touching profile lifecycle, deploy path, and debug widget integration
QA_REQUIRED: YES


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

Comment thread Explorer/Assets/DCL/Profiles/Self/ForcedWearables.cs
Comment thread Explorer/Assets/DCL/Infrastructure/Global/Dynamic/ProfileContainer.cs Outdated
Comment thread Explorer/Assets/DCL/Profiles/Self/ForcedWearables.cs Outdated
Comment thread Explorer/Assets/DCL/Profiles/Self/SelfProfile.cs Outdated
Comment thread Explorer/Assets/DCL/Profiles/Self/SelfProfile.cs Outdated
dalkia and others added 2 commits September 18, 2026 14:38
- Add ForcedWearablesShould: apply/strip round-trip, the everForced
  retention that keeps an un-forced wearable strippable, URN shortening,
  empty-URN rejection, Changed firing only on real mutations, and a pin on
  Avatar.Wearables' backing type so the cast cannot regress silently (R22).
- Fold the wearable and emote URN parsing into one pair of helpers (R13).
- Drop the unused ForcedWearables.Wearables property (R15).
- Trim comments that narrated backpack and debug-widget behaviour (R23).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Launch arguments and the editor debug settings cover the use case, so the
widget and its debug category go.

Without a runtime caller the mutation API had no consumers: Add/Remove/Clear
and the Changed event are gone, and with them SelfProfile's subscription and
avatar rebuild. The set is fixed at construction, so tracking what was
forced earlier in the session no longer says anything the current set does
not - everForced collapses into it.

ForcedWearables stays a type rather than a plain IReadOnlyList<URN> for the
test seam: the strip is the safety property worth covering, and reaching it
through SelfProfile would need the repository, identity cache and world.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dalkia

dalkia commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up on a design change since the review: the debug widget is gone (9bcf63d). Launch arguments and the editor debug settings cover the use case, so the panel entry and its FORCED_WEARABLES category were removed.

That invalidates part of the Step 3 design note, which justified ForcedWearables partly on "two independent callers" and the Changed event. With no runtime caller, Add/Remove/Clear and Changed had zero consumers — the same R15 rule applied to Wearables — so they are gone too, along with SelfProfile's subscription and avatar rebuild. The set is now fixed at construction, which also collapses everForced into wearables: tracking what was forced earlier in the session no longer says anything the current set does not.

ForcedWearables stays a type rather than a plain IReadOnlyList<URN> for the test seam — the strip is the safety property worth covering, and reaching it through SelfProfile would need the repository, identity cache and world. Tests dropped from 11 to 8 accordingly; StripAForcedWearableTheProfileAlreadyCarries now covers the laundering case that everForced used to.

PR description and test steps updated to match.

Drop 6 redundant usings, 4 redundant null-forgiving operators, declare the
lazily-created ScreenRecorderTester.recorder nullable, and initialize
DebugSettings.appParameters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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

Review — PR #10160: feat: forced wearables debug tool

STEP 2 — Root-cause check: PASS

The PR solves a real problem: there is no way to check an unowned wearable (e.g. a locally converted asset bundle) on your own avatar. The approach is direct — inject into the profile on read, strip before deploy — rather than a workaround. Not a symptom fix.

STEP 3 — Design & integration

MANDATORY OWNER SEARCH — ForcedWearables:

  1. It manages the lifecycle of a session-scoped set of wearable URNs applied to and stripped from profiles.
  2. The existing profile lifecycle is owned by SelfProfile (construction via ProfileContainer.Create, disposal via ProfileContainer.Dispose). The profile fetch path (ProfileAsync) and deploy path (UpdateProfileAsync → IProfileRepository.SetAsync) are both in SelfProfile.
  3. ForcedWearables could be inlined into SelfProfile, but it earns its existence through the test seam: ForcedWearablesShould tests apply/strip logic in isolation without constructing the full SelfProfile dependency tree. The TryGetBackingSet cast + error path is non-trivial and worth covering independently.

Teardown trace:

  • SelfProfile subscribes to web3IdentityCache.OnIdentityCleared and OnIdentityChanged in constructor (lines 64-65), unsubscribes in Dispose (lines 70-71). ✓
  • ForcedWearables has no subscriptions, no disposables, no teardown needed. ✓
  • No new subscriptions, events, or pools in the diff.

R13 consolidation: The diff refactors ParseDebugForcedEmotes/ParseParamsForcedEmotes into generic ParseDebugUrns/ParseParamsUrns. The old ParseParamsForcedEmotes had a dead ?. on string.Split() and a ?? ArraySegment<URN>.Empty fallback (Split never returns null). The refactored version correctly drops both. Behavior-preserving.

Assembly boundary note: Avatar.wearables is internal to DCL.SharedAPI. ForcedWearables reaches the backing HashSet<URN> through a cast on the public IReadOnlyCollection<URN> view. This is fragile by nature, but the PR defends it well: TryGetBackingSet reports an error rather than silently no-oping, and the KeepAvatarWearablesBackedByAHashSet test pins the backing type in CI.

STEP 4 — Member audit

New public members in ForcedWearables:

  • ApplyTo(Profile) — called in 3 places in SelfProfile (ProfileAsync, and both success paths of UpdateProfileAsync). Correct.
  • RemoveFrom(Profile) — called in 1 place in SelfProfile (UpdateProfileAsync before SetAsync). Correct.
  • Constructor — called in 3 places: ProfileContainer.Create, ScreenRecorderTester.CreateProfile, SelfProfilePlayground. All correct.

No single-use derived predicates, no redundant guards.

STEP 5 — Line-level findings

See inline comments below.

STEP 6 — Complexity: COMPLEX

Touches profile fetch/deploy pipeline, adds a new type with safety-critical strip-before-deploy logic, modifies SelfProfile constructor and both deploy paths, refactors shared parse helpers.

STEP 7 — QA: YES

Runtime code change affecting avatar rendering and profile deployment. Testable on both platforms.

STEP 8 — Non-blocking warnings

No main scene modification. ✓

Review-bar rule coverage

Category Status
R1/R2 perf-alloc Clean — LINQ in ParseDebugUrns/ParseParamsUrns is startup-only
R3 class-in-struct N/A
R4 ECS lifecycle Clean — no per-frame work, no new systems
R5 Entity by-ref N/A
R6 Acquire/release Clean — no new subscriptions or disposables
R7 Nullability P2 on null! out param (see inline)
R8 Root cause PASS
R9 Logging Clean — ReportHub.LogError with correct ReportCategory.PROFILE
R10 Catch-scope N/A — no new catches
R11 Async/CT N/A — no new async
R12 Abstraction PASS — ForcedWearables earns its test seam
R13 Reuse PASS — parse helpers consolidated
R14 Contract honesty Clean
R15 Dead weight Clean — unused imports removed
R16/R17 Naming Clean
R18 Magic numbers Clean
R19 File hygiene Clean
R20 Idiom cluster Clean — readonly on fields ✓
R21 MVC N/A
R22 Tests P0 — 3 tests fail in CI
R23 Comments P2 — see inline
R24 Scope Clean — all files relate to forced wearables

Merge gates

  • R25 — Outstanding comments: 5 prior review comments from decentraland-bot, all addressed by the author in subsequent commits. 0 unresolved threads.
  • R26 — QA sign-off: Awaiting QA. No no QA needed label. PR description includes detailed test instructions.

REVIEW_RESULT: FAIL ❌
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies profile fetch/deploy pipeline in SelfProfile, adds safety-critical strip-before-deploy logic, refactors shared URN parse helpers.
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by Juan Ignacio Molteni (<@U03JSUQ5Z7U>) via Slack

Comment thread Explorer/Assets/DCL/Profiles/Tests/ForcedWearablesShould.cs Outdated
Comment thread Explorer/Assets/DCL/Profiles/Tests/ForcedWearablesShould.cs Outdated
Comment thread Explorer/Assets/DCL/Profiles/Tests/ForcedWearablesShould.cs Outdated
Comment thread Explorer/Assets/DCL/Profiles/Self/ForcedWearables.cs Outdated
Comment thread Explorer/Assets/DCL/Profiles/Self/SelfProfile.cs Outdated
Comment thread Explorer/Assets/DCL/Profiles/Self/ForcedWearables.cs Outdated
dalkia and others added 2 commits September 21, 2026 15:26
URN converts implicitly to string, so Does.Contain(new URN(...)) bound to
the string overload and compared URN elements against a string. The
positive assertions always failed and the negative ones passed vacuously.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Trim the class summary and the strip comment to what the annotated code
itself does, and declare TryGetBackingSet's out parameter as nullable
with NotNullWhen(true) instead of assigning null!.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@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 — feat: forced wearables debug tool (Round 3)

STEP 2 — Root-cause check: PASS

New feature: adds a debug mechanism for rendering wearables on the own avatar without owning them (e.g. verifying a locally converted asset bundle). Not a fix — no root-cause concern.

STEP 3 — Design & integration: PASS

Owner search. ForcedWearables manages a session-scoped set of wearable URNs to inject/strip on the self profile.

  • Profile creation/deployment owner: SelfProfile — owns ProfileAsync (read) and UpdateProfileAsync (deploy). Both are the natural home for apply/strip. Confirmed: ForcedWearables.ApplyTo is called in ProfileAsync (line 87) and at both post-save sites (lines 166, 194). ForcedWearables.RemoveFrom is called in UpdateProfileAsync (line 134), immediately before the deploy path. IProfileRepository.SetAsync is called only from SelfProfile — no unguarded deploy path exists.
  • Construction owner: ProfileContainer.Create (composition root) — constructs ForcedWearables from app args and debug settings, injects it into SelfProfile. Correct placement.
  • Lifecycle: Fixed at construction, read-only thereafter. No subscriptions, no event hookups, no disposables. No teardown needed.

Should ForcedWearables be a separate class? Yes. One consumer (SelfProfile), but it encapsulates the safety-critical strip-before-deploy invariant and provides the test seam through which ForcedWearablesShould validates apply/strip round-trips, extended URN shortening, and the backing-type pin — all without needing the full SelfProfile dependency graph (repository, identity cache, world). R12 is satisfied by the test seam justification.

Asymmetry with forced emotes: Forced emotes are applied inline in ProfileAsync (slot replacement) with no strip before deploy — emotes don't require the same safety gate because CreateNewProfileForUpdate rebuilds emotes from IEquippedEmotes, not from the profile's emote array. Forced wearables follow a different path (Backpack copies URNs into IEquippedWearables, from where CreateNewProfileForUpdate would carry them into the deployed profile), so the strip is necessary and justifies the dedicated type.

TryGetBackingSet cast: Avatar.wearables is internal readonly HashSet<URN> (Avatar.cs:13), exposed as public IReadOnlyCollection<URN> Wearables (Avatar.cs:21). The internal field is scoped to the SharedAPI assembly and not reachable from the Profiles assembly. The cast through IReadOnlyCollection is pragmatic given the assembly boundary; the KeepAvatarWearablesBackedByAHashSet test (line 114) pins it in CI, and ReportHub.LogError surfaces a backing-type change at runtime. The symmetric failure property (both ApplyTo and RemoveFrom use the same cast) means a backing-type change disables both injection and stripping — forced wearables cannot enter a profile that can't be stripped.

STEP 4 — Member audit

Member Consumers Verdict
ForcedWearables.ApplyTo(Profile) 3 (SelfProfile: lines 87, 166, 194) Justified — injects at read time and after both save paths
ForcedWearables.RemoveFrom(Profile) 1 (SelfProfile: line 134) Justified — the deploy-time strip is the safety gate
TryGetBackingSet (private) 2 (ApplyTo, RemoveFrom) Justified — shared cast + error reporting
DebugSettings.WearablesToAddToUserProfile 1 (ProfileContainer.Create) Mirrors EmotesToAddToUserProfile
AppArgsFlags.FORCED_WEARABLES 1 (ProfileContainer.Create) Mirrors FORCED_EMOTES

No single-use-then-merge candidates. No absent-vs-false confusion. No redundant guards.

STEP 5 — Line-level findings

See inline comments below. Two P2 findings.

STEP 5b — Review bar (R1–R26)

Lint pre-flight: scripts/lint/custom-rules.sh not present on this branch — rules checked by hand.

Category Result
R1 perf-alloc Clean — no hot-path code added. LINQ in ParseDebugUrns/ParseParamsUrns runs once at startup
R2 no-LINQ Clean — LINQ is in container construction only
R3 class-in-struct Not applicable
R4 ECS discipline Clean — ForcedWearables is a plain service, no systems or queries
R5 entity by-ref Not applicable
R6 acquire/release Clean — no subscriptions, connections, or disposables added
R7 nullability One P2 finding (see inline)
R8 root cause PASS (new feature)
R9 logging Clean — ReportHub.LogError with ReportCategory.PROFILE
R10 catch-scope Not applicable (no new catches)
R11 async/CT Clean — no new async flows introduced
R12 abstraction ForcedWearables justified by test seam (see Step 3)
R13 reuse Clean — ParseParamsUrns/ParseDebugUrns generalization removes duplication
R14 contract honesty The IReadOnlyCollection → HashSet cast is technically a contract violation, but pragmatically justified by the assembly boundary and pinned by CI test
R15 dead weight Clean — unused imports removed across 3 files, no dead members remain
R16/R17 naming Clean — PascalCase, CQRS verbs (ApplyTo/RemoveFrom/TryGetBackingSet)
R18 magic numbers None
R19 file hygiene Clean — one class per file, correct placement
R20 idiom cluster Clean — readonly fields, no concurrent collections, no Debug.Log
R21 MVC Not applicable
R22 tests 8 tests covering apply, strip, cross-path strip, URN shortening, empty URN filtering, no-op, and backing-type pin. One minor gap noted inline.
R23 comments One P2 finding (see inline)
R24 scope Clean — all files relate to the forced wearables feature or the shared URN parsing generalization

STEP 6 — Complexity: COMPLEX

Modifies profile infrastructure (SelfProfile, ProfileContainer), introduces a new dependency (ForcedWearables), and touches the async profile deployment path.

STEP 7 — QA assessment: YES

Runtime code that affects avatar rendering and profile deployment.

STEP 8 — Non-blocking warnings

No warnings. Main scene not modified.

Merge gates

  • R25 — Open threads: All 11 prior bot comments have author replies with fixes (commits d972016, f121641, 26c15be). 0 unresolved prior threads. This review adds 2 new P2 inline comments.
  • R26 — QA: No no QA needed label. PR carries detailed, followable test instructions with a concrete wearable URN and step-by-step verification. Testable on both platforms in the same build. Awaiting QA.

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies async profile deployment paths (SelfProfile.UpdateProfileAsync), profile container wiring, and introduces cross-assembly type dependency (Avatar.Wearables backing set)
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by Juan Ignacio Molteni (<@U03JSUQ5Z7U>) via Slack

Comment thread Explorer/Assets/DCL/Profiles/Self/ForcedWearables.cs Outdated
dalkia and others added 3 commits September 22, 2026 16:47
A session with forced wearables (or emotes) no longer deploys a profile at
all, replacing the strip-before-deploy path. Every deploy route builds its
profile from one ProfileAsync handed out, so suppressing the deploy covers
all of them at once, mirroring how --self-preview-wearables already
suppresses backpack publishing.

Drops ForcedWearables.RemoveFrom and both post-deploy re-applies. Also
closes the pre-existing forced-emote leak, which had no strip counterpart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@balducciv

Copy link
Copy Markdown

Both Mac and Windows test steps work as expected using the latest build ✅
Smoke test on both platforms ✅

Note: The name change takes effect, but somehow it takes a while longer to appear in the nametag; the name change can be seen in the profile.
When executing the build without the args, the name change is disregarded

  • Launch with --self-force-wearables . The avatar wears the dress from boot.
  • Change your profile name, save.
  • Relaunch without the flag. The dress must be gone — and so must the name change. That is the trade: nothing persists while the flag is on. :
  • Have a second client look at you in step 1: no dress. It was never deployed.

Mac
Player-prev.log
Player.log

PR.10160.name.change.mp4
PR.10160.name.change.mp4

Windows
Player PR 10160 windows args 2.log
Player PR 10160 windows no args 2.log

23.09.2026_16.46.25_REC.name.change.+.no.args.windows.mp4

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

LGTM

@dalkia
dalkia merged commit bcd2291 into dev Sep 24, 2026
47 of 55 checks passed
@dalkia
dalkia deleted the feat/forced-wearables-debug-tool branch September 24, 2026 17:16
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