Refactor configuration endpoints and update client installation scripts

- Removed Photon configuration fields from dorknet-clientmod.json.template.
- Added new backtrace and Azure Speech configuration endpoints in ConfigController.
- Updated response format for IsCreationEnabled and IsRenderingEnabled endpoints in CustomAvatarItemsController.
- Enhanced InfluencerPartnerProgramController to include additional player properties.
- Implemented RoomKeepsakes endpoint in KeepsakesController to retrieve keepsakes by room ID.
- Modified ReportsController to standardize response for Hile reporting.
- Added PlayerDataForMe and RoomsRequiring endpoints in RoomsController for enhanced player data retrieval.
- Introduced new dorm matchmaking methods in MatchController for player session management.
- Updated MatchPlayerController to handle exclusive login functionality.
- Revised install scripts to reflect changes in Photon AppId handling and patching process.
- Added new patch-client.ps1 script for applying static byte patches to the Rec Room client.
This commit is contained in:
Alexa
2026-06-23 10:42:25 +03:00
parent 4dc8dc9b86
commit 766717abd2
15 changed files with 823 additions and 641 deletions
@@ -21,6 +21,102 @@ public class ConfigController(ConfigService configService) : ControllerBase
[HttpGet("amplitude")]
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
/// <summary>
/// GET <c>api/config/v1/backtrace</c> — 2023 boot downloads this before
/// audio/core initialization. Returning 404 leaves the static backtrace
/// config null and later crashes <c>ConfigSettings</c> consumers. Keep
/// crash upload/deobfuscation disabled locally while preserving the
/// object shape and readable config keys the client inspects.
/// </summary>
[HttpGet("backtrace")]
public ActionResult<object> GetBacktrace(
[FromQuery(Name = "platformType")] string? platformType,
[FromQuery(Name = "allocate")] bool? allocate)
{
var baseUrl = $"{Request.Scheme}://{Request.Host}";
var submissionUrl = $"{baseUrl}/backtrace/submit";
var minidumpSubmissionUrl = $"{baseUrl}/backtrace/minidump";
var config = new Dictionary<string, object>
{
["Backtrace.SampleRate"] = 0.0,
["Backtrace.ReportBudget"] = 0,
["Backtrace.deobfuscationRate"] = 0.0,
["Backtrace.Deobfuscate"] = false,
["Backtrace.DeobfuscationUrl"] = string.Empty,
["Backtrace.Enabled"] = false,
["Backtrace.PlatformType"] = platformType ?? string.Empty,
["Backtrace.Allocate"] = allocate ?? false,
};
return Ok(new Dictionary<string, object>
{
["MaxMessagesPerMinute"] = 0,
["MaxReportsPerMinute"] = 0,
["SampleRate"] = 0.0,
["ReportBudget"] = 0,
["ErrorReportBudget"] = 0,
["WarningReportBudget"] = 0,
["LogReportBudget"] = 0,
["SubmissionUrl"] = submissionUrl,
["MinidumpSubmissionUrl"] = minidumpSubmissionUrl,
["ServerUrl"] = submissionUrl,
["DatabasePath"] = "backtrace/database",
["Config"] = config,
["maxMessagesPerMinute"] = 0,
["maxReportsPerMinute"] = 0,
["sampleRate"] = 0.0,
["reportBudget"] = 0,
["errorReportBudget"] = 0,
["warningReportBudget"] = 0,
["logReportBudget"] = 0,
["submissionUrl"] = submissionUrl,
["minidumpSubmissionUrl"] = minidumpSubmissionUrl,
["serverUrl"] = submissionUrl,
["databasePath"] = "backtrace/database",
["config"] = config,
});
}
[HttpGet("azurespeech")]
public IActionResult GetAzureSpeech()
{
return Ok(new Dictionary<string, object>
{
["Enabled"] = false,
["SpeechRecognitionEnabled"] = false,
["SpeechSynthesisEnabled"] = false,
["Region"] = string.Empty,
["SubscriptionKey"] = string.Empty,
["TokenEndpoint"] = string.Empty,
["SpeechRecognitionEndpoint"] = string.Empty,
["SpeechSynthesisEndpoint"] = string.Empty,
["enabled"] = false,
["speechRecognitionEnabled"] = false,
["speechSynthesisEnabled"] = false,
["region"] = string.Empty,
["subscriptionKey"] = string.Empty,
["tokenEndpoint"] = string.Empty,
["speechRecognitionEndpoint"] = string.Empty,
["speechSynthesisEndpoint"] = string.Empty,
});
}
[HttpGet("/voice/config")]
public IActionResult GetVoiceConfig()
{
return Ok(new Dictionary<string, object>
{
["ToxMod.Enabled"] = false,
["ToxMod.SampleRate"] = 0.0,
["ToxMod.SessionRecordingEnabled"] = false,
["ToxMod.Endpoint"] = string.Empty,
["ToxMod.ApiKey"] = string.Empty,
["Enabled"] = false,
["enabled"] = false,
["toxModEnabled"] = false,
});
}
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
/// 2020.12 client deserializes this endpoint as a raw Boolean.
/// This server does not run the timed free-gift campaign, so the
@@ -187,11 +187,11 @@ public class CustomAvatarItemsController(
[HttpGet("isCreationEnabled")]
[AllowAnonymous]
public IActionResult IsCreationEnabled() => Ok(new { IsCreationEnabled = true, Enabled = true });
public IActionResult IsCreationEnabled() => Content("true", "application/json");
[HttpGet("isRenderingEnabled")]
[AllowAnonymous]
public IActionResult IsRenderingEnabled() => Ok(new { IsRenderingEnabled = true, Enabled = true });
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
[HttpGet("minPriceForPublicItem")]
[AllowAnonymous]
@@ -95,8 +95,15 @@ public class InfluencerPartnerProgramController(DorkNetDbContext db) : Controlle
private static object ToInfluencerWire(PlayerEntity player) => new
{
AccountId = (int)player.Id,
RawUsername = player.Username,
player.Username,
DisplayName = player.DisplayName,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
IsInfluencer = true,
SupportedInfluencerId = (int?)null,
LocalSupportedInfluencerId = (int?)null,
};
}
@@ -46,6 +46,25 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
return Ok(rows.Select(ToWire));
}
[HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> RoomKeepsakes(long roomId)
{
var roomExists = await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId);
if (!roomExists) return NotFound();
var roomIdText = roomId.ToString(System.Globalization.CultureInfo.InvariantCulture);
var rows = await db.Keepsakes
.Where(k => k.Category == "room"
&& (k.EventKey == $"room:{roomIdText}"
|| k.EventKey == $"room/{roomIdText}"
|| k.EventKey == roomIdText
|| EF.Functions.Like(k.EventKey, $"room:{roomIdText}:%")
|| EF.Functions.Like(k.EventKey, $"room/{roomIdText}/%")))
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("globalconfig")]
[AllowAnonymous]
public IActionResult GlobalConfig() => Ok(new
@@ -202,7 +202,7 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
private async Task<IActionResult> SaveHileAsync(string? rawDetail, int? type, long? gameSessionId)
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new { success = true });
if (pid is not long me) return HileResult();
var detail = (rawDetail ?? string.Empty).Trim();
if (type is int hileType)
detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}";
@@ -216,6 +216,13 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
Category = "hile",
});
await db.SaveChangesAsync();
return Ok(new { success = true });
return HileResult();
}
private static IActionResult HileResult() => new ContentResult
{
Content = "true",
ContentType = "application/json",
StatusCode = StatusCodes.Status200OK,
};
}
@@ -1532,6 +1532,144 @@ public class RoomsController(
[HttpGet("api/rooms/v2/myrecent")]
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
[HttpGet("rooms/{roomId:long}/playerdata/me")]
[Authorize]
public async Task<IActionResult> PlayerDataForMe(long roomId)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
var room = await db.Rooms.AsNoTracking()
.Where(r => r.Id == roomId)
.Select(r => new { r.Id, r.VisitCount, r.CheerCount, r.FavoriteCount })
.FirstOrDefaultAsync();
if (room is null) return NotFound();
var favorited = await db.RoomBookmarks
.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid.Value);
var cheered = await db.Cheers
.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid.Value);
var visit = await db.RoomVisits.AsNoTracking()
.Where(v => v.RoomId == roomId && v.PlayerId == pid.Value)
.Select(v => new { v.VisitCount, v.FirstVisitAt, v.LastVisitAt })
.FirstOrDefaultAsync();
return Ok(new
{
RoomId = room.Id,
PlayerId = pid.Value,
Favorite = favorited,
Favorited = favorited,
IsFavorite = favorited,
Cheer = cheered,
Cheered = cheered,
IsCheered = cheered,
IsBookmarked = favorited,
IsCheering = cheered,
VisitCount = visit?.VisitCount ?? 0,
FirstVisitedAt = visit?.FirstVisitAt,
LastVisitedAt = visit?.LastVisitAt,
RoomVisitCount = room.VisitCount,
RoomCheerCount = room.CheerCount,
RoomFavoriteCount = room.FavoriteCount,
});
}
[HttpGet("rooms/requiring/{restriction}")]
[HttpGet("roomserver/rooms/requiring/{restriction}")]
[Authorize]
public async Task<IActionResult> RoomsRequiring(string restriction)
{
var key = (restriction ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant();
if (key.Length == 0) return Ok(new List<object>());
IQueryable<RoomEntity> query = db.Rooms.AsNoTracking()
.Where(r => !r.HiddenFromBrowse && r.State == 0);
query = key switch
{
"developer" or "studio" or "rrstudio" => query.Where(r =>
r.IsStudioRoom
|| r.IsRoomLinkedToRecRoomStudio
|| EF.Functions.Like(r.TagsCsv, "%developer%")
|| EF.Functions.Like(r.TagsCsv, "%studio%")),
"rrplus" or "recroomplus" => query.Where(r =>
EF.Functions.Like(r.TagsCsv, "%rrplus%")
|| EF.Functions.Like(r.TagsCsv, "%recroomplus%")),
_ => query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{key}%")),
};
var rows = await query
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Take(100)
.ToListAsync();
return Ok(rows.Select(RoomService.ToWireRoom).ToList());
}
[HttpGet("rooms/curated_playlists")]
[HttpGet("roomserver/rooms/curated_playlists")]
[Authorize]
public async Task<IActionResult> CuratedPlaylistsCompat()
{
var curated = await playlists.CuratedAsync();
return Ok(curated.Select(BuildPlaylistUnionEntry).ToList());
}
[HttpGet("clubhousesearch/mostactivenow")]
[HttpGet("roomserver/clubhousesearch/mostactivenow")]
[Authorize]
public async Task<IActionResult> MostActiveClubhouses()
{
var rows = await db.Clubs.AsNoTracking()
.Where(c => c.State == 0 && c.ClubhouseRoomId != null)
.GroupJoin(
db.ClubMemberships.AsNoTracking(),
c => c.Id,
m => m.ClubId,
(club, memberships) => new
{
Club = club,
MemberCount = memberships.Count(),
})
.Join(
db.Rooms.AsNoTracking(),
c => c.Club.ClubhouseRoomId!.Value,
r => r.Id,
(c, room) => new
{
c.Club,
c.MemberCount,
Room = room,
})
.OrderByDescending(x => x.MemberCount)
.ThenByDescending(x => x.Room.HotScore)
.Take(50)
.ToListAsync();
return Ok(rows.Select(x => new
{
ClubId = x.Club.Id,
x.Club.Name,
x.Club.Description,
MainImageName = x.Club.ImageName,
ImageName = x.Club.ImageName,
State = x.Club.State,
CreatorAccountId = x.Club.CreatorPlayerId,
Category = x.Club.Category,
Visibility = x.Club.Visibility,
Joinability = x.Club.Joinability,
x.Club.AllowJuniors,
MemberCount = x.MemberCount,
x.Club.IsRRO,
x.Club.ClubhouseRoomId,
x.Club.ClubType,
Room = RoomService.ToWireRoom(x.Room),
RoomId = x.Room.Id,
RoomName = x.Room.Name,
}).ToList());
}
// ── Bookmark mutations ───────────────────────────────────────────────
[HttpPost("api/rooms/v1/bookmark/{roomId:long}")]
@@ -100,6 +100,11 @@ public class CdnController(
// String.Concat("/invention/", filename). Same byte-fetch flow as
// /room/, routed to S3 via BlobRouter on the filename.
[Route("/invention/{*path:minlength(1)}")]
// 2023 builds a CDN URL as `/sigs/<signature-set-id>` during boot
// file-hash verification. The original Rec Room CDN no longer
// serves the embedded 2023 id, so keep this on a dedicated path and
// fail soft instead of letting the request 404.
[Route("/sigs/{*path:minlength(1)}")]
// ASP.NET route templates treat `[` / `]` as token markers
// (e.g. `[controller]`). To use them as literals inside a regex
// constraint they must be doubled — so `[^/]` becomes `[[^/]]`.
@@ -266,6 +271,17 @@ public class CdnController(
// ── CDN serve path (cdn.*, storage.*, data.*, apex) ────────────────
private async Task<IActionResult> ServeCdn(string? path)
{
if (!string.IsNullOrEmpty(path) &&
path.StartsWith("sigs/", StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning("[cdn] signature set MISS host={Host} path={Path} -> empty signature payload",
Request.Host.Host, path);
var payload = Array.Empty<byte>();
Response.Headers.CacheControl = "public, max-age=60";
signatures.AddContentSignature(Response, payload);
return new FileContentResult(payload, "application/octet-stream");
}
if (string.Equals(path, "config/LoadingScreenTipData", StringComparison.OrdinalIgnoreCase))
{
// Tips live in the LoadingScreenTips DB table; admins
@@ -17,10 +17,12 @@ namespace DorkNet.Server.Controllers.Match;
[ApiController]
[Authorize]
public class MatchController(
IConfiguration config,
GameSessionService sessionService,
PlayerPresenceService presence,
PrivateInstanceService privateInstances,
JoinTimeoutService joinTimeouts,
RoomService rooms,
DorkNetDbContext db,
ILogger<MatchController> logger) : ControllerBase
{
@@ -84,6 +86,138 @@ public class MatchController(
return Ok();
}
[HttpPost("/matchmake/none")]
public async Task<IActionResult> MatchmakeNone()
{
var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Unauthorized();
var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid);
presence.SetRoom(pid, current);
presence.MarkActive(pid);
logger.LogInformation("[matchmake-none] player={Player}", pid);
return Ok(new MatchmakingResponseDto
{
ErrorCode = 0,
RoomInstance = current,
});
}
[HttpPost("/matchmake/dorm")]
public async Task<IActionResult> MatchmakeDorm()
{
var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Unauthorized();
var room = await BuildDormRoomInstanceAsync(pid);
presence.SetRoom(pid, room);
presence.MarkActive(pid);
await RecordVisitAsync(pid, room.RoomId);
logger.LogInformation(
"[matchmake-dorm] player={Player} room={Room} instance={Instance} photon={Photon}",
pid, room.RoomId, room.RoomInstanceId, room.PhotonRoomId);
return Ok(new MatchmakingResponseDto
{
ErrorCode = 0,
RoomInstance = room,
});
}
private async Task<RoomInstanceDto> BuildDormRoomInstanceAsync(long playerId)
{
var personal = await rooms.EnsurePersonalDormAsync(playerId);
var dataBlob = await rooms.ResolveDormDataBlobNameAsync(playerId, personal.Id);
if (string.IsNullOrWhiteSpace(dataBlob))
dataBlob = personal.CurrentDataBlobName;
var photonRegion = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
var photonRoomId = $"^dormroom_p{playerId}";
var dormInstanceName = await BuildDormInstanceNameAsync(playerId);
var dorm = await privateInstances.EnsureForDormAsync(
ownerPlayerId: playerId,
roomId: personal.Id,
subRoomId: 0,
baseName: dormInstanceName,
location: personal.LocationReplicationId,
dataBlob: dataBlob ?? string.Empty,
photonRegion: photonRegion,
maxCapacity: personal.MaxCapacity,
photonRoomId: photonRoomId);
return new RoomInstanceDto
{
RoomInstanceId = dorm.InstanceId,
RoomId = personal.Id,
SubRoomId = 0,
Location = personal.LocationReplicationId,
PhotonRegionId = photonRegion,
PhotonRoomId = photonRoomId,
Name = dormInstanceName,
MaxCapacity = personal.MaxCapacity,
IsFull = false,
IsPrivate = true,
IsInProgress = true,
DataBlob = dataBlob ?? string.Empty,
EventId = 0,
};
}
private async Task<string> 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 RecordVisitAsync(long playerId, long roomId)
{
var visit = await db.RoomVisits
.FirstOrDefaultAsync(v => v.RoomId == roomId && v.PlayerId == playerId);
var isFirst = visit is null;
if (isFirst)
{
visit = new RoomVisitEntity
{
RoomId = roomId,
PlayerId = playerId,
FirstVisitAt = DateTime.UtcNow,
};
db.RoomVisits.Add(visit);
}
visit!.LastVisitAt = DateTime.UtcNow;
visit.VisitCount += 1;
if (isFirst)
{
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.VisitCount, r => r.VisitCount + 1)
.SetProperty(r => r.VisitorCount, r => r.VisitorCount + 1)
.SetProperty(r => r.HotScore, r => r.HotScore + 2.0));
}
else
{
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.VisitCount, r => r.VisitCount + 1)
.SetProperty(r => r.HotScore, r => r.HotScore + 0.25));
}
await db.SaveChangesAsync();
}
/// <summary>PUT <c>/roominstance/{id}/inprogress</c> — fired by
/// <c>RecNet.Matchmaking.SetGameInProgress(bool inProgress)</c>
@@ -38,6 +38,25 @@ public class MatchPlayerController(
[HttpPost("/player/login")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
=> await RecordLoginLockAsync(loginLock, "player-login");
/// <summary>
/// POST <c>/player/exclusivelogin</c> — newer clients use this instead
/// of <c>/player/login</c> and include a
/// <c>TakeOverExclusiveSession</c> form value. The lock semantics are
/// otherwise the same: the latest LoginLock wins for this account.
/// </summary>
[HttpPost("/player/exclusivelogin")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> PlayerExclusiveLogin(
[FromForm(Name = "LoginLock")] string? loginLock,
[FromForm(Name = "TakeOverExclusiveSession")] bool? takeOverExclusiveSession)
=> await RecordLoginLockAsync(loginLock, "player-exclusive-login", takeOverExclusiveSession);
private async Task<IActionResult> RecordLoginLockAsync(
string? loginLock,
string logScope,
bool? takeOverExclusiveSession = null)
{
var playerId = TryGetCurrentPlayerId();
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
@@ -46,8 +65,8 @@ public class MatchPlayerController(
if (replaced)
{
log.LogWarning(
"[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push",
playerId);
"[{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