Skip to content

fix(velvet): treat an unknown race as unavailable instead of allowed - #61

Closed
Valiice wants to merge 1 commit into
XeldarAlz:masterfrom
Valiice:fix/velvet-availability-gate
Closed

fix(velvet): treat an unknown race as unavailable instead of allowed#61
Valiice wants to merge 1 commit into
XeldarAlz:masterfrom
Valiice:fix/velvet-availability-gate

Conversation

@Valiice

@Valiice Valiice commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

What

Velvet is meant to be unavailable on Lalafell characters. The check behind that can't tell your race until a character is loaded, and when it can't tell it answers "not a Lalafell". So at the login screen a Lalafell gets in. This gives the check a third answer, "don't know yet", and treats that as unavailable.

Why

Came out of this report: https://discord.com/channels/1527213298087493732/1530436723899633855

cachedLalafell is a plain bool defaulting to false and both early returns in IsLalafellCharacter() hand it back, so "couldn't read the race" and "confirmed not a Lalafell" were the same value. With no character loaded it is always false.

Two things consume it:

  • the unavailable screen from 048db79, so the app opens at the title screen
  • the heartbeat, which sends &lalafell=false. sinceHeartbeat starts already expired, so the first drawn frame sends it before any read could have succeeded.

The 403 doesn't cover this on its own. EnsureMe returns early once me is not null, and me and accessBlocked only reset on an account change, so the 403 can only land on the first profile load after signing in. After one 200 the client side check is the only thing left. The gate at the top of Draw also runs before the profile load it depends on has finished.

How to test

  1. Sign in, then sit at the title screen with Velvet open (reloading the plugin there is quickest). On master the app opens normally. With this it shows the Unavailable screen.
  2. Log in on a Lalafell with Velvet open. Unavailable, same as master.
  3. Log in on anything else. Velvet works as before, though it can take up to a second after the character loads.

Step 1 is the one that matters.

Extra

RaceWatch (new, Core/Game/) holds the race id it read, or null if it doesn't have one yet, and IsLalafell comes off that. Race id 0 is ignored since it isn't a real race, and a failed read never overwrites an answer it already has. It's a volatile byte because bool? is two fields and can be caught half written from the draw thread.

VelvetShell reads it on a FrameworkTicker (1s, gated on the app being installed) rather than during Draw, so it no longer depends on whether Draw counts as the framework thread. It forgets the answer only on a real logout, so a loading screen or a zone change keeps the last known race.

The two call sites want opposite defaults, which is the reason for the tri-state: the gate blocks on unknown, the heartbeat sends nothing on unknown instead of a guess.

11 tests in RaceWatchTests.

Couple of open questions:

  • Does the 403 depend on the lalafell flag we send? If it does, some stored tags are probably wrong, since master can send false for a character it never read.
  • Skipping the beat also skips region and last seen, so presence stops refreshing at the title screen. Beating without the flag would avoid that, but &lalafell= is always appended, so it needs a server side change.

Checklist

  • dotnet build -c Release passes
  • Verified in-game: opened the phone and exercised the affected app/screen
  • If this changes user-visible behavior, README is updated
  • Matches existing style: uses the shared Windows/Components/ widgets, no what comments

@Valiice
Valiice force-pushed the fix/velvet-availability-gate branch from d93139f to 6a2c639 Compare July 26, 2026 03:50
@Valiice Valiice changed the title fix(velvet): stop the heartbeat reporting a guessed race fix(velvet): treat an unknown race as unavailable instead of allowed Jul 26, 2026

@XeldarAlz XeldarAlz left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Superseded by the inline review below, ignore this one. Posted twice by mistake.

@XeldarAlz XeldarAlz left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Builds clean in Release. Comments inline.

Branch does not merge: based on 8bf1669f, 222 commits behind. Watch the ImGuiHelpers.GlobalScale to UiScale.Current migration on rebase, CI fails on it.

Your open questions: presence stopping at the title screen is correct, leave it. The stored lalafell flag may have bad rows from master, I will check Aethernet.


private volatile byte race = UnknownRaceId;

public byte? Race => race == UnknownRaceId ? null : race;

@XeldarAlz XeldarAlz Aug 6, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Two volatile loads. If Forget() lands between them this returns a non-null (byte?)0, so IsLalafell answers false for an unknown race. Verified against real Dalamud: 2.2M bad reads in 3s under contention.

Suggested change
public byte? Race => race == UnknownRaceId ? null : race;
public byte? Race
{
get
{
var observed = race;
return observed == UnknownRaceId ? null : observed;
}
}

}

if (IsLalafellCharacter() || store.AccessBlocked)
if (Unavailable)

@XeldarAlz XeldarAlz Aug 6, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

velvet.unavailableBody reads "not available on Lalafell characters". Unknown lands here too now, so logging out to character select tells a Highlander they are a Lalafell. Needs its own empty state: new LocString plus all nine JSONs.

internal sealed partial class VelvetShell : IPhoneApp
{
private const float HeartbeatSeconds = 45f;
private const long RaceProbeIntervalMilliseconds = 1000;

@XeldarAlz XeldarAlz Aug 6, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This one second floor means every login with Velvet open shows that screen briefly, not just the title screen. Skip the rate limit while the race is unknown.

configuration.VelvetAcknowledgedGate &&
configuration.VelvetAcknowledgedGateVersion >= Configuration.VelvetGateVersion;

private bool Unavailable => race.IsLalafell is not false || store.AccessBlocked;

@XeldarAlz XeldarAlz Aug 6, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Nit: is not false is a double negative. IsLalafellOrUnknown on RaceWatch would read better here.

@XeldarAlz XeldarAlz closed this Aug 15, 2026
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.

2 participants