Skip to content

Release Candidate 1.13.1 - #2797

Draft
leanmendoza wants to merge 2 commits into
releasefrom
release-1.13.1
Draft

Release Candidate 1.13.1#2797
leanmendoza wants to merge 2 commits into
releasefrom
release-1.13.1

Conversation

@leanmendoza

Copy link
Copy Markdown
Collaborator

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; main also carries the HUD revamp second iteration (#2723), which is deliberately left out.

  • Base: release · Head: release-1.13.1 (= release-1.13.0 @ 34b0820 + 1 commit)
  • Merge after Release Candidate 1.13.0 #2787. While 1.13.0 is unmerged the diff below shows its 19 commits too; once it lands this PR reduces to the delta.

What's included

FTUE ad campaign — 0673d0a (#2788, closes #2670) — merged to main and 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 carrying position=/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=true now ships in non-release* builds (TestFlight included); lobby deeplink handling is narrower (_on_deep_link_received requires 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_events stays gated).

Sentry / crash visibility (#2749) — ⏳ not in the branch yet, cherry-picked as each lands:

Issue What it lands Status
#2751 Quota-safe reporting Server-side rate limits + allow-list ✅ Done, no client code
#2756 Symbolication & releases from CI Readable Android stacks (file:line), "introduced in / resolved in" per release Open — CI change
#2757 sentry-godot 1.6.0 → 2.1.1 + ANR First ANRs ever captured; res:// tail collapsed via fingerprints Open — client change (src/consts.rs)
#2758 Crash context Scene, device, memory and Android exit reason on every crash Open — client change

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

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

  • Version — login screen and Settings → About read v1.13.1.<build>-<hash>-prod
  • FTUE unchangeddecentraland://open?rotate-guest=true → the usual first-time screen #2788
  • Campaign bootdecentraland://open?rotate-guest=true&c=aesironline → skips the FTUE, lands in aesironline.dcl.eth #2788
  • Bad token falls back — same link with c=doesnotexist → the usual first-time screen, no error surfaced #2788
  • iOS unaffected — fresh install with no deeplink → the usual first-time screen (no attribution path on iOS) #2788
  • Regression — enter a few scenes, chat, change a wearable, play an emote: no crashes

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

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📦 Build Report

🤖 Android

Artifact Status
APK 📱 Download APK
AAB main/release builds only
Debug Symbols main/release builds only

Build Status: ✅ Success

🍏 iOS

The signed APK is uploaded to R2 on every build (link above). Add the build label (alias: build-ios) to build & ship iOS to TestFlight and post a Slack "Android build ready" notification.


🔗 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant