Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions src/Aetherphone.Tests/RaceWatchTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using Aetherphone.Core.Game;
using Xunit;

namespace Aetherphone.Tests;

public sealed class RaceWatchTests
{
private const byte HyurRace = 1;
private const byte Unpopulated = 0;
private const int CustomizeLength = 26;

[Fact]
public void StartsUnknown_SoNothingIsReportedBeforeACharacterLoads()
{
var watch = new RaceWatch();

Assert.Null(watch.Race);
Assert.Null(watch.IsLalafell);
}

[Fact]
public void Observe_RecordsTheRaceItRead()
{
var watch = new RaceWatch();

watch.Observe(Customize(HyurRace));

Assert.Equal(HyurRace, watch.Race);
}

[Fact]
public void Observe_RecordsALalafell()
{
var watch = new RaceWatch();

watch.Observe(Customize(RaceWatch.LalafellRaceId));

Assert.Equal(RaceWatch.LalafellRaceId, watch.Race);
Assert.True(watch.IsLalafell);
}

[Fact]
public void Observe_RecordsANonLalafell()
{
var watch = new RaceWatch();

watch.Observe(Customize(HyurRace));

Assert.False(watch.IsLalafell);
}

[Fact]
public void Observe_LeavesTheAnswerUnknownWhenThereIsNoCustomizeData()
{
var watch = new RaceWatch();

watch.Observe([]);

Assert.Null(watch.Race);
Assert.Null(watch.IsLalafell);
}

[Fact]
public void Observe_TreatsAnUnpopulatedRaceByteAsNoAnswer()
{
var watch = new RaceWatch();

watch.Observe(Customize(Unpopulated));

Assert.Null(watch.Race);
Assert.Null(watch.IsLalafell);
}

[Fact]
public void AnUnpopulatedReadDoesNotOverwriteAKnownAnswer()
{
var watch = new RaceWatch();
watch.Observe(Customize(RaceWatch.LalafellRaceId));

watch.Observe(Customize(Unpopulated));

Assert.True(watch.IsLalafell);
}

[Fact]
public void AFailedReadDoesNotEraseAnAnswerWeAlreadyHave()
{
var watch = new RaceWatch();
watch.Observe(Customize(RaceWatch.LalafellRaceId));

watch.Observe([]);

Assert.True(watch.IsLalafell);
}

[Fact]
public void Observe_TracksARaceChangeRatherThanLatchingTheFirstRead()
{
var watch = new RaceWatch();
watch.Observe(Customize(HyurRace));

watch.Observe(Customize(RaceWatch.LalafellRaceId));

Assert.True(watch.IsLalafell);
}

[Fact]
public void Forget_ReturnsToUnknownWhenTheCharacterGoesAway()
{
var watch = new RaceWatch();
watch.Observe(Customize(RaceWatch.LalafellRaceId));

watch.Forget();

Assert.Null(watch.Race);
Assert.Null(watch.IsLalafell);
}

[Fact]
public void ObserveAfterForget_DoesNotKeepThePreviousCharactersAnswer()
{
var watch = new RaceWatch();
watch.Observe(Customize(RaceWatch.LalafellRaceId));
watch.Forget();

watch.Observe(Customize(HyurRace));

Assert.False(watch.IsLalafell);
}

private static byte[] Customize(byte race)
{
var customize = new byte[CustomizeLength];
customize[RaceWatch.RaceIndex] = race;
return customize;
}
}
43 changes: 26 additions & 17 deletions src/Aetherphone/Apps/Velvet/VelvetShell.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
using Aetherphone.Core.Wallpapers;
using Aetherphone.Windows.Components;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Interface;
using Dalamud.Interface.Utility;

Expand All @@ -31,6 +30,7 @@ namespace Aetherphone.Apps.Velvet;
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.


private readonly VelvetStore store;
private readonly StoryPresenter stories;
Expand Down Expand Up @@ -66,11 +66,13 @@ internal sealed partial class VelvetShell : IPhoneApp
private readonly RouterDraw<VelvetView> drawView;
private readonly Action back;

private readonly RaceWatch race = new();
private readonly FrameworkTicker raceTicker;

private PhoneTheme theme = PhoneTheme.Default;
private INavigator navigation = null!;
private VelvetPage activeTab = VelvetPage.Discover;
private float sinceHeartbeat = HeartbeatSeconds;
private bool cachedLalafell;

public VelvetShell(AethernetSession session, AethernetApi net, LodestoneService lodestone,
Configuration configuration, PhotoLibrary library, HttpService http, RemoteImageCache images,
Expand Down Expand Up @@ -109,6 +111,8 @@ public VelvetShell(AethernetSession session, AethernetApi net, LodestoneService
drawView = DrawView;
back = () => router.Pop();
threadView = new ThreadView(this);
raceTicker = new FrameworkTicker(Plugin.Framework, RaceProbeIntervalMilliseconds, ProbeRace,
installer.Gate(Id));
}

public string Id => "velvet";
Expand Down Expand Up @@ -206,7 +210,7 @@ public void Draw(in PhoneContext context)
return;
}

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.

{
TourHolds.Hold(Id);
store.EnsureMe();
Expand Down Expand Up @@ -263,6 +267,7 @@ public void Draw(in PhoneContext context)

public void Dispose()
{
raceTicker.Dispose();
threadView.Dispose();
stories.Dispose();
store.Dispose();
Expand All @@ -272,34 +277,38 @@ public void Dispose()
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.


private void TickHeartbeat()
{
sinceHeartbeat += ImGui.GetIO().DeltaTime;
if (sinceHeartbeat >= HeartbeatSeconds)
if (sinceHeartbeat < HeartbeatSeconds)
{
return;
}

if (race.IsLalafell is not { } isLalafell)
{
sinceHeartbeat = 0f;
store.Heartbeat(SocialRegion.EffectiveCode(configuration, gameData), IsLalafellCharacter());
return;
}

sinceHeartbeat = 0f;
store.Heartbeat(SocialRegion.EffectiveCode(configuration, gameData), isLalafell);
}

private bool IsLalafellCharacter()
private void ProbeRace()
{
const byte lalafellRaceId = 3;
if (!Plugin.Framework.IsInFrameworkUpdateThread)
if (!Plugin.ClientState.IsLoggedIn)
{
return cachedLalafell;
race.Forget();
return;
}

var local = gameData.LocalPlayer;
if (local is null)
if (local is not null)
{
return cachedLalafell;
race.Observe(local.Customize);
}

var customize = local.Customize;
var raceIndex = (int)CustomizeIndex.Race;
cachedLalafell = customize.Length > raceIndex && customize[raceIndex] == lalafellRaceId;
return cachedLalafell;
}

private void DrawView(VelvetView view, Rect area, int depth)
Expand Down
35 changes: 35 additions & 0 deletions src/Aetherphone/Core/Game/RaceWatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Dalamud.Game.ClientState.Objects.Enums;

namespace Aetherphone.Core.Game;

internal sealed class RaceWatch
{
internal const int RaceIndex = (int)CustomizeIndex.Race;
internal const byte LalafellRaceId = 3;

private const byte UnknownRaceId = 0;

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;
}
}


public bool? IsLalafell => Race is { } known ? known == LalafellRaceId : null;

public void Forget() => race = UnknownRaceId;

public void Observe(ReadOnlySpan<byte> customize)
{
if (customize.Length <= RaceIndex)
{
return;
}

var observed = customize[RaceIndex];
if (observed == UnknownRaceId)
{
return;
}

race = observed;
}
}