Compare commits

..

4 Commits

Author SHA1 Message Date
Alexa 5ab28b1d4f fix: creating a room failed with a blank "Failed to copy room"
The 2023 client sends rooms/{id}/clone through the same RecNet rooms
helper as "save room" and "restore subroom save", which reads the
{success, value, error} envelope. The clone handler answered with the
bare room details, so the client found no success flag and rejected the
reply with an empty error, even though the room had been created.

Clone now returns the envelope with the full room details in value, and
refusals (unknown source, cloning not allowed) are a 200 with
success=false and a message instead of a bodyless 404/403.
2026-10-17 20:21:54 +03:00
Alexa d60b0642a8 Merge pull request #59 from DorkSquadRR/feat/quest-web-installer
Install the Quest build from the website
2026-10-16 02:19:31 +03:00
Alexa a7879d7ede feat: install the Quest build from the website
A Quest page on the public site (/quest) installs a DorkNet .xapk from
Chrome or Edge over WebUSB, with no adb, SideQuest or scripts: pick the
.xapk, connect the headset, install. It installs the APK, copies the OBB
to its install path (skipped when an identical one is already there) and
launches the app, with progress bars and plain-language errors for an
unsupported browser, a headset held by adb/SideQuest, a disconnect and a
copy signed with a different key.

The .xapk is read from the player's own disk: the APK and OBB are stored
uncompressed, so they stream straight out of the file without loading
1.4 GB into memory, and the server never hosts game files.

Uses Tango (@yume-chan/adb 2.6.4 and friends, MIT), pinned and bundled;
the page is lazy-loaded so the rest of the site doesn't grow. Credited in
NOTICE; README, DorkNet.Quest/README.md, docs/quest.md and the packaged
README.txt point players at the page.
2026-10-15 22:34:25 +03:00
Alexa 6272844445 Merge pull request #58 from DorkSquadRR/fix/base-avatar-items
Owned items showed a price: serve only the real base avatar items
2026-10-15 22:09:23 +03:00
18 changed files with 650 additions and 34 deletions
+12 -1
View File
@@ -129,7 +129,18 @@ the running `libil2cpp.so`, so a table can never be applied to the wrong build.
## Install ## Install
From a release folder, one command does everything (finds adb and the headset, **From the browser (easiest).** Open the **Quest** page of your DorkNet site
(`https://<apex>/quest`) in Chrome or Edge on a computer, plug the headset in
over USB, pick the `.xapk`, click **Connect**, accept "Allow USB debugging" in
the headset, then **Install**. The page talks to the headset over WebUSB
(Tango, `@yume-chan/adb`): it installs the APK, copies the OBB to its install
path (skipped if an identical one is already there) and launches the app. The
`.xapk` never leaves the player's computer; the server hosts no game files.
Close SideQuest / Meta Quest Developer Hub, or run `adb kill-server`, first:
only one program can hold the headset's USB connection. Firefox, Safari and
phones don't support WebUSB.
**From a release folder**, one command does everything (finds adb and the headset,
installs the APK, copies the OBB to the right path, skips the OBB if it is installs the APK, copies the OBB to the right path, skips the OBB if it is
already there, and launches the app): already there, and launches the app):
+3 -1
View File
@@ -150,7 +150,9 @@ $($manifest.name) $verName - Rec Room for DorkNet (Meta Quest)
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it 1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
in over USB and accept "Allow USB debugging" inside the headset. in over USB and accept "Allow USB debugging" inside the headset.
2. Install: 2. Install. Easiest: open the Quest page of your DorkNet server's website
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
steps. Or use the scripts here:
Windows: double-click install.cmd Windows: double-click install.cmd
macOS / Linux: ./install.sh macOS / Linux: ./install.sh
The installer needs adb (Android platform-tools) on PATH or in The installer needs adb (Android platform-tools) on PATH or in
@@ -42,7 +42,7 @@ public sealed class PrivateRoomInstanceTests : IClassFixture<DorkNetServerFactor
var text = await resp.Content.ReadAsStringAsync(); var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}"); Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text); using var json = JsonDocument.Parse(text);
var cloneId = json.RootElement.GetProperty("RoomId").GetInt64(); var cloneId = json.RootElement.GetProperty("value").GetProperty("RoomId").GetInt64();
await using var scope = _factory.Services.CreateAsyncScope(); await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>(); var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
+31 -7
View File
@@ -10,13 +10,15 @@ using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests; namespace DorkNet.Server.Tests;
/// <summary> /// <summary>
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and deserialises the /// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and reads the reply
/// reply into <c>FGCPNAACHIK</c> — the SAME type <c>GET rooms/{id}</c> returns /// as the <c>{success, value, error}</c> envelope (the same RecNet rooms helper
/// (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares /// as "save room"), whose <c>value</c> is <c>FGCPNAACHIK</c> — the SAME type
/// <c>GET rooms/{id}</c> returns (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
/// <c>Task&lt;FGCPNAACHIK&gt; GDHIIAHCBMN(Int64, String, CancellationToken)</c>, /// <c>Task&lt;FGCPNAACHIK&gt; GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
/// with the route literal on :4802). /// with the route literal on :4802). A bare room with no <c>success</c> is
/// rejected with an empty error.
/// ///
/// So the two responses must be shape-identical. When they drift, the server /// So <c>value</c> and the GET response must be shape-identical. When they drift, the server
/// still answers 200 and the client throws while reading it — and because the /// still answers 200 and the client throws while reading it — and because the
/// clone promise's exception is never observed, it surfaces from the finalizer /// clone promise's exception is never observed, it surfaces from the finalizer
/// thread seconds later as a bare "Failed to copy room: Exception of type /// thread seconds later as a bare "Failed to copy room: Exception of type
@@ -63,8 +65,12 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
} }
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}"); var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
var cloneBody = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone", var cloneEnvelope = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])])); new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
// The client reads clone through the {success, value, error} envelope.
Assert.True(cloneEnvelope.GetProperty("success").GetBoolean(), cloneEnvelope.ToString());
Assert.Equal("", cloneEnvelope.GetProperty("error").GetString());
var cloneBody = cloneEnvelope.GetProperty("value");
var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
@@ -157,7 +163,7 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone", var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])])); new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
var cloneId = clone.GetProperty("RoomId").GetInt64(); var cloneId = clone.GetProperty("value").GetProperty("RoomId").GetInt64();
await using (var scope = factory.Services.CreateAsyncScope()) await using (var scope = factory.Services.CreateAsyncScope())
{ {
@@ -203,6 +209,24 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}")); Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
} }
[Fact]
public async Task Clone_refusal_is_a_200_envelope_with_a_message()
{
using var client = Client();
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
// No room has this id, so the clone is refused; the client shows
// `error`, which a bare 404 would leave empty.
var envelope = await ReadJsonAsync(client, HttpMethod.Post, "/rooms/987654321987/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", "NoSuchSource")]));
Assert.False(envelope.GetProperty("success").GetBoolean());
Assert.False(string.IsNullOrEmpty(envelope.GetProperty("error").GetString()));
Assert.Equal(JsonValueKind.Null, envelope.GetProperty("value").ValueKind);
}
private static async Task<JsonElement> ReadJsonAsync( private static async Task<JsonElement> ReadJsonAsync(
HttpClient client, HttpMethod method, string path, HttpContent? content = null) HttpClient client, HttpMethod method, string path, HttpContent? content = null)
{ {
+4 -2
View File
@@ -267,8 +267,10 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}"); Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text); using var json = JsonDocument.Parse(text);
var root = json.RootElement; // {success, value, error} envelope around the full room-details
// Full room-details shape (FGCPNAACHIK), not a status wrapper. // shape (FGCPNAACHIK).
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), text);
var root = json.RootElement.GetProperty("value");
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" }) foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}"); Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
return root.GetProperty("RoomId").GetInt64(); return root.GetProperty("RoomId").GetInt64();
@@ -1375,13 +1375,13 @@ public class RoomsModerationController(
{ {
var newName = await ReadCloneNameAsync(); var newName = await ReadCloneNameAsync();
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (source is null) return NotFound(); if (source is null) return CloneResult(null, "That room doesn't exist.");
// RRO/AG rooms are first-party templates meant to be cloned — the // RRO/AG rooms are first-party templates meant to be cloned — the
// room-creation "base room" picker starts a new build by cloning one // room-creation "base room" picker starts a new build by cloning one
// (RecCenter, MakerRoom, …). Their seed rows have CloningAllowed=false, // (RecCenter, MakerRoom, …). Their seed rows have CloningAllowed=false,
// so without the IsAGRoom exception every base-room create 403s. // so without the IsAGRoom exception every base-room create is refused.
if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me) if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me)
return Forbid(); return CloneResult(null, "You can't clone this room!");
if (string.IsNullOrWhiteSpace(newName)) if (string.IsNullOrWhiteSpace(newName))
newName = $"{source.Name}Copy"; newName = $"{source.Name}Copy";
@@ -1517,17 +1517,22 @@ public class RoomsModerationController(
await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me); await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me);
// The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN) // The value is the FULL room-details object (FGCPNAACHIK), the exact
// deserializes the response as the FULL room-details object // shape GET rooms/{id} returns for the 2023 client — not the slim
// (FGCPNAACHIK — the same type get-by-id / rename return on the // list-room shape, which made the strict reader fail.
// roomserver host), NOT the slim list-room shape. This is the return CloneResult(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
// roomserver-family path (rooms/{id}/clone), so use
// BuildRoomServerDetails — the exact shape GET rooms/{id} returns for
// the 2023 client. Returning ToWireRoom (or the wrong details variant)
// made the strict reader fail → "Failed to copy room: Failed to clone
// room".
return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
} }
/// <summary>Reply for <c>rooms/{id}/clone</c>. The 2023 client sends the
/// clone through the same RecNet rooms helper as "save room" and "restore
/// subroom save" (NLDBPDCNNCF.BDBHCEGKGDC with an op name), which reads the
/// legacy <c>{success, value, error}</c> envelope. A bare room-details body
/// has no <c>success</c>, so the client rejects it with an empty error:
/// "Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown", even
/// though the room was created. Refusals are 200 with success=false and a
/// message the client shows.</summary>
private OkObjectResult CloneResult(object? room, string error = "") =>
Ok(new { success = error.Length == 0, value = room, error });
/// <summary>Give a freshly-cloned room its OWN copy of the source's save /// <summary>Give a freshly-cloned room its OWN copy of the source's save
/// data instead of pointing its scenes at the source's blob. /// data instead of pointing its scenes at the source's blob.
/// ///
+98
View File
@@ -8,6 +8,11 @@
"name": "dorknet-site", "name": "dorknet-site",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@yume-chan/adb": "2.6.4",
"@yume-chan/adb-credential-web": "2.1.0",
"@yume-chan/adb-daemon-webusb": "2.3.2",
"@yume-chan/android-bin": "2.1.0",
"@yume-chan/stream-extra": "2.6.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router": "^8.3.0" "react-router": "^8.3.0"
@@ -1350,6 +1355,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/w3c-web-usb": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz",
"integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@@ -1371,6 +1382,93 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/@yume-chan/adb": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/@yume-chan/adb/-/adb-2.6.4.tgz",
"integrity": "sha512-aaN21ZOLjWW1nC09C21f0yQkCqxkuMWMqheryGEbmGLPhuzIyOhTurJK8cXh5ZnQ+ptdKBexqvoG6zYoul9XPA==",
"license": "MIT",
"dependencies": {
"@yume-chan/async": "^4.1.3",
"@yume-chan/event": "^2.0.0",
"@yume-chan/no-data-view": "^2.0.0",
"@yume-chan/stream-extra": "^2.6.1",
"@yume-chan/struct": "^2.3.2"
}
},
"node_modules/@yume-chan/adb-credential-web": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@yume-chan/adb-credential-web/-/adb-credential-web-2.1.0.tgz",
"integrity": "sha512-ps5XWt+xxm6Mi9eF80ePprPJ/uRFMhfR437betQE8hixrjdZpkZgMlqZU6GlnuebMi0djrbP8GWB49jJekJVYA==",
"license": "MIT",
"dependencies": {
"@yume-chan/adb": "^2.1.0"
}
},
"node_modules/@yume-chan/adb-daemon-webusb": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@yume-chan/adb-daemon-webusb/-/adb-daemon-webusb-2.3.2.tgz",
"integrity": "sha512-5ANaqsRJWFc5kKMnGALjYCrSXo5eC4u51dvYppvuBCLVjv12n7he2TPy5yEZ3XtlToRFS0kWCQC0XS8M60lRSA==",
"license": "MIT",
"dependencies": {
"@types/w3c-web-usb": "^1.0.12",
"@yume-chan/adb": "^2.3.1",
"@yume-chan/event": "^2.0.0",
"@yume-chan/stream-extra": "^2.1.0",
"@yume-chan/struct": "^2.3.2"
}
},
"node_modules/@yume-chan/android-bin": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@yume-chan/android-bin/-/android-bin-2.1.0.tgz",
"integrity": "sha512-is97IQwmSTmGCLe8GKO3rRDib2is6Xumo9KzeArgHwizIF0XnpCUm/xIVJLnpifR9PlMyLldDUttTZMTnaNivg==",
"license": "MIT",
"dependencies": {
"@yume-chan/adb": "^2.1.0",
"@yume-chan/stream-extra": "^2.1.0",
"@yume-chan/struct": "^2.0.1"
}
},
"node_modules/@yume-chan/async": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@yume-chan/async/-/async-4.1.3.tgz",
"integrity": "sha512-0vzhNJMkWUPyjKzUK4rqHEeCU6YQtF78RsB1kFRB6Y2BLupmEQNxcSb0mjKabPL9jZpCCiLa5KL8oTOJClUVaw==",
"license": "MIT"
},
"node_modules/@yume-chan/event": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@yume-chan/event/-/event-2.0.0.tgz",
"integrity": "sha512-z56MDOcX1QlgLUCuA6th3r10negVb7A3gzY//TwSC9ZOvzuRlrAqXcxZf1T3hHfNMk/NFO9RIgQgegXYSfaqLw==",
"license": "MIT",
"dependencies": {
"@yume-chan/async": "^4.0.2"
}
},
"node_modules/@yume-chan/no-data-view": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@yume-chan/no-data-view/-/no-data-view-2.0.0.tgz",
"integrity": "sha512-0GRJrrt6wtZlbiE92jocHOnaAvjQ+Y7xwwhwOPqLkwf90Kj1JIHJ5Zh4wJVQSQIkzfRSOpM+jeEQdC2K15snlA==",
"license": "MIT"
},
"node_modules/@yume-chan/stream-extra": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@yume-chan/stream-extra/-/stream-extra-2.6.1.tgz",
"integrity": "sha512-GQ+Q0SQf9p6Fl7ZK0UO5vHVGBJgkTJm/WXKurUMpjHQV4VUpjZ2hrQ744aCfT7WwSol7Bz/uEiXWmk240Hr/hg==",
"license": "MIT",
"dependencies": {
"@yume-chan/async": "^4.1.3",
"@yume-chan/struct": "^2.3.2"
}
},
"node_modules/@yume-chan/struct": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@yume-chan/struct/-/struct-2.3.2.tgz",
"integrity": "sha512-afoCnSKV+5HRK7e4innVd9YTYDyNWdjA1CVQa1j8rWYnmr7HGZfdkHMQr+AESLk6GxWFMzOIPQIG6nYOTGMFIw==",
"license": "MIT",
"dependencies": {
"@yume-chan/async": "^4.1.3",
"@yume-chan/no-data-view": "^2.0.0"
}
},
"node_modules/any-promise": { "node_modules/any-promise": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+5
View File
@@ -13,6 +13,11 @@
"typecheck": "tsc -b --noEmit" "typecheck": "tsc -b --noEmit"
}, },
"dependencies": { "dependencies": {
"@yume-chan/adb": "2.6.4",
"@yume-chan/adb-credential-web": "2.1.0",
"@yume-chan/adb-daemon-webusb": "2.3.2",
"@yume-chan/android-bin": "2.1.0",
"@yume-chan/stream-extra": "2.6.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router": "^8.3.0" "react-router": "^8.3.0"
+5
View File
@@ -1,3 +1,4 @@
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router'; import { Routes, Route, Navigate } from 'react-router';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Home } from './pages/Home'; import { Home } from './pages/Home';
@@ -9,6 +10,9 @@ import { Rooms } from './pages/Rooms';
import { Join } from './pages/Join'; import { Join } from './pages/Join';
import { NotFound } from './pages/NotFound'; import { NotFound } from './pages/NotFound';
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest.
const Quest = lazy(() => import('./pages/Quest').then((m) => ({ default: m.Quest })));
export function App() { export function App() {
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
@@ -22,6 +26,7 @@ export function App() {
<Route path="/players/:id" element={<PlayerProfile />} /> <Route path="/players/:id" element={<PlayerProfile />} />
<Route path="/rooms" element={<Rooms />} /> <Route path="/rooms" element={<Rooms />} />
<Route path="/join" element={<Join />} /> <Route path="/join" element={<Join />} />
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
{/* Legacy paths from the vanilla-JS feed.localhost site */} {/* Legacy paths from the vanilla-JS feed.localhost site */}
<Route path="/p/:id" element={<RedirectPhoto />} /> <Route path="/p/:id" element={<RedirectPhoto />} />
<Route path="/u/:id" element={<RedirectPlayer />} /> <Route path="/u/:id" element={<RedirectPlayer />} />
@@ -8,6 +8,7 @@ const NAV = [
{ to: '/players', label: 'Players' }, { to: '/players', label: 'Players' },
{ to: '/rooms', label: 'Rooms' }, { to: '/rooms', label: 'Rooms' },
{ to: '/join', label: 'Join' }, { to: '/join', label: 'Join' },
{ to: '/quest', label: 'Quest' },
]; ];
export function Header() { export function Header() {
+142
View File
@@ -0,0 +1,142 @@
// Install a DorkNet .xapk onto a Meta Quest from the browser over WebUSB, using
// Tango (@yume-chan/adb). Mirrors DorkNet.Quest/scripts/installer/install.ps1:
// install the APK, copy each OBB to its install path (skipped when an identical
// size is already there), then launch.
import { Adb, AdbDaemonTransport } from '@yume-chan/adb';
import { AdbDaemonWebUsbDevice, AdbDaemonWebUsbDeviceManager } from '@yume-chan/adb-daemon-webusb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
import { PackageManager } from '@yume-chan/android-bin';
import type { ReadableStream as TangoReadableStream } from '@yume-chan/stream-extra';
import { entryStream, type Xapk } from './xapk';
export const webUsbSupported = () => typeof navigator !== 'undefined' && 'usb' in navigator && !!AdbDaemonWebUsbDeviceManager.BROWSER;
export interface Headset {
adb: Adb;
model: string;
serial: string;
}
export type Phase = 'app' | 'data' | 'launch';
export interface Progress {
phase: Phase;
label: string;
done: number;
total: number;
}
const credentialStore = new AdbWebCredentialStore('DorkNet Quest installer');
export async function connectHeadset(): Promise<Headset> {
const manager = AdbDaemonWebUsbDeviceManager.BROWSER;
if (!manager) throw new Error('This browser does not support WebUSB. Use Chrome or Edge on a computer.');
const device = await manager.requestDevice();
if (!device) throw new Error('No headset selected.');
let connection;
try {
connection = await device.connect();
} catch (e) {
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
throw new Error('The headset is in use by another program. Close SideQuest, Meta Quest Developer Hub or adb (run "adb kill-server"), then try again.');
}
throw e;
}
const transport = await AdbDaemonTransport.authenticate({
serial: device.serial,
connection,
credentialStore,
});
const adb = new Adb(transport);
const model = (await adb.getProp('ro.product.model')).trim() || device.name;
return { adb, model, serial: device.serial };
}
function counted(stream: ReadableStream<Uint8Array>, onBytes: (n: number) => void): TangoReadableStream<Uint8Array> {
const counter = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
onBytes(chunk.byteLength);
controller.enqueue(chunk);
},
});
return stream.pipeThrough(counter) as unknown as TangoReadableStream<Uint8Array>;
}
async function remoteSize(adb: Adb, path: string): Promise<number | null> {
const sync = await adb.sync();
try {
const st = await sync.lstat(path);
return Number(st.size);
} catch {
return null;
} finally {
await sync.dispose();
}
}
/**
* Install the package. `confirmUninstall` is asked before removing an existing
* copy signed with a different key (that clears its local data).
*/
export async function installXapk(
headset: Headset,
xapk: Xapk,
onProgress: (p: Progress) => void,
confirmUninstall: (pkg: string) => Promise<boolean>,
): Promise<void> {
const { adb } = headset;
const pkg = xapk.manifest.package_name;
const pm = new PackageManager(adb);
// ---- APK ----
const installApk = async () => {
let done = 0;
const total = xapk.apk.size;
onProgress({ phase: 'app', label: 'Installing the app', done, total });
const stream = counted(await entryStream(xapk.file, xapk.apk), (n) => {
done += n;
onProgress({ phase: 'app', label: 'Installing the app', done, total });
});
await pm.installStream(total, stream);
};
try {
await installApk();
} catch (e) {
const msg = String((e as Error)?.message ?? e);
if (!/INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match/i.test(msg)) throw e;
if (!(await confirmUninstall(pkg))) throw new Error('Cancelled.');
await pm.uninstall(pkg);
await installApk();
}
// ---- game data (OBB) ----
for (const { entry, installPath } of xapk.expansions) {
const dest = `/sdcard/${installPath}`;
const total = entry.size;
if ((await remoteSize(adb, dest)) === total) {
onProgress({ phase: 'data', label: 'Game data already on the headset', done: total, total });
continue;
}
const dir = dest.substring(0, dest.lastIndexOf('/'));
await adb.subprocess.noneProtocol.spawnWaitText(['mkdir', '-p', dir]);
let done = 0;
onProgress({ phase: 'data', label: 'Copying game data', done, total });
const stream = counted(await entryStream(xapk.file, entry), (n) => {
done += n;
onProgress({ phase: 'data', label: 'Copying game data', done, total });
});
const sync = await adb.sync();
try {
await sync.write({ filename: dest, file: stream, permission: 0o660 });
} finally {
await sync.dispose();
}
}
// ---- launch ----
onProgress({ phase: 'launch', label: 'Starting the game', done: 1, total: 1 });
await adb.subprocess.noneProtocol.spawnWaitText(['monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']);
}
+137
View File
@@ -0,0 +1,137 @@
// Read a DorkNet .xapk (zip) straight from a File the player picked, without
// loading it into memory. DorkNet.Quest/scripts/package.ps1 stores the APK and
// the OBB uncompressed, so those entries are plain byte ranges of the file and
// stream out with Blob.slice(); manifest.json is deflated and goes through
// DecompressionStream. Handles zip64 so packages over 4 GB still open.
export interface XapkEntry {
name: string;
method: number; // 0 = stored, 8 = deflate
compressedSize: number;
size: number;
localHeaderOffset: number;
}
export interface XapkExpansion {
file: string;
install_path: string;
}
export interface XapkManifest {
package_name: string;
name?: string;
version_code?: string;
version_name?: string;
expansions?: XapkExpansion[];
}
export interface Xapk {
file: File;
manifest: XapkManifest;
entries: Map<string, XapkEntry>;
apk: XapkEntry;
expansions: { entry: XapkEntry; installPath: string }[];
}
const EOCD = 0x06054b50;
const ZIP64_LOCATOR = 0x07064b50;
const ZIP64_EOCD = 0x06064b50;
const CENTRAL = 0x02014b50;
const LOCAL = 0x04034b50;
async function bytes(file: Blob, start: number, end: number): Promise<DataView> {
return new DataView(await file.slice(start, end).arrayBuffer());
}
function u64(v: DataView, at: number): number {
return Number(v.getBigUint64(at, true));
}
export async function openXapk(file: File): Promise<Xapk> {
// End of central directory: last 22 bytes plus up to 64 KiB of comment.
const tailStart = Math.max(0, file.size - (22 + 0xffff + 20));
const tail = await bytes(file, tailStart, file.size);
let eocd = -1;
for (let i = tail.byteLength - 22; i >= 0; i--) {
if (tail.getUint32(i, true) === EOCD) { eocd = i; break; }
}
if (eocd < 0) throw new Error('This is not a zip/.xapk file.');
let count = tail.getUint16(eocd + 10, true);
let cdSize = tail.getUint32(eocd + 12, true);
let cdOffset = tail.getUint32(eocd + 16, true);
if (cdOffset === 0xffffffff || count === 0xffff || cdSize === 0xffffffff) {
const loc = eocd - 20;
if (loc < 0 || tail.getUint32(loc, true) !== ZIP64_LOCATOR) throw new Error('Unsupported zip64 layout.');
const z64At = u64(tail, loc + 8);
const z64 = await bytes(file, z64At, z64At + 56);
if (z64.getUint32(0, true) !== ZIP64_EOCD) throw new Error('Unsupported zip64 layout.');
count = u64(z64, 32);
cdSize = u64(z64, 40);
cdOffset = u64(z64, 48);
}
const cd = await bytes(file, cdOffset, cdOffset + cdSize);
const entries = new Map<string, XapkEntry>();
const decoder = new TextDecoder();
let p = 0;
for (let n = 0; n < count; n++) {
if (cd.getUint32(p, true) !== CENTRAL) throw new Error('Corrupt zip directory.');
const method = cd.getUint16(p + 10, true);
let compressedSize = cd.getUint32(p + 20, true);
let size = cd.getUint32(p + 24, true);
const nameLen = cd.getUint16(p + 28, true);
const extraLen = cd.getUint16(p + 30, true);
const commentLen = cd.getUint16(p + 32, true);
let localHeaderOffset = cd.getUint32(p + 42, true);
const name = decoder.decode(new Uint8Array(cd.buffer, cd.byteOffset + p + 46, nameLen));
// zip64 extended info: present fields appear in this order, only for
// values saturated in the fixed header.
let e = p + 46 + nameLen;
const extraEnd = e + extraLen;
while (e + 4 <= extraEnd) {
const id = cd.getUint16(e, true);
const len = cd.getUint16(e + 2, true);
if (id === 0x0001) {
let q = e + 4;
if (size === 0xffffffff) { size = u64(cd, q); q += 8; }
if (compressedSize === 0xffffffff) { compressedSize = u64(cd, q); q += 8; }
if (localHeaderOffset === 0xffffffff) { localHeaderOffset = u64(cd, q); }
}
e += 4 + len;
}
entries.set(name, { name, method, compressedSize, size, localHeaderOffset });
p += 46 + nameLen + extraLen + commentLen;
}
const manifestEntry = entries.get('manifest.json');
if (!manifestEntry) throw new Error('Not an .xapk: manifest.json is missing.');
const manifest = JSON.parse(await new Response(await entryStream(file, manifestEntry)).text()) as XapkManifest;
if (!manifest.package_name) throw new Error('manifest.json has no package_name.');
const apk = entries.get(`${manifest.package_name}.apk`)
?? [...entries.values()].find((x) => x.name.endsWith('.apk'));
if (!apk) throw new Error('The .xapk contains no APK.');
const expansions = (manifest.expansions ?? []).map((x) => {
const entry = entries.get(x.file);
if (!entry) throw new Error(`The .xapk is missing ${x.file}.`);
return { entry, installPath: x.install_path };
});
return { file, manifest, entries, apk, expansions };
}
/** The entry's uncompressed bytes as a stream. */
export async function entryStream(file: File, entry: XapkEntry): Promise<ReadableStream<Uint8Array>> {
const local = await bytes(file, entry.localHeaderOffset, entry.localHeaderOffset + 30);
if (local.getUint32(0, true) !== LOCAL) throw new Error(`Corrupt entry ${entry.name}.`);
const start = entry.localHeaderOffset + 30 + local.getUint16(26, true) + local.getUint16(28, true);
const raw = file.slice(start, start + entry.compressedSize).stream();
if (entry.method === 0) return raw;
if (entry.method === 8) return raw.pipeThrough(new DecompressionStream('deflate-raw'));
throw new Error(`Unsupported compression in ${entry.name}.`);
}
+166
View File
@@ -0,0 +1,166 @@
import { useRef, useState } from 'react';
import { openXapk, type Xapk } from '../lib/xapk';
import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall';
const fmtSize = (n: number) =>
n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`;
function Step({ n, title, done, children }: { n: number; title: string; done?: boolean; children: React.ReactNode }) {
return (
<section className="card !p-5 space-y-3">
<h2 className="flex items-center gap-3 text-base font-semibold text-ink-50">
<span className={`grid size-7 place-items-center rounded-full text-sm ${done ? 'bg-brand-500 text-white' : 'bg-ink-800 text-ink-200'}`}>
{done ? '✓' : n}
</span>
{title}
</h2>
{children}
</section>
);
}
function Bar({ p }: { p: Progress }) {
const pct = p.total > 0 ? Math.min(100, Math.round((p.done / p.total) * 100)) : 0;
return (
<div className="space-y-1">
<div className="flex justify-between text-xs text-ink-300">
<span>{p.label}</span>
<span>{p.phase === 'launch' ? '' : `${fmtSize(p.done)} / ${fmtSize(p.total)} · ${pct}%`}</span>
</div>
<div className="h-2 rounded-full bg-ink-800 overflow-hidden">
<div className="h-full bg-brand-500 transition-[width] duration-200" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
export function Quest() {
const supported = webUsbSupported();
const [xapk, setXapk] = useState<Xapk | null>(null);
const [headset, setHeadset] = useState<Headset | null>(null);
const [progress, setProgress] = useState<Record<string, Progress>>({});
const [busy, setBusy] = useState<'file' | 'connect' | 'install' | null>(null);
const [err, setErr] = useState<string | null>(null);
const [done, setDone] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const pickFile = async (f: File | undefined) => {
if (!f) return;
setErr(null); setDone(false); setProgress({}); setBusy('file');
try { setXapk(await openXapk(f)); }
catch (e) { setXapk(null); setErr((e as Error).message); }
finally { setBusy(null); }
};
const connect = async () => {
setErr(null); setBusy('connect');
try { setHeadset(await connectHeadset()); }
catch (e) {
const m = (e as Error).message;
// Closing the browser's device picker isn't an error worth showing.
if (!/No device selected|No headset selected/i.test(m)) setErr(m);
}
finally { setBusy(null); }
};
const install = async () => {
if (!xapk || !headset) return;
setErr(null); setDone(false); setProgress({}); setBusy('install');
try {
await installXapk(
headset,
xapk,
(p) => setProgress((prev) => ({ ...prev, [p.phase]: p })),
async (pkg) => window.confirm(
`An older copy of ${pkg} is installed with a different signing key and has to be removed first.\n\n` +
'Your account is on the server, but local settings on the headset will be lost. Continue?'),
);
setDone(true);
} catch (e) {
const m = String((e as Error)?.message ?? e);
setErr(/disconnected|NetworkError|transfer/i.test(m)
? 'The headset disconnected. Keep it plugged in and awake (wear it or cover the sensor), reconnect and try again.'
: m);
if (/disconnected|NetworkError/i.test(m)) setHeadset(null);
} finally {
setBusy(null);
}
};
const name = xapk?.manifest.name || 'DorkNet';
const totalSize = xapk ? xapk.apk.size + xapk.expansions.reduce((s, x) => s + x.entry.size, 0) : 0;
return (
<div className="max-w-2xl mx-auto space-y-4">
<div>
<h1 className="text-2xl font-semibold text-ink-50">Install on Meta Quest</h1>
<p className="text-sm text-ink-400">
Installs the DorkNet Quest build straight from your browser: no SideQuest or command line.
Your headset needs <span className="text-ink-200">developer mode</span> on (Meta Horizon phone app → your
headset → Developer mode).
</p>
</div>
{!supported && (
<div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">
This browser can't talk to USB devices. Open this page in <b>Chrome</b> or <b>Edge</b> on a computer
(Windows, macOS, Linux or ChromeOS).
</div>
)}
<Step n={1} title="Choose the .xapk file" done={!!xapk}>
<p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p>
<input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} />
<div className="flex flex-wrap items-center gap-3">
<button className="btn-secondary" disabled={busy !== null} onClick={() => fileRef.current?.click()}>
{busy === 'file' ? 'Reading…' : xapk ? 'Choose a different file' : 'Choose file'}
</button>
{xapk && (
<span className="text-sm text-ink-300">
<span className="text-ink-100 font-medium">{name}</span> {xapk.manifest.version_name} · {fmtSize(totalSize)}
</span>
)}
</div>
</Step>
<Step n={2} title="Connect your headset" done={!!headset}>
<ol className="list-decimal pl-5 text-sm text-ink-400 space-y-1">
<li>Plug the Quest into this computer with a USB cable.</li>
<li>Click <b className="text-ink-200">Connect</b> and pick the Quest in the browser's list.</li>
<li>Put the headset on and tap <b className="text-ink-200">Allow</b> on "Allow USB debugging" (tick "Always allow").</li>
</ol>
<p className="text-xs text-ink-500">If it says the device is in use, close SideQuest / Meta Quest Developer Hub, or run <code>adb kill-server</code>.</p>
<div className="flex flex-wrap items-center gap-3">
<button className="btn-secondary" disabled={!supported || busy !== null} onClick={connect}>
{busy === 'connect' ? 'Waiting for the headset…' : headset ? 'Reconnect' : 'Connect'}
</button>
{headset && <span className="text-sm text-ink-300">Connected: <span className="text-ink-100 font-medium">{headset.model}</span></span>}
</div>
</Step>
<Step n={3} title="Install" done={done}>
<p className="text-sm text-ink-400">
Installs the app and copies the game data. The game data is large, so this takes a few minutes. Keep the
cable in and the headset awake (wear it or cover the sensor above the nose).
</p>
<button className="btn-primary w-full" disabled={!xapk || !headset || busy !== null} onClick={install}>
{busy === 'install' ? 'Installing…' : done ? 'Install again' : 'Install'}
</button>
{(['app', 'data', 'launch'] as const).map((k) => progress[k] && <Bar key={k} p={progress[k]} />)}
{done && (
<div className="rounded-lg border border-brand-500/30 bg-brand-500/10 px-4 py-3 text-sm text-ink-100">
Installed! The game is starting in the headset. Next time, find it under
<b> Library → Unknown Sources → {name}</b>.
</div>
)}
</Step>
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>}
<p className="text-xs text-ink-500">
The app installs alongside the Meta Store version of Rec Room and doesn't replace it. You'll need a DorkNet
account to sign in.
</p>
</div>
);
}
+5
View File
@@ -47,6 +47,11 @@ Third-party components
text is kept alongside the sources at text is kept alongside the sources at
DorkNet.Quest/src/third_party/And64InlineHook/LICENSE. DorkNet.Quest/src/third_party/And64InlineHook/LICENSE.
The public site's Quest installer page (DorkNet.Server/site, /quest)
bundles Tango (@yume-chan/adb, adb-daemon-webusb, adb-credential-web,
android-bin, stream-extra) by Simon Chan, under the MIT License
(https://github.com/yume-chan/ya-webadb).
-------------------------------------------------------------------------- --------------------------------------------------------------------------
Acknowledgements Acknowledgements
+7 -4
View File
@@ -167,11 +167,14 @@ account. Each hook checks a fingerprint of its target first, so the patch
refuses to apply to any other build. refuses to apply to any other build.
You supply your own extracted APK and OBB; no game binaries are You supply your own extracted APK and OBB; no game binaries are
redistributed. Build, install and troubleshooting steps are in redistributed. The build can package itself as a `.xapk` that players
[DorkNet.Quest/README.md](DorkNet.Quest/README.md), and the operator view install from the **Quest** page of your DorkNet site (`https://<apex>/quest`)
is in [docs/quest.md](docs/quest.md). in Chrome or Edge over USB, with no adb or SideQuest. Build, install and
troubleshooting steps are in [DorkNet.Quest/README.md](DorkNet.Quest/README.md),
and the operator view is in [docs/quest.md](docs/quest.md).
> The Quest build is new and has not yet been tested on a headset. > Tested on a Quest 2: the hooks arm, the client reaches the server and the
> browser installer works. Full in-game play has not been verified yet.
### Debugging (opt-in — `DorkNet.DebugMod`) ### Debugging (opt-in — `DorkNet.DebugMod`)
+5 -2
View File
@@ -69,8 +69,11 @@ pitfall in their Quest client notes.
3. Image signing key configured (`ImageSignatureService`), as for PC. 3. Image signing key configured (`ImageSignatureService`), as for PC.
4. Build with `ServerHost=<apex>`, the Photon ids and region baked in, passing 4. Build with `ServerHost=<apex>`, the Photon ids and region baked in, passing
`-Obb` so you get a release folder (`DorkNet-<ver>.xapk` plus `-Obb` so you get a release folder (`DorkNet-<ver>.xapk` plus
`install.cmd`/`install.sh`). Players run the installer with the headset `install.cmd`/`install.sh`). Players install it from your site's
plugged in; the app appears as **DorkNet** under Unknown Sources. **Quest** page (`https://<apex>/quest`, Chrome or Edge over USB) or with the
installer scripts; the app appears as **DorkNet** under Unknown Sources.
Share the `.xapk` however you like: the page reads it from the player's own
disk, so your server never hosts game files.
No server component is Quest-specific beyond the placeholder-platform-id No server component is Quest-specific beyond the placeholder-platform-id
handling above. handling above.
+1 -1
View File
@@ -659,7 +659,7 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
| GET + POST | `rooms/{0}/bans` | GET: none. POST 'ban players': form fields id (repeated Int32 account ids) + banMask (Int32 enum DCDPJPHBHOA) | GET → [IBHAKOOKEEE {"AccountId": Int32, "BannedByAccountId": Int32, "BanStartTime": DateTime string}]. POST → none parsed. | | GET + POST | `rooms/{0}/bans` | GET: none. POST 'ban players': form fields id (repeated Int32 account ids) + banMask (Int32 enum DCDPJPHBHOA) | GET → [IBHAKOOKEEE {"AccountId": Int32, "BannedByAccountId": Int32, "BanStartTime": DateTime string}]. POST → none parsed. |
| POST | `rooms/{0}/bans/import` | form field: sourceRoomId (Int64) | none parsed; return 200 | | POST | `rooms/{0}/bans/import` | form field: sourceRoomId (Int64) | none parsed; return 200 |
| DELETE | `rooms/{0}/bans/{1}` | form field: banMask (Int32 enum) | none parsed; return 200 | | DELETE | `rooms/{0}/bans/{1}` | form field: banMask (Int32 enum) | none parsed; return 200 |
| POST | `rooms/{0}/clone` | x-www-form-urlencoded field: name (String) — new room name | FGCPNAACHIK FULL room details of the clone (not a status wrapper); client 404→empty-body surfaces as message-less 'Failed to copy room' | | POST | `rooms/{0}/clone` | x-www-form-urlencoded field: name (String) — new room name | `{success, value, error}` envelope with `value` = FGCPNAACHIK FULL room details of the clone (same helper as "save room"; a bare room has no `success` and is rejected with an empty error). Client 404→empty-body also surfaces as message-less 'Failed to copy room' |
| PUT | `rooms/{0}/cloning` | form field: cloningAllowed (Boolean) | FGCPNAACHIK full room details | | PUT | `rooms/{0}/cloning` | form field: cloningAllowed (Boolean) | FGCPNAACHIK full room details |
| PUT | `rooms/{0}/comments` | form field: disable (Boolean) — key is 'disable', maps to DisableRoomComments | FGCPNAACHIK full room details | | PUT | `rooms/{0}/comments` | form field: disable (Boolean) — key is 'disable', maps to DisableRoomComments | FGCPNAACHIK full room details |
| PUT | `rooms/{0}/creator` | form field: accountId (Int32) | FGCPNAACHIK full room details | | PUT | `rooms/{0}/creator` | form field: accountId (Int32) | FGCPNAACHIK full room details |
+9 -2
View File
@@ -89,8 +89,15 @@ body — the same `roomserver/` prefix as every other room mutation from
that client. A bare-only `rooms/{id}/clone` route 404s with an empty that client. A bare-only `rooms/{id}/clone` route 404s with an empty
body, which the client's promise layer surfaces as a message-less body, which the client's promise layer surfaces as a message-less
`Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown`. `Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown`.
The response must be the FULL room-details object (`FGCPNAACHIK`, The response is the legacy `{success, value, error}` envelope, the same
same shape as `GET roomserver/rooms/{id}`), not a status wrapper. one "save room" and "restore subroom save" use: the clone goes through the
same RecNet rooms helper (`NLDBPDCNNCF.BDBHCEGKGDC`, which takes an op
name). `value` is the FULL room-details object (`FGCPNAACHIK`, same shape
as `GET roomserver/rooms/{id}`). A bare room-details body has no `success`,
so the client rejects it with an empty error and shows the same
message-less `Failed to copy room`, even though the room was created.
Refusals (unknown source, cloning not allowed) are HTTP 200 with
`success: false` and the message in `error`.
Blob semantics on clone (`RoomsModerationController.BareClone`): Blob semantics on clone (`RoomsModerationController.BareClone`):