using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
using System.Text.Json.Serialization;
namespace DorkNet.Server.Controllers.Match;
///
/// match.rec.net/player/* — endpoints that operate on the local player's
/// matchmaking session. These were previously falling through to the
/// GlobalCatchAllController which returned the kitchen-sink "RecNetResult
/// shotgun" body. That body lacks the required keys for the actual response
/// types and the client crashes with KeyNotFoundException.
///
/// The endpoints here use the same camelCase PlayerPresence shape as
/// PlayerPresenceController (verified at RVA 0x1458920).
///
[ApiController]
public class MatchPlayerController(
PlayerPresenceService presence,
NotificationService notifications,
RoomService rooms,
PrivateInstanceService privateInstances,
JoinTimeoutService joinTimeouts,
PresenceBroadcastService presenceFanout,
IConfiguration config,
DorkNetDbContext db,
ILogger log) : ControllerBase
{
///
/// POST /player/login — the 2020 watch starts every session by
/// posting here with a fresh LoginLock GUID it generated client-side.
/// We persist the lock as the canonical "active session" marker for this
/// player so subsequent heartbeats can be validated against it; a NEW
/// login overwrites the lock and any older session's heartbeats start
/// failing, which is the intended kick-the-first-session behaviour.
///
[HttpPost("/player/login")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
=> await RecordLoginLockAsync(loginLock, "player-login");
///
/// POST /player/exclusivelogin — newer clients use this instead
/// of /player/login and include a
/// TakeOverExclusiveSession form value. The lock semantics are
/// otherwise the same: the latest LoginLock wins for this account.
///
[HttpPost("/player/exclusivelogin")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task PlayerExclusiveLogin(
[FromForm(Name = "LoginLock")] string? loginLock,
[FromForm(Name = "TakeOverExclusiveSession")] bool? takeOverExclusiveSession)
=> await RecordLoginLockAsync(loginLock, "player-exclusive-login", takeOverExclusiveSession);
private async Task RecordLoginLockAsync(
string? loginLock,
string logScope,
bool? takeOverExclusiveSession = null)
{
var playerId = TryGetCurrentPlayerId();
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
{
presence.Clear(playerId);
presence.BeginBootDormGrace(playerId);
var replaced = presence.SetLock(playerId, loginLock);
if (replaced)
{
log.LogWarning(
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push",
logScope, playerId, takeOverExclusiveSession);
// The new session hasn't opened its SignalR socket yet
// (negotiate fires AFTER /player/login on the watch's
// boot sequence — verified in the live trace), so the
// ModerationKick push hits only the OLDER session's
// existing connections. They'll show the "you've been
// kicked" dialog and gracefully disconnect via the
// watch's LocalModerationKickSelf path.
await notifications.KickStaleSession(playerId,
"Your account was signed in on another device.");
}
}
return Ok(Array.Empty());
}
///
/// POST /player/logout — fires when the client gives up on a session
/// (e.g. Photon connection couldn't be established, client aborts).
/// Clear the cached presence so the next /goto starts fresh, and tell
/// friends right away — otherwise they keep seeing the player in their
/// last room until a bulk poll happens to notice.
///
[HttpPost("/player/logout")]
public async Task PlayerLogout()
{
var playerId = TryGetCurrentPlayerId();
if (playerId != 0)
{
joinTimeouts.Cancel(playerId, "logout");
presence.Clear(playerId);
await presenceFanout.PublishOfflineAsync(playerId);
}
return Ok(new { success = true, error = "" });
}
///
/// POST /player/notifydisconnect — fired by
/// RecNet.Matchmaking.NotifyPlayerDisconnected(int otherPlayerId)
/// (Matchmaking.cs:326). Body is form-urlencoded
/// otherPlayerId=<id> . The watch uses this to tell the
/// server "I just saw this other Photon participant disappear" —
/// e.g. when someone leaves a room via the menu. Real Rec Room
/// likely used it to GC sticky presence rows; we just acknowledge
/// and log so it stops falling through to GlobalCatchAll.
///
/// Returns 200 with no body (BestHTTP ignores the response per
/// Matchmaking.txt:6668-6699).
///
[HttpPost("/player/notifydisconnect")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public IActionResult NotifyDisconnect(
[FromForm(Name = "otherPlayerId")] int? otherPlayerId,
// The 2023-03-21 client sends PascalCase PlayerId + RoomInstanceId
// instead of 2020's otherPlayerId, so the legacy binding logged null.
[FromForm(Name = "PlayerId")] long? playerId,
[FromForm(Name = "RoomInstanceId")] long? roomInstanceId)
{
var caller = TryGetCurrentPlayerId();
log.LogInformation(
"[player-notify-disconnect] caller={Caller} reported other={Other} instance={Instance} left",
caller, otherPlayerId ?? (int?)playerId, roomInstanceId);
return Ok();
}
///
/// POST /player/heartbeat — every ~5–30s while connected. Client expects
/// a PlayerPresence response that mirrors its own cached state.
/// CRITICAL: must include the player's CURRENT roomInstance, not null.
/// Returning null while the client knows it's in DormRoom triggers
/// "presence heartbeat response indicates local presence is out-of-sync"
/// repeatedly → eventual "Error: -2" disconnect dialog.
///
/// We pull the current room from PlayerPresenceService, which records
/// each successful /goto/room/* call.
///
[HttpPost("/player/heartbeat")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task> Heartbeat([FromForm(Name = "LoginLock")] string? loginLock)
{
// We track the LoginLock per player in /player/login (still useful
// for admin diagnostics + future session-management) but
// intentionally DO NOT 401 here on stale locks. Returning 401 on
// a heartbeat causes the watch to yank the player out of the
// current Photon room mid-session — symptoms include
// "Cannot send messages when not connected" + "Head/LeftHand/
// RightHand being destroyed" + "presence heartbeat response
// indicates local presence is out-of-sync" — which is worse than
// letting two sessions co-exist briefly. If we want true
// single-session enforcement we'll do it via SignalR push to the
// older session and a graceful client-side disconnect, not via
// an HTTP 401 mid-heartbeat.
var playerId = TryGetCurrentPlayerId();
var room = await GetPresenceRoomForResponseAsync(playerId);
if (playerId != 0)
{
presence.MarkActive(playerId);
if (room is not null) presence.TouchRoom(playerId);
}
return Ok(new PresenceDto
{
PlayerId = playerId,
// Defaults: 0 = Everyone. Both come from the player's stored
// preference so a value set via /player/statusvisibility or
// /player/vrmovementmode survives the next heartbeat.
StatusVisibility = await GetPlayerPrefIntAsync(playerId, PrefStatusVisibility, 0),
DeviceClass = 0,
VrMovementMode = await GetPlayerPrefIntAsync(playerId, PrefVrMovementMode, 0),
RoomInstance = room,
});
}
[HttpPut("/player/statusvisibility")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value)
{
var playerId = TryGetCurrentPlayerId();
if (value is int v)
{
await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString());
// Friends only. The client does not apply this response to its own
// cached presence, and a PresenceUpdate about the local player runs
// the same apply-and-rejoin path as an out-of-sync heartbeat
// (Matchmaking.EGPABEDPEJO -> FLAOOBNJAAD, Matchmaking.txt:6917), so no self push.
await presenceFanout.PublishCurrentAsync(playerId);
}
var room = await GetPresenceRoomForResponseAsync(playerId);
return Ok(new PresenceDto
{
PlayerId = playerId,
StatusVisibility = value ?? await GetPlayerPrefIntAsync(playerId, PrefStatusVisibility, 0),
DeviceClass = 0,
VrMovementMode = await GetPlayerPrefIntAsync(playerId, PrefVrMovementMode, 0),
RoomInstance = room,
});
}
[HttpPut("/player/vrmovementmode")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task> SetVrMovementMode([FromForm(Name = "vrMovementMode")] int? value)
{
var playerId = TryGetCurrentPlayerId();
if (value is int v) await SetPlayerPrefAsync(playerId, PrefVrMovementMode, v.ToString());
var room = await GetPresenceRoomForResponseAsync(playerId);
return Ok(new PresenceDto
{
PlayerId = playerId,
StatusVisibility = await GetPlayerPrefIntAsync(playerId, PrefStatusVisibility, 0),
DeviceClass = 0,
VrMovementMode = value ?? await GetPlayerPrefIntAsync(playerId, PrefVrMovementMode, 0),
RoomInstance = room,
});
}
// ── per-player matchmaking preferences ───────────────────────────
// These three were previously echo-only: the client's chosen value came
// back in the response and was then dropped, so the setting reverted on the
// next heartbeat and never survived a restart. They live in PlayerSettings
// (no schema change needed) and are read back wherever presence is built.
private const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey;
private const string PrefVrMovementMode = "pref:vrMovementMode";
private const string PrefAvoidJuniors = "pref:avoidJuniors";
private async Task SetPlayerPrefAsync(long playerId, string key, string value)
{
if (playerId == 0) return;
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new DorkNet.Server.Data.Entities.PlayerSettingEntity
{
PlayerId = playerId, Key = key, Value = value,
});
}
else
{
row.Value = value;
}
await db.SaveChangesAsync();
}
private async Task GetPlayerPrefAsync(long playerId, string key)
{
if (playerId == 0) return null;
return await db.PlayerSettings
.Where(s => s.PlayerId == playerId && s.Key == key)
.Select(s => s.Value)
.FirstOrDefaultAsync();
}
private async Task GetPlayerPrefIntAsync(long playerId, string key, int fallback)
=> int.TryParse(await GetPlayerPrefAsync(playerId, key), out var v) ? v : fallback;
///
/// GET /player/avoidjuniors — returns a primitive boolean.
/// Verified against
/// Cpp2IL_ISIL/.../RecNet/Matchmaking.txt:7728 (URL) and 7737
/// (PromiseExtensions.ExpectPrimitiveResponse ) — the watch
/// expects a bare JSON true /false , not an array or
/// object. Returning [] from the catch-all triggered
/// "Failed to get avoid juniors status: Malformed Response" + a
/// Convert.ChangeType InvalidCastException, which left the
/// matchmaking init in a never-completes state on some boot flows.
///
[HttpGet("/player/avoidjuniors")]
public async Task> GetAvoidJuniors()
{
var playerId = TryGetCurrentPlayerId();
var stored = await GetPlayerPrefAsync(playerId, PrefAvoidJuniors);
return Ok(bool.TryParse(stored, out var v) && v);
}
/// PUT /player/avoidjuniors — body
/// avoidJuniors=true|false (Matchmaking.txt:7883). Persisted
/// per-player in PlayerSettings and read back by
/// . Returns the new value as a JSON
/// primitive so the watch's ExpectPrimitiveResponse is happy.
[HttpPut("/player/avoidjuniors")]
[HttpPost("/player/avoidjuniors")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task> SetAvoidJuniors([FromForm(Name = "avoidJuniors")] bool? value)
{
var playerId = TryGetCurrentPlayerId();
if (value is bool v)
await SetPlayerPrefAsync(playerId, PrefAvoidJuniors, v ? "true" : "false");
return Ok(value ?? false);
}
/// POST /player/photonregionpings — watch reports
/// its measured Photon region ping vector so the server can pick
/// the best region for this player's matchmaking. Body is form-
/// urlencoded: region=eu&ping=42®ion=us&ping=80&...
/// (one (region, ping) pair per row, repeated). Persists the
/// freshest measurement per (player, region) pair in
/// 's in-memory map — the
/// matchmaking GoTo flow reads this to bias instance creation
/// toward the player's lowest-ping region.
/// Verified URL at Matchmaking.txt:7280.
// 2020.12 calls this with the BestHTTP method enum value 3 (= PUT, not POST).
// Server-side route was POST → 405 Method Not Allowed → "Failed to upload
// photon region pings: HTTP Error 405". Accept GET/POST/PUT to cover both
// 2020.03 (POST) and 2020.12 (PUT) without guessing wrong on the enum.
[HttpPost("/player/photonregionpings")]
[HttpPut("/player/photonregionpings")]
[HttpGet("/player/photonregionpings")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public IActionResult PhotonRegionPings()
{
var playerId = TryGetCurrentPlayerId();
if (playerId == 0) return Ok();
var regions = Request.HasFormContentType
? Request.Form["region"].ToArray()
: Request.Query["region"].ToArray();
var pings = Request.HasFormContentType
? Request.Form["ping"].ToArray()
: Request.Query["ping"].ToArray();
var pairs = new Dictionary(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < Math.Min(regions.Length, pings.Length); i++)
{
var r = regions[i]?.Trim();
if (string.IsNullOrEmpty(r)) continue;
if (!int.TryParse(pings[i], out var p)) continue;
// Sanity bound — discard wild values from misbehaving clients.
pairs[r] = Math.Clamp(p, 0, 2000);
}
// The 2023-03-21 client does NOT use the repeated (region, ping) pair
// shape above. It sends one field per measured region, keyed by the
// region name with the ping as the value (us=42&eu=90&...). Against the
// pair parser both arrays came back empty, so no 2023 client ever
// registered a ping and region selection fell back to the default.
if (pairs.Count == 0)
{
var fields = Request.HasFormContentType
? (IEnumerable>)Request.Form
: Request.Query;
foreach (var (key, value) in fields)
{
var name = key?.Trim();
if (string.IsNullOrEmpty(name)) continue;
if (name.Equals("region", StringComparison.OrdinalIgnoreCase) ||
name.Equals("ping", StringComparison.OrdinalIgnoreCase)) continue;
if (!int.TryParse(value.FirstOrDefault(), out var p)) continue;
pairs[name] = Math.Clamp(p, 0, 2000);
}
}
if (pairs.Count > 0)
presence.SetPhotonRegionPings(playerId, pairs);
log.LogInformation("[player-region-pings] player={Player} regions={Count}", playerId, pairs.Count);
return Ok();
}
// Heartbeat / logout are unauthenticated endpoints (the client hits them
// before any token is acquired in some boot flows), so we use the
// optional `CurrentPlayerId()` extension and treat 0 as "anonymous".
// Cast to int because the wire shape's PlayerId field is an int — fits
// every accountId we mint (random in [100_000, 9_999_999)).
private int TryGetCurrentPlayerId() =>
this.CurrentPlayerId() is long id ? (int)id : 0;
private async Task GetPresenceRoomForResponseAsync(int playerId)
{
if (playerId == 0) return null;
var room = presence.GetRoom(playerId);
var pending = joinTimeouts.GetPending(playerId);
if (pending is { DeferPresenceCommit: true })
{
// Report the instance the player is joining — NOT null.
//
// Deferring the commit is about what everyone ELSE sees: presence
// stays uncommitted until the join is confirmed, so a failed join
// never leaves a ghost in the room. It was never meant to change
// what we tell the joining player about themselves.
//
// Returning null did exactly that, and the client treats it as the
// room vanishing mid-join: OnPresenceHeartbeatResponse XORs
// (cachedRoomInstance != null) against (serverRoomInstance != null),
// so a client holding the target while we say null fires
// "presence heartbeat response indicates local presence is
// out-of-sync", then "Attempting to go to Invalid RoomInstance"
// and cancels its own load ("OnPlayerPresenceUpdate is cancelling
// room load"). The join it aborted was the one we were waiting on,
// so it retried until a heartbeat happened to miss the window —
// entering a dorm took several attempts and looked like a load
// failure. The branch below documents this same XOR for the
// boot-race case; this one contradicted it.
//
// Echoing the pending target satisfies the XOR and matches what the
// client already has, while presence itself stays uncommitted.
log.LogInformation(
"[player-heartbeat] player={PlayerId} pending deferred room={RoomId} instance={InstanceId}; echoing target until join result",
playerId,
pending.Value.TargetRoom.RoomId,
pending.Value.TargetRoom.RoomInstanceId);
return pending.Value.TargetRoom;
}
if (room is null)
{
// The player went to the offline room (matchmake/none). Their
// client now holds RoomInstanceId -1, which the heartbeat compare
// treats as "no room" (HCHDEHIGEBE.GIICPDHJOOH, sign bit of the id;
// RecNet.Runtime/RecNet/Matchmaking.txt:18415-18437) and is only in
// sync with a null roomInstance. The dorm fallback below would
// read as out-of-sync and drag the player out of the offline room.
if (presence.IsInNoRoom(playerId))
{
presence.TouchNoRoom(playerId);
log.LogInformation(
"[player-heartbeat] player={PlayerId} online in no room (left via matchmake/none); returning null roomInstance",
playerId);
return null;
}
if (presence.IsBootDormGraceActive(playerId))
{
log.LogInformation(
"[player-heartbeat] player={PlayerId} in boot dorm grace; returning null roomInstance",
playerId);
return null;
}
// Boot-time race: the watch hits /player/heartbeat before
// /goto/room/DormRoom has finished recording presence. The
// watch's OnPresenceHeartbeatResponse compares
// (cachedRoomInstance != null) XOR (serverRoomInstance != null)
// (Cpp2IL_ISIL/.../RecNet/Matchmaking.txt:8584) — if its local
// cache already has a dorm but we hand back null, it fires
// "RecNet presence heartbeat response indicates local presence
// is out-of-sync!" and tears down the local player (the
// RigidbodyEx Head/Hand "Cannot set the parent" warnings at
// [line 585] are the destruction shrapnel). Synthesise a
// default dorm RoomInstance instead so the heartbeat never
// returns null for an authenticated player. Matches what
// /goto/room/DormRoom builds — same RoomId, same
// PhotonRoomId pattern, same location GUID — so the watch's
// RoomInstancesAreDifferent finds them identical.
var fallback = await BuildDefaultDormPresenceAsync(playerId);
if (fallback is not null)
{
log.LogInformation(
"[player-heartbeat] synthesised dorm fallback player={PlayerId} room={RoomId} dataBlob={DataBlob} (presence cache miss)",
playerId, fallback.RoomId, fallback.DataBlob);
presence.SetRoom(playerId, fallback);
presence.MarkActive(playerId);
await presenceFanout.PublishCurrentAsync(playerId);
return fallback;
}
log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId);
return null;
}
var resolvedDataBlob = await ResolveDataBlobForPresenceAsync(
room.RoomId,
playerId,
room.RoomInstanceId,
room.SubRoomId,
room.IsPrivate);
if (!string.IsNullOrWhiteSpace(resolvedDataBlob)
&& !string.Equals(room.DataBlob, resolvedDataBlob, StringComparison.Ordinal))
{
log.LogWarning(
"[player-heartbeat] repairing stale presence dataBlob player={PlayerId} room={RoomId} instance={InstanceId} cached={CachedDataBlob} resolved={ResolvedDataBlob}",
playerId, room.RoomId, room.RoomInstanceId, room.DataBlob, resolvedDataBlob);
room.DataBlob = resolvedDataBlob;
presence.SetRoom(playerId, room);
}
presence.MarkActive(playerId);
presence.TouchRoom(playerId);
log.LogInformation(
"[player-heartbeat] player={PlayerId} room={RoomId} instance={InstanceId} subRoom={SubRoomId} location={Location} photon={PhotonRoomId} dataBlob={DataBlob}",
playerId, room.RoomId, room.RoomInstanceId, room.SubRoomId, room.Location, room.PhotonRoomId, room.DataBlob);
return room;
}
/// Build a RoomInstance that matches the shape
/// /goto/room/DormRoom would have produced for this player
/// — used as a heartbeat fallback when presence is empty (boot
/// race, replica restart, Redis TTL expiry).
///
/// CRITICAL: this MUST reproduce /goto 's RoomInstance
/// byte-for-byte (RoomInstanceId, PhotonRoomId, Location, DataBlob),
/// or the watch's RoomInstancesAreDifferent fires
/// "presence heartbeat response indicates local presence is
/// out-of-sync!" and boot-loops the player. The previous version
/// computed RoomInstanceId = roomId * 100_000 , but
/// GoToController stamps the dorm with
/// EnsureForDormAsync 's id (0xDD0000 ^ … ) — so the two
/// disagreed and every cache-miss heartbeat (e.g. right after a
/// server restart) kicked the player. We now go through the exact
/// same +
/// path
/// /goto/room/DormRoom uses, so the id math can never drift
/// again. Returns null only for anonymous callers.
private async Task BuildDefaultDormPresenceAsync(int playerId)
{
if (playerId == 0) return null;
// Same resolution /goto does: the caller's personal dorm row is
// the source of truth for RoomId + LocationReplicationId.
var personal = await rooms.EnsurePersonalDormAsync(playerId);
var roomId = personal.Id;
var location = personal.LocationReplicationId;
var dataBlob = await ResolveDataBlobForPresenceAsync(roomId, playerId);
var photonRegion = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
var photonRoomId = $"^dormroom_p{playerId}";
var dormInstanceName = await BuildDormInstanceNameAsync(playerId);
// EnsureForDormAsync owns the deterministic dorm instance-id
// formula; reusing it here guarantees the heartbeat's
// RoomInstanceId equals the one /goto handed the watch.
var dorm = await privateInstances.EnsureForDormAsync(
ownerPlayerId: playerId,
roomId: roomId,
subRoomId: 0,
baseName: dormInstanceName,
location: location,
dataBlob: dataBlob,
photonRegion: photonRegion,
maxCapacity: personal.MaxCapacity,
photonRoomId: photonRoomId);
return new RoomInstanceDto
{
RoomInstanceId = dorm.InstanceId,
RoomId = roomId,
SubRoomId = 0,
Location = location,
PhotonRegionId = photonRegion,
PhotonRoomId = photonRoomId,
Name = dormInstanceName,
MaxCapacity = personal.MaxCapacity,
IsFull = false,
IsPrivate = true,
IsInProgress = true,
DataBlob = dataBlob ?? string.Empty,
EventId = 0,
};
}
private async Task BuildDormInstanceNameAsync(long ownerPlayerId)
{
var player = await db.Players.AsNoTracking()
.Where(p => p.Id == ownerPlayerId)
.Select(p => new { p.DisplayName, p.Username })
.FirstOrDefaultAsync();
var name = !string.IsNullOrWhiteSpace(player?.DisplayName)
? player!.DisplayName
: !string.IsNullOrWhiteSpace(player?.Username)
? player!.Username
: $"Player{ownerPlayerId}";
var cleaned = new string(name
.Select(ch => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' ? ch : '_')
.ToArray()).Trim('_');
return string.IsNullOrWhiteSpace(cleaned) ? $"Player{ownerPlayerId}_dorm" : $"{cleaned}_dorm";
}
private async Task ResolveDataBlobForPresenceAsync(
long roomId,
int playerId,
long? roomInstanceId = null,
long? subRoomId = null,
bool isPrivateInstance = false)
{
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return string.Empty;
if (room.IsDormRoom)
{
var dormBlob = await rooms.ResolveDormDataBlobNameAsync(playerId, room.Id);
if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob;
}
if (isPrivateInstance && roomInstanceId is > 0)
{
var instanceBlob = await db.PrivateInstances.AsNoTracking()
.Where(instance => instance.Id == roomInstanceId && instance.RoomId == roomId)
.Select(instance => instance.DataBlob)
.FirstOrDefaultAsync();
if (!string.IsNullOrWhiteSpace(instanceBlob)) return instanceBlob;
}
if (subRoomId is >= 0)
{
var sceneBlob = await db.RoomScenes.AsNoTracking()
.Where(scene => scene.RoomId == roomId && scene.OrderIndex == subRoomId)
.Select(scene => scene.DataBlobName)
.FirstOrDefaultAsync();
if (!string.IsNullOrWhiteSpace(sceneBlob)) return sceneBlob;
}
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (RoomService.IsBakedOriginalRoom(room)) return string.Empty;
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
}
}
///
/// Wire shape of RecNet.PlayerPresence (RVA 0x1458920). Lives here as a
/// local DTO; same fields as PlayerPresenceController.PlayerPresenceDto.
///
public class PresenceDto
{
[JsonPropertyName("playerId")]
public int PlayerId { get; set; }
[JsonPropertyName("statusVisibility")]
public int StatusVisibility { get; set; }
[JsonPropertyName("deviceClass")]
public int DeviceClass { get; set; }
[JsonPropertyName("vrMovementMode")]
public int VrMovementMode { get; set; }
[JsonPropertyName("roomInstance")]
public object? RoomInstance { get; set; } = null;
// 2020.12 PlayerPresence.Deserialize reads these as required keys.
[JsonPropertyName("isOnline")]
public bool IsOnline { get; set; } = true;
[JsonPropertyName("appVersion")]
// 2023.03.21 client build version ("20230317"); must match the joining
// client's own version or it reports a version mismatch. Was a stale 2020
// value (20201210) left over from the December branch this forked from.
public string AppVersion { get; set; } = "20230317";
}