using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Inventions;
///
/// api.rec.net/api/inventions/* — Maker-Pen creations the player can
/// re-spawn or share. URL surface + verbs verified against
/// Cpp2IL_ISIL/.../RecNet/Inventions.txt : the "update*"
/// routes use Core.Get (GET) despite being mutations, so we
/// expose them as [HttpGet]; only cheer , report ,
/// settags , save , addversion , and batch
/// are POST. delete , publish , unpublish ,
/// download are also GET.
///
/// Wire DTO matches the watch's OBBBPCBIMME.PPGFHEDFBEA
/// deserializer (Cpp2IL ISIL OBBBPCBIMME.txt:588-704 ): keys
/// InventionId, ReplicationId, CreatorPlayerId, Name, Description,
/// ImageName, CurrentVersionNumber, IsPublished, AllowTrial,
/// ModifiedAt, CreatedAt, FirstPublishedAt, CreationRoomId,
/// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount,
/// CreatorPermission, GeneralPermission, IsAGInvention, Price,
/// HideFromPlayer . Casing is significant — note IsAGInvention
/// has a capital G.
///
[ApiController]
public class InventionsController(
DorkNetDbContext db,
LevelService level,
NotificationService notifications) : ControllerBase
{
private long? CurrentPlayerIdOrNull => this.CurrentPlayerId();
private long CurrentPlayerId => this.RequireCurrentPlayerId();
// ── Browse ───────────────────────────────────────────────────────────
[HttpGet("api/inventions/v1/featured")]
[HttpGet("api/inventions/v3/popular")]
public async Task Popular([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished)
.OrderByDescending(i => i.CheerCount)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
/// GET api/inventions/v1/toptoday — the client fires
/// this as its own no-arg shelf query
/// (OEGFNFEAAGO_NestedType___c_NestedType___GetTopInventionsToday_b__43_0_d.txt:147,
/// verb 0) and shows it NEXT TO the featured shelf, so aliasing it onto
/// the all-time popular query rendered the same twelve tiles twice.
/// Ranked by cheers received in the last 24h, then backfilled with
/// all-time popular so a quiet day still fills the shelf.
[HttpGet("api/inventions/v1/toptoday")]
public async Task TopToday([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var since = DateTime.UtcNow.AddDays(-1);
var hot = await db.Cheers
.Where(c => c.TargetInventionId > 0 && c.CheeredAt >= since)
.GroupBy(c => c.TargetInventionId)
.Select(g => new { InventionId = g.Key, Score = g.Count() })
.OrderByDescending(x => x.Score)
.Take(take)
.ToListAsync();
var order = hot.Select(h => h.InventionId).ToList();
var hits = order.Count == 0
? new List()
: await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && order.Contains(i.Id))
.ToListAsync();
var ranked = order
.Select(id => hits.FirstOrDefault(r => r.Id == id))
.Where(r => r is not null)
.Select(r => r!)
.ToList();
if (ranked.Count < take)
{
var have = ranked.Select(r => r.Id).ToList();
var filler = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && !have.Contains(i.Id))
.OrderByDescending(i => i.CheerCount)
.Take(take - ranked.Count)
.ToListAsync();
ranked.AddRange(filler);
}
return Ok(ranked.Select(ToWire));
}
/// GET api/inventions/v1/featureddormskins — separate
/// shelf in the client
/// (OEGFNFEAAGO_NestedType___c_NestedType___GetFeaturedDormSkins_b__45_0_d.txt:147,
/// verb 0, no params) which previously returned generic popular
/// inventions. has no dorm-skin column, so
/// the marker lives in — see
/// . The filter runs in memory because
/// TagsCsv is a packed list no provider can split.
[HttpGet("api/inventions/v1/featureddormskins")]
public async Task FeaturedDormSkins([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && i.TagsCsv != "")
.OrderByDescending(i => i.CheerCount)
.Take(500)
.ToListAsync();
return Ok(rows.Where(i => IsDormSkinTags(i.TagsCsv)).Take(take).Select(ToWire));
}
/// Dorm-skin marker. There is no IsDormSkin column on
/// , so a tag decides: any tag that reads
/// "dormskin" / "dorm skin" (case- and space-insensitive).
private static bool IsDormSkinTags(string? tagsCsv) =>
!string.IsNullOrEmpty(tagsCsv)
&& tagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(t => t.Replace(" ", string.Empty)
.Equals("dormskin", StringComparison.OrdinalIgnoreCase));
[HttpGet("api/inventions/v3/saved")]
[Authorize]
public async Task Saved([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var pid = CurrentPlayerId;
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.CreatorPlayerId == pid)
.OrderByDescending(i => i.UpdatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/inventions/v3/created")]
[Authorize]
public Task Created([FromQuery] int take = 50) => Saved(take);
/// GetLocalPlayerInventions — same as Saved but
/// at v1 path. Watch hits both depending on tab.
[HttpGet("api/inventions/v1/mine")]
[HttpGet("api/inventions/v2/mine")]
[Authorize]
public Task Mine() => Saved(100);
/// The 2023 client sends value + skip + take on every store
/// search (OEGFNFEAAGO.txt:10805/10820/10832 — three AFGEDDANEKP pairs
/// on a verb-0 request). Ignoring skip/take pinned every page to the
/// first 50 rows, so scrolling the store re-showed the same tiles.
[HttpGet("api/inventions/v1/search")]
[HttpGet("api/inventions/v2/search")]
public async Task Search(
[FromQuery] string value = "",
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 200);
if (string.IsNullOrWhiteSpace(value))
return Ok(Array.Empty());
// The watch's @-prefix means "search by player". Two callers
// use this with different payload shapes — both end up here:
// * PlayerDetailsWatchUIFlow.SetAccount → @ (numeric)
// loads the Inventions tab on a player profile. Verified
// in Cpp2IL — see PlayerDetailsWatchUIFlow.txt:1867,1950.
// * Inventions search UI → @ (text) free-text
// prefix match on Username.
if (value.StartsWith('@'))
{
var tail = value[1..];
List creators;
if (long.TryParse(tail, out var accountId))
{
creators = new List { accountId };
}
else
{
creators = await db.Players
.Where(p => p.Username.StartsWith(tail))
.Select(p => p.Id).Take(50).ToListAsync();
}
var byCreator = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished
&& creators.Contains(i.CreatorPlayerId))
.OrderByDescending(i => i.CheerCount)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(byCreator.Select(ToWire));
}
var v = value.ToLowerInvariant();
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished
&& (i.Name.ToLower().Contains(v)
|| i.TagsCsv.ToLower().Contains(v)))
.OrderByDescending(i => i.CheerCount)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
// ── Id-list endpoints (runtime GET/POST) ─────────────────────────────
//
// The 2023 client picks the verb at RUNTIME for every id-list route:
// ALHIJCJOLCB.JIECAFGCODK (IsilDump/RecNet.Runtime/ALHIJCJOLCB.txt:3198)
// is `count < limit ? GET : POST` with limit=100. On a non-GET the
// key/value pairs move out of the query string and into an
// HTTPUrlEncodedForm body — BNDIAONDFFF.txt:2971-3010 branches on the
// verb (GET → "?a=b&…", otherwise → HTTPUrlEncodedForm). So the POST
// actions must NOT declare a complex parameter, or [ApiController]
// infers [FromBody] and answers 415 before the handler runs; we read
// query + form by hand instead.
//
// Field names are literal:
// "id" → v2/batch (OEGFNFEAAGO_NestedType_LANMKGKDNDC.txt:228)
// "id" → fromcreators (…CDILMPCKMEO…GetInventionsByCreators_b__0_d.txt:330)
// "ids" → dormskinsfromids (OEGFNFEAAGO.txt:5913)
/// Body shape for non-2023 callers, which still send a JSON
/// {"InventionIds":[…]} envelope rather than repeated fields.
private sealed class InventionIdsBody
{
public List? InventionIds { get; set; }
public List? Ids { get; set; }
public List? Id { get; set; }
}
private static readonly System.Text.Json.JsonSerializerOptions IdsJson =
new() { PropertyNameCaseInsensitive = true };
/// Gather ids from the query string, the url-encoded form and
/// (as a fallback) a JSON id envelope. See the block comment above for
/// why all three have to be accepted on one route.
private async Task> CollectIdsAsync(params string[] keys)
{
var ids = new List();
void Take(string? raw)
{
if (string.IsNullOrEmpty(raw)) return;
foreach (var part in raw.Split(
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
}
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
foreach (var key in keys)
{
foreach (var v in Request.Query[key]) Take(v);
if (form is not null)
foreach (var v in form[key]) Take(v);
}
if (ids.Count == 0 && form is null
&& (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) ?? false))
{
try
{
var body = await System.Text.Json.JsonSerializer
.DeserializeAsync(Request.Body, IdsJson);
foreach (var list in new[] { body?.InventionIds, body?.Ids, body?.Id })
{
if (list is not null) ids.AddRange(list.Where(id => id > 0));
}
}
catch (System.Text.Json.JsonException)
{
// Not an id envelope — treat as "no ids" rather than 400.
}
}
return ids.Distinct().Take(200).ToList();
}
/// Read one scalar field from the query string, falling back to
/// the url-encoded form body (POST variants — see the block comment
/// above).
private async Task ReadFieldAsync(string key)
{
var fromQuery = Request.Query[key].FirstOrDefault();
if (!string.IsNullOrEmpty(fromQuery)) return fromQuery;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var fromForm = form[key].FirstOrDefault();
if (!string.IsNullOrEmpty(fromForm)) return fromForm;
}
return null;
}
private async Task ReadIntFieldAsync(string key)
=> int.TryParse(await ReadFieldAsync(key), out var v) ? v : null;
private async Task ReadLongFieldAsync(string key)
=> long.TryParse(await ReadFieldAsync(key), out var v) ? v : null;
[HttpGet("api/inventions/v1/batch")]
[HttpGet("api/inventions/v2/batch")]
[HttpPost("api/inventions/v1/batch")]
[HttpPost("api/inventions/v2/batch")]
public async Task Batch()
=> await BatchIdsAsync(await CollectIdsAsync("id", "ids", "InventionIds"));
/// api/inventions/v1/dormskinsfromids — the client
/// deserialises a BARE List<Int64> here, not a list of
/// invention objects: the continuation at OEGFNFEAAGO.txt:5941 is typed
/// FGLDKEJLAKB<List`1<Int64>> and is then projected by a
/// Func<List<Int64>, List<Int64>> (:5966). The reply is the
/// SUBSET of the posted ids that are dorm skins; aliasing this onto the
/// generic batch handler fed objects to a number reader and killed
/// dorm-skin filtering outright.
[HttpGet("api/inventions/v1/dormskinsfromids")]
[HttpPost("api/inventions/v1/dormskinsfromids")]
public async Task DormSkinsFromIds()
{
var ids = await CollectIdsAsync("ids", "id", "InventionIds");
if (ids.Count == 0) return Ok(Array.Empty());
var rows = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.Select(i => new { i.Id, i.TagsCsv })
.ToListAsync();
return Ok(rows.Where(r => IsDormSkinTags(r.TagsCsv)).Select(r => r.Id).ToList());
}
private async Task BatchIdsAsync(IReadOnlyCollection requestedIds)
{
if (requestedIds.Count == 0)
return Ok(Array.Empty());
var ids = requestedIds.Take(200).ToList();
var rows = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/inventions/v1/fromcreators")]
[HttpPost("api/inventions/v1/fromcreators")]
public async Task FromCreators()
{
// Only the literal "id" key carries creator ids. The previous
// SelectMany-over-every-query-field also swallowed the client's
// "skip"/"take" pairs, so take=100 quietly added player 100's
// inventions to the shelf.
var creatorIds = await CollectIdsAsync("id");
if (creatorIds.Count == 0) return Ok(Array.Empty());
var skip = Math.Max(0, await ReadIntFieldAsync("skip") ?? 0);
var take = Math.Clamp(await ReadIntFieldAsync("take") ?? 100, 1, 200);
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId))
.OrderByDescending(i => i.UpdatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
/// GET api/inventions/v1/tagfilters — drives
/// RecNet.Inventions.GetTags . The watch deserialises the
/// response as RecNet.GetFiltersResponse (an
/// IRecNetObject with PinnedFilters +
/// PopularFilters string-list properties), then a
/// Func<GetFiltersResponse, List<string>>
/// projects whichever list the caller wanted. Verified at
/// Cpp2IL_ISIL/.../RecNet/Inventions.txt:4834-4852 — the URL
/// literal is followed by a typeof(Func`2<GetFiltersResponse,
/// List`1<String>>) . Returning a bare string array
/// (the previous shape) tripped the LitJson importer's
/// List → Dictionary cast inside Util.Deserialize<T>
/// and surfaced as "Failed to get tags: Malformed Response"
/// (output_log.txt:1602+1639) — and that bubbles up as the
/// dorm-load destabilisation we've been hunting.
///
/// The 2023 DTO reads a THIRD list, TrendingFilters
/// (HMDONHHKKGA.txt:099/169), which we never sent — callers projecting that
/// list got an empty filter row. Popular/Trending are now derived from the
/// tags actually in use ( ) rather than
/// echoing the pinned array three times; the curated list is the fallback
/// so a fresh server still shows filters. The 2020.12 watch's
/// AEBEPCMAABC reads only Pinned/Popular, so the extra key is
/// inert there.
[HttpGet("api/inventions/v1/tagfilters")]
public async Task TagFilters()
{
string[] pinned =
[
"sport", "game", "vehicle", "weapon", "decor", "tool",
"art", "music", "puzzle", "combat", "build", "race",
];
// TagsCsv is a packed list no provider can split, so the frequency count
// runs in memory over a bounded window of the most recently touched
// tagged inventions.
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && i.TagsCsv != "")
.OrderByDescending(i => i.UpdatedAt)
.Select(i => new { i.TagsCsv, i.UpdatedAt })
.Take(2000)
.ToListAsync();
static IEnumerable SplitTags(string csv) => csv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
static List Rank(IEnumerable tags) => tags
.GroupBy(t => t, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(g => g.Count())
.ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.Select(g => g.Key)
.Take(12)
.ToList();
var since = DateTime.UtcNow.AddDays(-7);
var popular = Rank(rows.SelectMany(r => SplitTags(r.TagsCsv)));
var trending = Rank(rows.Where(r => r.UpdatedAt >= since)
.SelectMany(r => SplitTags(r.TagsCsv)));
if (popular.Count == 0) popular = [.. pinned];
if (trending.Count == 0) trending = popular;
return Ok(new
{
PinnedFilters = pinned,
PopularFilters = popular,
TrendingFilters = trending,
});
}
/// GET api/inventions/v1/creatorIds — the distinct
/// creator account ids of every invention placed in a room
/// (GetCreatorIdsOfInventionsInRoom ). The watch uses this to
/// resolve which creators it must request invention permissions for
/// before spawning a room's invention-based geometry. Returns
/// List<int> — empty when the room has no tracked
/// inventions.
[HttpGet("api/inventions/v1/creatorIds")]
public async Task CreatorIdsInRoom([FromQuery] long roomId)
{
var inventions = await ResolveRoomInventionsAsync(roomId);
var creatorIds = inventions
.Select(i => (int)i.CreatorPlayerId)
.Distinct()
.ToList();
return Ok(creatorIds);
}
/// Resolve the inventions that belong to a room. Direct
/// matches are inventions whose
/// equals the room's
/// local id (RR-Originals, dorms, anything created in-place).
/// Zip-imported rooms instead carry each invention's ORIGINAL Rec
/// Room CreationRoomId, which won't equal our local id and can't be
/// reverse-resolved without an OriginalRoomId column; we approximate
/// that cluster as the most-frequent non-local CreationRoomId among
/// the room creator's inventions. The two sets are unioned so a room
/// with both kinds resolves fully.
private async Task> ResolveRoomInventionsAsync(long roomId)
{
var creatorId = await db.Rooms
.Where(r => r.Id == roomId)
.Select(r => (long?)r.CreatorPlayerId)
.FirstOrDefaultAsync();
long? importedRoomId = null;
if (creatorId is long creator)
{
importedRoomId = await db.Inventions
.Where(i => !i.IsDeleted && i.CreatorPlayerId == creator
&& i.CreationRoomId != null && i.CreationRoomId != roomId)
.GroupBy(i => i.CreationRoomId)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.FirstOrDefaultAsync();
}
return await db.Inventions
.Where(i => !i.IsDeleted
&& (i.CreationRoomId == roomId
|| (importedRoomId != null && i.CreationRoomId == importedRoomId)))
.ToListAsync();
}
/// GET api/inventions/v1/fulllineageowner —
/// raw Boolean. The client uses this to decide whether a set of
/// referenced inventions can be saved together under one owner.
/// DorkNet tracks current creator ownership, so the concrete rule
/// here is: every requested invention id must exist and share the
/// same CreatorPlayerId.
[HttpGet("api/inventions/v1/fulllineageowner")]
public async Task FullLineageOwner()
{
var ids = Request.Query
.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0L)
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Content("true", "application/json");
var owners = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.Select(i => new { i.Id, i.CreatorPlayerId })
.ToListAsync();
var ok = owners.Count == ids.Count
&& owners.Select(o => o.CreatorPlayerId).Distinct().Count() <= 1;
return Content(ok ? "true" : "false", "application/json");
}
// ── Single fetch ─────────────────────────────────────────────────────
/// GET /api/inventions/v1/room?id={roomId} — every
/// invention used by the room. The 2020.12 watch fires this on
/// EVERY room load right after fetching the room data blob; it uses
/// the response to pre-resolve invention references the blob will
/// spawn. Without this, imported rooms with invention-based
/// geometry (floors, walls, custom shapes) render with everything
/// missing — the watch silently skips invention spawns when it
/// has no list, and you fall through the void.
///
/// We match on which
/// the zip importer populates from each Invention.json's
/// CreationRoomId field. Returns an array of
/// -shaped Invention objects (same wire shape
/// the single-fetch endpoint returns) so the watch's existing
/// deserializer works.
[HttpGet("api/inventions/v1/room")]
public async Task ByRoom([FromQuery] long id)
{
var inventions = await ResolveRoomInventionsAsync(id);
return Ok(inventions.Select(ToWire).ToList());
}
[HttpGet("api/inventions/v1")]
public async Task SingleByQuery([FromQuery] long inventionId)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (i is null) return NotFound();
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
return Forbid();
return Ok(ToWire(i));
}
/// POST api/storefronts/v1/trialInvention — start a free
/// trial of a store invention, spawned for the window returned by
/// trialInvention/duration . Hosted here — not in
/// StorefrontsController — so it can reuse the invention wire builder.
/// We record the trial start so it's an auditable action.
///
/// TWO wire corrections. (1) The id is a FORM field, not a query param:
/// DCFKEFHJAGC.txt:9037 sets verb 2 (POST) and :9050 adds the pair via
/// AFGEDDANEKP("inventionId", …) , and on a non-GET those pairs go
/// into an HTTPUrlEncodedForm body (see the id-list block comment above), so
/// [FromQuery] bound 0 and every trial 404'd. (2) The reply is the
/// Status/Invention/InventionVersion envelope: the continuation at
/// DCFKEFHJAGC.txt:9086 is a
/// Func<BDNCJIPHHOK, FGLDKEJLAKB<IFJONDCAKKM>> which
/// projects .Invention — a bare invention left that null.
[HttpPost("api/storefronts/v1/trialInvention")]
[Authorize]
public async Task TrialInvention()
{
var inventionId = await ReadLongFieldAsync("inventionId") ?? 0;
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (i is null) return NotFound();
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
return Forbid();
if (CurrentPlayerIdOrNull is long me)
{
var key = $"invention:trial:{inventionId}";
var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == key);
if (row is null)
db.PlayerSettings.Add(new Data.Entities.PlayerSettingEntity
{
PlayerId = me, Key = key, Value = DateTime.UtcNow.ToString("O"),
});
else
row.Value = DateTime.UtcNow.ToString("O");
await db.SaveChangesAsync();
}
return Ok(await EnvelopeAsync(i));
}
/// GET api/inventions/v1/details — the ONLY key either
/// client reads off this response is Tags : the 2023 continuation is
/// Action<OIABGAKJABE> (OEGFNFEAAGO.txt:6312) whose reader takes
/// exactly one literal, "Tags" (DBAHPLOPFIO.txt:034), and the 2020.12 watch's
/// HJPDBNLCGIB does the same. Each element is
/// {Tag:String, Type:Int32} (PCMLHLIBLNJ.txt:038/057). Without the key
/// the invention detail page's tag row was permanently empty. Invention +
/// Versions stay on the wire — unknown members are ignored by both readers.
/// Type is always 0: merges the client's
/// AutoTags and CustomTags into one
/// column, so the auto/custom distinction is not recoverable without a
/// second column.
[HttpGet("api/inventions/v1/details")]
public async Task Details([FromQuery] long inventionId)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (i is null) return NotFound();
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
return Forbid();
var versions = await db.InventionVersions
.Where(v => v.InventionId == inventionId)
.OrderByDescending(v => v.VersionNumber)
.ToListAsync();
return Ok(new
{
Tags = i.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => new { Tag = t, Type = 0 }),
Invention = ToWire(i),
Versions = versions.Select(ToVersionWire),
});
}
/// GET api/inventions/v1/personaldetails/{id} — the caller's
/// own relationship to the invention. Both clients read a single key here:
/// the 2023 continuation is FGLDKEJLAKB<CEAFHBOOBKL>
/// (OEGFNFEAAGO.txt:8995) and CEAFHBOOBKL's reader takes only "IsCheering"
/// (BCLCHNGENDE.txt:036); the 2020.12 watch's OEGPIPBKHCN is identical.
/// Without it the detail page never lit the player's own cheer.
[HttpGet("api/inventions/v1/personaldetails/{id:long}")]
[Authorize]
public async Task PersonalDetails(long id)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
if (i is null) return NotFound();
var pid = CurrentPlayerId;
return Ok(new
{
IsCheering = await db.Cheers.AnyAsync(c =>
c.FromPlayerId == pid && c.TargetInventionId == id),
Invention = ToWire(i),
CanEdit = i.CreatorPlayerId == pid,
});
}
[HttpGet("api/inventions/v1/versions")]
public async Task Versions([FromQuery] long inventionId)
{
var rows = await db.InventionVersions
.Where(v => v.InventionId == inventionId)
.OrderByDescending(v => v.VersionNumber)
.ToListAsync();
return Ok(rows.Select(ToVersionWire));
}
[HttpGet("api/inventions/v1/version")]
public async Task Version(
[FromQuery] long inventionId, [FromQuery] int version)
{
var v = await db.InventionVersions
.FirstOrDefaultAsync(x => x.InventionId == inventionId && x.VersionNumber == version);
if (v is null) return NotFound();
return Ok(ToVersionWire(v));
}
// ── Create / version ─────────────────────────────────────────────────
public sealed class SaveInventionV4Request
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public int InstantiationCost { get; set; }
public int LightsCost { get; set; }
/// RecNet.NewInventionRequestDTO carries chipsCost and
/// cloudVariablesCost too (2023.06 dump.cs:1283258-1283259).
/// has no column for either, so
/// they cannot be persisted — but binding them lets the save response
/// echo back the budget the client just reported instead of two
/// zeroes.
public int ChipsCost { get; set; }
public int CloudVariablesCost { get; set; }
public int AiCost { get; set; }
public long? CreationRoomId { get; set; }
public string? InventionDataFilename { get; set; }
public List? ReferencedInventions { get; set; }
public int CreatorAccountRole { get; set; }
}
/// POST api/inventions/v4/save — 2020.12 maker-pen
/// save flow. v4 wraps the new invention plus its first version in
/// a Status/Invention/InventionVersion object.
[HttpPost("api/inventions/v4/save")]
[HttpPost("api/inventions/v6/save")]
[Authorize]
public async Task SaveV4([FromBody] SaveInventionV4Request req)
{
var pid = CurrentPlayerId;
var name = (req.Name ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { Status = 1, Error = "missing name" });
var blobName = (req.InventionDataFilename ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(blobName)) return BadRequest(new { Status = 1, Error = "missing inventionDataFilename" });
var inv = new InventionEntity
{
CreatorPlayerId = pid,
ReplicationId = Guid.NewGuid().ToString("D"),
Name = name,
Description = req.Description ?? string.Empty,
ImageName = req.ImageName ?? string.Empty,
Permission = 0,
CurrentBlobName = blobName,
TagsCsv = string.Empty,
CreationRoomId = req.CreationRoomId,
CreatorPermission = 100,
GeneralPermission = 0,
IsPublished = false,
CurrentVersionNumber = 1,
};
db.Inventions.Add(inv);
await db.SaveChangesAsync();
var v1 = new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = 1,
BlobName = blobName,
InstantiationCost = Math.Max(0, req.InstantiationCost),
LightsCost = Math.Max(0, req.LightsCost),
};
db.InventionVersions.Add(v1);
if (req.ReferencedInventions is { Count: > 0 })
{
foreach (var referencedId in req.ReferencedInventions.Distinct().Take(200))
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = $"invention:{inv.Id}:references:{referencedId}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
}
}
await db.SaveChangesAsync();
await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
return Ok(new
{
Status = 0,
Invention = ToWireV4(inv),
InventionVersion = ToVersionWire(
v1, Math.Max(0, req.ChipsCost), Math.Max(0, req.CloudVariablesCost)),
});
}
/// The 2023-03-21 client names the blob field
/// inventionDataFilename and sends four extra cost fields plus the
/// creation room and referenced inventions. The record only had
/// BlobName , which the client never sends, so the required-parameter
/// check rejected every publish with 400 and no new invention version could
/// ever be saved.
public sealed record AddVersionRequest(
long InventionId,
string? BlobName,
string? InventionDataFilename,
int? InstantiationCost, int? LightsCost,
int? ChipsCost, int? CloudVariablesCost, int? AiCost,
long? CreationRoomId,
List? ReferencedInventions)
{
public string? Blob => !string.IsNullOrWhiteSpace(InventionDataFilename)
? InventionDataFilename
: BlobName;
}
[HttpPost("api/inventions/v3/addversion")]
[HttpPost("api/inventions/v4/addversion")]
[Authorize]
public async Task AddVersion([FromBody] AddVersionRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
var blobName = req.Blob;
if (string.IsNullOrWhiteSpace(blobName)) return BadRequest("missing inventionDataFilename");
var nextVer = inv.CurrentVersionNumber + 1;
inv.CurrentBlobName = blobName;
inv.CurrentVersionNumber = nextVer;
inv.UpdatedAt = DateTime.UtcNow;
var version = new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = nextVer,
BlobName = blobName,
InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = req.LightsCost ?? 0,
};
db.InventionVersions.Add(version);
await db.SaveChangesAsync();
// Envelope, not a bare invention — see EnvelopeAsync. The version we
// just wrote is the one the client wants back, and the two cost fields
// it sent are echoed because no column stores them.
return Ok(new
{
Status = 0,
Invention = ToWire(inv),
InventionVersion = ToVersionWire(
version, Math.Max(0, req.ChipsCost ?? 0), Math.Max(0, req.CloudVariablesCost ?? 0)),
});
}
// ── GET-based mutations (Core.Get pattern) ───────────────────────────
/// Watch sends ONE of name/description/imgName/permission
/// per request — `UpdateInvention*` ISIL builds different
/// query strings. Pull whichever ones came in and apply.
[HttpGet("api/inventions/v1/update")]
[Authorize]
public async Task Update(
[FromQuery] long inventionId,
[FromQuery] string? name,
[FromQuery] string? description,
[FromQuery] string? imgName,
[FromQuery] int? permission)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
if (!string.IsNullOrWhiteSpace(name)) inv.Name = name.Trim();
if (description is not null) inv.Description = description;
if (imgName is not null) inv.ImageName = imgName;
if (permission.HasValue)
{
inv.GeneralPermission = ClampInventionPermission(permission.Value);
if (inv.GeneralPermission > 0 && !inv.IsPublished)
{
inv.IsPublished = true;
inv.FirstPublishedAt = DateTime.UtcNow;
}
// Mirror legacy 0/1/2 column for back-compat.
inv.Permission = inv.GeneralPermission >= 60 ? 2 : (inv.GeneralPermission >= 20 ? 1 : 0);
}
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await EnvelopeAsync(inv));
}
/// Body shape for the POST form of updateprice —
/// RecNet.UpdatePriceRequest, real field names preserved in the 2023.06
/// dump (dump.cs:1283355-1283356). Settable properties + a parameterless
/// ctor because constructs the
/// instance itself.
public sealed class UpdatePriceRequest
{
public long InventionId { get; set; }
public int Price { get; set; }
}
/// Both clients POST updateprice with a body; only the GET
/// query form was registered, so ASP.NET answered 405 and re-pricing a paid
/// invention always failed. 2023: OEGFNFEAAGO.txt:9850 sets verb 2 and
/// :9870-9876 hands JsonUtility.ToJson(UpdatePriceRequest) to
/// FJLLPHFOOJJ , which wraps it in a BestHTTP RawJsonForm
/// (application/json , RawJsonForm.txt:40). December POSTs the same
/// route form-encoded (2020.12 BBHENFCNLAB.txt:6480). The binder accepts
/// both; the GET stays for callers that still use the query string.
[HttpGet("api/inventions/v1/updateprice")]
[Authorize]
public Task UpdatePrice(
[FromQuery] long inventionId,
[FromQuery] int price)
=> ApplyPriceAsync(inventionId, price);
[HttpPost("api/inventions/v1/updateprice")]
[Authorize]
public Task UpdatePricePost(
[ModelBinder(typeof(Binding.FormOrJsonModelBinder))] UpdatePriceRequest req)
=> ApplyPriceAsync(req.InventionId, req.Price);
private async Task ApplyPriceAsync(long inventionId, int price)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
inv.Price = Math.Clamp(price, 0, 1000000);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await EnvelopeAsync(inv));
}
[HttpGet("api/inventions/v1/delete")]
[Authorize]
public async Task Delete([FromQuery] long inventionId)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
inv.IsDeleted = true;
inv.GeneralPermission = 0;
inv.IsPublished = false;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await EnvelopeAsync(inv));
}
/// The 2023 publish call carries FOUR query params, not two:
/// OEGFNFEAAGO.txt:9437/9449/9461/9473 add inventionId ,
/// permissionLevel , accessibility and a
/// Nullable<Int32> price — the wrapper signature is
/// OEGFNFEAAGO.txt:9216. Dropping accessibility meant "publish as Private /
/// Unlisted" still went fully public, and a paid publish lost its price.
/// Accessibility constants: Private=0, Public=1, Unlisted=2
/// (AEFFFPIJDHG.GAKJKOGJEEH, 2023.06 dump.cs:1282644-1282646). With no
/// Accessibility column on the entity only the Private/not-Private half can
/// be stored, so Unlisted currently behaves as Public. The param is
/// NULLABLE on purpose — the 2020.12 watch sends only inventionId +
/// permissionLevel (2020.12 BBHENFCNLAB.txt:6171), and a missing value must
/// not be read as Private or December could never publish.
[HttpGet("api/inventions/v3/publish")]
[Authorize]
public async Task Publish(
[FromQuery] long inventionId,
[FromQuery] int permissionLevel,
[FromQuery] int? accessibility,
[FromQuery] int? price)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
var perm = ClampInventionPermission(permissionLevel);
if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)");
var access = accessibility ?? 1 /* Public */;
inv.GeneralPermission = perm;
inv.IsPublished = access != 0;
if (inv.IsPublished) inv.FirstPublishedAt ??= DateTime.UtcNow;
inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0);
if (price.HasValue) inv.Price = Math.Clamp(price.Value, 0, 1000000);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await EnvelopeAsync(inv));
}
[HttpGet("api/inventions/v1/unpublish")]
[Authorize]
public async Task Unpublish([FromQuery] long inventionId)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
inv.GeneralPermission = 0;
inv.IsPublished = false;
inv.Permission = 0;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await EnvelopeAsync(inv));
}
[HttpGet("api/inventions/v1/download")]
public async Task Download([FromQuery] long inventionId)
{
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
inv.SpawnCount += 1;
var pid = CurrentPlayerIdOrNull;
if (pid is long me && me != inv.CreatorPlayerId)
{
// NumPlayersHaveUsedInRoom — increment per-distinct-player.
// Approximate by distinct cheer/check; for now bump once
// per download by non-creator.
inv.NumPlayersHaveUsedInRoom += 1;
}
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
// ── POST mutations ───────────────────────────────────────────────────
/// The client sends AutoTags and CustomTags as JSON
/// ARRAYS of strings. Binding them to string made deserialization
/// throw, so every tag edit 400'd. PlayerAddedTags is kept as an
/// alias for older callers. Field names are literal in the 2023.06 dump —
/// RecNet.ModifyTagsRequest keeps real names (dump.cs:1283295-1283301) —
/// and the body is a RAW JSON document
/// (JsonUtility.ToJson → BNDIAONDFFF.FJLLPHFOOJJ ,
/// OEGFNFEAAGO.txt:3082-3088), so [FromBody] is right here.
public sealed record SetTagsRequest(
long InventionId,
List? AutoTags,
List? CustomTags,
List? PlayerAddedTags);
/// POST api/inventions/v1/settags — the reply is NOT an
/// invention. The 2023 issuing method is typed
/// FGLDKEJLAKB<PNGLFHEAJIH> (OEGFNFEAAGO.txt:2744) and
/// PNGLFHEAJIH exposes exactly two members — an int-backed result enum and
/// List<String> (PNGLFHEAJIH.txt:3/83) — read from the literals
/// "Result" and "Tags" (ALJPHDEAHBK.txt:191/210). The 2020.12 watch is
/// identical: NJMAEIPIOAP.PPGFHEDFBEA pulls "Result" then "Tags"
/// (2020.12 NJMAEIPIOAP.txt:85/90) with the throwing Util.GetKey, so the
/// old bare-invention reply killed every tag edit on December too.
/// Result = 0 is success: the enum's message formatter jump-tables on
/// the value and case 0 returns "Success!" (PNGLFHEAJIH.txt:334-341).
/// Tags is a flat string list — NOT the {Tag,Type} pairs
/// returns.
[HttpPost("api/inventions/v1/settags")]
[Authorize]
public async Task SetTags([FromBody] SetTagsRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
var tags = new[] { req.AutoTags, req.CustomTags, req.PlayerAddedTags }
.Where(list => list is not null)
.SelectMany(list => list!)
.Select(t => (t ?? string.Empty).Trim())
.Where(t => t.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
inv.TagsCsv = string.Join(',', tags);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { Result = 0, Tags = tags });
}
/// RecNet.CheerRequest carries a SECOND field — Cheer , the
/// desired state — whose real name survives in the 2023.06 dump
/// (dump.cs:1283338-1283342: long InventionId @0x10,
/// bool Cheer @0x18). Both writes are visible in the ISIL: 2023 sets
/// [rdi+16] and [rdi+24] before JsonUtility.ToJson
/// (OEGFNFEAAGO.txt:11228-11233) and December does the same
/// (2020.12 BBHENFCNLAB.txt:7624-7628) — its wrapper signature is
/// ANJHOOPIAKM(Int64, Boolean) (BBHENFCNLAB.txt:7503). Ignoring the
/// flag made un-cheering a no-op: the button toggled off in the UI and the
/// cheer stayed on the invention forever.
public sealed record CheerRequest(long InventionId, bool Cheer = true);
/// POST api/inventions/v1/cheer — answers with the
/// Status/Invention/InventionVersion envelope like every other invention
/// mutation: the 2023 method is FGLDKEJLAKB<BDNCJIPHHOK>
/// (OEGFNFEAAGO.txt:11065) and December's is
/// IPromise<AHEPPAEOLOD> (2020.12 BBHENFCNLAB.txt:7503). The old
/// {Id, CheerCount} reply left Invention null on 2023 and threw
/// KeyNotFoundException on December, so the tile never refreshed its
/// count.
[HttpPost("api/inventions/v1/cheer")]
[Authorize]
public async Task Cheer([FromBody] CheerRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
// Idempotent in both directions: one cheer row per (player, invention).
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == pid && c.TargetInventionId == req.InventionId);
if (req.Cheer && existing is null)
{
db.Cheers.Add(new CheerEntity
{
FromPlayerId = pid,
TargetInventionId = req.InventionId,
CheeredAt = DateTime.UtcNow,
});
inv.CheerCount += 1;
await db.SaveChangesAsync();
// No push: InventionModerationStateChanged ("100") means "your
// inventions' moderation state changed" and takes no payload; sent
// on every cheer it only made the creator refetch for nothing.
}
else if (!req.Cheer && existing is not null)
{
db.Cheers.Remove(existing);
inv.CheerCount = Math.Max(0, inv.CheerCount - 1);
await db.SaveChangesAsync();
}
return Ok(await EnvelopeAsync(inv));
}
public sealed record ReportInventionRequest(
long InventionId, int? ReportCategory, string? Details);
/// POST api/inventions/v1/report — the reply is a
/// success/message pair, not a status flag of our own invention. The 2023
/// method is FGLDKEJLAKB<PHMHCPEMABG> (OEGFNFEAAGO.txt:10917)
/// and PHMHCPEMABG holds a Boolean + a String (PHMHCPEMABG.txt:3/23) read
/// from the literals "Success"/"Message" (GBPDOLJBABB.txt:191/210); the
/// 2020.12 watch's KLAMKCBENEA.PPGFHEDFBEA pulls the same two literals
/// (2020.12 KLAMKCBENEA.txt:85/90). Our old {Reported:true} left
/// Success at default false, so the report UI reported failure even though
/// the row persisted. Message must be a string — December's
/// Util.GetKey casts it — so send "" rather than null.
[HttpPost("api/inventions/v1/report")]
[Authorize]
public async Task Report([FromBody] ReportInventionRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = pid,
TargetPlayerId = inv.CreatorPlayerId,
TargetInventionId = req.InventionId,
Category = req.ReportCategory ?? 5 /* Other */,
Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)],
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty });
}
// ── Rec Room Studio remote run ───────────────────────────────────────
/// One uploaded blob as the client describes it: three strings.
/// Wire keys are literal in the generated serializer — "Filename", "Hash",
/// "OwnershipProof" (FLELPHJDLNG.txt:255/274/290, with camelCase and
/// all-lowercase aliases registered alongside each). The backing type is
/// CFPEPOJAMHH/IFIBBJDIHJH , three System.String accessors
/// (CFPEPOJAMHH_NestedType_IFIBBJDIHJH.txt:3/25/47). Same triple the room
/// save commit sends on rooms/{id}/subrooms/{id}/data ; declared here
/// so this controller binds its own body.
public sealed class StudioBlobRef
{
public string? Filename { get; set; }
public string? Hash { get; set; }
public string? OwnershipProof { get; set; }
}
/// Body of remote-run/push-to-studio — RecNet type
/// FNAGBPCAGJD . Field order/types come from the request builder
/// (EHIOLHBGODG.txt:045-070 writes +16 String, +24 Int64, +32 Int64,
/// +40 String, +48/+56 the two blob refs, +64 a Nullable<Int32> )
/// and the property accessors on FNAGBPCAGJD.txt:3-141. The JSON key for
/// each is literal in the generated serializer OFMNNCMPEPA.txt:535-698:
/// SessionId, RoomId, SubRoomId, UnityAssetId, RoomData, SubRoomData,
/// SavedByAccountId (camel/lower aliases registered too).
public sealed class PushToStudioRequest
{
public string? SessionId { get; set; }
public long RoomId { get; set; }
public long SubRoomId { get; set; }
public string? UnityAssetId { get; set; }
public StudioBlobRef? RoomData { get; set; }
public StudioBlobRef? SubRoomData { get; set; }
public int? SavedByAccountId { get; set; }
}
/// POST remote-run/push-to-studio — hands the room the
/// player just uploaded to the paired Rec Room Studio session, WITHOUT
/// committing it as the room's live save (that is the separate
/// rooms/{id}/subrooms/{id}/data path; the client's two flows are
/// sibling methods OGPDOMCNIFM.txt:271 vs :449).
///
/// Verb. POST. The request builder at EHIOLHBGODG.txt:411-417
/// moves the route literal "remote-run/push-to-studio" into r9 and
/// 2 (BestHTTP HTTPMethods.Post) into rdx before
/// BNDIAONDFFF..ctor(verb, host, route) . No cmov — one verb only.
/// The body is RAW JSON, not form fields: :433 hands the serialized DTO to
/// BNDIAONDFFF.FJLLPHFOOJJ (RawJsonForm, application/json ),
/// so this binds via — the
/// explicit [ModelBinder] is required or [ApiController] re-infers
/// [FromBody] and 415s a form-shaped caller.
///
/// Response. FGLDKEJLAKB<CEELGOLBHIL>
/// (EHIOLHBGODG.txt:217) — a single RemoteRunDetails object, EIGHT keys,
/// all literal in its generated reader PPHLKNGCGOE.txt:599-786:
/// SessionId (String), RoomId and SubRoomId
/// (Nullable<Int64> — CEELGOLBHIL.txt:25/51), then
/// UnityAssetId, RoomDataFilename, RoomDataHash, SubRoomDataFilename,
/// SubRoomDataHash (String). Note the response FLATTENS the two blob
/// refs the request nests, and drops OwnershipProof. A failed/malformed
/// reply surfaces the client's "Failed to push to Rec Room Studio" toast
/// (EHIOLHBGODG.txt:445).
///
/// What it persists. The pushed blobs were already stored by
/// /upload (every FileType there inserts a
/// ), so the push is a registration step:
/// it stamps the sub-room save blob with the room + sub-room it belongs to
/// — that is what makes it appear in the per-sub-room
/// datahistory /"Restore to old version" list, which the FileType=6
/// room-metadata upload path (RoomId=0, SubRoomId=null) cannot do on its
/// own — writes the session id into
/// (the column that exists for
/// exactly this and is echoed in room details), records the pushed Unity
/// asset id on the sub-room's , and keeps the
/// whole push under remoterun:{SessionId} in
/// so the details survive for the paired
/// Studio session to resolve.
/// is deliberately NOT flipped: it changes in-room MakerPen UI and the
/// client has no unlink call, so a push must not one-way-toggle it.
///
/// Not implemented: live relay of the details to a second
/// logged-in session. The client's receive side
/// (EHIOLHBGODG.MCPBJLHFOBC(CEELGOLBHIL) → the
/// Action<CEELGOLBHIL> that MBOJJFBIAGE.txt:366/978 subscribes)
/// has no call site anywhere in the IsilDump, so the notification id that
/// carries it cannot be established from the binary — inventing one would
/// be a guess. The HTTP round-trip is complete and the push is durable
/// either way.
[HttpPost("remote-run/push-to-studio")]
[HttpPost("roomserver/remote-run/push-to-studio")]
[Authorize]
public async Task PushToStudio(
[ModelBinder(typeof(Binding.FormOrJsonModelBinder))] PushToStudioRequest req)
{
var pid = CurrentPlayerId;
var sessionId = Clamp((req.SessionId ?? string.Empty).Trim(), 100);
if (sessionId.Length == 0)
return BadRequest(new { Error = "missing SessionId" });
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
if (room is null) return NotFound();
// Same gate the room-save upload uses (StorageController.UploadRoomSave):
// creator, admin, or an accepted CoOwner (Role 0).
var canPush = room.CreatorPlayerId == pid
|| await db.Players.AnyAsync(p => p.Id == pid && p.IsAdmin)
|| await db.RoomRoles.AnyAsync(r =>
r.RoomId == room.Id && r.PlayerId == pid && r.Accepted && r.Role == 0);
if (!canPush) return Forbid();
var subRoomId = req.SubRoomId;
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
if (subRoomDataFilename.Length == 0)
return BadRequest(new { Error = "missing SubRoomData.Filename" });
// The push names blobs the client uploaded moments ago; if the row is
// absent the bytes are not on this server and Studio could never fetch
// them. Fail loudly rather than answering with a filename that 404s.
var subRoomBlob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == subRoomDataFilename);
if (subRoomBlob is null)
return BadRequest(new { Error = "unknown SubRoomData.Filename" });
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
subRoomBlob.SubRoomId ??= subRoomId;
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
if (roomDataFilename.Length > 0)
{
// FileType=6 (RoomMetadata) uploads land with RoomId=0 — the push
// is the first point at which the owning room is known.
var roomBlob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == roomDataFilename);
if (roomBlob is null)
return BadRequest(new { Error = "unknown RoomData.Filename" });
if (roomBlob.RoomId == 0) roomBlob.RoomId = room.Id;
}
var unityAssetId = Clamp((req.UnityAssetId ?? string.Empty).Trim(), 64);
if (unityAssetId.Length == 0)
{
unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
}
else if (scene is not null && scene.StudioUnityAssetId != unityAssetId)
{
scene.StudioUnityAssetId = unityAssetId;
scene.DataModifiedAt = DateTime.UtcNow;
}
var roomDataHash = Clamp((req.RoomData?.Hash ?? string.Empty).Trim(), 128);
var subRoomDataHash = Clamp((req.SubRoomData?.Hash ?? string.Empty).Trim(), 128);
room.StudioSessionId = sessionId;
var record = System.Text.Json.JsonSerializer.Serialize(new
{
RoomId = room.Id,
SubRoomId = subRoomId,
UnityAssetId = unityAssetId,
RoomDataFilename = roomDataFilename,
RoomDataHash = roomDataHash,
SubRoomDataFilename = subRoomDataFilename,
SubRoomDataHash = subRoomDataHash,
SavedByAccountId = req.SavedByAccountId ?? (int)pid,
PushedAt = DateTime.UtcNow,
});
if (record.Length > 1024)
{
// PlayerSettingEntity.Value is varchar(1024); truncating JSON would
// store an unparseable fragment, so drop the optional hashes first.
record = System.Text.Json.JsonSerializer.Serialize(new
{
RoomId = room.Id,
SubRoomId = subRoomId,
UnityAssetId = unityAssetId,
RoomDataFilename = roomDataFilename,
SubRoomDataFilename = subRoomDataFilename,
SavedByAccountId = req.SavedByAccountId ?? (int)pid,
PushedAt = DateTime.UtcNow,
});
}
var key = $"remoterun:{sessionId}";
var setting = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == pid && s.Key == key);
if (setting is null)
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid, Key = key, Value = record,
});
else
setting.Value = record;
await db.SaveChangesAsync();
return Ok(new
{
SessionId = sessionId,
RoomId = (long?)room.Id,
SubRoomId = (long?)subRoomId,
UnityAssetId = unityAssetId,
RoomDataFilename = roomDataFilename,
RoomDataHash = roomDataHash,
SubRoomDataFilename = subRoomDataFilename,
SubRoomDataHash = subRoomDataHash,
});
}
/// Trim a wire string to a column's length. Every string that
/// reaches a MaxLength column here comes straight off the request.
private static string Clamp(string value, int max)
=> value.Length <= max ? value : value[..max];
// ── Wire serializers ─────────────────────────────────────────────────
private static int ClampInventionPermission(int v) => v switch
{
0 or 10 or 20 or 40 or 60 or 80 or 100 => v,
_ => 0,
};
// Wire shape verified against the 2020.12 watch's
// OBBBPCBIMME.PPGFHEDFBEA deserializer (Cpp2IL ISIL OBBBPCBIMME.txt
// lines 588-704). All keys read via Util.GetKey are required —
// a missing key throws KeyNotFoundException → the watch logs
// "Received malformed RecNet response" and aborts the join/fetch.
//
// Required (in disasm order):
// InventionId, ReplicationId, CreatorPlayerId, Name, Description,
// ImageName, CurrentVersionNumber, IsPublished, AllowTrial,
// ModifiedAt, CreatedAt, NumPlayersHaveUsedInRoom, NumDownloads,
// CheerCount, CreatorPermission, GeneralPermission, IsAGInvention,
// Price, HideFromPlayer.
// Optional (Util.GetKeyOrDefault): FirstPublishedAt, CreationRoomId.
//
// Casing is significant — LitJson + Util.GetKey are case-sensitive.
// In particular IsAGInvention has a capital G (matches the asset's
// "IsAGInvention" property, not the entity column IsAgInvention).
private static object ToWire(InventionEntity i) => new
{
InventionId = i.Id,
ReplicationId = string.IsNullOrEmpty(i.ReplicationId)
? Guid.Empty.ToString("D") : i.ReplicationId,
// Wire field is int — IDs in this server are <2^31 so safe.
CreatorPlayerId = (int)i.CreatorPlayerId,
i.Name,
i.Description,
i.ImageName,
i.CurrentVersionNumber,
i.IsPublished,
AllowTrial = true,
ModifiedAt = i.UpdatedAt,
i.CreatedAt,
i.FirstPublishedAt,
i.CreationRoomId,
i.NumPlayersHaveUsedInRoom,
NumDownloads = i.SpawnCount,
i.CheerCount,
i.CreatorPermission,
i.GeneralPermission,
IsAGInvention = i.IsAgInvention,
// The 2023-03-21 DTO dropped IsPublished and reads "Accessibility"
// instead (IOEPPCKGBFL.txt:309 / :1602 — the member switch has no
// IsPublished arm at all). Constants come from the nested enum
// AEFFFPIJDHG.GAKJKOGJEEH in the 2023.06 dump
// (dump.cs:1282644-1282646): Private=0, Public=1, Unlisted=2. With the
// key absent the reader left the field at its default, so EVERY
// published store invention presented as Private in the 2023 UI.
// InventionEntity has no Accessibility column, so only the
// Private/Public bit round-trips; Unlisted needs a real column.
// IsPublished stays on the wire because the 2020.12 watch's
// OBBBPCBIMME reader still requires it.
Accessibility = i.IsPublished ? 1 : 0,
// IOEPPCKGBFL.txt:565 — DorkNet runs no certification programme.
IsCertifiedInvention = false,
i.Price,
HideFromPlayer = false,
};
private static object ToWireV4(InventionEntity i) => ToWire(i);
private static object ToVersionWire(InventionVersionEntity v)
=> ToVersionWire(v, 0, 0);
/// The 2023 InventionVersion DTO reads NINE keys
/// (OLPHKLCPFEF.txt:084-295): the six we already emitted plus
/// ChipsCost , CloudVariablesCost and BlobHash .
/// has no column for any of the three,
/// so stored rows report 0/empty and only the save/addversion round-trip can
/// echo back the costs the client just sent. An empty BlobHash is safe:
/// PLIKEBBPJGI's hash getter (EHENFDMIAIM , OLPHKLCPFEF's owner type
/// PLIKEBBPJGI.txt:167) has no call site anywhere in the IsilDump — the
/// client only ever writes it — so the key's presence is what matters.
private static object ToVersionWire(
InventionVersionEntity v, int chipsCost, int cloudVariablesCost) => new
{
v.InventionId,
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
? Guid.Empty.ToString("D") : v.ReplicationId,
v.VersionNumber,
v.InstantiationCost,
v.LightsCost,
ChipsCost = chipsCost,
CloudVariablesCost = cloudVariablesCost,
v.BlobName,
BlobHash = string.Empty,
};
/// Every invention MUTATION route answers with the
/// Status/Invention/InventionVersion envelope, never a bare invention: the
/// 2023 client deserialises BDNCJIPHHOK (key literals at
/// GMBGBPNMGBA.txt:044-095) and the 2020.12 watch deserialises
/// AHEPPAEOLOD , whose reader pulls the same three literals
/// (2020.12 IsilDump AHEPPAEOLOD.txt:110/115/120). Returning the invention
/// flat left Invention null on 2023 and threw KeyNotFoundException on
/// December. Status = 0 is success —
/// EnterInventionNameDialog.txt:622-624 loads the boxed Status field and
/// does test eax,eax / je <success> .
private async Task EnvelopeAsync(InventionEntity inv)
{
var current = await db.InventionVersions
.Where(x => x.InventionId == inv.Id)
.OrderByDescending(x => x.VersionNumber)
.FirstOrDefaultAsync();
object? versionWire = current is null ? null : ToVersionWire(current);
return new
{
Status = 0,
Invention = ToWire(inv),
InventionVersion = versionWire,
};
}
}