Compare commits

..

7 Commits

Author SHA1 Message Date
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
Alexa 2dbd03b9ae fix: owned items showed a price because they were served as base avatar items
defaultbaseavataritems returned the first 100 catalog items. The 2023
client flags every row of that list as a custom-clothing template
(AvatarItemData.IsBaseAvatarItem, keyed by AvatarItemId) and skips
templates when it rebuilds the owned outfit-menu entries, so those 100
ordinary items (80sHipHopJacket, Beard Close, ...) showed a price even
when owned and equipped, while the cart (which checks the owned set)
said "owned".

- defaultbaseavataritems returns only the UGC templates, UGCTee_Shirt
  (AvatarItemId 2184) and UGCPulloverHoodie_Shirt (2918), as the real
  service served them
- those two keep the same ids in every list and carry IsBaseAvatarItem
- they're no longer sold (existing shop rows are removed on seed) or
  listed as owned clothing
2026-10-15 22:08:59 +03:00
Alexa f4b4836375 Merge pull request #57 from DorkSquadRR/fix/owned-item-metadata
Owned avatar items use their shop tile's name, tooltip and rarity
2026-10-15 21:45:40 +03:00
Alexa ee7d614262 fix: owned avatar items use their shop tile's name, tooltip and rarity
The owned list (v4/items, defaultunlocked, lockeditems) took names from
the catalog dump, so colour variants read as asset names like
"(Shirt_Winter)", every rarity was 0 and every tooltip empty. 1,864 of
the 2,719 items in both lists disagreed with their shop tile.

Each owned row now takes FriendlyName, Tooltip and Rarity from the store
row with the same AvatarItemDesc (exact match; colour-variant combination
ids are case-sensitive). Rows with no shop tile keep the catalog name.
Rarity uses the same GiftRarity enum on both DTOs.
2026-10-15 21:44:49 +03:00
Alexa 93b4e43316 Merge pull request #56 from DorkSquadRR/feat/cv2-network-limits
Free default avatar items + DorkNet.Quest client patcher
2026-10-15 20:33:18 +03:00
Alexa 8373e592f4 Merge pull request #55 from DorkSquadRR/feat/cv2-network-limits
2023 client: presence, profile, outfits, RecNetResult writes, CircuitsV2 limits
2026-10-15 14:10:25 +03:00
16 changed files with 767 additions and 23 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
@@ -215,6 +215,88 @@ public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
} }
} }
[Fact]
public async Task Owned_items_carry_the_same_name_tooltip_and_rarity_as_their_shop_tile()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
await using (var scope = _factory.Services.CreateAsyncScope())
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(true);
try
{
using var client = ApiClient(player);
var owned = await GetJsonAsync(client, "/api/avatar/v4/items");
var shop = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
var tiles = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
foreach (var row in shop.GetProperty("StoreItems").EnumerateArray())
{
var drop = row.GetProperty("GiftDrop");
var desc = drop.GetProperty("AvatarItemDesc").GetString();
if (!string.IsNullOrEmpty(desc)) tiles.TryAdd(desc, drop);
}
var matched = 0;
foreach (var item in owned.EnumerateArray())
{
if (!tiles.TryGetValue(item.GetProperty("AvatarItemDesc").GetString()!, out var tile)) continue;
matched++;
Assert.Equal(tile.GetProperty("FriendlyName").GetString(), item.GetProperty("FriendlyName").GetString());
Assert.Equal(tile.GetProperty("Tooltip").GetString(), item.GetProperty("Tooltip").GetString());
Assert.Equal(tile.GetProperty("Rarity").GetInt32(), item.GetProperty("Rarity").GetInt32());
}
Assert.True(matched > 0, "no owned item matched a shop tile");
}
finally
{
await using var scope = _factory.Services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(false);
}
}
[Fact]
public async Task Base_avatar_items_are_only_the_custom_clothing_templates()
{
// The client flags every defaultbaseavataritems row as a custom-
// clothing template and leaves it out of the owned rebuild, so a
// normal item listed here showed a price even when owned.
using var anonymous = ApiClient();
var rows = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultbaseavataritems");
var byDesc = rows.EnumerateArray().ToDictionary(
r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0],
r => r);
Assert.Equal(2, byDesc.Count);
Assert.Equal(2184, byDesc["c5d70cb4-71dd-4fe4-b719-34fe2073c611"].GetProperty("AvatarItemId").GetInt32());
Assert.Equal(2918, byDesc["95a519de-f2cb-429c-b014-508477f20d42"].GetProperty("AvatarItemId").GetInt32());
Assert.All(byDesc.Values, r => Assert.True(r.GetProperty("IsBaseAvatarItem").GetBoolean()));
// Templates aren't sold or listed as owned clothing.
var shop = await GetJsonAsync(anonymous, "/api/storefronts/v3/giftdropstore/3");
var shopDescs = shop.GetProperty("StoreItems").EnumerateArray()
.Select(r => r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString() ?? "");
Assert.DoesNotContain(shopDescs, d => byDesc.ContainsKey(d.Split(',')[0]));
await using (var scope = _factory.Services.CreateAsyncScope())
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(true);
try
{
var owned = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
var ownedDescs = owned.EnumerateArray().Select(r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]).ToList();
Assert.DoesNotContain(ownedDescs, byDesc.ContainsKey);
Assert.Contains("3c2ed30c-ca2a-4f9f-9bfa-ffe561c4a686", ownedDescs); // 80sHipHopJacket
}
finally
{
await using var scope = _factory.Services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(false);
}
}
// Beard_Close: one of the client's defaultUnlockedVariants. // Beard_Close: one of the client's defaultUnlockedVariants.
private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f"; private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f";
@@ -115,10 +115,10 @@ public class AvatarItemsController(
// reverts to the real per-player wardrobe below. Bounded by the same // reverts to the real per-player wardrobe below. Bounded by the same
// maxItems ceiling so a future watch build can't be over-fed. // maxItems ceiling so a future watch build can't be over-fed.
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync()) if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
return Ok(BuildEntireCatalogOwned(maxItems)); return await WithStoreInfoAsync(BuildEntireCatalogOwned(maxItems));
var pid = this.CurrentPlayerId(); var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(BuildDefaultUnlocked(maxItems)); if (pid is not long me) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems));
var avatar = await db.Avatars var avatar = await db.Avatars
.Where(a => a.PlayerId == me) .Where(a => a.PlayerId == me)
@@ -134,7 +134,7 @@ public class AvatarItemsController(
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _)) .Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(BuildDefaultUnlocked(maxItems)); if (owned.Count == 0 && ownedHairDyes.Count == 0) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems));
var catalog = _catalog.Value; var catalog = _catalog.Value;
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections); var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
@@ -167,6 +167,7 @@ public class AvatarItemsController(
// miss and silently drop every owned colored item (the contest // miss and silently drop every owned colored item (the contest
// hat "I bought it but it never shows in my wardrobe" bug). // hat "I bought it but it never shows in my wardrobe" bug).
if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue; if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue;
if (item.IsBaseAvatarItem) continue; // custom-clothing template, not clothing
var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1; var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1;
perSlot.TryGetValue(slot, out var count); perSlot.TryGetValue(slot, out var count);
@@ -226,7 +227,34 @@ public class AvatarItemsController(
IsBaseAvatarItem = false, IsBaseAvatarItem = false,
}); });
} }
return Ok(result); return await WithStoreInfoAsync(result);
}
/// <summary>Give each owned row the same FriendlyName, Tooltip and
/// Rarity its shop tile shows. The client keeps this list's metadata
/// per item, and the catalog dump only carries asset names (e.g.
/// "(Shirt_Winter)" for colour variants) with no rarity or tooltip, so
/// the owned list disagreed with the shop for most items. Rows with no
/// shop tile keep the catalog name. Catalog DTOs are shared, so matched
/// rows are copied, never mutated.</summary>
private async Task<List<UnlockedAvatarItemDto>> WithStoreInfoAsync(List<UnlockedAvatarItemDto> rows)
{
var info = await StoreService.LoadAvatarItemStoreInfoAsync(db);
if (info.Count == 0) return rows;
return rows.Select(r => info.TryGetValue(r.AvatarItemDesc, out var s)
? new UnlockedAvatarItemDto
{
AvatarItemType = r.AvatarItemType,
AvatarItemDesc = r.AvatarItemDesc,
AvatarItemId = r.AvatarItemId,
PlatformMask = r.PlatformMask,
FriendlyName = string.IsNullOrWhiteSpace(s.FriendlyName) ? r.FriendlyName : s.FriendlyName,
Tooltip = s.Tooltip,
Rarity = s.Rarity,
TagList = r.TagList,
IsBaseAvatarItem = r.IsBaseAvatarItem,
}
: r).ToList();
} }
/// <summary>The free items every player owns /// <summary>The free items every player owns
@@ -250,6 +278,8 @@ public class AvatarItemsController(
foreach (var item in catalog.Items.Values) foreach (var item in catalog.Items.Values)
{ {
if (result.Count >= maxItems) return result; if (result.Count >= maxItems) return result;
// Custom-clothing templates aren't ownable clothing.
if (item.IsBaseAvatarItem) continue;
result.Add(item); result.Add(item);
} }
// Colored variants aren't in the base avatar_item_lookup catalog, so // Colored variants aren't in the base avatar_item_lookup catalog, so
@@ -299,14 +329,33 @@ public class AvatarItemsController(
return result; return result;
} }
/// <summary>The custom-clothing templates ("base avatar items") and
/// their RecNet AvatarItemIds, as the real service served them. The
/// client flags every row of <c>defaultbaseavataritems</c> as a base
/// item (AvatarItemData.IsBaseAvatarItem, keyed by AvatarItemId in
/// OutfitManager.AODPBDNAGII) and leaves base items out of the owned
/// rebuild (MBPOFMEHDIJ), so they never read as owned clothing. This
/// endpoint used to return the first 100 catalog items, which turned
/// ordinary items (80sHipHopJacket, Beard Close, ...) into templates
/// that showed a price even when owned and equipped.</summary>
public static readonly IReadOnlyDictionary<string, int> BaseAvatarItemIds =
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["c5d70cb4-71dd-4fe4-b719-34fe2073c611"] = 2184, // UGCTee_Shirt
["95a519de-f2cb-429c-b014-508477f20d42"] = 2918, // UGCPulloverHoodie_Shirt
};
public static bool IsBaseAvatarItemGuid(string? guid) =>
!string.IsNullOrEmpty(guid) && BaseAvatarItemIds.ContainsKey(guid);
[HttpGet("api/avatar/v1/defaultbaseavataritems")] [HttpGet("api/avatar/v1/defaultbaseavataritems")]
[AllowAnonymous] [AllowAnonymous]
public IActionResult DefaultBaseAvatarItems() public IActionResult DefaultBaseAvatarItems()
{ {
var rows = _catalog.Value.Items.Values var items = _catalog.Value.Items;
.OrderBy(i => i.AvatarItemType) var rows = BaseAvatarItemIds.Keys
.ThenBy(i => i.FriendlyName) .Where(items.ContainsKey)
.Take(100) .Select(g => items[g])
.ToList(); .ToList();
return Ok(rows); return Ok(rows);
} }
@@ -379,8 +428,8 @@ public class AvatarItemsController(
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null) .Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
.Where(item => item is not null) .Where(item => item is not null)
.Select(item => item!) .Select(item => item!)
.ToArray(); .ToList();
return Ok(rows); return Ok(await WithStoreInfoAsync(rows));
} }
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot. /// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
@@ -580,13 +629,13 @@ public class AvatarItemsController(
// GUID as a color GUID and log "Can not find hair dye color". // GUID as a color GUID and log "Can not find hair dye color".
AvatarItemType = AvatarItemType.Outfit, AvatarItemType = AvatarItemType.Outfit,
AvatarItemDesc = row.Guid + ",,,", AvatarItemDesc = row.Guid + ",,,",
AvatarItemId = StableAvatarItemId(row.Guid), AvatarItemId = BaseAvatarItemIds.TryGetValue(row.Guid, out var baseId) ? baseId : StableAvatarItemId(row.Guid),
PlatformMask = unchecked((int)0xFFFFFFFF), PlatformMask = unchecked((int)0xFFFFFFFF),
FriendlyName = pretty, FriendlyName = pretty,
Tooltip = string.Empty, Tooltip = string.Empty,
Rarity = 0, Rarity = 0,
TagList = string.Empty, TagList = string.Empty,
IsBaseAvatarItem = false, IsBaseAvatarItem = IsBaseAvatarItemGuid(row.Guid),
}; };
if (!bySlot.TryGetValue(row.OutfitType, out var lst)) if (!bySlot.TryGetValue(row.OutfitType, out var lst))
bySlot[row.OutfitType] = lst = new List<string>(); bySlot[row.OutfitType] = lst = new List<string>();
@@ -599,7 +648,7 @@ public class AvatarItemsController(
var defaults = dump.DefaultUnlockedVariants var defaults = dump.DefaultUnlockedVariants
.Where(v => string.IsNullOrEmpty(v.Swatch) && string.IsNullOrEmpty(v.Mask) && string.IsNullOrEmpty(v.Decal)) .Where(v => string.IsNullOrEmpty(v.Swatch) && string.IsNullOrEmpty(v.Mask) && string.IsNullOrEmpty(v.Decal))
.Select(v => v.Item) .Select(v => v.Item)
.Where(g => !string.IsNullOrEmpty(g) && items.ContainsKey(g)) .Where(g => !string.IsNullOrEmpty(g) && items.ContainsKey(g) && !IsBaseAvatarItemGuid(g))
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
+34 -1
View File
@@ -179,7 +179,11 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
foreach (var row in stale) foreach (var row in stale)
{ {
var guid = row.Slug.Substring("wardrobe-".Length); var guid = row.Slug.Substring("wardrobe-".Length);
if (!safe.Contains(guid)) toDelete.Add(row.Id); // Custom-clothing templates aren't sold; drop rows seeded
// before they were excluded.
if (!safe.Contains(guid) ||
DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.IsBaseAvatarItemGuid(guid))
toDelete.Add(row.Id);
} }
if (toDelete.Count > 0) if (toDelete.Count > 0)
{ {
@@ -690,6 +694,9 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
var added = false; var added = false;
foreach (var (guid, dto) in catalog) foreach (var (guid, dto) in catalog)
{ {
// The UGC templates are the base of custom clothing, not shop
// items (the real store never listed them).
if (dto.IsBaseAvatarItem) continue;
var slug = $"wardrobe-{guid}"; var slug = $"wardrobe-{guid}";
if (existingSlugs.Contains(slug)) continue; if (existingSlugs.Contains(slug)) continue;
existingSlugs.Add(slug); existingSlugs.Add(slug);
@@ -1191,6 +1198,32 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
/// id-lists (toptoday) resolve against the gift-drop cache.</summary> /// id-lists (toptoday) resolve against the gift-drop cache.</summary>
public static int PurchasableItemIdFor(StoreItemEntity i) => (int)(i.Id & 0x7fffffff); public static int PurchasableItemIdFor(StoreItemEntity i) => (int)(i.Id & 0x7fffffff);
/// <summary>Shop metadata for an avatar item, keyed by the exact
/// AvatarItemDesc a shop tile carries.</summary>
public sealed record AvatarItemStoreInfo(string FriendlyName, string Tooltip, int Rarity);
/// <summary>FriendlyName / Tooltip / Rarity of every avatar-item and
/// hair-dye shop tile, keyed by the same AvatarItemDesc
/// <see cref="BuildGiftDrop"/> sends (ordinal: colour-variant
/// combination ids are case-sensitive). The owned-items list uses it so
/// an item reads the same in the wardrobe as in the shop.</summary>
public static async Task<Dictionary<string, AvatarItemStoreInfo>> LoadAvatarItemStoreInfoAsync(DorkNetDbContext db)
{
var rows = await db.StoreItems.AsNoTracking()
.Select(i => new { i.Slug, i.DisplayName, i.Description, i.Category })
.ToListAsync();
var info = new Dictionary<string, AvatarItemStoreInfo>(StringComparer.Ordinal);
foreach (var row in rows)
{
string desc;
if (TryGetHairDyePayload(row.Slug, out _, out var hairColorGuid)) desc = hairColorGuid;
else if (!TryGetAvatarItemPayload(row.Slug, out _, out desc)) continue;
if (string.IsNullOrEmpty(desc)) continue;
info.TryAdd(desc, new AvatarItemStoreInfo(row.DisplayName ?? string.Empty, row.Description ?? string.Empty, MapRarity(row.Category)));
}
return info;
}
/// <summary>GiftManager.GiftRarity wire values: /// <summary>GiftManager.GiftRarity wire values:
/// None=-1, Common=0, Uncommon=10, Rare=20, Epic=30, Legendary=50.</summary> /// None=-1, Common=0, Uncommon=10, Rare=20, Epic=30, Legendary=50.</summary>
private static int MapRarity(string? category) => category?.ToLowerInvariant() switch private static int MapRarity(string? category) => category?.ToLowerInvariant() switch
+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.
+3 -1
View File
@@ -216,6 +216,8 @@ verified against the binary before the change, and the full test suite passes.
| `GET api/avatar/v1/lockeditems` | 5-key subset: AvatarItemId 0, no TagList/IsBaseAvatarItem | full IFNGBPJGINL owned-item shape | | `GET api/avatar/v1/lockeditems` | 5-key subset: AvatarItemId 0, no TagList/IsBaseAvatarItem | full IFNGBPJGINL owned-item shape |
| `GET api/avatar/v4/items` (colored variants) | a colorway reused its base item's AvatarItemId, so owning a base + colorways collided in the id-keyed wardrobe | id hashed from the full descriptor | | `GET api/avatar/v4/items` (colored variants) | a colorway reused its base item's AvatarItemId, so owning a base + colorways collided in the id-keyed wardrobe | id hashed from the full descriptor |
| `GET api/avatar/v4/items` / `defaultunlocked` / `lockeditems` (default items) | the client REPLACES its unlocked set with each downloaded list (OutfitManager.GFEIDFKKNMO clears field +0x70) and never merges its own defaults, so the free `default_unlocked_variants` items (e.g. Beard Close) showed a price | every list includes the 239 default items; `defaultunlocked` serves them before login; `lockeditems` never reports them | | `GET api/avatar/v4/items` / `defaultunlocked` / `lockeditems` (default items) | the client REPLACES its unlocked set with each downloaded list (OutfitManager.GFEIDFKKNMO clears field +0x70) and never merges its own defaults, so the free `default_unlocked_variants` items (e.g. Beard Close) showed a price | every list includes the 239 default items; `defaultunlocked` serves them before login; `lockeditems` never reports them |
| `GET api/avatar/v4/items` / `defaultunlocked` / `lockeditems` (metadata) | names came from the catalog dump (`(Shirt_Winter)` for colour variants), rarity was always 0 and tooltips empty, so 1,864 items disagreed with their shop tile | each owned row takes FriendlyName / Tooltip / Rarity from its shop tile (`StoreService.LoadAvatarItemStoreInfoAsync`), matched on the exact AvatarItemDesc |
| `GET api/avatar/v1/defaultbaseavataritems` | returned the first 100 catalog items; the client flags every row as a custom-clothing template (`AvatarItemData.IsBaseAvatarItem`, keyed by AvatarItemId) and skips templates in the owned rebuild (`OutfitManager.MBPOFMEHDIJ`), so those 100 ordinary items (80sHipHopJacket, Beard Close, ...) showed a price even when owned and equipped | only the UGC templates `UGCTee_Shirt` (AvatarItemId 2184) and `UGCPulloverHoodie_Shirt` (2918), `IsBaseAvatarItem: true`; they're no longer sold or listed as owned clothing |
| `GET api/customAvatarItems/v2/fromCreator/{id}` | bare array; client reads {Results,TotalResults} | paged container | | `GET api/customAvatarItems/v2/fromCreator/{id}` | bare array; client reads {Results,TotalResults} | paged container |
| `POST econ/customAvatarItems/v1/{id}/purchase` | 404 — route only existed under api/ | econ/ alias added | | `POST econ/customAvatarItems/v1/{id}/purchase` | 404 — route only existed under api/ | econ/ alias added |
| `GET api/ugcPurchasables/v1/items/room/{roomId}` | 404 — only the ?roomId= form existed | path form added | | `GET api/ugcPurchasables/v1/items/room/{roomId}` | 404 — only the ?roomId= form existed | path form added |
@@ -1348,7 +1350,7 @@ Verified all 37 real client HTTP calls against the DorkNet server source (every
| Verb | Route | Request | Response | | Verb | Route | Request | Response |
|---|---|---|---| |---|---|---|---|
| GET | `api/avatar/v1/defaultbaseavataritems` | none | JSON array of the same UnlockedAvatarItem shape as v4/items | | GET | `api/avatar/v1/defaultbaseavataritems` | none | JSON array of the same UnlockedAvatarItem shape as v4/items: only the custom-clothing templates (UGCTee_Shirt 2184, UGCPulloverHoodie_Shirt 2918) with `IsBaseAvatarItem: true`. Every row becomes a template client-side, so never list ordinary clothing here |
| GET | `api/avatar/v1/defaultunlocked` | none | Same array shape as v4/items (server aliases it to the same handler; logged out it returns the `default_unlocked_variants` items, logged in the full owned set including them) | | GET | `api/avatar/v1/defaultunlocked` | none | Same array shape as v4/items (server aliases it to the same handler; logged out it returns the `default_unlocked_variants` items, logged in the full owned set including them) |
| GET | `api/avatar/v1/lockeditems` | Query: repeated key "desc" carrying avatar-item desc strings (list added via helper 0x181F2E250 with key literal "desc") | JSON array of List<IFNGBPJGINL> — the same element as v4/items (CLIANCNHNIE reader: AvatarItemType, AvatarItemDesc, FriendlyName, Tooltip, Rarity, TagList, AvatarItemId, IsBaseAvatarItem) for each requested desc the caller does NOT own | | GET | `api/avatar/v1/lockeditems` | Query: repeated key "desc" carrying avatar-item desc strings (list added via helper 0x181F2E250 with key literal "desc") | JSON array of List<IFNGBPJGINL> — the same element as v4/items (CLIANCNHNIE reader: AvatarItemType, AvatarItemDesc, FriendlyName, Tooltip, Rarity, TagList, AvatarItemId, IsBaseAvatarItem) for each requested desc the caller does NOT own |
| GET | `api/avatar/v2` | none | Object with keys (exact literals in reader EKHJKDNHOPB, case-tolerant Pascal/camel/lower): OutfitSelections(string), OutfitSelectionsV2(string), FaceFeatures(string), SkinColor(string), HairColor(string), CustomAvatarIte | | GET | `api/avatar/v2` | none | Object with keys (exact literals in reader EKHJKDNHOPB, case-tolerant Pascal/camel/lower): OutfitSelections(string), OutfitSelectionsV2(string), FaceFeatures(string), SkinColor(string), HairColor(string), CustomAvatarIte |