Release Candidate 1.13.1 - #2797
Draft
leanmendoza wants to merge 2 commits into
Draft
Conversation
Contributor
📦 Build Report🤖 Android
Build Status: ✅ Success 🍏 iOS
🔗 Workflow Run: View logs 🔄 Updated: 2026-09-02 11:56:19 UTC |
…2788) * feat(ftue): boot an ad-attributed install into the scene the ad sold An install attributed to an ad campaign carries an opaque token. This resolves that token against the map served by the mobile-bff and boots straight into the target, skipping the FTUE. Anything that does not resolve — no token, unknown or expired token, already consumed, resolver unreachable, unusable target — gets today's FTUE, unchanged. That path stays the default, not a degraded one. The token is opaque by necessity, not for flexibility. A deeplink carrying position= or realm= trips _should_go_to_explorer_from_deeplink() and boots the explorer directly, skipping avatar creation and naming — and the FTUE hangs off the end of that flow. A link carrying its own destination would skip the very screen this personalizes. An opaque param is inert to every existing deeplink branch and survives the cold start unrouted. The boot goes through _async_redirect_by_deep_link() on purpose rather than teleporting directly: that is where the pre-boot private-world gate from #2569 lives, and where explorer.gd already reads realm/location off deep_link_obj. Reproducing either here would fork two behaviours that have to stay identical. Resolution logic lives in campaign_resolution.gd, free of Global and of node state, so it can be exercised headless. The test runs as a scene rather than with --script: a SceneTree script does not register the project's global classes, so Realm and CampaignResolution would not resolve and the assertions would never run. There is deliberately no disk cache for the campaign map. Whether a campaign is still live is decided server-side and the payload carries no end date, so a cached map is one that can resurrect a campaign the server already retired. The FTUE only runs on a first launch, where a cache is empty by definition. campaigns() is pinned to .zone for now, with a TODO: the endpoint only exists on the dev deployment, so resolving it per environment would break the org build rather than degrade. Part of #2669 / #2670. * feat(analytics): resolve the campaign token from Android install attribution An ad-driven install has no deeplink to carry the token — the app did not exist when the ad was clicked. This adds the two Android mechanisms that can carry one on first launch, and reports which of them fired. They cover disjoint traffic, which is why both are needed. Measured on Segment over 30 days, 87% of attributed installs (8991 of 10303) arrive with a bare `gclid` in the Play referrer and nothing of ours: Google Ads replaces the referrer with its own click id, so tagging the landing page does nothing for that traffic and only a GA4F deferred deep link carries a destination. The remaining organic traffic (7.6%) keeps its query string intact, so a token added there survives the install. Android side: getDeferredDeepLink() reads the SharedPreferences GA4F writes, both directly and through a change listener, since that write races app startup. The listener is held in a field on purpose — SharedPreferences keeps only a weak reference, so a local would be collected and the callback would never fire. The GA4F flag goes in the plugin's manifest, which is the only in-repo way to inject one; without it GA4F never writes anything. Engine side: install_attribution.rs replaces install_referrer.rs and resolves both sources into one event. GA4F wins when both are present — an ad group's link is more specific than whatever referrer Google attached. It gets a 10s deadline first, because GA4F cannot say "there will never be a link": on an organic install its read stays pending forever, so without a deadline the referrer would never get to answer. Install Attribution now carries attribution_source (ga4f_deferred_deeplink | play_install_referrer | none), campaign_token and deferred_deep_link. `none` is deliberate: a gclid install with no token of ours is still an attributed install worth reporting, and naming it that way keeps "there was no campaign" separate from "the referrer path won". The token travels as `c=` in both sources, not utm_content — that already carries a platform label on our own links (utm_content=android), and overloading it would make every existing organic install look like a campaign whose token does not resolve. One parser serves both: a referrer is a bare query string, a deep link is the same with a scheme in front. Tokens are validated against the shape the BFF accepts before use, so a malformed link cannot become a lookup. Tests cover the extraction against both real shapes and, more importantly, the strings that must NOT yield a token: gclid, gbraid, and our current organic tagging. Part of #2670. * feat(qa): reach the FTUE again with a rotate-guest deeplink Testing either FTUE path needs a brand-new guest wallet, and reinstalling is not enough to get one: the anchor the wallet derives from is device-bound and survives it (Android SSAID, iOS Keychain). Until now the only way back was editing a constant and rebuilding, which puts a build between a tester and a test. `decentraland://open?rotate-guest=true` sets that override persistently, so the tester flips it from the device. Hard-gated to non-production on both sides — when the flag is read and when it is captured — so a release cut from main ignores it either way. It also wipes the on-disk guest identity and resets the campaign state. Both are needed for the steps to be repeatable rather than one-shot: - without the guest wipe the flag would only take effect on the launch *after* the one that set it, so a tester would have to open the app twice; - without the campaign reset a stored token is never replaced (see _capture_campaign_token) and a consumed one resolves to the default FTUE, so a second run of the campaign scenario would show the default FTUE and read as a pass while actually having failed. Also fixes a stall this exposed: _async_capture_attribution_token waited for a non-empty token, but "settled with no campaign" and "not resolved yet" both read as empty. Every campaign-less launch therefore sat behind a spinner for the full 12s timeout — the common case, since 87% of attributed installs carry no token of ours — and every launch after the first stalled the same way for attribution that never starts twice. It now stops on a pending flag exposed from Rust, with the timeout kept only as a backstop. Verified on device (Galaxy A54, clean install each time, real deeplink, onboarding tapped through by hand): no campaign lands on the default FTUE, `?c=aesironline` skips it and boots into aesironline.dcl.eth. * fix: address review findings in the campaign attribution path Nine defects, found by review of the previous two commits. **The GA4F click timestamp was garbage.** GA4F stores it as Double.doubleToRawLongBits(seconds), not as a number — verified by decompiling play-services-measurement-impl 22.3.0, zzqd.zzb(String, double). Reading it with getLong yielded ~4.79e15 for a real click, a year-151,000,000 timestamp, and every GA4F-attributed install would have shipped that to Segment as fact. Now decoded with Double.fromBits. The same method writes only "deeplink" and "timestamp", so the gclid field it advertised was permanently empty and is gone. **The resolved token could not survive the first process.** Attribution starts once per install and the token lived only in Rust memory, read at the FTUE. An ad install backgrounded during avatar creation lost its campaign for good — the exact flow this exists for. It is now persisted from _ready as soon as it resolves. A process killed before it settles still loses it; there is nothing to persist yet. **Attribution reported itself pending forever.** poll() sets done and returns None on two settle paths, but the tracker was only dropped when an event came back. Every campaign-less launch on a device whose referrer answers anything but "ok" then sat behind the full 12s timeout — the stall the pending check was added to avoid. The tracker is now dropped on an explicit is_done(). **GA4F could win without a token and discard the referrer's.** The branch keyed on status == "ok", so an ad-group link with no token beat a referrer that had one, losing the campaign permanently. It now wins only when its link actually yields a token. It also read the referrer dict regardless of status, shipping install_timestamp: 0 and empty utm_* as data; that is gated now. **The referrer had no timeout.** Only GA4F was bounded, so a referrer that never called back meant no event ever, unretryable. A 25s overall deadline emits what is known. **A campaign was consumed without knowing it booted.** _try_boot_into_campaign_target fired the redirect without awaiting and always returned true. A private-world target routes to Discover and an in-flight redirect declines outright — either way the user saw no FTUE and no campaign, token spent. The redirect now reports whether it booted, and the caller awaits it. **A `?c=`-only deeplink was not inert.** _on_deep_link_received redirected unconditionally, unlike the other two call sites, so a campaign link could boot the explorer past avatar creation and the FTUE. It now carries the same guard. Also: _async_start_ftue can suspend for tens of seconds, so it checks is_inside_tree() before touching the lobby on resume; extract_token keeps scanning past a malformed pair; and the listener body is wrapped, since SharedPreferences dispatches on the main thread where an uncaught throw is a process crash. The campaign map is now fetched lazily, only when a token needs resolving. It was fetched on every boot, which — with the endpoint pinned to the dev deployment — put every production client on the dev BFF. * fix: address a second review round on the attribution path Found by three independent reviews of the branch. The first is the one that mattered. **GA4F never got read.** Metrics::timer_timeout gates on the EULA before polling, but InstallAttribution stamps its deadlines from Instant::now() at boot. The 10s GA4F window therefore burned down in wall-clock time while nothing polled, since the EULA is only accepted when the user taps Play as guest — routinely later than that. By the first poll the deadline had expired, the referrer answered instead, and GA4F was never read. That is the path 87% of attributed installs depend on, and attribution runs once per install, so it was unrecoverable. Polling now happens before the gate. That is safe because polling only reads local state and queues an event; process_and_send_events stays behind the gate, so nothing leaves the device before consent. **attribution_source reported the opposite of what it is for.** It named the mechanism only when a token was found, so a Google Ads install — referrer, bare gclid, no token of ours — was labelled `none`. Anyone querying the column later would conclude the referrer path barely fires, when it is the majority of installs. It now names the mechanism that answered; whether a campaign was found is already told by campaign_token. **A malformed parcel booted to 0,0 and spent the token.** int() answers 0 for anything it cannot parse, so a target of "abc,def" resolved to the perfectly valid parcel 0,0: the boot "succeeded", mark_consumed() ran, and every install of that campaign landed on the Genesis spawn. Coordinates are checked with is_valid_int() now, with tests for six malformed shapes and one padded-but-valid one so the fix does not reject the legitimate case. **The lazy fetch had three seconds to finish a five-second request.** Moving the fetch to lazy in 41b3f7b meant it starts when a token needs resolving rather than at boot, but RESOLVE_MAX_WAIT_SECONDS stayed at 3.0 against a TIMEOUT_SECONDS of 5.0 — so a slow cold start gave up on a request still inside its own timeout and lost the campaign. The wait is now derived from the timeout rather than being a second loose number. **Deeplink tokens were stored unvalidated.** A stored token is never replaced, so a junk `?c=` from any link permanently blocked the real install-attribution token on that install, and rode along on every FTUE metrics payload. Same rule as the BFF and the Rust path, shared as CampaignResolution.is_valid_token and written out rather than as a regex so the three copies read alike. **The new deeplink guard was too wide.** Requiring _should_go_to_explorer_from_deeplink() also swallowed /events and /places links while the lobby was on screen — push-notification and marketing traffic with nothing to do with campaigns. The guard now allows anything the explorer will act on, and still blocks a campaign-token-only link. Also: referrer_settled marks the events where the referrer never answered, so its placeholder zeros are not mistaken for data; and the comments claiming a build cut from main ignores rotate-guest are corrected — is_production() is true only for release* branches, so main and TestFlight builds do honour it. That is deliberate, and recoverable: the flag lives in user://, and the native device anchor is never touched. Deliberately not changed: deep_link_obj.realm is still not cleared after a campaign boot, and rotate-guest still has no off switch other than uninstalling. * docs: correct comments the last two fix commits falsified No behaviour change. The campaign-token docstring had come loose from its function and was sitting on top of _capture_debug_guest_rotate, so _capture_campaign_token had none; reattached and corrected, since inertness now also depends on the guard this feature added to Lobby._on_deep_link_received. The comment beside Campaigns.new() still said the map is fetched there rather than lazily, the config comment still described the deeplink as the only wired source, and the freshness bound was still described as protecting against a reinstall replaying a dead campaign — it does not, because both paths stamp capture time; the server-side filter is what covers that. The Kotlin KDoc advertised a "not_available" status getDeferredDeepLink never returns. The Rust side depends on the opposite — GA4F stays pending forever on an organic install, which is exactly why it has its own deadline. * fix: stop discarding GA4F data, and bound the genesis parcel Third review round. Two of these are defects the previous round introduced while fixing something else. **The GA4F payload was dropped on the traffic this feature exists for.** 41b3f7b narrowed the GA4F win to "answered ok AND its link yields a token", which fixed a real bug but threw the rest of the dict away. Most Google Ads links carry no token of ours, so on that path — 87% of attributed installs — the event shipped deferred_deep_link: null and the referrer's click timestamp, meaning the deep link was never reported and the Double.fromBits decode from the same commit never ran. Worse, GA4F ok + referrer not_available (no Play Store, or the service down) emitted no event at all, which main did emit. The two sources are now independent: whichever one decides the destination, both contribute their own fields, and an event is emitted whenever anything answered. build_from_ga4f and build_from_referrer collapse into one build() that reads only from dicts that are present, so a source that never answered contributes nothing rather than zeros. **A parcel could still boot to 0,0 and spend the token.** is_valid_int() only checks characters, and Vector2i holds int32 — verified on the engine, "4294967296" passes the check and truncates to 0, which is exactly the failure the check was added to prevent. Coordinates are now bounded to ±9999, matching the BFF regex and the DB CHECK, so a position that could not have been stored is not routed either. Tests cover the overflow shapes and the widest storable parcel. **The deeplink guard still swallowed two shapes the router acts on.** A bare decentraland://open or /jump raises the jump-in panel, and /events or /places without an id opens Discover. Neither has a location or realm, so the guard blocked both and the link died silently from the lobby. It now mirrors DeepLinkRouter.route() directly rather than approximating it, and a campaign-token-only link is still correctly inert. * fix: close three holes the previous round left or opened Fourth review round. Two of these were introduced by the commit before it. **A garbage deeplink booted the explorer past the FTUE.** The guard added an arm for an empty path, reasoning it meant a bare decentraland:// link. It does not: parse_deep_link returns its default — empty path, no params — for every link it rejects, including an unparseable URL, an unknown host and an unknown scheme (lib/src/deep_link.rs:81,110,114). So any garbage link satisfied the guard and dropped a user sitting on avatar creation straight into the explorer, while the router itself would have done nothing with it. The arm is gone; an empty path reaches _route_teleport(), which no-ops without a destination, and a destination is already answered by the first check. **The parcel bound was bypassable, so a campaign could still boot to 0,0.** absi(INT64_MIN) is INT64_MIN, not a positive number, so it is never greater than the bound — and to_int() saturates to INT64_MIN on both negative underflow and positive overflow. Verified on the engine: "-99999999999999999999,0" passed the check and truncated to (0,0), spending the token on the Genesis spawn. This is the third attempt at this one bug; the first two fixes each looked complete. Comparison now happens before the absolute value, and the tests cover the negative twin of every overflow shape, which is what the previous round missed. **attribution_source said "none" on a path where GA4F had answered.** When GA4F returned a link with no token of ours and the referrer was unusable, the event shipped the deep link and GA4F's click time but labelled the source "none" — indistinguishable in Segment from an install where nothing answered at all. It now names GA4F when GA4F answered. Also: click_timestamp follows the source that decided the event rather than always preferring GA4F, so it describes the same click as the campaign_token beside it. * fix: make attribution_source deterministic Fifth review round. The two GDScript fixes from the previous commit came back verified correct and complete — the parcel bound was brute-forced over 288,960 inputs with no violation, and the deeplink guard was compared against the router across 51 link shapes with no mismatch. This commit is the analytics half. **The same install could land in either bucket.** attribution_source was decided partly by token presence and partly by which source answered, so a Google Ads install whose ad link carried no token of ours reported `play_install_referrer` when Play replied and `ga4f_deferred_deeplink` when it did not — identical GA4F facts, different bucket, decided by something unrelated. It is now one rule: name the most specific mechanism that answered, GA4F first, independent of whether a campaign came back. Whether one did is what campaign_token is for, and the schema comment now says so — a count of GA4F-attributed campaigns is this field plus a non-null token. **click_timestamp could describe a different click than the token beside it.** The GA4F-wins branch still fell back to the referrer's click when GA4F reported none, which it does whenever its timestamp pref is missing. It is now read from the source the label names, with no cross-source fallback: a 0 means that source reported no click time, which is more honest than substituting another one. Also corrects the rationale comment on the parcel bound. It claimed to_int() saturates to INT64_MIN on positive overflow; it clamps to INT64_MAX, and only "9223372036854775808" wraps. The fix is right either way, but this bug has already been fixed three times off reasoning that looked sound. * docs: drop internal attribution figures from code comments The share of installs arriving with a bare gclid is internal data and does not belong in a public repository. The reasoning it supported — that Google Ads replaces the Play referrer, so only a GA4F deferred deep link reaches that traffic — stands without the number. * fix: address review — production gate, freed-lobby FTUE, CI wiring Both P1s from the review, plus the P2s that were straightforwardly right. **[P1] The .zone endpoint no longer resolves in production.** The URL stays pinned — the endpoint does not exist on .org yet — but resolution now stops before the fetch on a production build. Without it, whoever can write a row in the dev backoffice could choose where a real ad-attributed install lands. It costs nothing: .org 404s anyway, so the fallback is the same default FTUE either way. Lift it in the same change that restores the per-environment URL. **[P1] The FTUE could be rendered onto a lobby already being replaced.** The is_inside_tree() guard covered the first await in _async_start_ftue, but the boot attempt is a second one — and the pre-boot gate calls change_scene_to_file() on its way to declining a private world. Rendering the FTUE there ran the carousel's fetch against a dying node, which the release template does not check. Guarded, and the decline now has its own fallback reason (boot_declined) so the metric stops blaming the campaign data for a routing decision. **[P2] deep_link_obj is restored when the boot declines.** It is shared state, written before the redirect can refuse; a redirect already in flight would otherwise read the campaign's destination instead of its own. **[P2] The campaign test runs in CI.** New `cargo run -- test-campaigns` xtask, wired into linux_builds.yml next to test-avatar. It runs as a scene rather than with --script: a SceneTree script does not register the project's global classes, so the assertions would silently never run. **[P2] Corrected the doc claiming a server-side active window.** The shipped BFF has neither a window nor an enabled flag — deletion is how a campaign is retired. The no-disk-cache decision still holds on that case; the comment just described a server that was trimmed away. **[P2] OS.get_name() instead of Global.is_android()**, per the convention in REVIEW.md. * revert: drop the test-campaigns xtask and its CI step Not asked for. The scene test stays in the repo and is run by hand; wiring it into the build system and the Linux workflow was scope I added on a reviewer's suggestion rather than a decision that was made. * fix: resolve the campaigns endpoint by environment The endpoint answers on .org now, so both halves of the pin go: the hardcoded .zone URL and the production hard stop that existed only because of it. A prod build resolves against the prod bff rather than the dev backoffice, and `dclenv=mobilebff::zone` works again for QA. * refactor: trim the comments the campaign path did not need Comments are kept short, relevant, and only where the context is worth it. Analysis that belongs in the PR or the issue (traffic-share figures, re-argued design alternatives) is gone; the non-obvious why — the absi bound, the pre-consent poll, GA4F's precedence rule — stays. Two changes are not cosmetic: - `Global._capture_campaign_token()` loses `occurred_at`. Its own doc said no caller passes it, and none did. - Two docstrings still described the personalized-FTUE mode that was dropped. A campaign is always a destination.
leanmendoza
force-pushed
the
release-1.13.1
branch
from
September 2, 2026 11:34
0673d0a to
53e05a5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release Candidate 1.13.1
Stacks on 1.13.0 (#2787) — same branch point, plus a narrow scope: the FTUE ad campaign boot and the Sentry / crash-visibility work from Sprint 24 (#2749). Nothing else is promoted;
mainalso carries the HUD revamp second iteration (#2723), which is deliberately left out.release· Head:release-1.13.1(=release-1.13.0@34b0820+ 1 commit)What's included
FTUE ad campaign —
0673d0a(#2788, closes #2670) — merged tomainand cherry-picked here.An install attributed to an ad campaign boots straight into the scene the ad sold, skipping the FTUE; everything else gets today's FTUE unchanged. The ad link carries an opaque
?c=<token>resolved server-side against the mobile-bff (decentraland/mobile-bff#81, backoffice in decentraland/mobile-hub#12, both merged). Opaque is required, not just convenient: a link carryingposition=/realm=trips_should_go_to_explorer_from_deeplink()and boots past avatar creation — the very screen this personalizes.Two delivery paths, covering disjoint traffic: an ad-driven install has no deeplink (the app didn't exist when the ad was clicked), so on Android the token arrives via install attribution — Google Ads replaces the Play referrer with a bare
gclid(87% of attributed installs, measured on Segment over 30 days) and only a GA4F deferred deep link reaches those, while organic installs keep their query string.Every failure path — no token, unknown, expired, already consumed, resolver unreachable, unusable target — falls back to today's FTUE.
Three behaviour changes ride along, outside the feature:
rotate-guest=truenow ships in non-release*builds (TestFlight included); lobby deeplink handling is narrower (_on_deep_link_receivedrequires a destination the router acts on, so a token-only link stays inert); attribution is polled before the EULA gate (deadlines are wall-clock from boot, so behind the gate the sources went unread until the user tapped through — polling only queues,process_and_send_eventsstays gated).Sentry / crash visibility (#2749) — ⏳ not in the branch yet, cherry-picked as each lands:
file:line), "introduced in / resolved in" per releaseres://tail collapsed via fingerprintssrc/consts.rs)Context: 1.12.2 switched Sentry to crash-only to stop the quota bleed, which left us blind to errors while the Play Store crash rate sat above 10% (#2714). These four are fixes 2–5 of the overhaul epic, targeted at Sprint 24 (2026-08-24 → 09-06).
Open items before this is ready
1.13.1—lib/Cargo.toml,lib/Cargo.lock,godot/export_presets.cfgstill read1.13.0. Store build numbers come from the Cloudflare worker at build time and are untouched.mainTest plan
Build:
v1.13.1.<build>-<hash>-prod· one Android + one iPhone.Everything QA'd for 1.13.0 (#2787) carries over unchanged — the list below is only what this RC adds.
FTUE ad campaign (Android). Each scenario needs a fresh install and the deeplink: reinstalling clears app data, the link rotates the device anchor so a new guest is minted and the first-time flow is reachable. Skip either and the result is wrong.
v1.13.1.<build>-<hash>-proddecentraland://open?rotate-guest=true→ the usual first-time screen#2788decentraland://open?rotate-guest=true&c=aesironline→ skips the FTUE, lands inaesironline.dcl.eth#2788c=doesnotexist→ the usual first-time screen, no error surfaced#2788#2788Known gap: the attribution delivery path has never run on a device — every scenario above delivers the token by deeplink. Ad spend has been paused since late August, so GA4F and the Play referrer stay unexercised until an ad group is turned back on.
Sentry items get their own checks once they are cherry-picked.