fix: second pass over the 2023-client wire defects, plus a coverage harness

Fourteen agents worked one server file each over the audit's open findings,
re-verifying every claim against the client binary before changing anything.
Route+verb coverage against the recovered client inventory is now 474/522
(90.8%), up from 86.2% when the harness was first wired up.

Adds ClientRouteCoverageTests, which turns "are we compliant?" into a number
instead of an opinion. It reads data/client-routes-2023.tsv — every request the
2023-03-21 client can issue, recovered from the binary — and asserts each one
has a handler under the verb the client actually uses, separating "no handler"
from "handler exists, wrong verb" since those have different fixes. Remaining
gaps live in KnownGaps, which is expected to shrink and never grow.

Also fixes a real blind spot in EndpointContractDiscovery: it only collected
HttpMethodAttribute, so an action carrying a bare [Route] with no verb attribute
was invisible to it. The CDN's unity_assets handler is exactly that shape, which
had been showing up as a phantom gap.

Notable findings from this pass:

- The club permissions envelope key is literally "Type", not "MembershipType"
  as the audit's suggested fix guessed. Read off the generated reader, then
  cross-checked against the 2020 decompile — the int bitmask we were emitting
  was wrong for BOTH client generations, not just 2023.
- Room-consumable purchase responses carry OperationResult as an OBJECT
  { Status, InventoryItem }; the scalar operation enum belongs in
  BalanceUpdateResult. The base type OKHNOPLBOFP holds a single BGHBAILNNJJ,
  which exposes exactly those two properties. A test asserting the old scalar
  shape was corrected rather than the code.
- buyProgressionEventXpBoost blind-inserted an ObjectiveProgress row whose
  (PlayerId, Key) is unique, so a second purchase of the same boost threw
  "UNIQUE constraint failed" and 500'd AFTER taking the player's currency.
  Now an upsert.

Agents were forbidden from faking fixes they could not complete in their own
file, and several correctly refused: club role permissions, club additional
image slots, announcement meta, MinLevel/ClubChatEnabled, currency purchase
offer ordering, and room-key image/currency columns all need new entities or
columns. Those are deliberately left for a single coherent schema change rather
than stubbed out here.
This commit is contained in:
Alexa
2026-07-28 08:50:58 +03:00
parent 289072583e
commit 90e069ee4b
14 changed files with 3747 additions and 790 deletions
@@ -2409,8 +2409,31 @@ public class RoomsController(
[HttpGet("api/rooms/v2/myrecent")]
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
/// <summary>Per-(player, room) persistence blob, base64. Lives in the
/// generic <see cref="PlayerSettingEntity"/> key/value table — the same
/// "no schema change needed" trick MatchPlayerController uses for its
/// matchmaking prefs (MatchPlayerController.cs:213-217). The Value column
/// is declared <c>MaxLength(1024)</c> but SQLite stores it as unconstrained
/// TEXT and EF Core does not run DataAnnotations validation on SaveChanges,
/// so oversized blobs round-trip intact.</summary>
private static string PlayerRoomDataKey(long roomId) => $"roomplayerdata:{roomId}";
/// <summary>Fallback blob handed to a player who has never saved anything
/// in this room. <c>CAE=</c> is a one-field protobuf the client parses as
/// "empty, version 1"; a truly empty string makes the 2023 reader throw.</summary>
private const string DefaultPlayerRoomData = "CAE=";
/// <summary>GET <c>rooms/{roomId}/playerdata/me</c> — the per-room player
/// blob (CircuitsV2 "player persistence"). Client contract is
/// <c>Task&lt;ILKMFMCOPPO&gt;</c> whose reader requires the key
/// <c>Data</c> (OPNPNPMKLGI.txt ISIL 034/060); the request is issued from
/// NLDBPDCNNCF (NLDBPDCNNCF_NestedType_IGENGEIMGMJ.txt:169), so it arrives
/// with the <c>roomserver/</c> prefix — the bare-only registration 404'd.
/// This used to return a hardcoded <c>CAE=</c> for everyone; it now returns
/// whatever <see cref="SavePlayerDataForMe"/> last stored.</summary>
[HttpGet("player_room_data/{roomId:long}")]
[HttpGet("rooms/{roomId:long}/playerdata/me")]
[HttpGet("roomserver/rooms/{roomId:long}/playerdata/me")]
[Authorize]
public async Task<IActionResult> PlayerDataForMe(long roomId)
{
@@ -2431,9 +2454,15 @@ public class RoomsController(
.Select(v => new { v.VisitCount, v.FirstVisitAt, v.LastVisitAt })
.FirstOrDefaultAsync();
var key = PlayerRoomDataKey(roomId);
var stored = await db.PlayerSettings.AsNoTracking()
.Where(s => s.PlayerId == pid.Value && s.Key == key)
.Select(s => s.Value)
.FirstOrDefaultAsync();
return Ok(new
{
Data = "CAE=",
Data = string.IsNullOrEmpty(stored) ? DefaultPlayerRoomData : stored,
RoomId = room?.Id ?? roomId,
PlayerId = pid.Value,
Favorite = favorited,
@@ -2453,6 +2482,71 @@ public class RoomsController(
});
}
public sealed class PlayerRoomDataRequest { public string? Data { get; set; } }
/// <summary>PUT <c>rooms/{roomId}/playerdata/me</c> — persist the caller's
/// per-room blob. Client method <c>NLDBPDCNNCF.LIHFBBCDAGO(long, string)</c>
/// ("save player room data") moves verb ordinal 3 = PUT into r8 and posts a
/// single form field <c>data</c>
/// (NLDBPDCNNCF.txt ISIL 045/061/065 around :2566-2586; form key at
/// NLDBPDCNNCF_NestedType_ANPKFGHOOAJ.txt ISIL 015). Its return type is the
/// non-generic <c>Task</c>, so no response body is parsed.
///
/// <para>Only the GET was registered before, so every save 405'd and per-room
/// player state never persisted.</para></summary>
[HttpPut("rooms/{roomId:long}/playerdata/me")]
[HttpPost("rooms/{roomId:long}/playerdata/me")]
[HttpPut("roomserver/rooms/{roomId:long}/playerdata/me")]
[HttpPost("roomserver/rooms/{roomId:long}/playerdata/me")]
[HttpPut("player_room_data/{roomId:long}")]
[Authorize]
public async Task<IActionResult> SavePlayerDataForMe(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] PlayerRoomDataRequest req)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
var data = req.Data ?? string.Empty;
var key = PlayerRoomDataKey(roomId);
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == pid.Value && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid.Value, Key = key, Value = data,
});
}
else
{
row.Value = data;
}
await db.SaveChangesAsync();
// The client ignores the body (non-generic Task); the echo is for
// curl/admin debugging only.
return Ok(new { Result = 0, RoomId = roomId, PlayerId = pid.Value, Data = data });
}
/// <summary>GET <c>rooms/requiring/{restriction}</c> — the restricted-rooms
/// list. The path segment is <c>mode.ToString().ToLower()</c>
/// (Matchmaking.txt ISIL 067-072 around :21263, concat onto the literal
/// <c>"rooms/requiring/"</c> at :21264).
///
/// <para><b>Element type is String, not Int64 — verified, do not "fix" this
/// to room ids.</b> The issuing method
/// <c>Matchmaking.POIMPHMONON(IOCOJFJEDCM)</c> (Matchmaking.txt:21030)
/// resolves its promise as <c>FGLDKEJLAKB&lt;List&lt;String&gt;&gt;</c>
/// (typeof load at Matchmaking.txt:21327, continuation delegate
/// <c>Action&lt;List&lt;String&gt;&gt;</c> at :21315) and hands the payload to
/// <c>&lt;RequestRoomRestrictions&gt;b__2(List&lt;String&gt; rooms)</c>
/// (Matchmaking_NestedType_PMNEOAMKLJL.txt:14), which forwards it to
/// <c>Matchmaking.IINEGHJJBAN(IEnumerable&lt;String&gt;, IOCOJFJEDCM)</c>
/// (:20758) where the strings become the KEYS of the restriction map
/// (<c>List&lt;IOCOJFJEDCM&gt;</c> per key, ISIL 069-079 at :20963-20974).
/// So a bare array of room NAMES is the correct wire shape. Failures log
/// "Failed to get restricted rooms list: " (Matchmaking.txt:21306).</para></summary>
[HttpGet("rooms/requiring/{restriction}")]
[HttpGet("roomserver/rooms/requiring/{restriction}")]
[Authorize]
@@ -2708,19 +2802,31 @@ public class RoomsController(
/// <para>We accept both POST and PUT because the watch's ISIL
/// doesn't expose the explicit HTTPMethod literal at this call
/// site — covering both is cheap and means we can't get bitten
/// by the wrong one.</para></summary>
/// by the wrong one.</para>
///
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.LHNDKOJNKBE</c>
/// ("change room name", NLDBPDCNNCF.txt:5069) moves verb ordinal 3 = PUT,
/// sends the lowercase form key <c>name</c>
/// (NLDBPDCNNCF_NestedType_LMADOAEHOPP.txt ISIL 015) and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c> — the FULL room-details object, not a
/// status wrapper. It also carries the <c>roomserver/</c> prefix like every
/// other room mutation from that client
/// (docs/recroom-2023-room-save.md:83-93), so the bare-only routes 404'd.
/// The old signature mixed <c>[FromForm]</c> with a <c>[FromBody]</c>
/// parameter, which under <c>[ApiController]</c> makes the form PUT 415
/// before the handler runs — hence the shared FormOrJsonModelBinder.</para></summary>
public sealed class RenameBody { public string? Name { get; set; } }
[HttpPost("rooms/{roomId:long}/name")]
[HttpPut("rooms/{roomId:long}/name")]
[HttpPost("roomserver/rooms/{roomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/name")]
[Authorize]
public async Task<IActionResult> RenameRoom(
long roomId,
[FromForm(Name = "Name")] string? nameForm,
[FromForm(Name = "name")] string? nameFormLower,
[FromBody] RenameBody? body)
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
{
var newName = nameForm ?? nameFormLower ?? body?.Name;
var newName = body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
newName = newName.Trim();
@@ -2748,35 +2854,24 @@ public class RoomsController(
await db.SaveChangesAsync();
logger.LogInformation("[room-rename] room={Room} by={By} → '{Name}'", roomId, me, newName);
// Return the full updated Room DTO. The watch's
// <c>PPENFJMFPNE</c> deserialiser inherits from
// <c>KLCOGEIGEBJ</c> (the standard Room shape) so we can
// forward to the same v3-details payload builder by re-reading.
// BuildRoomDetailsV3Async is too heavy here — just return the
// minimal mutated subset; the watch UI re-fetches details
// shortly after for the room-info screen.
return Ok(new
{
Result = 0,
RoomId = room.Id,
Name = room.Name,
Description = room.Description,
ImageName = room.ImageName,
CreatorPlayerId = room.CreatorPlayerId,
CreatorAccountId = room.CreatorPlayerId,
State = room.State,
Accessibility = room.Accessibility,
IsAGRoom = room.IsAGRoom,
IsRRO = false,
IsDormRoom = room.IsDormRoom,
});
// Full room details, not the old minimal {Result,RoomId,Name,…} object:
// the 2023 issuing method returns Task<FGCPNAACHIK> (NLDBPDCNNCF.txt:5069)
// and that reader needs Room/Scenes/CoOwners/Stats — the trimmed payload
// made a successful rename surface as a parse failure in the client.
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>POST <c>rooms/{roomId}/subrooms/{subRoomId}/name</c> —
/// rename a scene/sub-room within a multi-scene room. Watch path:
/// <c>EJDCNGBEICB.PAENPJCDLNJ(long roomId, long subRoomId, string newName)</c>.
/// Updates the matching <see cref="RoomSceneEntity.Name"/> after
/// verifying ownership and intra-room uniqueness.</summary>
/// verifying ownership and intra-room uniqueness.
///
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.EJOHFBMFEGB</c>
/// ("change subroom name", NLDBPDCNNCF.txt:8694) is verb 3 = PUT with form
/// key <c>name</c> and returns <c>Task&lt;FGCPNAACHIK&gt;</c>. Same
/// [FromForm]+[FromBody] 415 hazard as the room rename above, so it binds
/// through the shared FormOrJsonModelBinder and echoes full details.</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
@@ -2785,11 +2880,9 @@ public class RoomsController(
public async Task<IActionResult> RenameSubroom(
long roomId,
long subRoomId,
[FromForm(Name = "Name")] string? nameForm,
[FromForm(Name = "name")] string? nameFormLower,
[FromBody] RenameBody? body)
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
{
var newName = nameForm ?? nameFormLower ?? body?.Name;
var newName = body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
newName = newName.Trim();
@@ -2822,19 +2915,7 @@ public class RoomsController(
await db.SaveChangesAsync();
logger.LogInformation("[subroom-rename] room={Room} sub={Sub} by={By} → '{Name}'", roomId, subRoomId, me, newName);
return Ok(new
{
Result = 0,
RoomId = roomId,
RoomSceneId = scene.OrderIndex,
SubRoomId = scene.OrderIndex,
Name = scene.Name,
scene.DataBlobName,
RoomSceneLocationId = scene.RoomSceneLocationId,
scene.IsSandbox,
scene.MaxPlayers,
scene.CanMatchmakeInto,
});
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>POST <c>api/rooms/v1/cheer</c> — cheer a room (body
@@ -2881,9 +2962,21 @@ public class RoomsController(
}
// ── Bare-path interactionby/me toggles ─────────────────────────────
//
// Verbs come straight from the 2023 client's four one-liner methods on
// NLDBPDCNNCF (each a non-generic Task, so no response body is parsed):
// "cheer room" :13467 r8 = 3 (PUT)
// "uncheer room" :13550 r8 = 4 (DELETE)
// "favorite room" :13633 r8 = 3 (PUT)
// "unfavorite room" :13716 r8 = 4 (DELETE)
// All four are issued from NLDBPDCNNCF, so they arrive with the
// roomserver/ prefix (docs/recroom-2023-room-save.md:83-93) — without the
// twins the cheer/favorite buttons 404'd and failed silently.
[HttpPost("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPut("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPut("roomserver/rooms/{roomId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> BareCheer(long roomId)
{
@@ -2893,6 +2986,7 @@ public class RoomsController(
}
[HttpDelete("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpDelete("roomserver/rooms/{roomId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> BareUncheer(long roomId)
{
@@ -2903,6 +2997,8 @@ public class RoomsController(
[HttpPost("rooms/{roomId:long}/interactionby/me/favorite")]
[HttpPut("rooms/{roomId:long}/interactionby/me/favorite")]
[HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/favorite")]
[HttpPut("roomserver/rooms/{roomId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> BareFavorite(long roomId)
{
@@ -2912,6 +3008,7 @@ public class RoomsController(
}
[HttpDelete("rooms/{roomId:long}/interactionby/me/favorite")]
[HttpDelete("roomserver/rooms/{roomId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> BareUnfavorite(long roomId)
{
@@ -3418,10 +3515,20 @@ public class RoomsController(
}
/// <summary>POST <c>rooms/{id}/subrooms</c> — append a new scene.
/// Owner-gated. OrderIndex = max+1, Name unique within room.</summary>
/// Owner-gated. OrderIndex = max+1, Name unique within room.
///
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.CMHBCOIBDME</c> ("add new
/// subroom", NLDBPDCNNCF.txt:9396) is verb 2 = POST with a single form key
/// <c>name</c> (NLDBPDCNNCF_NestedType_AIPHLHBILDN.txt ISIL 015) and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c>. It needs the roomserver/ twin, a binder
/// that accepts the form body (<c>[FromBody]</c> 415s it), and full room
/// details in the response instead of the scene row.</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
[Authorize]
public async Task<IActionResult> AddSubRoom(long roomId, [FromBody] CreateSubRoomRequest req)
public async Task<IActionResult> AddSubRoom(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -3448,10 +3555,18 @@ public class RoomsController(
};
db.RoomScenes.Add(scene);
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>DELETE <c>rooms/{id}/subrooms/{sub}</c>. The 2023 caller
/// <c>NLDBPDCNNCF.HGNGMIMJFJL</c> ("delete subroom", NLDBPDCNNCF.txt:9827)
/// moves verb ordinal 4 = DELETE and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c>; the only registration here used to be the
/// bare one (the roomserver/ twin at the top of the file is the GET), so
/// deleting a subroom 404'd, and the <c>{Result:0}</c> body was not the
/// details object the client re-parses.</summary>
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
{
@@ -3465,20 +3580,69 @@ public class RoomsController(
if (scene.OrderIndex == 0) return BadRequest(new { error = "cannot_delete_entry_scene" });
db.RoomScenes.Remove(scene);
await db.SaveChangesAsync();
return Ok(new { Result = 0 });
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
public sealed class SubRoomIntRequest { public int? Value { get; set; } }
public sealed class SubRoomBoolRequest { public bool? Value { get; set; } }
// Subroom mutation DTOs. Property names mirror the LOWERCASE form keys the
// 2023 client actually sends (FormOrJsonModelBinder matches case-insensitively):
// maxPlayers NLDBPDCNNCF_NestedType_BLNOGFGHIIF.txt ISIL 020
// accessibility NLDBPDCNNCF_NestedType_GNCLJBNJEKP.txt ISIL 020
// name/accessibility/maxPlayers ..._JEEACJHABAF.txt ISIL 021/035/048
// newRoomId NLDBPDCNNCF_NestedType_COIOGADEBKJ.txt ISIL 020
// The older {Value:…} DTOs matched no client key at all, and being bound
// [FromBody] they 415'd the form body before the handler ran.
public sealed class SubRoomMaxPlayersRequest { public int? MaxPlayers { get; set; } }
// Accessibility is typed string, not int, on purpose — the two client call
// sites disagree about how they stringify it. The standalone
// "change subroom accessibility" closure boxes the value as
// typeof(JHAOFLCJNAL) (GNCLJBNJEKP.txt ISIL 015), and the form writer's
// object overload falls through to a virtual ToString() for anything that
// isn't String/DateTime (BNDIAONDFFF.txt ISIL 107-110) — i.e. an enum
// MEMBER NAME goes on the wire. The bulk "modify" closure boxes the very
// same value as typeof(System.Int32) (JEEACJHABAF.txt ISIL 031) and sends a
// number. ParseSubRoomAccessibility below accepts both spellings.
public sealed class SubRoomAccessibilityRequest { public string? Accessibility { get; set; } }
public sealed class SubRoomModifyRequest
{
public string? Name { get; set; }
public string? Accessibility { get; set; }
public int? MaxPlayers { get; set; }
public bool? IsSandbox { get; set; }
public bool? CanMatchmakeInto { get; set; }
public string? RoomSceneLocationId { get; set; }
}
public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } }
public sealed class SubRoomMoveRequest
{
/// <summary>Target ROOM for the scene — the 2023 "move subroom" flow
/// re-parents a scene into another room. Nullable&lt;Int64&gt; on the
/// wire (NLDBPDCNNCF.MGGALMIAMBG, NLDBPDCNNCF.txt:9659).</summary>
public long? NewRoomId { get; set; }
/// <summary>Legacy in-room reorder used by the admin/import tooling.
/// Not something the 2023 client sends.</summary>
public int? NewIndex { get; set; }
}
/// <summary>One row of the per-subroom role-permission matrix the 2023
/// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the
/// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary>
public sealed class SubRoomPermissionEntry
{
// Field order + CLR types come from the BNLPBMJJOMM constructor
// (BNLPBMJJOMM.txt:126): (String Permission, OMMBGJMJJPN Role,
// Boolean Override, FEOJFGECKGH Type, String Value). The two enum
// members serialize as numbers (the role writer boxes them
// System.Int32 — BFKNABDHMGG.txt ISIL 015), hence int? here.
public string? Permission { get; set; }
public int? Role { get; set; }
public bool? Override { get; set; }
public int? Type { get; set; }
public string? Value { get; set; }
}
public sealed class PublishSubRoomSaveRequest
{
public long? SubRoomDataSaveId { get; set; }
@@ -3501,29 +3665,94 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[Authorize]
public Task<IActionResult> SubRoomMaxPlayers(long roomId, long subRoomId, [FromBody] SubRoomIntRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is int v) s.MaxPlayers = Math.Max(1, v); });
public Task<IActionResult> SubRoomMaxPlayers(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMaxPlayersRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.MaxPlayers is int v) s.MaxPlayers = Math.Max(1, v); });
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/accessibility</c>. Client:
/// <c>NLDBPDCNNCF.OAINKEAFPGE(long, long, JHAOFLCJNAL)</c>
/// (NLDBPDCNNCF.txt:9032, verb 3), form key <c>accessibility</c>, response
/// <c>Task&lt;FGCPNAACHIK&gt;</c>. We only model the matchmaking bit
/// (<see cref="RoomSceneEntity.CanMatchmakeInto"/>), so Private collapses to
/// "not a matchmaking target" and everything else to "is one".</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[Authorize]
public Task<IActionResult> SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; });
public Task<IActionResult> SubRoomAccessibility(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomAccessibilityRequest req) =>
MutateScene(roomId, subRoomId, s =>
{
if (ParseSubRoomAccessibility(req.Accessibility) is int a)
s.CanMatchmakeInto = a != AccessibilityPrivate;
});
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — the
/// role-permission matrix. Client:
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList&lt;BNLPBMJJOMM&gt;)</c>
/// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT
/// form-encode: the closure calls <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c>
/// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string
/// (BNDIAONDFFF.txt:275, <c>mov dword ptr [rbx+38h],4</c>) — i.e. a raw
/// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON
/// deserialize into the List).
///
/// <para><b>Scope note — deliberately not persisted.</b> DorkNet has no
/// per-subroom permission storage: <see cref="RoomSceneEntity"/> has no
/// column for it and there is no permissions table. Adding one means an
/// entity + DbContext + migration, which is outside this file. Until then
/// the matrix is accepted, logged, and the room details echoed back so the
/// client's promise resolves instead of 415-ing; the values do not survive
/// a round-trip. The old handler silently bound an unrelated DTO whose keys
/// the client never sends, which was strictly worse.</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[Authorize]
public Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest? req) =>
MutateScene(roomId, subRoomId, s =>
{
if (req?.CanMatchmakeInto is bool canMatchmakeInto) s.CanMatchmakeInto = canMatchmakeInto;
if (req?.MaxPlayers is int maxPlayers) s.MaxPlayers = Math.Max(1, maxPlayers);
if (req?.IsSandbox is bool isSandbox) s.IsSandbox = isSandbox;
});
public async Task<IActionResult> SubRoomPermissions(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List<SubRoomPermissionEntry> req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
await db.SaveChangesAsync();
logger.LogWarning(
"[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)",
roomId, subRoomId, pid, req?.Count ?? 0);
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
// JHAOFLCJNAL member values. The numeric leg is verified (the "modify"
// closure boxes the same value as System.Int32 — JEEACJHABAF.txt ISIL 031);
// the NAMES are inferred from the room-accessibility enum in the nearest
// dump with real names (RecRoom-2023.06.21 dump.cs:1293997-1294004
// MKDFFJGGKDL { Private = 0, Public = 1, Unlisted = 2 }), which is the enum
// Enum.ToString() would spell out on the standalone accessibility call.
private const int AccessibilityPrivate = 0;
private static int? ParseSubRoomAccessibility(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var value = raw.Trim();
if (int.TryParse(value, out var numeric)) return numeric;
if (value.StartsWith("priv", StringComparison.OrdinalIgnoreCase)) return 0;
if (value.StartsWith("pub", StringComparison.OrdinalIgnoreCase)) return 1;
if (value.StartsWith("unlist", StringComparison.OrdinalIgnoreCase)) return 2;
return null;
}
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
@@ -3558,7 +3787,9 @@ public class RoomsController(
scene.DataModifiedAt = DateTime.UtcNow;
room.UpdatedAt = scene.DataModifiedAt;
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
// "restore subroom save" returns Task<FGCPNAACHIK> (NLDBPDCNNCF.txt:10476),
// so the scene row alone made a successful restore read as a failure.
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
private async Task<PublishSubRoomSaveRequest> ReadPublishSubRoomSaveRequestAsync()
@@ -3640,34 +3871,68 @@ public class RoomsController(
};
db.RoomScenes.Add(clone);
await db.SaveChangesAsync();
return Ok(SceneWire(clone));
// Task<FGCPNAACHIK> ("clone subroom", NLDBPDCNNCF.txt:9554) — the scene
// row is not what the client re-parses.
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
// Subroom settings save POSTs here on the roomserver host ("failed to
// modify room" = the bare-only routes 404'd). Same roomserver/ prefix as
// the sibling subroom mutations.
//
// Body is form-urlencoded name/accessibility/maxPlayers ("modify room",
// NLDBPDCNNCF.txt:10846 verb 3; keys at JEEACJHABAF.txt ISIL 021/035/048).
// [FromBody] rejected that with 415 before the handler ran, and the DTO had
// no 'accessibility' member at all, so the bulk subroom-settings save was a
// guaranteed failure.
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[Authorize]
public Task<IActionResult> ModifySubRoom(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest req) =>
public Task<IActionResult> ModifySubRoom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomModifyRequest req) =>
MutateScene(roomId, subRoomId, s =>
{
if (!string.IsNullOrWhiteSpace(req.Name)) s.Name = req.Name.Trim();
if (req.MaxPlayers is int mp) s.MaxPlayers = Math.Max(1, mp);
if (req.IsSandbox is bool sb) s.IsSandbox = sb;
if (req.CanMatchmakeInto is bool cmi) s.CanMatchmakeInto = cmi;
if (ParseSubRoomAccessibility(req.Accessibility) is int a)
s.CanMatchmakeInto = a != AccessibilityPrivate;
if (req.RoomSceneLocationId is not null) s.RoomSceneLocationId = req.RoomSceneLocationId;
});
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/move</c> — reorder a
/// scene. Body NewIndex is the target OrderIndex; the in-between
/// rows shift to fill the gap.</summary>
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/move</c>.
///
/// <para><b>The 2023 semantics are "move to another ROOM", not "reorder".</b>
/// <c>NLDBPDCNNCF.MGGALMIAMBG(long roomId, long subRoomId,
/// Nullable&lt;Int64&gt; newRoomId)</c> ("move subroom",
/// NLDBPDCNNCF.txt:9659, verb 2 = POST) sends the single form key
/// <c>newRoomId</c> (COIOGADEBKJ.txt ISIL 013-020, boxed as
/// <c>Nullable&lt;Int64&gt;</c>) and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c>. The old handler bound
/// <c>[FromBody] {NewIndex}</c> — a key the client never sends, on a body
/// binding that 415s form posts — and reordered scenes in place instead.</para>
///
/// <para>The caller must own BOTH rooms. The scene is re-parented to the end
/// of the target room's scene list and the source room's remaining scenes
/// are re-packed so <c>OrderIndex</c> stays a dense 0..n-1 sequence — the
/// wire <c>RoomSceneId</c> and matchmaking <c>SubRoomId</c> are that index.
/// Scene 0 is the room's entry scene and cannot be moved out.
/// <c>newIndex</c> (no client sends it) keeps the old in-room reorder for
/// the import tooling.</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[Authorize]
public async Task<IActionResult> MoveSubRoom(long roomId, long subRoomId, [FromBody] SubRoomMoveRequest req)
public async Task<IActionResult> MoveSubRoom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -3677,19 +3942,69 @@ public class RoomsController(
.Where(s => s.RoomId == roomId).OrderBy(s => s.OrderIndex).ToListAsync();
var moving = scenes.FirstOrDefault(s => s.OrderIndex == subRoomId);
if (moving is null) return NotFound();
var newIndex = Math.Clamp(req.NewIndex ?? 0, 0, scenes.Count - 1);
scenes.Remove(moving);
scenes.Insert(newIndex, moving);
// Recompute OrderIndex on the entire row set. The unique
// (RoomId, OrderIndex) index would clash if we wrote rows in
// place — flush a sentinel offset first, then settle to
// final indices.
// `newRoomId` is Nullable<Int64> on the wire and the form writer
// stringifies it with ToString(), which yields "" for the empty case —
// so an absent target arrives as a blank field, not a missing one.
// Treat "no target and no index" as a no-op; the old code fell through
// to `NewIndex ?? 0` and silently reordered the scene to the front.
if (req.NewRoomId is not long targetRoomId || targetRoomId == roomId)
{
if (req.NewIndex is not int requestedIndex)
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
var newIndex = Math.Clamp(requestedIndex, 0, scenes.Count - 1);
scenes.Remove(moving);
scenes.Insert(newIndex, moving);
await RepackSceneOrderAsync(scenes);
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
// Cross-room move — the path the 2023 client actually takes.
var target = await db.Rooms.FirstOrDefaultAsync(r => r.Id == targetRoomId);
if (target is null) return NotFound(new { error = "target_room_not_found", targetRoomId });
if (target.CreatorPlayerId != pid) return Forbid();
if (moving.OrderIndex == 0)
return BadRequest(new { error = "cannot_move_entry_scene" });
// Names are unique per room (RoomScenes index on (RoomId, Name)).
var movedName = moving.Name;
var suffix = 1;
while (await db.RoomScenes.AnyAsync(s => s.RoomId == targetRoomId && s.Name == movedName))
{
suffix += 1;
movedName = $"{moving.Name} {suffix}";
}
var targetOrder = (await db.RoomScenes.Where(s => s.RoomId == targetRoomId)
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
moving.RoomId = targetRoomId;
moving.OrderIndex = targetOrder;
moving.Name = movedName;
moving.DataModifiedAt = DateTime.UtcNow;
room.UpdatedAt = moving.DataModifiedAt;
target.UpdatedAt = moving.DataModifiedAt;
await db.SaveChangesAsync();
// Re-pack the source room so its remaining scenes keep dense indices
// (same sentinel dance as the in-room reorder above).
await RepackSceneOrderAsync(scenes.Where(s => s.Id != moving.Id).ToList());
logger.LogInformation("[subroom-move] scene={Scene} room={From}→{To} by={By}",
moving.Id, roomId, targetRoomId, pid);
return Ok(await BuildRoomServerDetailsWithRolesAsync(targetRoomId));
}
/// <summary>Rewrite <see cref="RoomSceneEntity.OrderIndex"/> to 0..n-1 in
/// list order. The unique (RoomId, OrderIndex) index would clash if rows
/// were written in place, so flush a sentinel offset first, then settle.</summary>
private async Task RepackSceneOrderAsync(List<RoomSceneEntity> scenes)
{
if (scenes.Count == 0) return;
const int sentinelOffset = 100_000;
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i + sentinelOffset;
await db.SaveChangesAsync();
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i;
await db.SaveChangesAsync();
return Ok(SceneWire(moving));
}
/// <summary>POST <c>roomserver/rooms/{id}/subrooms/{sub}/data</c> —
@@ -3762,6 +4077,11 @@ public class RoomsController(
return null;
}
/// <summary>Shared owner-gate + mutate + save for the subroom setting
/// endpoints. Responds with the FULL room details: every 2023 caller that
/// lands here (maxplayers / accessibility / modify) is typed
/// <c>Task&lt;FGCPNAACHIK&gt;</c>, so the old scene-row payload made a
/// successful mutation surface as a client-side parse failure.</summary>
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
{
var pid = this.RequireCurrentPlayerId();
@@ -3773,7 +4093,7 @@ public class RoomsController(
mutator(scene);
scene.DataModifiedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
private async Task<RoomSceneEntity?> GetOrCreateSceneForMutationAsync(RoomEntity room, long subRoomId)
@@ -3798,25 +4118,21 @@ public class RoomsController(
return scene;
}
private static object SceneWire(RoomSceneEntity s) => new
{
Result = 0,
RoomId = s.RoomId,
RoomSceneId = s.OrderIndex,
SubRoomId = s.OrderIndex,
Name = s.Name,
s.DataBlobName,
RoomSceneLocationId = s.RoomSceneLocationId,
s.IsSandbox,
s.MaxPlayers,
s.CanMatchmakeInto,
};
// SceneWire (the old {Result,RoomId,RoomSceneId,…} scene-row payload) is
// gone: every subroom mutation the 2023 client issues is typed
// Task<FGCPNAACHIK> and re-parses the full room details, so all of these
// handlers now go through BuildRoomServerDetailsWithRolesAsync.
// ── Bare-path roles ────────────────────────────────────────────────
/// <summary>GET <c>rooms/{id}/roles</c> — account role DTOs consumed by
/// 2023's EFHPLDPNGIM deserializer.</summary>
/// 2023's EFHPLDPNGIM deserializer
/// (<c>NLDBPDCNNCF.LAJDCNIMBAB</c> → <c>Task&lt;List&lt;EFHPLDPNGIM&gt;&gt;</c>,
/// NLDBPDCNNCF.txt:2111, verb 0 = GET). Like every other NLDBPDCNNCF call it
/// arrives with the <c>roomserver/</c> prefix, so the bare-only registration
/// 404'd the permissions screen's list fetch.</summary>
[HttpGet("rooms/{roomId:long}/roles")]
[HttpGet("roomserver/rooms/{roomId:long}/roles")]
public async Task<IActionResult> RolesList(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -3939,7 +4255,12 @@ public class RoomsController(
return new StudioBundleInfo(saveId, target, version, name);
}
/// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row
/// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task&lt;EFHPLDPNGIM&gt;</c>,
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
/// above.</summary>
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -3954,10 +4275,17 @@ public class RoomsController(
public sealed class GrantRoleRequest { public int? Role { get; set; } }
/// <summary>POST <c>rooms/{id}/roles/{playerId}</c> — owner grants
/// a role to a player. Auto-accepted (no invite step).</summary>
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — owner grants
/// a role to a player. Auto-accepted (no invite step).
/// <c>NLDBPDCNNCF.LMIEIJMCEAB</c> ("change room account role",
/// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key <c>role</c> carrying a
/// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full
/// <c>FGCPNAACHIK</c> details — which this already does. Only the
/// roomserver/ twin was missing.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
@@ -3999,9 +4327,13 @@ public class RoomsController(
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
/// as grant but Accepted=false; the target's accept-invite flow
/// flips the flag (separate endpoint, not yet exposed). For now
/// invited rows surface in RoomDetails.InvitedCoOwners etc.</summary>
/// invited rows surface in RoomDetails.InvitedCoOwners etc.
/// <c>NLDBPDCNNCF.KIJDCLINOIC</c> ("invite account to role",
/// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]