Skip to content

feat: font_src on TextShape, UiText, UiInput and UiDropdown - #10118

Closed
eordano wants to merge 24 commits into
devfrom
feat/font-src
Closed

eordano wants to merge 24 commits into
devfrom
feat/font-src

Conversation

@eordano

@eordano eordano commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

What does this PR change?

Adds font_src to TextShape, UiText, UiInput and UiDropdown: scenes can use bundled TTF files or a Google Fonts family name resolved through Fontsource. Unsupported or failed sources fall back to the built-in font. Arbitrary URLs and OTF files are not supported.

Protocol: decentraland/protocol#489.

Test Instructions

Run metaforge explorer run 10118 and launch with:

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

The scene is already deployed. If already on zone, use /goto sdk7testscenes.dcl.eth/84,-6. MetaMask users: select SEPOLIA.

Hold Alt to click. Check all four samples: 3D text, UI text, input and dropdown.

Click / do Expected
Left panel: Bungee Shade TTF; type a word and select a dropdown option. Switch to Azeret Mono, Built-in, then Family name. Fonts change on all four samples, including dropdown options. Your word and selection stay. Family name looks like Bungee Shade.
Right: 1. Lora: four styles Regular, bold, italic and bold-italic text; built-in comparison; Lora returns.
2. Shared font: remove owners Samples disappear one by one; remaining text keeps its font. All four return.
3. Fallbacks + recovery Invalid sources show built-in text; Bungee Shade returns between cases and at the end. Warnings are expected.
4. Recreate x20 Ends with one of each sample; input/dropdown work. Their values reset.
7. Scene boundary; accept movement if prompted 3D text hides fully outside the cyan edge and returns inside. UI stays visible.
Leave and return All samples and controls still work.

Run buttons 1, 2, 3, 4, 7 one at a time, watching until Finished. Skip 5–6: developer-only local tests. Finished is not an automatic pass. Judge font appearance using Latin text; Cyrillic may use fallback. Report failures with the button/step and a screenshot.

Quality Checklist

  • Original test scene manually checked in Editor
  • Standalone test scene builds and is deployed on zone
  • QA instructions included above
  • QA pass on the deployed scene with this PR build
Screenshots overview-south row4-south

Implements the optional font_src of decentraland/protocol#487 without an
external service: a scene content file, a URL or a Google Fonts family (via
Fontsource) is downloaded and built into TextMeshPro and UI Toolkit font
assets at runtime; a source that fails keeps the built-in font. The bindings
are the pinned @dcl/protocol plus the four font_src lines.
@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac built successfully in Unity Cloud.

Name Links & timing
Build 753b1d1 · Logs · built 2026-10-01T14:55:33Z
Windows GitHub job · Unity Cloud #13 · Unity log · ⏱ 37m 51s build + 10m 11s queue · Download .zip · .zip via S3
Mac GitHub job · Unity Cloud #12 · Unity log · ⏱ 44m 40s build + 2m 0s queue · Download .zip · .zip via S3

Lint

Lint did not finish (failure) — the warning ratchet could not be evaluated. See logs.

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped Tests time Job time
EditMode ✅ Passed 26620 0 13 4m 38s 18m 34s
PlayMode ✅ Passed 243 0 37 38s 13m 17s

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] 15.9s DCL.Tests.Editor.ValidationTests.CheckForDebugUsage
  • [editmode] 11.9s DCL.Tests.Editor.ValidationTests.CheckUnityObjectsForMissingReferences
  • [editmode] 10.0s DCL.Notifications.Tests.NotificationsRequestControllerShould.ReuseSingleListInstanceAcrossPollIterations
  • [editmode] 5.4s DCL.Tests.Editor.ValidationTests.SettingsAreValid
  • [editmode] 5.0s DCL.Friends.Tests.FriendsConnectivityStatusTrackerShould.RaiseOnlineEventWhenSameStatusIsRebroadcastAfterReset
  • [editmode] 5.0s CrdtEcsBridge.WorldSynchronizer.Tests.CrdtWorldSynchronizerShould.ThrowIfSyncBufferIsAlreadyRented
  • [editmode] 4.4s DCL.AvatarRendering.AvatarShape.Tests.FinishAvatarMatricesCalculationSystemShould.CullAnInWorldAvatarBehindTheCamera
  • [editmode] 4.3s DCL.AvatarRendering.AvatarShape.Tests.FinishAvatarMatricesCalculationSystemShould.KeepThePreviewAvatarLiveWhereverThePlayerCameraLooks
  • [editmode] 4.2s SceneRunner.Tests.SceneFacadeShould.ContinueUpdateLoopOnBackgroundThread(20,4000)
  • [playmode] 2.6s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.ContinuousTweensRunIndefinitelyWhenDurationIsZero
  • [playmode] 2.3s Global.Tests.PlayMode.CubeWaveSceneShould.EmitECSComponents
  • [playmode] 2.2s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TextureMoveSequenceUpdatesMaterial
  • [playmode] 2.1s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.TextureMoveContinuousOffsetCompletesAndUpdatesMaterial
  • [playmode] 2.1s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithoutLoopCompletesOnce
  • [playmode] 2.0s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.RotateContinuousCompletesAfterDuration
  • [playmode] 2.0s DCL.SDKComponents.Tween.Tests.TweenUpdaterSystemShould.MoveContinuousMovesAndCompletesAfterDuration
  • [playmode] 1.5s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithMoveRotateScaleWithOmittedScale_ResolvesScaleFromCurrentTransform
  • [playmode] 1.4s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceWithMultipleTweens
  • [playmode] 1.4s DCL.SDKComponents.Tween.Tests.TweenSequenceSystemShould.TweenSequenceCompletesAllTweens

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

Performance

🏁 Bare-metal benchmark finished — run #36880301713.

Full report

PR #10118, run #36880301713

Overall: 🟢 GPU average improved on Apple M1

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 4216 (×3) 4480 (×3)
CPU average 21.2 ms (19.6–21.2) 20.0 ms (19.5–20.2) -1.3 ms ⚪ within noise
CPU 1% worst 202.6 ms (185.6–222.6) 194.9 ms (161.4–210.3) -7.7 ms ⚪ within noise
CPU 0.1% worst 238.9 ms (237.7–240.6) 222.9 ms (222.0–224.4) -16.0 ms — informational
GPU average 34.8 ms (33.2–35.7) 30.8 ms (30.6–32.6) -4.0 ms 🟢 11% faster
GPU 1% worst 44.9 ms (42.6–45.4) 40.8 ms (40.2–42.9) -4.2 ms ⚪ within noise
GPU 0.1% worst 46.3 ms (43.6–46.4) 42.4 ms (41.6–44.1) -3.9 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Intel Core i5

Metric Baseline Change Δ Result
Samples 5003 (×3) 4547 (×3)
CPU average 17.9 ms (15.9–18.4) 19.6 ms (19.6–20.2) 1.7 ms 🔴 9% slower
CPU 1% worst 248.8 ms (215.4–260.9) 240.3 ms (236.0–247.3) -8.5 ms ⚪ within noise
CPU 0.1% worst 417.8 ms (307.2–441.1) 316.3 ms (279.0–365.2) -101.5 ms — informational
GPU average 11.3 ms (9.3–11.5) 10.9 ms (10.6–11.9) -0.4 ms ⚪ within noise
GPU 1% worst 125.3 ms (92.5–136.7) 120.4 ms (115.3–134.9) -5.0 ms ⚪ within noise
GPU 0.1% worst 417.7 ms (304.4–440.7) 300.7 ms (279.9–361.1) -117.0 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
popuz marked this pull request as ready for review September 17, 2026 14:11
@popuz
popuz requested review from a team as code owners September 17, 2026 14:11
@decentraland-bot decentraland-bot added the ext-contribution Identifies a contribution which was not initiated by a Unity Developer label Sep 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Slack notification sent to #explorer-ext-contributions for external review.
To re-send, delete this comment and re-add the ext-contribution label.

@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: font_src on TextShape, UiText, UiInput and UiDropdown

STEP 2 — Root-cause check

PASS. This is a feature implementation (protocol font_src field on four SDK components), not a bug fix. The diff directly implements the requirement: scenes can specify custom fonts via bundled TTF files or Google Fonts family names resolved through Fontsource. Unsupported or failed sources fall back to the built-in font.

STEP 3 — Design & integration

PASS. Mandatory owner search completed for all new long-lived units:

New unit Lifecycle it manages Existing owner searched Conclusion
FontsLoadingPlugin FontsCache, FontFileStore, RuntimeFontAssetFactory TexturesLoadingPlugin, TextShapePlugin (peers) Follows the plugin pattern exactly — reads from container, wires systems. No lifecycle duplication.
LoadFontSystem Font download & asset creation LoadTextureSystem, LoadAudioClipSystem (peers in StreamableLoadingGroup) Extends LoadSystemBase<FontData, GetFontIntention> — the canonical loading pattern. Correctly registered in AssetsDeferredLoadingSystem.
FontFileStore Temp font files on disk (FreeType re-reads at glyph rasterization) No existing font-file owner New responsibility with no prior owner. Ref-counted Lease pattern is correct for files that must outlive the TMP_FontAsset that reads them.
SceneFontRequest Per-entity font promise lifecycle Per-entity asset requests (textures on backgrounds, etc.) Embedded struct in each component — the standard per-entity async-asset pattern. Not a lifecycle duplication.
FontData Ref-counted cache entry TextureData, AudioClipData (peers) Extends StreamableRefCountData — the standard cache-entry type.
RuntimeFontAssetFactory None (stateless factory) N/A Produces TMP/UIToolkit font assets from file paths. No lifecycle ownership.

Teardown/consumption trace:

  • FontFileStore.Lease: Created in StoreAsync → disposed via ReleaseAfterDestructionAsync (waits one frame for Object.Destroy to complete, then disposes leases on thread pool). Properly paired.
  • Font promises (AssetPromise): Created in SceneFontRequest.Update → released in SceneFontRequest.Release (called from all 4 release systems + FinalizeComponents). Properly paired.
  • TMP_FontAsset / FontAsset: Created in RuntimeFontAssetFactory.Create → destroyed in FontFamilyAssets.Destroy → called from FontData.DestroyObject. Properly paired.
  • FontsCache: Created in plugin constructor, registered with CacheCleaner, disposed in FontsLoadingPlugin.Dispose. Properly paired.

Cross-cutting change — ReleaseReferenceComponentsSystem: The PR adds respectDeferredDeletion so entities with DeferDeletion = true skip pool-return during normal Update. This is a behavioral change affecting ALL pooled reference types, but it is correct: previously, reference components could be returned to the pool while the entity's deletion was still deferred, creating a race with release systems that also check DeferDeletion. FinalizeComponents still processes everything unconditionally. The change is tested (ReleaseReferenceComponentsSystemShould).

STEP 4 — Member audit

All new public members audited. SceneFontRequest.Update/TryConsume/Release are consumed by exactly the 4 component integration points each. FontFamilyAssets.TextMeshProFont/UIToolkitFont are consumed by their respective component systems. FontFileStore.LooksLikeTrueTypeFont/MAX_FILE_BYTES are consumed by LoadFontSystem.DownloadAsync. No single-use-should-merge, absent≠false, or redundant-guard issues found.

STEP 5 — Line-level findings

See inline comments below.

STEP 6 — Complexity assessment

COMPLEX. Touches ECS systems, components, component structural operations, the StreamableLoading pipeline, async/UniTask patterns, plugin registration, assembly definitions, object pooling, and resource cleanup paths.

STEP 7 — QA assessment

QA_REQUIRED: YES. Runtime changes affecting what the user sees (font rendering on TextShape, UiText, UiInput, UiDropdown). The PR includes a deployed test scene on zone with detailed QA instructions.

STEP 8 — Non-blocking warnings

No Main.unity changes detected. ✅

Merge gates

R25 — Outstanding comments: 0 prior review threads (first review). This review raises findings below.
R26 — QA sign-off: Awaiting QA. Test instructions are comprehensive and include a deployed scene on zone. SDK test scene PR linked (sdk7-test-scenes#100).

Categories checked clean

  • R1/R2 (hot-path allocations, LINQ): No per-frame allocations in system Update paths. Font operations only run on IsDirty or promise completion. No LINQ in systems. ✅
  • R3 (class refs in structs): SceneFontRequest holds FontPromise? (a struct) and string? — no class reference stored in a struct on a hot path. ✅
  • R5 (entity by-ref): All [Query] methods use the sanctioned in Entity convention. No structural changes over by-ref entities. ✅
  • R6 (acquire/release pairs): All traced above. ✅
  • R8 (root cause): Feature implementation, not a symptom fix. ✅
  • R9 (logging): All logging uses ReportHub with explicit ReportCategory.SDK_FONTS. No Debug.Log. ✅
  • R10 (catch-scope): catch (Exception e) when (e is not OperationCanceledException) in DownloadVariantIfPresentAsync — correct pattern for optional variant downloads. The catch in FontFileStore.Clear/Release targets specific exceptions (IOException, UnauthorizedAccessException). ✅
  • R11 (async/cancellation): CTs threaded through all async paths. FlowInternalAsync uses try/finally for cleanup. .Forget(handler) used with error handler per DCLA002. SuppressCancellationThrow() used for optional variant downloads. ✅
  • R12 (abstractions): No single-implementation interfaces introduced. FontsCache is the required cache type for the pipeline. ✅
  • R14 (contract honesty): FontSourceKind enum, FontVariant enum — state modeled in types. ✅
  • R15 (dead weight): No commented-out code, unused flags, or platform-specific code without defines. ✅
  • R16/R17 (naming): PascalCase types/methods, camelCase locals. GetFontIntention follows the Intention family. LoadFontSystem follows the LoadSystem family. FontData follows the Data family. ✅
  • R19 (file hygiene): One class per file. No build scripts under Assets/. ✅
  • R20 (idiom cluster): No Concurrent* collections. Fields that should be readonly are readonly. ✅
  • R22 (tests): Comprehensive — 1,562 lines across 12 new/updated test files covering all new types, all integration points, font request lifecycle, release systems (including deferred deletion), and deferred loading. Edit-mode and play-mode tests present. ✅
  • R23 (comments): No AI-style narrating comments. Comments explain "why" (e.g., "FreeType re-reads the file whenever the atlas needs glyphs"). ✅
  • R24 (scope): All files are directly related to the font_src feature. GraphicsSettings.asset adds the SDF shader needed for runtime font creation. ReleaseReferenceComponentsSystem change is a necessary cross-cutting fix for deferred deletion. ✅

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches ECS systems/components, StreamableLoading pipeline, async/UniTask patterns, plugin registration, assembly definitions, and resource cleanup paths across 4 SDK component types.
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by unknown via Slack

Comment thread Explorer/Assets/DCL/SDKComponents/SceneUI/Systems/UIText/UITextReleaseSystem.cs Outdated

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

Re-review: feat: font_src on TextShape, UiText, UiInput and UiDropdown

Re-review after commit 8a6f22c5 ("fix: bound font downloads and follow threading conventions").

STEP 2 — Root-cause check

PASS. Same as prior review — this is a feature implementation.

STEP 3 — Design & integration

PASS. No structural changes to the design since the previous review. The new FontDownloadHandler is the correct mechanism for streaming download size enforcement within Unity's UnityWebRequest pipeline — it does not introduce a new lifecycle owner, it replaces a post-hoc buffer check with an inline streaming check.

STEP 4 — Member audit

FontDownloadHandler exposes one new public member: LimitExceeded. Consumed by LoadFontSystem.DownloadBytesAsync (2 call sites: post-success check and catch when filter). Not single-use-should-merge — the handler is a separate concern from the system. Clean.

UiElementUtils.ReleaseCustomFont is a new static method consumed by 3 UI release systems — correct extraction of the duplicated ReleaseFont body.

STEP 5 — Line-level findings

Resolved findings from prior review:

# Prior finding Status
1 P1 — Full download before size check ✅ Resolved: FontDownloadHandler enforces limit during streaming via ReceiveData/ReceiveContentLengthHeader
2 P2 — npmVersion interpolated without sanitization ✅ Resolved: semver regex validation in FontsourceCatalog.TryGetTtf (line 94–96) with test coverage for path traversal, query, fragment, percent-encoding
3 P2 — Null-forgiving ! without justifying comment ✅ Resolved: comment on line 53 states the contract ("Both download branches populate the required regular face before returning")
4 P2 — ReleaseFont duplicated across 3 UI release systems ✅ Resolved: extracted to UiElementUtils.ReleaseCustomFont

Remaining findings (unchanged from prior review, still active as inline comments):

# Finding File Severity
5 [LogCategory(ReportCategory.PRIMITIVE_MESHES)] should be ReportCategory.SDK_FONTS ReleaseTextShapeSystem.cs:22 P2
6 REGULAR_WEIGHT_INDEX/BOLD_WEIGHT_INDEX duplicated between RuntimeFontAssetFactory and test code RuntimeFontAssetFactory.cs:23 P2

Both have existing inline comments from the prior review that are still applicable.

New code assessment:

  • FontDownloadHandler: Correct streaming implementation. ReceiveData returns false to abort when the limit is exceeded. Buffer growth strategy (Math.Min(maxBytes, ...)) ensures the buffer never exceeds the cap. The |= on LimitExceeded in ReceiveContentLengthHeader correctly latches the flag. 4 new tests cover accept-up-to-limit, overflow-rejection, header-rejection, and UTF-8 decoding.
  • DownloadBytesAsync in LoadFontSystem: RetryPolicy.NONE is correct — the using var handler scopes the handler to the method, and retry is handled at the intention level by LoadSystemBase. The dual error handling (post-success LimitExceeded check + catch when filter) covers both content-length-declared-but-body-smaller and data-exceeded-during-streaming scenarios.
  • MAX_CATALOG_BYTES = 1MB: The Fontsource catalog JSON is also now bounded. Good defense-in-depth.
  • Threading conventions: DCLTask.SwitchToThreadPool() and DCLInterlocked.Exchange follow the project's established wrappers.
  • Semver regex uses \A/\z anchors (not ^/$) preventing partial matches even with RegexOptions.Multiline.

STEP 6 — Complexity assessment

COMPLEX. Same as prior review — touches ECS systems, StreamableLoading pipeline, async patterns, plugin registration, and resource cleanup paths.

STEP 7 — QA assessment

QA_REQUIRED: YES. Runtime changes affecting font rendering.

STEP 8 — Non-blocking warnings

No Main.unity changes. ✅

Merge gates

R25 — Outstanding comments: 2 remaining P2 findings from prior review. No P0/P1 outstanding.
R26 — QA sign-off: Awaiting QA.
CI: Tests pending on this push (editmode/playmode not yet run).

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches ECS systems/components, StreamableLoading pipeline, async/UniTask patterns, plugin registration, assembly definitions, and resource cleanup paths across 4 SDK component types.
QA_REQUIRED: YES


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

Resolve the UIInput overlap with #10143:

- UIInputComponent.Initialize resets IsFocused alongside the font state.
- UIInputInstantiationSystemShould keeps both the font test and the two
  panel-based focus tests, with the merged fixture and constructor.
- The release-system tests added on both sides declared the same type name
  in two assemblies. Move the focus one next to ours in the EditMode folder
  as UIInputReleaseSystemFocusShould; the fixtures cannot be merged because
  ours asserts on a mocked pool while it needs the real onRelease callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
popuz and others added 7 commits September 23, 2026 17:59
- Correct nullability annotations to match actual behavior (DCLImage.Texture, CommonLoadingArguments, CacheCleaner profiling counters)
- Resolve UIDropdownComponent.TextElement once in the constructor, like UIInputComponent
- Drop always-true pool null check in UITextReleaseSystem
- Replace obsolete TMP enableWordWrapping with textWrappingMode
- Remove [Serializable] from the Newtonsoft-only FontsourceFamilyRecord DTO
- Remove redundant usings, arguments, qualifiers, using-disposals and a dead constant

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
{
ReleaseComponentsToPool(query, componentPoolsRegistry);
}
public static void ReleaseComponentsToPool(in Query query, IComponentPoolsRegistry componentPoolsRegistry) =>

@dalkia dalkia Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ReleaseReferenceComponentsSystem: please move this change to its own PR

The respectDeferredDeletion change isn't related to font_src, but it changes cleanup for every pooled reference component in every scene world. I don't think it should be hidden inside a 3.7k-line feature PR.

As far as I can tell, it also has no effect at runtime today:

  • ReleaseReferenceComponentsSystem is only added to scene worlds (ECSWorldFactory.cs:95).
  • Nothing in a scene world sets DeferDeletion = true. The only places that set it are in the global world (AvatarShapeHandlerSystem, ResolveSceneStateByIncreasingRadiusSystem, RapidSceneReloadDebugSystem, and AvatarCleanUpSystem
    resetting it to false). This PR only reads the flag outside of tests.
  • So the new continue only runs in ReleaseReferenceComponentsSystemShould.

The fix itself makes sense as a guard for the future. It lines up with ReleasePoolableComponentSystem and DestroyEntitiesSystem. It would also stop repeated double-releases to the pool if a scene-world system ever defers deletion. But
that's a speculative change to shared ECS cleanup, and it deserves its own PR and review: something like fix: don't release pooled components of deferred-deletion entities, together with ReleaseReferenceComponentsSystemShould. It also
keeps the font feature easy to revert or bisect.

A small nit for when it moves: the public 2-argument overload (which means false) next to a private 3-argument one is a bit awkward. Putting the check inline in Update(), or giving the flag a default value, would be simpler.

The same applies, less strongly, to the if (deleteEntityIntention.DeferDeletion) return; early-outs in the new ReleaseTextShapeSystem / UITextReleaseSystem / UIInputReleaseSystem / UIDropdownReleaseSystem. They can't trigger in
scene worlds either, though at least they follow the existing ReleasePoolableComponentSystem pattern.

public static FontFileStore InPersistentData()
{
var store = new FontFileStore(Path.Combine(Application.persistentDataPath, DIRECTORY_NAME));
store.Clear();

@dalkia dalkia Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This means that fonts dont survive sessions, right? How can we do it?

:AG2:
I get why FontFileStore exists: FreeType keeps re-reading the font from its path, and DiskCache can't guarantee a file stays put (LRU clean-up or an overwrite may remove it). But could we still use the disk cache for the downloaded bytes? DownloadAsync would look up the raw IDiskCache by URL first and only hit the network on a miss (then PutAsync the result), while FontFileStore keeps its role of providing the pinned copy FreeType reads from. It would just need an IDiskCache passed in through FontsLoadingPlugin, the same way TexturesLoadingPlugin gets one.

this.referenceFont = referenceFont;
}

public FontFamilyAssets? Create(string assetName, string regularFilePath, string? boldFilePath = null, string? italicFilePath = null, string? boldItalicFilePath = null)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Every font creates both a TMP and a UI Toolkit asset for every face, so a Fontsource family ends up with 8 CreateFontAsset calls, each with its own 1024² atlas (which can grow into more atlases). That's twice the atlas memory whenever the font is only used by TextShape or only by UI. And since it all runs on the main thread in one go after SwitchToMainThread in LoadFontSystem, it's likely to cause a noticeable hitch.

Could the TMP and UI Toolkit variants be created lazily, the first time a consumer of that type asks for them?

private async UniTask DownloadFamilyAsync(GetFontIntention intention, FontFileStore.Lease?[] files, CancellationToken ct)
{
byte[] catalogBytes = await DownloadBytesAsync(intention.CommonArguments, MAX_CATALOG_BYTES, ct);
FontsourceFamilyRecord record = JsonConvert.DeserializeObject<FontsourceFamilyRecord>(Encoding.UTF8.GetString(catalogBytes))

@dalkia dalkia Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I worry this runs on the main thread, could we swithc to the thread pool?

return false;
}

private static bool TryGetTtf(FontsourceFamilyRecord record, string weight, string style, out string url)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The ttf URL is taken from the Fontsource response as-is and downloaded, and those bytes then go straight into FreeType, which has a long CVE history with malformed fonts. The 16 MB cap and the sfnt header check help, but if the API response were ever tampered with, the client would download from any host it's given.

Could we validate the host against a small allowlist (e.g. cdn.jsdelivr.net) before downloading? It's cheap and limits the blast radius.

- {fileID: 4800000, guid: d197ba80c994be24b8d3c757150237f4, type: 3}
- {fileID: 4800000, guid: c87696e8d053c4e49a546cd5b75e224d, type: 3}
- {fileID: 4800000, guid: 7c27dfc00496c5442a73aeede131877d, type: 3}
- {fileID: 4800000, guid: fe393ace9b354375a9cb14cdbbc28be4, type: 3}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is this really needed?

Assets/Rendering/ShaderVariants.shadervariants is already listed in GraphicsSettings → m_PreloadedShaders, and it already references TMP_SDF-Mobile with 3 variants (none, UNDERLAY_ON, UNITY_UI_CLIP_RECT). A preloaded variant collection pulls its shaders into the build and loads them at startup, so Shader.Find("TextMeshPro/Mobile/Distance Field") should already resolve in a player without the Always Included entry.

@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: font_src on TextShape, UiText, UiInput and UiDropdown

STEP 2 — Root-cause check

This PR implements a new protocol feature (font_src from decentraland/protocol#489), not a bug fix. The diff adds the capability for scenes to specify custom fonts via bundled TTF files or Fontsource family names. The change addresses the cause (no custom font support) directly — PASS.

STEP 3 — Design & integration

Owner search for new long-lived units:

  1. FontsLoadingPlugin — a new IDCLWorldPlugin that owns FontsCache, FontFileStore, and RuntimeFontAssetFactory. Constructed in StaticContainer (line 292), injected per-world via InjectToWorld. This follows the existing plugin pattern (TextShapePlugin, SceneUIPlugin). The font subsystem is orthogonal to existing font storage (IFontsStorage holds the 3 built-in fonts) and correctly introduces a parallel loading pipeline for scene-provided fonts. ✅

  2. LoadFontSystem — extends LoadSystemBase<FontData, GetFontIntention>, the same base used by LoadTextureSystem, LoadAudioClipSystem, etc. Registered in AssetsDeferredLoadingSystem. Follows the established streamable loading pattern. ✅

  3. FontFileStore — holds persistent state (a Dictionary<string, int> reference counter) outside ECS. Per CLAUDE.md §1, systems must not hold persistent collections — but FontFileStore is not a system; it is an infrastructure service (like CacheCleaner or the disk cache) that manages file lifecycle. The ref-counting is necessary because FreeType re-reads font files from disk, so files must outlive individual font assets. The store is constructed once in FontsLoadingPlugin and shared across worlds. This is a legitimate infrastructure concern, not a lifecycle violation. ✅

  4. SceneFontRequest — a struct embedded in each component (TextShapeComponent, UITextComponent, UIInputComponent, UIDropdownComponent). It manages promise lifecycle (create, consume, release) co-located with the component that uses it. This is the correct ECS-aligned pattern — the state lives on the component, not in the system. ✅

  5. ReleaseTextShapeSystem — new system that handles font cleanup on entity destruction, component removal, and world disposal. The existing TextShapePlugin had no dedicated release system for TextShape (it relied on ReleasePoolableComponentSystem). The new system adds IFinalizeWorldSystem for world disposal and handles DeleteEntityIntention.DeferDeletion. This is the correct lifecycle owner for font cleanup. ✅

Teardown trace:

  • SceneFontRequest.Release() → promise.TryDereference(world) + promise.ForgetLoading(world) — traced in all 4 release systems (ReleaseTextShapeSystem, UITextReleaseSystem, UIInputReleaseSystem, UIDropdownReleaseSystem) and in all 3 lifecycle paths (entity destruction, component removal, world finalization). ✅
  • FontData.DestroyObject() → FontFamilyAssets.Destroy() + FontFileStore.ReleaseAfterDestructionAsync() — traced. ReleaseAfterDestructionAsync waits one frame for Object.Destroy to complete, then releases file leases on the thread pool. ✅
  • FontFileStore.Lease.Dispose() → DCLInterlocked.Exchange ensures single-release, then FontFileStore.Release() decrements ref count and deletes file at zero. ✅
  • FontFamilyAssets.Destroy() → TMP_ResourceManager.RemoveFontAsset + SafeDestroy for all TMP and UI Toolkit assets. ✅

Design verdict: PASS. The new subsystem follows established patterns, does not duplicate existing lifecycle owners, and has complete teardown coverage.

STEP 4 — Member audit

  • SceneFontRequest.Update() — called from 4 instantiation/update systems, returns bool indicating whether the source changed. Single responsibility, correctly resets CustomFont in callers. ✅
  • SceneFontRequest.TryConsume() — called from 4 ApplyLoadedFont queries. Early-returns on null/consumed promise. ✅
  • SceneFontRequest.Release() — called from 4 release systems + Update() (on source change). ✅
  • UiElementUtils.SetFont() — centralizes font application for UI elements (3 callers + 4 ApplyLoadedFont callers). ✅
  • UiElementUtils.ReleaseCustomFont() — centralizes font cleanup for UI elements (3 UI release systems). ✅
  • FontFileStore.LooksLikeTrueTypeFont() — validates SFNT header. Used in LoadFontSystem.DownloadAsync. ✅

STEP 5 — Line-level findings

See inline comments below.

STEP 6 — Complexity assessment

COMPLEX. This PR introduces a new ECS subsystem (systems, components, plugin, cache), modifies component structural operations, adds async loading flows, changes plugin/container wiring, and touches 110+ files across infrastructure and SDK components.

STEP 7 — QA assessment

QA_REQUIRED: YES. The changes affect runtime rendering of text in scenes (TextShape, UI Text, UI Input, UI Dropdown), introduce network downloads (Fontsource API, CDN), and modify how fonts are applied to all text-bearing SDK components.

STEP 8 — Non-blocking warnings

No Main.unity scene modification detected. ✅

Merge gates

R25 — Outstanding comments: There are 6 prior inline comments from decentraland-bot and 6 from dalkia that remain unresolved. Key open threads include: ReleaseReferenceComponentsSystem scope concern (dalkia), font persistence across sessions (dalkia), atlas memory budget (dalkia), main-thread concern for font loading (dalkia), Fontsource TTF CVE surface (dalkia), and GraphicsSettings shader inclusion question (dalkia). These must be addressed before approval.

R26 — QA sign-off: Awaiting QA. The PR includes detailed test instructions with a deployed test scene. QA_REQUIRED: YES.

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: New ECS font loading subsystem with async flows, plugin wiring, 4 component integrations, cache management, and file I/O
QA_REQUIRED: YES


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

return false;

if (!string.IsNullOrEmpty(record.npmVersion)
&& !Regex.IsMatch(record.npmVersion, @"\A[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z", RegexOptions.CultureInvariant))

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.

[P2 — R1] Regex.IsMatch without RegexOptions.Compiled allocates the compiled automaton on every call. This runs on the font loading path (not per-frame), so it is not a hot-path blocker, but the repo's allocation discipline applies to all runtime code. Pre-compile the regex as a static readonly Regex field.

Suggested change
&& !Regex.IsMatch(record.npmVersion, @"\A[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z", RegexOptions.CultureInvariant))
&& !SemVerRegex.IsMatch(record.npmVersion))

With a field added at the top of the class:

private static readonly Regex SemVerRegex = new Regex(
    @"\A[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z",
    RegexOptions.CultureInvariant | RegexOptions.Compiled);

Comment on lines +23 to +24
private const int BOLD_WEIGHT_INDEX = 7;

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.

[P2 — R18/R13] Weight table indices duplicated between production and test code. REGULAR_WEIGHT_INDEX = 4 and BOLD_WEIGHT_INDEX = 7 are defined identically in RuntimeFontAssetFactory and RuntimeFontAssetFactoryShould. If TMP changes the weight table layout, only one site will be updated. Extract these into a shared location (e.g. a FontWeightConstants class in the Fonts namespace) or have the tests reference the production constants via InternalsVisibleTo.

Suggested change
private const int BOLD_WEIGHT_INDEX = 7;
internal const int REGULAR_WEIGHT_INDEX = 4;
internal const int BOLD_WEIGHT_INDEX = 7;

Then in the test, use RuntimeFontAssetFactory.REGULAR_WEIGHT_INDEX instead of a local constant.

[UpdateBefore(typeof(ReleasePoolableComponentSystem<TextMeshPro, TextShapeComponent>))]
[LogCategory(ReportCategory.PRIMITIVE_MESHES)]
[ThrottlingEnabled]
public partial class ReleaseTextShapeSystem : BaseUnityLoopSystem, IFinalizeWorldSystem

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.

[P2 — R9] ReportCategory mismatch. This system uses [LogCategory(ReportCategory.PRIMITIVE_MESHES)] (inherited from existing TextShape systems), but font loading/release is a distinct concern with its own ReportCategory.SDK_FONTS. All the new Fonts infrastructure uses SDK_FONTS. For consistency and to help filter font-related logs, consider using SDK_FONTS here too — or keep PRIMITIVE_MESHES if TextShape lifecycle logging should stay unified regardless of the font subsystem.

This is informational — the existing TextShape systems all use PRIMITIVE_MESHES, so keeping it is defensible for consistency within the TextShape subsystem.

using DCL.SDKComponents.SceneUI.Components;
using DCL.SDKComponents.SceneUI.Defaults;
using ECS.StreamableLoading.Fonts;
using System.Linq;

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.

[P2 — R15] Unused using System.Linq import. No LINQ methods are called in this file. Remove it to keep the file clean and avoid accidental LINQ usage in future edits.

Suggested change
using System.Linq;

{
ReleaseComponentsToPool(query, componentPoolsRegistry);
}
public static void ReleaseComponentsToPool(in Query query, IComponentPoolsRegistry componentPoolsRegistry) =>

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.

[P2 — R24] Scope: respectDeferredDeletion change is unrelated to font_src. This change modifies ReleaseReferenceComponentsSystem to skip entities with DeferDeletion = true during normal updates, which is a correctness fix for component pooling — but it is orthogonal to the font_src feature. As dalkia noted in a prior comment, this should ideally be in its own PR.

The change itself is correct and well-tested (ReleaseReferenceComponentsSystemShould covers both paths), but bundling it here makes the font_src PR harder to revert independently.

}

public Texture Texture
public Texture? Texture

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.

[P2 — R24/R7] Scope: Texture? nullability fix is tangential to font_src. Making DCLImage.Texture nullable is a correct R7 fix (the setter already handles null via SetTexture), but it is unrelated to the font feature. Consider splitting this into its own commit or PR for cleaner history.

Comment on lines +53 to +54
// Both download branches populate the required regular face before returning.
FontFamilyAssets? assets = fontAssetFactory.Create(intention.Src, files[0]!.Path, files[1]?.Path, files[2]?.Path, files[3]?.Path);

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.

[P2 — R7] Null-forgiving ! without justifying comment. files[0]!.Path uses the null-forgiving operator. Both code paths above (DownloadFamilyAsync sets files[0], DownloadAsync returns non-null) guarantee files[0] is non-null at this point, but per R7 the ! should carry a brief comment explaining the invariant.

Suggested change
// Both download branches populate the required regular face before returning.
FontFamilyAssets? assets = fontAssetFactory.Create(intention.Src, files[0]!.Path, files[1]?.Path, files[2]?.Path, files[3]?.Path);
// Both download branches populate the required regular face before returning.
FontFamilyAssets? assets = fontAssetFactory.Create(intention.Src, files[0]!.Path, files[1]?.Path, files[2]?.Path, files[3]?.Path); // files[0] set by DownloadAsync/DownloadFamilyAsync above

Comment on lines +59 to +62
}

private void WireVariant(string assetName, string? filePath, FontVariant variant, TMP_FontAsset textMeshProRegular, FontAsset uiToolkitRegular,
List<TMP_FontAsset> textMeshProAssets, List<FontAsset> uiToolkitAssets)

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.

[P2 — R10] Bare catch re-throws but destroys assets first — good, but the catch-all has no stated type. The catch block correctly cleans up on failure, but per R10 an untyped catch should state the exception type if known, or carry a brief comment explaining why it must be catch-all.

Suggested change
}
private void WireVariant(string assetName, string? filePath, FontVariant variant, TMP_FontAsset textMeshProRegular, FontAsset uiToolkitRegular,
List<TMP_FontAsset> textMeshProAssets, List<FontAsset> uiToolkitAssets)
catch (Exception)
{
FontFamilyAssets.Destroy(textMeshProAssets, uiToolkitAssets);
throw;
}

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@decentraland-bot
decentraland-bot self-requested a review September 28, 2026 22:20

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

Jarvis review: font_src on TextShape / UiText / UiInput / UiDropdown

Inspected head: 4966c7b5. The only change since the previous bot round (9e7cc84) is the profiler markers in RuntimeFontAssetFactory. The lint pre-flight script (scripts/lint/custom-rules.sh) is not on this branch, so the review-bar rules were checked by hand.

Step 2: Root cause

This is a new feature, not a fix. Scenes can set font_src to a bundled TTF, resolved only through the scene's content-hash map, or to a Google Fonts family name, resolved through Fontsource. External URLs are rejected up front (FontSrcResolver). The approach addresses the feature itself; nothing hides a symptom.

Step 3: Design and integration (owner search)

  • Loading stack (LoadFontSystem, GetFontIntention, FontData, FontsCache, FontsLoadingPlugin): follows the existing StreamableLoading pattern. The siblings are Textures/ (TexturesLoadingPlugin/TexturesCache), AudioClips/ (AudioSourcesPlugin/AudioClipsCache) and Cache/RefCountStreamableCacheBase. It is registered in CacheCleaner and AssetsDeferredLoadingSystem the same way those caches are. Placement OK.
  • Release systems (ReleaseTextShapeSystem, plus font release in the UiText/UiInput/UiDropdown release systems, all IFinalizeWorldSystem): ReleasePoolableComponentSystem can't dereference promises because its provider Dispose() has no World, so a per-component release step is justified. I traced all three paths (component removed, entity destroyed, world finalized): each releases the font exactly once, and the pooled TMP/VisualElement is reset to a built-in font. The one exception is the stale-font cases flagged inline.
  • FontFileStore duplicates part of Cache/Disk/ (CacheDirectory, DiskCache.PutAsync temp-then-move, FilesLock). A pinned file is a genuine extra requirement, because FreeType re-reads it. The maintainer's open thread already asks for the byte cache to go through IDiskCache, so it is not repeated inline.
  • FontsourceCatalog: there is no existing owner for Fontsource. Third-party endpoints belong in DecentralandUrlsSource (finding inline).
  • Teardown trace: every FontPromise.Create is matched by SceneFontRequest.Release (TryDereference + ForgetLoading). Every FontFileStore.Lease is released in LoadFontSystem's finally or in FontData.DestroyObject. Every FontData is released through DisposeAbandonedResult or the refcounted cache. No subscriptions or CTS are added.

Step 4: Member audit

  • SceneFontRequest.Src and .Promise: public, but used only by tests outside the struct.
  • UiElementUtils.SetFont: 4 consumers.
  • UiElementUtils.ReleaseCustomFont: 3 consumers.
  • UiElementUtils.ClearCustomFont: 1 consumer (inline).
  • TMPProSdkExtensions.SetFont: 2 consumers.
  • FontFileStore.MAX_FILE_BYTES / LooksLikeTrueTypeFont: used only by LoadFontSystem; this is download validation sitting on the storage type.
  • CustomFont is kept as a separate field on all four components, and each FontRequest.Update(...) call site resets it by hand. Folding the loaded FontFamilyAssets? into SceneFontRequest would remove the four copies of that reset (P2, R14; no inline comment).

Step 5: Findings (inline, each with a suggestion where there is a source fix)

# Sev Rule Location Summary
1 P1 R22 TextShape.gen.cs:713 (+ UiText/UiInput/UiDropdown) font_src was generated from the unmerged decentraland/protocol#489; @dcl/protocol pin not bumped
2 P1 R13 / privacy FontsourceCatalog.cs:10,49-50 Hardcoded third-party API URL; any scene can make every visitor contact Fontsource/jsDelivr without a media-host permission
3 P1 R22 LoadFontSystem.cs:73 No loader tests for the family path (missing regular, face fallback, cancel mid-WhenAll)
4 P2 R9 FontSrcResolver.cs:24-38 Each valid family name also logs a "not found in fileToHash" SCENE_LOADING warning
5 P2 R8 TMPProSdkExtensions.cs:27 Old custom font can stay on the TMP after release, then be destroyed while in use
6 P2 R8 UiElementUtils.cs:335-336 Same for UI; also a negative font index still throws
7 P2 availability FontsourceCatalog.cs:46 Unbounded family-name length from scene input
8 P2 R18 LoadFontSystem.cs:41 Magic 4 / slot-to-variant mapping by convention
9 P2 R23 RuntimeFontAssetFactory.cs:116 Comment narrates project settings
10 P2 Step 4 UiElementUtils.cs:339-340 Single-use public ClearCustomFont

Checked and clean:

  • No LINQ or per-frame allocation was added in [Query]/Update (R1/R2).
  • No structural change over a by-ref Entity (R5).
  • Catch blocks name the exception type, and cancellation is passed through (R10/R11).
  • No Concurrent* collections (R20).
  • HTTP goes through IWebRequestController.
  • Downloads are size-capped against both the declared length and the running total.
  • The family id charset ([A-Za-z0-9-]) rules out URL/path injection.
  • Files in the store are named by SHA-256, so there is no path traversal.

Merge gates

  • R25: open threads. 14 review threads are unresolved at this head. They include 6 from @dalkia, none addressed since 13370a2:

    • move the ReleaseReferenceComponentsSystem change to its own PR
    • reuse IDiskCache for downloaded bytes, so fonts survive sessions
    • create TMP and UI Toolkit assets lazily (8 CreateFontAsset calls and 8 1024² atlases per family, all on the main thread)
    • JSON parse on the main thread
    • host allowlist for Fontsource ttf URLs
    • whether the GraphicsSettings.asset always-included shader is needed

    The other 8 are prior bot findings: Regex allocation, the duplicated weight-table indices, the ReportCategory mismatch on ReleaseTextShapeSystem, the unused System.Linq import, the DCLImage scope item, files[0]!, and the bare catch. This review does not repeat them inline.

  • R26: QA. The PR has no no QA needed label, and QA has not signed off yet (the checklist item is unchecked). The test instructions are literal and followable, and they name the deployed sdk7testscenes.dcl.eth scene at 84,-6. QA must cover both Windows and Mac.

  • CI: the Build status comment reports success; the semantic-title and security-review checks pass.

  • Labels: new-dependency and ext-contribution. The new runtime dependency is on Fontsource/jsDelivr (finding 2).

REVIEW_RESULT: FAIL ❌
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Adds a new StreamableLoading pipeline (system, refcounted cache, disk store, plugin wiring) and changes ECS instantiate/update/release systems for TextShape and three SceneUI components, plus regenerated protocol code.
QA_REQUIRED: YES


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

}

/// <summary>Field number for the "font_src" field.</summary>
public const int FontSrcFieldNumber = 22;

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.

[P1 · R22] The generated font_src fields come from an unreleased protocol branch.

FontSrcFieldNumber (TextShape 22, UiText 9, UiInput 16, UiDropdown 13) comes from decentraland/protocol#489, which is still open. scripts/package.json still pins "@dcl/protocol": "^1.0.0-35089025179.commit-5810768", the same version as dev, so the next npm run build-protocol would regenerate these four files without font_src and silently break the feature. The field numbers could also still change during protocol review, and that would be a wire-level mismatch with deployed scenes.

Fix (not a source-line fix, so no suggestion block): merge protocol#489 → bump @dcl/protocol in scripts/package.json to the released version that contains it → regenerate TextShape.gen.cs, UiText.gen.cs, UiInput.gen.cs and UiDropdown.gen.cs from that version and commit the result.

Comment on lines +49 to +50
public static string ApiUrl(string familyId) =>
API_BASE_URL + familyId;

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.

[P1 · R13 + privacy] The third-party API base URL (line 10, API_BASE_URL) is a scattered hardcoded const, and scenes can reach it without any permission.

R13: URLs are resolved through DecentralandUrlsSource. Third-party APIs are already centralised there too; see DecentralandUrl.ManaUsdRateApiUrl → DecentralandUrlsSource.cs:421. Being there also lets the endpoint be swapped (for a mirror or proxy) or turned off without shipping a client build.

Privacy: setting font_src: "Roboto" makes every visitor's client call api.fontsource.org and then cdn.jsdelivr.net, up to 5 requests. That sends the player's IP, plus a font choice that effectively identifies the scene, to two outside operators. Other outside media needs the scene's allowedMediaHostnames (SceneData.TryGetMediaUrl / IsUrlDomainAllowed); this path skips that. Whether family names should need that permission, or a Decentraland-run mirror, is a product decision. Please record it explicitly in the PR or in protocol#489.

Fix: add FontsourceFontsApi to DecentralandUrl, map it in DecentralandUrlsSource.Url to https://api.fontsource.org/v1/fonts/, delete API_BASE_URL, and pass IDecentralandUrlsSource from StaticContainer → FontsLoadingPlugin → the resolver:

Suggested change
public static string ApiUrl(string familyId) =>
API_BASE_URL + familyId;
public static string ApiUrl(IDecentralandUrlsSource urlsSource, string familyId) =>
urlsSource.Url(DecentralandUrl.FontsourceFontsApi) + familyId;

}
}

private async UniTask DownloadFamilyAsync(GetFontIntention intention, FontFileStore.Lease?[] files, CancellationToken ct)

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.

[P1 · R22] The Fontsource family path has no loader tests.

LoadFontSystemShould only builds FontSourceKind.File intentions. FontSourceKind.FontsourceFamily only appears in FontSrcResolverShould, so none of the DownloadFamilyAsync behaviour is tested:

  • a catalog with no regular face → FontLoadException
  • a failed bold/italic face → warning and fallback to regular, with the lease released
  • cancellation during UniTask.WhenAll → every lease written so far is released, and nothing is left in FontFileStore
  • a npmVersion that fails the semver check → the face is skipped

Fix: add LoadFontSystemShould cases that set Kind = FontSourceKind.FontsourceFamily and point CommonArguments.URL at a local file:// catalog JSON fixture whose variants.*.url.ttf point at the existing test TTF. Use one fixture per scenario above, and assert on the result and on the store's lease count. (This is a test gap, so there is no suggestion block.)

Comment on lines +24 to +38
FontSourceKind kind = FontSourceKind.File;

if (!sceneData.TryGetContentUrl(fontSrc, out URLAddress url))
{
string? familyId = FontsourceCatalog.ToFamilyId(fontSrc);

if (familyId == null)
{
ReportHub.LogWarning(ReportCategory.SDK_FONTS, $"font_src \"{fontSrc}\" is neither a content file of the scene {sceneData.SceneShortInfo} nor a font family name");
return false;
}

kind = FontSourceKind.FontsourceFamily;
url = URLAddress.FromString(FontsourceCatalog.ApiUrl(familyId));
}

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.

[P2 · R9] Every family name also logs a misleading SCENE_LOADING warning.

TryGetContentUrl is tried first, and SceneHashedContent.TryGetContentUrl logs LogWarning(SCENE_LOADING, "... {contentPath} not found in fileToHash") on every miss. So each valid family name ("Lora", "Bungee Shade") raises a "file not found" warning for an expected path. ToFamilyId only accepts [A-Za-z0-9 -], which a content path with an extension or / never matches. Classifying the family name first keeps the content lookup, and its warning, for things that really are paths. (One behaviour change: an extensionless bundled file whose name is a valid family id would resolve as a family. TTF files in scene content carry .ttf, so this seems acceptable; please confirm.)

Suggested change
FontSourceKind kind = FontSourceKind.File;
if (!sceneData.TryGetContentUrl(fontSrc, out URLAddress url))
{
string? familyId = FontsourceCatalog.ToFamilyId(fontSrc);
if (familyId == null)
{
ReportHub.LogWarning(ReportCategory.SDK_FONTS, $"font_src \"{fontSrc}\" is neither a content file of the scene {sceneData.SceneShortInfo} nor a font family name");
return false;
}
kind = FontSourceKind.FontsourceFamily;
url = URLAddress.FromString(FontsourceCatalog.ApiUrl(familyId));
}
FontSourceKind kind = FontSourceKind.FontsourceFamily;
string? familyId = FontsourceCatalog.ToFamilyId(fontSrc);
URLAddress url;
if (familyId != null)
url = URLAddress.FromString(FontsourceCatalog.ApiUrl(familyId));
else if (sceneData.TryGetContentUrl(fontSrc, out url))
kind = FontSourceKind.File;
else
{
ReportHub.LogWarning(ReportCategory.SDK_FONTS, $"font_src \"{fontSrc}\" is neither a content file of the scene {sceneData.SceneShortInfo} nor a font family name");
return false;
}

TextMeshPro tmpText = textShapeComponent.TextMeshPro;

tmpText.font = fontsStorage.Font(textShape.Font) ?? tmpText.font;
SetFont(ref textShapeComponent, textShapeComponent.CustomFont ?? fontsStorage.Font(textShape.Font) ?? tmpText.font);

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.

[P2 · R8] A released custom font can stay on screen and then be destroyed while in use.

When font_src changes or is cleared, UpdateTexts calls FontRequest.Update. That dereferences the old promise and sets CustomFont = null, and then this line runs. If fontsStorage.Font(textShape.Font) returns null (its return type is TMP_FontAsset?, e.g. for an unmapped font value), the ?? tmpText.font fallback keeps the old custom font. Nothing references it any more, so CacheCleaner → FontData.DestroyObject destroys it and the text disappears. ReleaseTextShapeSystem.ReleaseFont doesn't fix it either: it returns early because CustomFont == null, so the pooled TMP goes back still pointing at a destroyed asset. Before this PR the fallback could only keep a built-in font, so it was harmless.

Suggested change
SetFont(ref textShapeComponent, textShapeComponent.CustomFont ?? fontsStorage.Font(textShape.Font) ?? tmpText.font);
SetFont(ref textShapeComponent, textShapeComponent.CustomFont ?? fontsStorage.Font(textShape.Font) ?? fontsStorage.Font(DCL.ECSComponents.Font.FSansSerif) ?? tmpText.font);

(The enum type is written out in full because this file imports both UnityEngine and DCL.ECSComponents.)

Comment on lines +335 to +336
if (fontIndex < styleFontDefinitions.Length)
element.style.unityFontDefinition = styleFontDefinitions[fontIndex];

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.

[P2 · R8] The UI side has the same stale-font problem as TMPProSdkExtensions.Apply.

When customFont is null and fontIndex is out of range, nothing is written. The element keeps new StyleFontDefinition(<released custom FontAsset>) after FontRequest.Update has dropped its reference, and UI Toolkit keeps drawing with it until CacheCleaner destroys it. A negative enum value also still throws IndexOutOfRangeException here (that predates this PR).

Suggested change
if (fontIndex < styleFontDefinitions.Length)
element.style.unityFontDefinition = styleFontDefinitions[fontIndex];
if (fontIndex >= 0 && fontIndex < styleFontDefinitions.Length)
element.style.unityFontDefinition = styleFontDefinitions[fontIndex];
else
ClearCustomFont(element);

id.Append(char.ToLowerInvariant(c));
}

return id.Length > 0 ? id.ToString() : null;

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.

[P2 · availability] The family name has no length limit, and each distinct name costs network requests.

font_src is an unbounded string from the scene. Any length of [A-Za-z0-9 -] becomes a Fontsource API request, and each unknown name is one more outgoing request, and a cache entry, per client. A scene cycling through generated names turns every visitor into a request source against Fontsource, which can also get their IP rate-limited for other scenes. Real family ids are short, so capping the id length costs nothing. A per-scene cap on distinct families (e.g. 8) is also worth considering as a follow-up.

Suggested change
return id.Length > 0 ? id.ToString() : null;
return id.Length is > 0 and <= MAX_FAMILY_ID_LENGTH ? id.ToString() : null;

with /// <summary>Longest Fontsource family id accepted from a scene.</summary> private const int MAX_FAMILY_ID_LENGTH = 64; declared at the top of the type.


protected override async UniTask<StreamableLoadingResult<FontData>> FlowInternalAsync(GetFontIntention intention, StreamableLoadingState state, IPartitionComponent partition, CancellationToken ct)
{
var files = new FontFileStore.Lease?[4];

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.

[P2 · R18] The magic 4 and the slot mapping 0..3 match FontVariant only by convention.

files[0..3] must match FontVariant.Regular/Bold/Italic/BoldItalic, and the same 4 appears again in RuntimeFontAssetFactory.Create (new List<...>(4)). Derive the count from the enum and index by variant (files[(int)FontVariant.Regular], …) at lines 49, 54, 82 and 90–92 so the mapping cannot drift.

Suggested change
var files = new FontFileStore.Lease?[4];
var files = new FontFileStore.Lease?[FONT_VARIANTS_COUNT];

with /// <summary>One file slot per <see cref="FontVariant"/>, indexed by the enum value.</summary> private const int FONT_VARIANTS_COUNT = (int)FontVariant.BoldItalic + 1; next to MAX_CATALOG_BYTES.

asset.name = name;
owned.Add(asset);

// CreateFontAsset looks up "TextMeshPro/Mobile/Distance Field" by name: that shader is in the always-included list

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.

[P2 · R23] This comment describes project settings, not this code.

It tells the reader about GraphicsSettings → Always Included Shaders, and the need for that entry is itself still being questioned in the open GraphicsSettings.asset thread. Whatever that thread decides, the comment will drift from the setting it describes. Delete it; if the dependency matters, keep the explanation in the PR/settings discussion.

Suggested change
// CreateFontAsset looks up "TextMeshPro/Mobile/Distance Field" by name: that shader is in the always-included list

Comment on lines +339 to +340
public static void ClearCustomFont(VisualElement element) =>
element.style.unityFontDefinition = new StyleFontDefinition(StyleKeyword.Null);

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.

[P2 · Step 4, single-use member] public static ClearCustomFont has one consumer, ReleaseCustomFont in this same class.

If you apply the SetFont suggestion above, that adds a second in-class caller, so make it private; otherwise inline it into ReleaseCustomFont. Either way it should not be part of the public surface of UiElementUtils.

Suggested change
public static void ClearCustomFont(VisualElement element) =>
element.style.unityFontDefinition = new StyleFontDefinition(StyleKeyword.Null);
private static void ClearCustomFont(VisualElement element) =>
element.style.unityFontDefinition = new StyleFontDefinition(StyleKeyword.Null);

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@decentraland-bot
decentraland-bot self-requested a review October 1, 2026 11:20

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

Jarvis review: font_src on TextShape / UiText / UiInput / UiDropdown

Inspected head: 753b1d12. Since the previous bot round (4966c7b5), the only change is 753b1d1 ("add profiler markers to scene font request and receipt"), in SceneFontRequest.cs. I re-checked the full diff anyway, using three parallel review passes (correctness/lifecycle, security, performance/standards). The lint pre-flight script (scripts/lint/custom-rules.sh) is not on this branch, so I checked the review-bar rules by hand.

Step 2: Root cause

PASS. This is a new feature from decentraland/protocol#489, not a bug fix. font_src resolves to a bundled TTF (through the scene's content map) or to a Fontsource family name. External URLs are rejected. Nothing here hides a symptom.

Step 3: Design and integration

No design change since 4966c7b5. The owner search in the previous round still holds:

  • Placement: LoadFontSystem/FontsCache/FontData/FontsLoadingPlugin follow the Textures/ and AudioClips/ StreamableLoading pattern. They are registered in CacheCleaner and AssetsDeferredLoadingSystem.
  • Release: the release systems run before ReleasePoolableComponentSystem on all three paths (component removed, entity destroyed, world finalized).
  • Teardown: every FontPromise.Create is matched by SceneFontRequest.Release, and every FontFileStore.Lease is released in LoadFontSystem's finally or in FontData.DestroyObject.

New in this round:

  • FontFileStore's disk lifetime is per-process, but its directory is shared across processes. References are counted in memory, but the folder persistentDataPath/SceneFonts is shared and is wiped on every startup. Two clients started with --multi-instance, or the Editor next to a player, can delete files the other still serves to FreeType (inline, P2). This is separate from @dalkia's open thread on fonts not surviving sessions.
  • Profiler markers added in 753b1d1: REQUEST_MARKER covers the resolve-and-create work correctly. RECEIVE_MARKER starts after promise.TryConsume, so it measures only a field read (inline, P2).

Step 4: Member audit

  • Unchanged since the last round: SceneFontRequest.Src/.Promise are used by tests only outside the struct; UiElementUtils.SetFont has 4 consumers; ReleaseCustomFont has 3; ClearCustomFont has 1 (open thread); TMPProSdkExtensions.SetFont has 2.
  • New in 753b1d1: two private static readonly ProfilerMarker fields. The interpolated names are built once at type initialisation, so the query path does not allocate.

Step 5: Findings (new in this round)

# Sev Rule Location Summary
1 P1 R22 UITextInstantiationSystemShould.cs:187 (+ UiInput/UiDropdown) No UI test checks that a loaded font is applied; they only check that a request is created
2 P2 R8 / lifecycle FontFileStore.cs:33-38 The shared SceneFonts folder is wiped at startup and deleted per file by per-process refcounts, so concurrent clients break each other's fonts
3 P2 R15 SceneFontRequest.cs:15, 54-55 RECEIVE_MARKER scopes only a field read, so the sample always reads close to 0 ms
4 P2 code-style (AAA) ReleaseTextShapeSystemShould.cs:65-71 (+ the other new font test files) New tests don't use // Arrange / // Act / // Assert, unlike the older tests in the same files

Still open from earlier rounds, not repeated inline. The P1s from the previous round are unchanged at this head:

  • font_src is generated from the unmerged decentraland/protocol#489 (still OPEN as of this review), and the @dcl/protocol pin is not bumped.
  • Fontsource is reached through a hardcoded third-party URL with no media-host permission or host allowlist.
  • LoadFontSystem has no tests for the Fontsource family path.

Checked and clean:

  • R1/R2: no LINQ or per-frame allocation in [Query]/Update.
  • R5: no structural change over a by-ref Entity; promise entities live in other archetypes.
  • R10/R11: catch filters name their exception types, and cancellation is passed through.
  • FontDownloadHandler limits: no overflow.
  • Fontsource URLs: the family-id charset and the semver check on npmVersion block URL and path injection.
  • Untrusted JSON: parsed with TypeNameHandling.None and capped at 1 MB.
  • Cache keys: Kind + content URL prevent cross-scene cache poisoning.

Merge gates

  • R25: open threads. 24 review threads are unresolved at this head.
    • 6 from @dalkia, none addressed since 13370a2:
      • move the ReleaseReferenceComponentsSystem change to its own PR
      • reuse IDiskCache so fonts survive sessions
      • create TMP/UI Toolkit assets lazily
      • JSON parse on the main thread
      • host allowlist for Fontsource ttf URLs
      • GraphicsSettings.asset always-included shader
    • 18 from earlier bot rounds.
  • R26: QA. The PR has no no QA needed label, and the QA checklist item is unchecked. The test instructions are concrete and name the deployed sdk7testscenes.dcl.eth scene at 84,-6. QA must cover both Windows and Mac.
  • CI: the semantic-title and security-review checks pass.
  • Labels: new-dependency (runtime dependency on Fontsource/jsDelivr) and ext-contribution.

REVIEW_RESULT: FAIL ❌
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Adds a new StreamableLoading pipeline (system, refcounted cache, disk store, plugin wiring) and changes ECS instantiate/update/release systems for TextShape and three SceneUI components, plus regenerated protocol code.
QA_REQUIRED: YES


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

Assert.That(world.IsAlive(firstPromiseEntity), Is.False);
Assert.That(uiTextComponent.FontRequest.Src, Is.EqualTo("fonts/Lobster-Regular.ttf"));
Assert.That(world.Get<GetFontIntention>(uiTextComponent.FontRequest.Promise!.Value.Entity).Src, Is.EqualTo(input.FontSrc));
}

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.

[P1 · R22] None of the UI systems has a test that applies a loaded font.

UITextInstantiationSystemShould, UIInputInstantiationSystemShould and UIDropdownInstantiationSystemShould only check that a font request is created (RequestFontWhen…, ReplaceRequestWhenFontSrcChanges). No test:

  • completes the promise and asserts that CustomFont and style.unityFontDefinition end up as the UI Toolkit font, which is the visible result of the feature, or
  • covers the failure case, where a failed load keeps the built-in font.

InstantiateTextShapeSystem has no tests either. Only UpdateTextShapeSystemShould exercises the apply path.

Suggested test (it needs using ECS.StreamableLoading.Common.Components;, using TMPro; and using Object = UnityEngine.Object;). Add the equivalent for Input (TextField) and Dropdown:

Suggested change
}
}
[Test]
public void ApplyTheLoadedFontToTheLabel()
{
// Arrange
sceneData.TryGetContentUrl("fonts/Roboto.ttf", out Arg.Any<URLAddress>())
.Returns(x =>
{
x[1] = URLAddress.FromString("https://peer.decentraland.org/content/contents/bafyfont");
return true;
});
TMP_FontAsset referenceFont = TestFonts.CreateTextMeshProFont();
FontFamilyAssets family = new RuntimeFontAssetFactory(referenceFont).Create("Custom", TestFonts.PATH)!;
var fontData = new FontData(family);
world.Add(entity, new PBUiText { FontSrc = "fonts/Roboto.ttf", IsDirty = true });
system.Update(0);
world.Add(world.Get<UITextComponent>(entity).FontRequest.Promise!.Value.Entity, new StreamableLoadingResult<FontData>(fontData));
try
{
// Act
system.Update(0);
// Assert
ref UITextComponent component = ref world.Get<UITextComponent>(entity);
Assert.That(component.CustomFont, Is.SameAs(family.UIToolkitFont));
Assert.That(component.Label.style.unityFontDefinition.value.fontAsset, Is.SameAs(family.UIToolkitFont));
}
finally
{
fontData.Dispose();
Object.DestroyImmediate(referenceFont);
}
}

Comment on lines +33 to +38
public static FontFileStore InPersistentData()
{
var store = new FontFileStore(Path.Combine(Application.persistentDataPath, DIRECTORY_NAME));
store.Clear();
return store;
}

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.

[P2 · R8 / lifecycle] Explorer processes running side by side can delete each other's font files.

InPersistentData() uses the fixed folder persistentDataPath/SceneFonts and calls Clear() (Directory.Delete(directory, true)) on every startup. Files are named by SHA-256, but references is counted only inside each process. Two clients can share persistentDataPath:

  • two clients started with --multi-instance (AppArgsFlags.MULTIPLE_RUNNING_INSTANCES), or
  • the Editor next to a built player (ShouldForceSingleRunningInstance returns false under UNITY_EDITOR).

Failure scenario:

  1. Instance A has font_src: "Roboto" loaded.
  2. Instance B starts and its Clear() wipes A's file. Even without a restart: if B loads the same family it gets the same path, and when B releases it, B's count drops to 0 and File.Delete removes the file A still uses.
  3. A's next glyph that is not in the atlas makes FreeType re-open sourceFontFilePath, which is exactly what the comment on line 14 warns about. The open fails, and A's text renders missing glyphs or falls back. This happens mostly on macOS/Linux; on Windows only while the file isn't held open.

Fix: give each process its own subdirectory, and clear only the subdirectories whose owner process has exited. This still works if the byte cache later moves to IDiskCache, as @dalkia's thread asks.

Suggested change
public static FontFileStore InPersistentData()
{
var store = new FontFileStore(Path.Combine(Application.persistentDataPath, DIRECTORY_NAME));
store.Clear();
return store;
}
public static FontFileStore InPersistentData()
{
string root = Path.Combine(Application.persistentDataPath, DIRECTORY_NAME);
ClearAbandonedSessions(root);
return new FontFileStore(Path.Combine(root, System.Diagnostics.Process.GetCurrentProcess().Id.ToString()));
}
// Each process owns a subdirectory: clients started with --multi-instance share persistentDataPath
private static void ClearAbandonedSessions(string root)
{
if (!Directory.Exists(root))
return;
foreach (string session in Directory.GetDirectories(root))
{
if (int.TryParse(Path.GetFileName(session), out int pid) && IsRunning(pid))
continue;
new FontFileStore(session).Clear();
}
}
private static bool IsRunning(int pid)
{
try
{
using var process = System.Diagnostics.Process.GetProcessById(pid);
return !process.HasExited;
}
catch (ArgumentException) { return false; }
}

Comment on lines +54 to +55
Promise = promise;
using ProfilerMarker.AutoScope _ = RECEIVE_MARKER.Auto();

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.

[P2 · R15] RECEIVE_MARKER measures nothing useful.

The scope starts after promise.TryConsume, which is where the receive work happens (world.TryGet plus destroying the promise entity). The cost of applying the font (SetFont/Apply) happens in the callers after this method returns. So the scope contains only a field read or TryLogException, and the SceneFontRequest.Receive sample always reads close to 0 ms, which is misleading when profiling.

Moving the marker above TryConsume would sample every pending request on every frame, so remove it. If receipt cost matters, wrap the callers' apply step instead (ApplyLoadedFont(s) in the four systems).

Suggested change
Promise = promise;
using ProfilerMarker.AutoScope _ = RECEIVE_MARKER.Auto();
Promise = promise;

Comment on lines +14 to +15
private static readonly ProfilerMarker REQUEST_MARKER = new ($"{nameof(SceneFontRequest)}.Request");
private static readonly ProfilerMarker RECEIVE_MARKER = new ($"{nameof(SceneFontRequest)}.Receive");

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.

[P2 · R15] Remove the RECEIVE_MARKER declaration together with its only use on line 55 (see the comment there).

Suggested change
private static readonly ProfilerMarker REQUEST_MARKER = new ($"{nameof(SceneFontRequest)}.Request");
private static readonly ProfilerMarker RECEIVE_MARKER = new ($"{nameof(SceneFontRequest)}.Receive");
private static readonly ProfilerMarker REQUEST_MARKER = new ($"{nameof(SceneFontRequest)}.Request");

Comment on lines +65 to +71
world.Remove<PBTextShape>(entity);

system.Update(0);

Assert.That(world.Has<TextShapeComponent>(entity), Is.False);
textMeshProPool.Received(1).Release(textMeshPro);
AssertFontReleased();

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.

[P2 · code style (docs/code-style-guidelines.md, AAA)] The new tests don't mark // Arrange / // Act / // Assert.

The older tests in the same suites do (for example UITextInstantiationSystemShould.InstantiateUIText). Counted as [Test] count / // Act count, the new font tests have none:

  • ReleaseTextShapeSystemShould 4/0
  • UITextReleaseSystemShould 4/0
  • UIInputReleaseSystemShould 5/0
  • UIDropdownReleaseSystemShould 5/0
  • SceneFontRequestShould 14/0
  • FontsourceCatalogShould 24/0
  • FontSrcResolverShould 17/0
  • FontFileStoreShould 17/0
  • RuntimeFontAssetFactoryShould 7/0
  • FontDownloadHandlerShould 5/0
  • LoadFontSystemShould 1/0
  • the new font_src tests in UITextInstantiationSystemShould

Example for this test:

Suggested change
world.Remove<PBTextShape>(entity);
system.Update(0);
Assert.That(world.Has<TextShapeComponent>(entity), Is.False);
textMeshProPool.Received(1).Release(textMeshPro);
AssertFontReleased();
// Arrange
world.Remove<PBTextShape>(entity);
// Act
system.Update(0);
// Assert
Assert.That(world.Has<TextShapeComponent>(entity), Is.False);
textMeshProPool.Received(1).Release(textMeshPro);
AssertFontReleased();

@popuz

popuz commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

migrated to #10317

@popuz popuz closed this Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ext-contribution Identifies a contribution which was not initiated by a Unity Developer new-dependency

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants