feat(ui): share indicator detail panels

Port the authored Link Status, Vitae, and Mini Game detail roots and register every indicator page with retail's one-active gmPanelUI owner. Helpful/Harmful and the new pages now replace Inventory, Character, or Magic at one canonical window position while preserving the DAT restore-previous flag.

Correct the retail ping wire to its payload-free request/response, publish measured RTT, and port Vitae recovery XP from the live modifier and player properties. Keep transport packet-loss averaging and mini-game gameplay explicitly tracked under AP-110.

Release build and all 5,814 tests pass with five intentional skips. Connected visual gate pending.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 10:27:41 +02:00
parent 52c529be86
commit a96767ba6d
28 changed files with 6539 additions and 32 deletions

View file

@ -529,20 +529,49 @@ public sealed class WorldSession : IDisposable
CurrentState,
Volatile.Read(ref _lastInboundPacketTicks),
Stopwatch.GetTimestamp(),
Stopwatch.Frequency);
Stopwatch.Frequency,
PingRoundTripSeconds);
internal double? PingRoundTripSeconds
{
get
{
double value = BitConverter.Int64BitsToDouble(
Volatile.Read(ref _lastPingRoundTripBits));
return double.IsFinite(value) && value >= 0d ? value : null;
}
}
internal static LinkStatusSnapshot BuildLinkStatus(
State state,
long lastInboundPacketTicks,
long nowTicks,
long frequency)
long frequency,
double? roundTripSeconds = null,
double packetLossPercentage = 0d)
{
bool connected = state is not State.Disconnected and not State.Failed;
if (!connected || frequency <= 0)
return LinkStatusSnapshot.Disconnected;
long elapsed = Math.Max(0, nowTicks - lastInboundPacketTicks);
return new LinkStatusSnapshot(true, elapsed / (double)frequency);
return new LinkStatusSnapshot(
true,
elapsed / (double)frequency,
packetLossPercentage,
roundTripSeconds);
}
private void RecordPingResponse(long nowTicks)
{
long requestTicks = Interlocked.Exchange(ref _lastPingRequestTicks, 0L);
if (requestTicks <= 0L || nowTicks < requestTicks || Stopwatch.Frequency <= 0)
return;
double elapsed = (nowTicks - requestTicks) / (double)Stopwatch.Frequency;
Volatile.Write(
ref _lastPingRoundTripBits,
BitConverter.DoubleToInt64Bits(elapsed));
}
/// <summary>Movement sequence counters for outbound MoveToState/AutonomousPosition.</summary>
@ -574,6 +603,8 @@ public sealed class WorldSession : IDisposable
private readonly NetClient _net;
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
private long _lastPingRequestTicks;
private long _lastPingRoundTripBits = BitConverter.DoubleToInt64Bits(double.NaN);
private readonly IPEndPoint _loginEndpoint;
private readonly IPEndPoint _connectEndpoint;
private readonly FragmentAssembler _assembler = new();
@ -656,6 +687,11 @@ public sealed class WorldSession : IDisposable
var parsed = SetTurbineChatChannels.TryParse(e.Payload.Span);
if (parsed is not null) TurbineChannelsReceived?.Invoke(parsed.Value);
});
GameEvents.Register(GameEventType.PingResponse, e =>
{
if (Messages.GameEvents.ParsePingResponse(e.Payload.Span))
RecordPingResponse(Stopwatch.GetTimestamp());
});
}
/// <summary>
@ -1701,6 +1737,18 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
/// <summary>
/// Request the round-trip sample displayed by retail <c>gmLinkStatusUI</c>.
/// The response is an empty 0x01EA GameEvent, so the session retains the
/// monotonic send timestamp rather than putting an invented id on the wire.
/// </summary>
public void RequestLinkStatusPing()
{
Volatile.Write(ref _lastPingRequestTicks, Stopwatch.GetTimestamp());
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildPingRequest(seq));
}
/// <summary>Send retail TargetedMeleeAttack (0x0008).</summary>
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
{