mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ad6735e93 | |||
| a07e9f41ea | |||
| a23284ce56 | |||
| 926a5c9bba | |||
| 8ec79ad06c | |||
| b55627a10a | |||
| af29fc3e8e | |||
| 4cdc5ee6d0 | |||
| 1e0015c18e | |||
| e5fedb7376 | |||
| 704abe8df4 | |||
| ab435c3f45 | |||
| 7617ca0367 | |||
| fbfabaf26e | |||
| 7da20f2047 | |||
| 50e03af296 | |||
| a1a29bb64d | |||
| ac1dcf3ad7 | |||
| 273352db08 | |||
| 159ca2c271 | |||
| 5af3fa8fc5 | |||
| c9ed2df278 | |||
| 80f0691516 | |||
| 9d33b1f4d6 | |||
| 44fc9047e0 | |||
| 98fa7b89cf | |||
| ff55baa0c5 | |||
| 312ecbbdc8 | |||
| 1d4777e3da | |||
| 38fbd0faf1 | |||
| 99d3faec5b | |||
| 02f7bc386d | |||
| 27e5e0064c | |||
| 4034449a6d | |||
| 142f52569a | |||
| df73507ee9 | |||
| 70980bf3b6 | |||
| 157cc457a9 | |||
| fa7486915b | |||
| 1794d4e777 | |||
| 8426091e91 | |||
| fa7dac9ec1 | |||
| 67970e5d23 | |||
| fc5b116263 | |||
| e2ec948c74 | |||
| c6a4357c6f | |||
| 6704505dae | |||
| 269a600869 | |||
| b3a06ce5e8 | |||
| 1b0a9a62b4 | |||
| babfd2833f | |||
| 78ff4481db | |||
| dccefb0158 | |||
| 49cf91f77d | |||
| 46e2d879cd | |||
| 26d183af7d | |||
| 35459fdfdd | |||
| 94bad2912b | |||
| 44f53e80b8 | |||
| 44282f8f3b | |||
| bd4ceada65 | |||
| 86be2d004f | |||
| 3c4c2e660a | |||
| 08c091c203 | |||
| 5f523db485 | |||
| 1b9afcd0c6 | |||
| 4f41253cdc | |||
| 9342e2da99 | |||
| a249a50952 | |||
| 243a61ada1 | |||
| 02b26c872d | |||
| f9b16b12b1 | |||
| 362509ef1b | |||
| 4ef463f664 | |||
| cce20ebdb6 | |||
| 2b87f3d7c5 | |||
| 5b13e24880 | |||
| 482c73e1bc | |||
| 64746cdaa2 | |||
| feccd439d4 | |||
| f27d16c4fe | |||
| 657402b613 | |||
| 73d77dffb6 | |||
| 92bd22d164 | |||
| 2375db50ed | |||
| 4d370bfcb9 | |||
| eb2fb0e1f1 | |||
| 9b39c2e8e4 | |||
| 31c53b9070 | |||
| fe1c2c53e3 | |||
| 97e0fa5716 | |||
| 4d58130acd | |||
| 3c0dd64293 | |||
| 399930c6ab | |||
| 3aa5739691 | |||
| e2f169cdea | |||
| 4d14ede3dc | |||
| 8dfe4c8d63 | |||
| 31e2cf7389 | |||
| f0d24d0858 | |||
| d040d5b93b | |||
| 376f7e917c | |||
| 13351a1847 | |||
| 3f4a1b0eb0 | |||
| 2b7246abe9 | |||
| f97fbc4f3e | |||
| 0011ae1039 | |||
| ff0fb65505 | |||
| 7e765bb536 | |||
| 78fcec7263 | |||
| 93833c8b93 | |||
| f581d03bec | |||
| d2bf57a20c | |||
| ef440bedaa | |||
| 810ba0992c | |||
| 218d92f856 | |||
| a2feb780c2 | |||
| f1a616e9c1 | |||
| 143d3a9a07 | |||
| c4e22e1326 | |||
| 7535cd823f | |||
| 24c8a920e4 | |||
| 96eb368d41 | |||
| 27c560906f | |||
| 5dbcf50d37 | |||
| e18e167660 | |||
| e404ed8352 | |||
| 35af6ff29e | |||
| 171afa8ce4 | |||
| 3d089f6e32 | |||
| 3480ca21fa | |||
| 18989fa6ec | |||
| fad8d81961 | |||
| 6db7f270a4 | |||
| 43fe3572c9 | |||
| dfd0d44641 | |||
| b8f1ab91fe | |||
| 23d328180d | |||
| c5db16eeb0 | |||
| cd3f8a2da3 | |||
| e29382f611 | |||
| 9c1caa936c | |||
| 18a5391e37 | |||
| f331530974 | |||
| f5061f014b | |||
| 4edf25b2d5 |
@@ -0,0 +1,72 @@
|
|||||||
|
# Keep the build context small + reproducible. Anything not strictly
|
||||||
|
# needed for `dotnet restore && dotnet publish` is excluded.
|
||||||
|
|
||||||
|
# Build artefacts
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/*.user
|
||||||
|
**/*.suo
|
||||||
|
|
||||||
|
# Dev-only configs (Local layer must NEVER ship into a container —
|
||||||
|
# secrets are passed by env vars on Coolify).
|
||||||
|
**/appsettings.Local.json
|
||||||
|
**/appsettings.Development.json
|
||||||
|
|
||||||
|
# Local runtime data
|
||||||
|
**/data/
|
||||||
|
**/server.log
|
||||||
|
**/*.log
|
||||||
|
|
||||||
|
# IDE noise
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Reverse-engineering scratch (not needed for production server)
|
||||||
|
Cpp2ILOutput/
|
||||||
|
Cpp2IL_CS/
|
||||||
|
Cpp2IL_ISIL/
|
||||||
|
Il2CppDump/
|
||||||
|
RR_protos/
|
||||||
|
RR_ProtobufDefinitions.zip
|
||||||
|
GameAssembly.dll
|
||||||
|
global-metadata.dat
|
||||||
|
il2cpp.h
|
||||||
|
dump.cs
|
||||||
|
script.json
|
||||||
|
RoomPersistenceManager_decompiled.txt
|
||||||
|
|
||||||
|
# Local TLS certs (mkcert) — never copy into the image
|
||||||
|
*.pem
|
||||||
|
hosts.dorknet.txt
|
||||||
|
|
||||||
|
# Tools that aren't part of the server runtime
|
||||||
|
tools/
|
||||||
|
Tools/
|
||||||
|
|
||||||
|
# Vite SPA build leftovers — node_modules is huge and the dist is
|
||||||
|
# rebuilt fresh by the npm-build stage in the Dockerfile, so neither
|
||||||
|
# should ship into the docker context. The source under admin-ui/src
|
||||||
|
# and admin-ui/{package.json,vite.config.ts,...} is what we copy in.
|
||||||
|
DorkNet.Server/admin-ui/node_modules/
|
||||||
|
DorkNet.Server/admin-ui/dist/
|
||||||
|
DorkNet.Server/admin-ui/.vite/
|
||||||
|
DorkNet.Server/wwwroot/admin/assets/
|
||||||
|
DorkNet.Server/wwwroot/admin/index.html
|
||||||
|
DorkNet.Server/wwwroot/admin/*.map
|
||||||
|
|
||||||
|
# Top-level docs / planning
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
|
NOTICE
|
||||||
|
BUILD_PROPER_PLAN.md
|
||||||
|
nginx.conf
|
||||||
|
|
||||||
|
# Other stages of the workflow
|
||||||
|
*.md
|
||||||
|
docs/
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Disabled template. GitHub ignores files outside .github/workflows/.
|
||||||
|
# Move this file to .github/workflows/endpoint-contract.yml when it should run.
|
||||||
|
name: Endpoint Contract
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
endpoint-contract:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
ASPNETCORE_ENVIRONMENT: Development
|
||||||
|
ASPNETCORE_URLS: http://127.0.0.1:18080
|
||||||
|
Database__Provider: sqlite
|
||||||
|
Database__SqlitePath: ${{ runner.temp }}/dorknet-endpoint-contract.db
|
||||||
|
DORKNET_JWT_SECRET: endpoint-contract-secret-000000000000000000000000000000000000000000
|
||||||
|
S3__Endpoint: ""
|
||||||
|
S3__AccessKey: ""
|
||||||
|
S3__SecretKey: ""
|
||||||
|
PROTOBUF_PROTOC: /usr/bin/protoc
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 9.0.x
|
||||||
|
|
||||||
|
- name: Install protobuf compiler
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y protobuf-compiler
|
||||||
|
|
||||||
|
- name: Build server
|
||||||
|
run: dotnet build DorkNet.Server/DorkNet.Server.csproj
|
||||||
|
|
||||||
|
- name: Build endpoint contract runner
|
||||||
|
run: dotnet build tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-restore
|
||||||
|
|
||||||
|
- name: Start server
|
||||||
|
run: |
|
||||||
|
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
|
||||||
|
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
|
||||||
|
|
||||||
|
- name: Wait for server readiness
|
||||||
|
run: |
|
||||||
|
for attempt in {1..60}; do
|
||||||
|
if curl --fail --silent http://127.0.0.1:18080/healthz; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
cat "$RUNNER_TEMP/dorknet-server.log"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Run endpoint contract
|
||||||
|
run: |
|
||||||
|
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
|
||||||
|
--server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \
|
||||||
|
--base-url http://127.0.0.1:18080 \
|
||||||
|
--report "$RUNNER_TEMP/endpoint-contract-report.json"
|
||||||
|
|
||||||
|
- name: Upload endpoint contract artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: endpoint-contract
|
||||||
|
path: |
|
||||||
|
${{ runner.temp }}/endpoint-contract-report.json
|
||||||
|
${{ runner.temp }}/dorknet-server.log
|
||||||
|
|
||||||
|
- name: Stop server
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f "$RUNNER_TEMP/dorknet-server.pid" ]; then
|
||||||
|
kill "$(cat "$RUNNER_TEMP/dorknet-server.pid")" || true
|
||||||
|
fi
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# Push / PR CI on `main` — fast feedback that the launcher + CLI
|
|
||||||
# both still build, and that the CLI publishes for the cross-platform
|
|
||||||
# RIDs the release workflow will package. Does not produce release
|
|
||||||
# artifacts — only proves things compile.
|
|
||||||
#
|
|
||||||
# Runs on:
|
|
||||||
# - every push to main
|
|
||||||
# - every pull request targeting main
|
|
||||||
# - manual workflow_dispatch (so you can re-run after a flake without
|
|
||||||
# pushing an empty commit)
|
|
||||||
|
|
||||||
name: ci
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOTNET_VERSION: "9.0.x"
|
|
||||||
DOTNET_NOLOGO: "true"
|
|
||||||
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# ── Launcher needs Windows for the WPF target framework ────────────
|
|
||||||
build-launcher:
|
|
||||||
runs-on: windows-2022
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
|
||||||
|
|
||||||
- name: Restore
|
|
||||||
run: dotnet restore launcher/DorkNet.Launcher.csproj
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: dotnet build launcher/DorkNet.Launcher.csproj -c Release --no-restore
|
|
||||||
|
|
||||||
# A self-contained publish is the closest thing to "what we ship"
|
|
||||||
# without invoking Inno Setup, which would require an extra
|
|
||||||
# toolchain install for every CI run.
|
|
||||||
- name: Publish (smoke)
|
|
||||||
run: dotnet publish launcher/DorkNet.Launcher.csproj -c Release -r win-x64 --self-contained true --no-restore
|
|
||||||
|
|
||||||
# ── CLI builds on Ubuntu — proves the shared Backend/*.cs files
|
|
||||||
# truly don't have Windows-only dependencies. The release pipeline
|
|
||||||
# later publishes the matrix; here we just confirm restore + build
|
|
||||||
# on the canonical Linux runner.
|
|
||||||
build-cli:
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
rid: [win-x64, linux-x64, linux-arm64, osx-x64, osx-arm64]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
|
||||||
|
|
||||||
- name: Restore
|
|
||||||
run: dotnet restore cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj
|
|
||||||
|
|
||||||
- name: Publish ${{ matrix.rid }}
|
|
||||||
run: |
|
|
||||||
dotnet publish cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj \
|
|
||||||
-c Release \
|
|
||||||
-r ${{ matrix.rid }} \
|
|
||||||
--self-contained true \
|
|
||||||
--no-restore
|
|
||||||
|
|
||||||
- name: Smoke-test --help (host RID only)
|
|
||||||
if: matrix.rid == 'linux-x64'
|
|
||||||
run: |
|
|
||||||
out=cli/DorkNet.ServerCli/bin/Release/net9.0/${{ matrix.rid }}/publish/dorknet-server
|
|
||||||
chmod +x "$out"
|
|
||||||
"$out" --help
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
# Tagged release pipeline for the host-side tooling on `main` —
|
|
||||||
# the Windows GUI launcher (installer + portable) and the
|
|
||||||
# cross-platform dorknet-server CLI. Triggers on a SemVer tag like
|
|
||||||
# v0.2.0 (or v0.2.0-rc1 for pre-releases).
|
|
||||||
#
|
|
||||||
# Produces, on each release:
|
|
||||||
# * dorknet-setup-<version>.exe Inno Setup installer
|
|
||||||
# * dorknet.exe Portable single-file Windows build
|
|
||||||
# * dorknet-server-win-x64.zip Cross-platform server CLI, per RID
|
|
||||||
# * dorknet-server-linux-x64.tar.gz
|
|
||||||
# * dorknet-server-linux-arm64.tar.gz
|
|
||||||
# * dorknet-server-osx-x64.tar.gz
|
|
||||||
# * dorknet-server-osx-arm64.tar.gz
|
|
||||||
# * SHA256SUMS Checksums of all of the above
|
|
||||||
#
|
|
||||||
# All of these get attached to the GitHub release the tag creates.
|
|
||||||
|
|
||||||
name: release-launcher
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
|
||||||
- "v[0-9]+.[0-9]+.[0-9]+-*"
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version to release (e.g. 0.2.0). Leave blank to use the workflow's git tag."
|
|
||||||
required: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write # required to create / update GitHub releases
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOTNET_VERSION: "9.0.x"
|
|
||||||
DOTNET_NOLOGO: "true"
|
|
||||||
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# ── Resolve the version once and pass it to all downstream jobs ─────
|
|
||||||
resolve-version:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
version: ${{ steps.v.outputs.version }}
|
|
||||||
steps:
|
|
||||||
- id: v
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
if [[ -n "${{ inputs.version }}" ]]; then
|
|
||||||
v="${{ inputs.version }}"
|
|
||||||
else
|
|
||||||
v="${GITHUB_REF_NAME#v}"
|
|
||||||
fi
|
|
||||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Releasing version: $v"
|
|
||||||
|
|
||||||
# ── Windows: GUI launcher (installer + portable) ─────────────────────
|
|
||||||
build-launcher-windows:
|
|
||||||
needs: resolve-version
|
|
||||||
runs-on: windows-2022
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
|
||||||
|
|
||||||
- name: Install Inno Setup
|
|
||||||
run: choco install innosetup --no-progress -y
|
|
||||||
|
|
||||||
- name: Build installer + portable exe
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
./installer/build-installer.ps1 -Version "${{ needs.resolve-version.outputs.version }}"
|
|
||||||
|
|
||||||
- name: Stage Windows artifacts
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$stage = "artifacts"
|
|
||||||
New-Item -ItemType Directory -Force -Path $stage | Out-Null
|
|
||||||
# Installer
|
|
||||||
Copy-Item "installer/out/dorknet-setup-${{ needs.resolve-version.outputs.version }}.exe" $stage
|
|
||||||
# Portable single-file build (same publish output the installer wraps)
|
|
||||||
Copy-Item "launcher/bin/Release/net9.0-windows/win-x64/publish/dorknet.exe" $stage
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: launcher-windows
|
|
||||||
path: artifacts/*
|
|
||||||
|
|
||||||
# ── Cross-platform server CLI, one RID per matrix entry ─────────────
|
|
||||||
build-server-cli:
|
|
||||||
needs: resolve-version
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- rid: win-x64
|
|
||||||
os: windows-2022
|
|
||||||
ext: zip
|
|
||||||
- rid: linux-x64
|
|
||||||
os: ubuntu-latest
|
|
||||||
ext: tar.gz
|
|
||||||
- rid: linux-arm64
|
|
||||||
os: ubuntu-latest
|
|
||||||
ext: tar.gz
|
|
||||||
- rid: osx-x64
|
|
||||||
os: macos-latest
|
|
||||||
ext: tar.gz
|
|
||||||
- rid: osx-arm64
|
|
||||||
os: macos-latest
|
|
||||||
ext: tar.gz
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
|
||||||
|
|
||||||
- name: Publish dorknet-server for ${{ matrix.rid }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
v="${{ needs.resolve-version.outputs.version }}"
|
|
||||||
dotnet publish cli/DorkNet.ServerCli \
|
|
||||||
-c Release \
|
|
||||||
-r "${{ matrix.rid }}" \
|
|
||||||
--self-contained true \
|
|
||||||
-p:Version="$v" \
|
|
||||||
-p:AssemblyVersion="${v}.0" \
|
|
||||||
-p:FileVersion="${v}.0" \
|
|
||||||
-p:InformationalVersion="$v"
|
|
||||||
|
|
||||||
- name: Package
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
src="cli/DorkNet.ServerCli/bin/Release/net9.0/${{ matrix.rid }}/publish"
|
|
||||||
out="artifacts"
|
|
||||||
mkdir -p "$out"
|
|
||||||
case "${{ matrix.ext }}" in
|
|
||||||
zip)
|
|
||||||
# Use PowerShell on Windows so the archive is consumable
|
|
||||||
# by Windows Explorer + curl without weird zip flags.
|
|
||||||
pwsh -Command "Compress-Archive -Path '$src/*' -DestinationPath '$out/dorknet-server-${{ matrix.rid }}.zip' -Force"
|
|
||||||
;;
|
|
||||||
tar.gz)
|
|
||||||
# chmod +x first — single-file publish drops a binary
|
|
||||||
# without the executable bit on macOS in some scenarios.
|
|
||||||
chmod +x "$src"/dorknet-server || true
|
|
||||||
tar -czf "$out/dorknet-server-${{ matrix.rid }}.tar.gz" -C "$src" .
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: server-cli-${{ matrix.rid }}
|
|
||||||
path: artifacts/*
|
|
||||||
|
|
||||||
# ── Aggregate, checksum, attach to the release ──────────────────────
|
|
||||||
release:
|
|
||||||
needs: [resolve-version, build-launcher-windows, build-server-cli]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: dist
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: List artifacts + compute SHA256SUMS
|
|
||||||
run: |
|
|
||||||
cd dist
|
|
||||||
ls -la
|
|
||||||
sha256sum * > SHA256SUMS
|
|
||||||
cat SHA256SUMS
|
|
||||||
|
|
||||||
- name: Build release notes
|
|
||||||
id: notes
|
|
||||||
run: |
|
|
||||||
v="${{ needs.resolve-version.outputs.version }}"
|
|
||||||
{
|
|
||||||
echo "## DorkNet launcher \`v$v\`"
|
|
||||||
echo ""
|
|
||||||
echo "Host-side release. For the matching server + client patcher per Rec Room build, see the per-version branch tags."
|
|
||||||
echo ""
|
|
||||||
echo "### Downloads"
|
|
||||||
echo ""
|
|
||||||
echo "| Platform | Use | Asset |"
|
|
||||||
echo "| --- | --- | --- |"
|
|
||||||
echo "| Windows | One-click installer (Start menu + auto-updates) | \`dorknet-setup-$v.exe\` |"
|
|
||||||
echo "| Windows | Portable single-file launcher | \`dorknet.exe\` |"
|
|
||||||
echo "| Linux x64 | Headless host CLI | \`dorknet-server-linux-x64.tar.gz\` |"
|
|
||||||
echo "| Linux arm64 | Headless host CLI | \`dorknet-server-linux-arm64.tar.gz\` |"
|
|
||||||
echo "| macOS Intel | Headless host CLI | \`dorknet-server-osx-x64.tar.gz\` |"
|
|
||||||
echo "| macOS Apple Silicon | Headless host CLI | \`dorknet-server-osx-arm64.tar.gz\` |"
|
|
||||||
echo "| Windows | Headless host CLI (same as GUI) | \`dorknet-server-win-x64.zip\` |"
|
|
||||||
echo ""
|
|
||||||
echo "Checksums in \`SHA256SUMS\`."
|
|
||||||
echo ""
|
|
||||||
echo "### Setup"
|
|
||||||
echo ""
|
|
||||||
echo "- Windows GUI: [easy-setup.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/easy-setup.md)"
|
|
||||||
echo "- Linux / macOS: [server-cli.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/server-cli.md)"
|
|
||||||
echo "- Photon setup: [photon-setup.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/photon-setup.md)"
|
|
||||||
} > notes.md
|
|
||||||
|
|
||||||
- name: Create / update GitHub release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
# Prerelease if the tag has a -suffix (e.g. v0.2.0-rc1).
|
|
||||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
|
||||||
body_path: notes.md
|
|
||||||
generate_release_notes: false
|
|
||||||
files: |
|
|
||||||
dist/*
|
|
||||||
# The installer registers HKCU\Software\DorkNet\Version on
|
|
||||||
# install — that means the auto-updater sees this version
|
|
||||||
# only after the tag actually exists on GitHub, so we let
|
|
||||||
# the action create the release on first push of the tag.
|
|
||||||
fail_on_unmatched_files: true
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
name: Release (december-2020-12-18)
|
||||||
|
|
||||||
|
# Auto-publishes dorknet-server + dorknet-clientpatch zips to a
|
||||||
|
# GitHub Release on tag push. The launcher (on the public main
|
||||||
|
# branch) fetches the most-recent tag matching its prefix and
|
||||||
|
# downloads these assets, so the tag format MUST start with the
|
||||||
|
# branch's release_tag_prefix declared in versions.json.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v1-december-*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # create the GitHub release + upload assets
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
VERSION_KEY: december_2020_12_18
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '9.0.x'
|
||||||
|
|
||||||
|
# ── Server self-contained publish ────────────────────────────────
|
||||||
|
- name: Publish DorkNet.Server (win-x64, self-contained)
|
||||||
|
run: |
|
||||||
|
dotnet publish DorkNet.Server/DorkNet.Server.csproj `
|
||||||
|
-c Release -r win-x64 --self-contained `
|
||||||
|
-p:PublishSingleFile=true `
|
||||||
|
-p:IncludeNativeLibrariesForSelfExtract=true `
|
||||||
|
-p:EnableCompressionInSingleFile=true `
|
||||||
|
-o publish/server
|
||||||
|
|
||||||
|
- name: Zip server publish
|
||||||
|
run: |
|
||||||
|
Compress-Archive -Path publish/server/* `
|
||||||
|
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
||||||
|
|
||||||
|
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ───
|
||||||
|
- name: Build client patcher zip
|
||||||
|
run: |
|
||||||
|
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }}
|
||||||
|
|
||||||
|
# ── Publish both as Release assets ───────────────────────────────
|
||||||
|
- name: Upload Release assets
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
||||||
|
dist/dorknet-clientpatch-${{ env.VERSION_KEY }}.zip
|
||||||
|
fail_on_unmatched_files: true
|
||||||
+85
-65
@@ -1,70 +1,90 @@
|
|||||||
# Build outputs
|
## .NET
|
||||||
**/bin/
|
bin/
|
||||||
**/obj/
|
obj/
|
||||||
**/node_modules/
|
build/
|
||||||
**/dist/
|
|
||||||
**/.vite/
|
|
||||||
**/build/
|
|
||||||
local-releases/
|
|
||||||
**/installer/out/
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vs/
|
|
||||||
.vscode/
|
|
||||||
*.user
|
*.user
|
||||||
*.suo
|
*.suo
|
||||||
*.csproj.user
|
.vs/
|
||||||
|
.vscode/
|
||||||
# Local config (NEVER commit)
|
*.csproj.bak
|
||||||
.claude/
|
dist/
|
||||||
appsettings.json
|
## Data / secrets
|
||||||
appsettings.Local.json
|
# Local SQLite + uploaded blobs only — DON'T match the source-tree
|
||||||
appsettings.Development.json
|
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
|
||||||
.env
|
# case-insensitive filesystems. Use the precise runtime data dirs.
|
||||||
.env.local
|
DorkNet.Server/bin/Debug/net9.0/data/
|
||||||
.env.*.local
|
DorkNet.Server/bin/Release/net9.0/data/
|
||||||
.backups
|
# appsettings.Production.json is intentionally versioned — contains
|
||||||
# Runtime data
|
# only the Postgres provider switch + Kestrel bind address. All real
|
||||||
data/*.db
|
# secrets come from Coolify env vars at runtime.
|
||||||
data/*.db-shm
|
tmp/
|
||||||
data/*.db-wal
|
## OS
|
||||||
data/*.db.bak.*
|
|
||||||
data/images/
|
|
||||||
keys/
|
|
||||||
**/MelonLoader/UserData/dorknet-clientmod.json
|
|
||||||
**/MelonLoader/Latest.log
|
|
||||||
**/MelonLoader/Logs/
|
|
||||||
**/output_log.txt
|
|
||||||
**/crash_reports/
|
|
||||||
|
|
||||||
# Decompilation dumps — never ship Rec Room IP derivative works
|
|
||||||
Cpp2IL_ISIL/
|
|
||||||
Cpp2IL_CS/
|
|
||||||
**/Recroom_Release_Data/
|
|
||||||
**/Recroom_Release.exe
|
|
||||||
**/GameAssembly.dll
|
|
||||||
**/global-metadata.dat
|
|
||||||
**/version.dll
|
|
||||||
|
|
||||||
# TLS certs / private keys — never ship under any name
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
*.crt
|
|
||||||
*.cer
|
|
||||||
*.pfx
|
|
||||||
*.p12
|
|
||||||
*.jks
|
|
||||||
*.asc
|
|
||||||
*.gpg
|
|
||||||
*.kdbx
|
|
||||||
|
|
||||||
# Maintainer-side scrub tool — operates ON this repo, must not be IN it
|
|
||||||
tools/scrub-for-public.ps1
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
.backups
|
||||||
|
## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit)
|
||||||
|
dump.cs
|
||||||
|
il2cpp.h
|
||||||
|
script.json
|
||||||
|
stringliteral.json
|
||||||
|
Il2CppDump/
|
||||||
|
Cpp2ILOutput/
|
||||||
|
Cpp2IL_CS/
|
||||||
|
Cpp2IL_ISIL/
|
||||||
|
RoomPersistenceManager_decompiled.txt
|
||||||
|
|
||||||
# Test outputs
|
## Per-machine editor / agent settings
|
||||||
TestResults/
|
.claude/
|
||||||
*.coverage
|
global-metadata.dat
|
||||||
|
GameAssembly.dll
|
||||||
|
_wildcard.rec.net+1.pem
|
||||||
|
_wildcard.rec.net+1-key.pem
|
||||||
|
dist$ZIPNAME.zip
|
||||||
|
|
||||||
|
## Vite admin SPA build output — derived from DorkNet.Server/admin-ui/
|
||||||
|
## via `npm run build` (local) or the admin-ui Dockerfile stage (deploy).
|
||||||
|
## Each build emits a new hash (assets/index-<hash>.js) so committing
|
||||||
|
## these files leaves a stale index.html → JS hash mismatch the next
|
||||||
|
## time someone rebuilds. Keep the source committed, regenerate output.
|
||||||
|
DorkNet.Server/wwwroot/admin/index.html
|
||||||
|
DorkNet.Server/wwwroot/admin/assets/
|
||||||
|
DorkNet.Server/wwwroot/admin/brand/
|
||||||
|
DorkNet.Server/wwwroot/admin/*.map
|
||||||
|
DorkNet.Server/admin-ui/node_modules/
|
||||||
|
DorkNet.Server/admin-ui/dist/
|
||||||
|
DorkNet.Server/admin-ui/.vite/
|
||||||
|
DorkNet.Server/admin-ui/tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
## Public-facing site (Vite + React, DorkNet.Server/site/)
|
||||||
|
## Same reasoning as admin-ui above: hashed output, regenerate per build.
|
||||||
|
DorkNet.Server/wwwroot/site/index.html
|
||||||
|
DorkNet.Server/wwwroot/site/assets/
|
||||||
|
DorkNet.Server/wwwroot/site/brand/
|
||||||
|
DorkNet.Server/wwwroot/site/*.map
|
||||||
|
DorkNet.Server/site/node_modules/
|
||||||
|
DorkNet.Server/site/dist/
|
||||||
|
DorkNet.Server/site/.vite/
|
||||||
|
DorkNet.Server/site/tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
|
||||||
|
steamless/
|
||||||
|
|
||||||
|
## Local recovery / cache-mining artifacts produced by tools/recover-inventions.ps1
|
||||||
|
## and friends. These can run hundreds of MB to multi-GB (HTTPCache snapshots,
|
||||||
|
## raw .inv bytes staged for re-import, ready-to-import zip bundles). Per-room,
|
||||||
|
## per-run, regeneratable from the source room zip + a live walkthrough — never
|
||||||
|
## belongs in source control.
|
||||||
|
*-cache-snapshot/
|
||||||
|
*-cache-snapshot-*/
|
||||||
|
*-recovered-inv/
|
||||||
|
*-recovered.zip
|
||||||
|
DorkSquad-recovered/
|
||||||
|
ProficioVII-recovered/
|
||||||
|
Recovered.zip
|
||||||
|
Recovered/
|
||||||
|
Recovered(*)/
|
||||||
|
|
||||||
|
|
||||||
|
local-releases/**
|
||||||
|
DorkNet.Models/bin-verify/**
|
||||||
|
DorkNet.Models/bin-verify2/**
|
||||||
|
|||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
# Branches
|
|
||||||
|
|
||||||
DorkNet keeps one long-lived branch for each supported Rec Room client
|
|
||||||
build. Each branch carries the server and client patcher for that
|
|
||||||
build's wire protocol. Pick the branch that matches your install.
|
|
||||||
|
|
||||||
## Active branches
|
|
||||||
|
|
||||||
### `december-2020-12-18`
|
|
||||||
|
|
||||||
- **Client build:** Rec Room 2020.12.18
|
|
||||||
- **Status:** active
|
|
||||||
- **Notes:**
|
|
||||||
- Single EF Initial migration; SQLite + Postgres both work out of the
|
|
||||||
box.
|
|
||||||
- Includes the chat-threads, clubs, playlists, weekly-challenge, and
|
|
||||||
room-keys subsystems added late-2020.
|
|
||||||
- Server's `Versions/Late2020VersionPlugin` is the active version
|
|
||||||
plugin; client header `X-DorkNet-Version: december_2020_12_18`.
|
|
||||||
|
|
||||||
### `march-2020-03-10`
|
|
||||||
|
|
||||||
- **Client build:** Rec Room 2020.03.10 (also serves 2020.03.06)
|
|
||||||
- **Status:** active
|
|
||||||
- **Notes:**
|
|
||||||
- Earlier wire shape — no clubs, no playlists, simpler store catalog.
|
|
||||||
- Diverges from `december-2020-12-18` by ~170 files; cannot be
|
|
||||||
unified with the plugin abstraction alone.
|
|
||||||
|
|
||||||
Setup starts the same way for both branches: pick the branch, then
|
|
||||||
follow that branch's own deploy notes. See
|
|
||||||
[docs/advanced-setup.md](docs/advanced-setup.md).
|
|
||||||
|
|
||||||
## How to pick
|
|
||||||
|
|
||||||
If you don't know which Rec Room build you have, check:
|
|
||||||
|
|
||||||
- **Steam**: right-click *Rec Room* → Properties → Local files →
|
|
||||||
Browse → look at the `Recroom_Release_Data/StreamingAssets/version.txt`
|
|
||||||
if present, or note the install timestamp.
|
|
||||||
- **Manual install** (the typical DorkNet setup path): the build is in
|
|
||||||
the folder name (`RecRoom-2020.12.18`, `RecRoom-2020.03.10`, etc.).
|
|
||||||
- **Easy launcher**: auto-detects the build and picks the branch.
|
|
||||||
|
|
||||||
## Machine-readable manifest
|
|
||||||
|
|
||||||
[`versions.json`](versions.json) carries the same information in a
|
|
||||||
format the Easy launcher and install scripts can read directly. When a
|
|
||||||
new version branch lands, add it there too.
|
|
||||||
|
|
||||||
## Adding a new version branch
|
|
||||||
|
|
||||||
1. Land the server code on a new orphan branch named `<month>-<build>`
|
|
||||||
(e.g. `august-2021-08-04`).
|
|
||||||
2. Add a row to the active-branches list above.
|
|
||||||
3. Add the matching entry to `versions.json`.
|
|
||||||
4. Add a `docs/<month>-setup.md` setup guide.
|
|
||||||
5. Tag a release on the new branch so the Easy launcher's
|
|
||||||
release-fetcher picks it up.
|
|
||||||
|
|
||||||
Branches are intentionally long-lived — once a build is supported, its
|
|
||||||
branch keeps getting patches against the same wire protocol. Don't
|
|
||||||
rebase across branches; cherry-pick when a fix applies to multiple.
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Contributing to DorkNet
|
|
||||||
|
|
||||||
Thanks for your interest. A few ground rules before you open a PR.
|
|
||||||
|
|
||||||
## Code style
|
|
||||||
|
|
||||||
- **C#**: nullable enabled. Match the file you're editing. No regions, no
|
|
||||||
multi-paragraph XML docs, no async-without-await.
|
|
||||||
- **TypeScript / React**: existing patterns — function components, named
|
|
||||||
exports, tailwind classes, no CSS modules. On a version branch, lint
|
|
||||||
with `npm run lint` from `DorkNet.Server/admin-ui/`.
|
|
||||||
- **No AI-co-author trailers in commit messages.** Use a real attribution
|
|
||||||
line or none at all.
|
|
||||||
|
|
||||||
## Branches and commits
|
|
||||||
|
|
||||||
- `main` is protected; PR from a feature branch.
|
|
||||||
- Conventional commits welcome but not required. One-line summary should
|
|
||||||
describe the *why*, not the *what* — the diff already shows what.
|
|
||||||
|
|
||||||
## Scope of contributions we welcome
|
|
||||||
|
|
||||||
- Bug fixes against documented behavior
|
|
||||||
- Feature parity with the 2020 client (anything reverse-engineered cleanly)
|
|
||||||
- Performance work
|
|
||||||
- Better docs, screenshots, video walkthroughs
|
|
||||||
- New translations of the admin UI
|
|
||||||
|
|
||||||
## Scope of contributions we don't accept
|
|
||||||
|
|
||||||
- **Rec Room game assets, decompiled source, or modified game binaries.**
|
|
||||||
We never ship Rec Room IP. See [DISCLAIMER.md](DISCLAIMER.md). PRs that
|
|
||||||
add these files will be closed.
|
|
||||||
- **Cheats, exploits, or features that would harm other public Rec Room
|
|
||||||
servers.** This project exists to let people self-host the 2020 backend
|
|
||||||
for themselves; it isn't a vehicle for griefing live games.
|
|
||||||
- **Crypto / NFT / monetization plugins.** Hard no.
|
|
||||||
|
|
||||||
## Reporting security issues
|
|
||||||
|
|
||||||
Don't open a public issue. Email the maintainer at the address in the repo
|
|
||||||
metadata, or use GitHub's private vulnerability reporting.
|
|
||||||
|
|
||||||
## Setting up a dev environment
|
|
||||||
|
|
||||||
See [docs/advanced-setup.md](docs/advanced-setup.md) for the full server
|
|
||||||
stack. The server and admin UI live on the version branches, not on
|
|
||||||
`main`. For just hacking on the admin UI after you check out a version
|
|
||||||
branch:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd DorkNet.Server/admin-ui
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Points at `http://localhost:5000` by default — start the server first.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# Disclaimer
|
|
||||||
|
|
||||||
DorkNet is an independent, fan-made, clean-room reimplementation of
|
|
||||||
backend protocols used by old **2020 Rec Room** builds. The supported
|
|
||||||
client builds live on separate branches, currently March 2020 and
|
|
||||||
December 2020.
|
|
||||||
|
|
||||||
It is **not** affiliated with, endorsed by, or sponsored by:
|
|
||||||
|
|
||||||
- Rec Room Inc.
|
|
||||||
- Against Gravity (the original developer)
|
|
||||||
- Photon (Exit Games GmbH)
|
|
||||||
- Steam / Valve Corporation
|
|
||||||
- Meta / Oculus
|
|
||||||
|
|
||||||
**Rec Room™**, the Rec Room logo, and all in-game art, audio, models,
|
|
||||||
locations, characters, and brand assets are the intellectual property of
|
|
||||||
Rec Room Inc. and are used here only for descriptive interoperability.
|
|
||||||
|
|
||||||
## What DorkNet does and doesn't ship
|
|
||||||
|
|
||||||
DorkNet's repository contains:
|
|
||||||
- Original server code (ASP.NET Core)
|
|
||||||
- Original admin tooling (React)
|
|
||||||
- Original MelonLoader mod (C#)
|
|
||||||
- Patcher scripts that operate on the user's own local Rec Room install
|
|
||||||
|
|
||||||
DorkNet's repository **does not** contain:
|
|
||||||
- The Rec Room game executable, assets, or any redistributable Rec Room IP
|
|
||||||
- Decompiled Rec Room source or IL2CPP dumps
|
|
||||||
- Patched copies of `Recroom_Release.exe`, `global-metadata.dat`, or any
|
|
||||||
Rec Room game file
|
|
||||||
|
|
||||||
You must acquire the matching 2020 Rec Room client yourself through
|
|
||||||
whatever means are legal in your jurisdiction, for example SteamDB depot
|
|
||||||
tools against an account that owns Rec Room. The patcher runs on your
|
|
||||||
machine, modifies files locally, and never uploads the game anywhere.
|
|
||||||
|
|
||||||
## Use at your own risk
|
|
||||||
|
|
||||||
This is hobby software. There are no warranties of any kind. If running
|
|
||||||
DorkNet violates a license you agreed to (e.g. Steam Subscriber Agreement,
|
|
||||||
Rec Room Terms of Service), that's between you and the rights holder.
|
|
||||||
DorkNet's authors take no responsibility for consequences arising from
|
|
||||||
your use of the software.
|
|
||||||
|
|
||||||
## Takedown requests
|
|
||||||
|
|
||||||
If you represent Rec Room Inc. or another rights holder and wish to discuss
|
|
||||||
this project, please open an issue on the public repository and we will
|
|
||||||
respond promptly.
|
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
# DorkNet.Server container image — Coolify deploy target.
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
# Multi-stage: SDK image builds + publishes, runtime image is the
|
||||||
|
# slimmer aspnet:10.0 with only the published output.
|
||||||
|
#
|
||||||
|
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
|
||||||
|
# to ProjectReference + dotnet restore). The .dockerignore at the
|
||||||
|
# repo root keeps the bin/, obj/, and other dev junk out of the
|
||||||
|
# context.
|
||||||
|
|
||||||
|
ARG DOTNET_VERSION=10.0
|
||||||
|
ARG NODE_VERSION=20
|
||||||
|
|
||||||
|
# ── admin SPA build stage ──────────────────────────────────────────
|
||||||
|
# The Vite + React admin UI under DorkNet.Server/admin-ui builds in a
|
||||||
|
# dedicated node image because the .NET SDK image doesn't ship npm.
|
||||||
|
# Output is dropped into /wwwroot/admin and copied into the .NET build
|
||||||
|
# stage just before `dotnet publish`, which is configured to skip its
|
||||||
|
# own inline npm step via -p:SkipAdminUIBuild=true.
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
||||||
|
WORKDIR /ui
|
||||||
|
# Lockfile first for layer caching: when only application source
|
||||||
|
# changes, the npm install step stays cached.
|
||||||
|
COPY DorkNet.Server/admin-ui/package.json DorkNet.Server/admin-ui/package-lock.json* ./
|
||||||
|
RUN npm install --no-fund --no-audit --loglevel=error
|
||||||
|
# Now the source. vite.config.ts writes to ../wwwroot/admin/, which
|
||||||
|
# from /ui is /wwwroot/admin/ — pre-create the parent so Vite's
|
||||||
|
# emptyOutDir won't refuse to create above its cwd.
|
||||||
|
COPY DorkNet.Server/admin-ui/ ./
|
||||||
|
RUN mkdir -p /wwwroot/admin \
|
||||||
|
&& npm run build \
|
||||||
|
&& ls -la /wwwroot/admin \
|
||||||
|
&& ls -la /wwwroot/admin/assets
|
||||||
|
|
||||||
|
# ── public-facing site build stage ─────────────────────────────────
|
||||||
|
# Mirrors the admin-ui stage. Source under DorkNet.Server/site,
|
||||||
|
# output emitted to /wwwroot/site for the .NET stage to copy.
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS site
|
||||||
|
WORKDIR /ui
|
||||||
|
COPY DorkNet.Server/site/package.json DorkNet.Server/site/package-lock.json* ./
|
||||||
|
RUN npm install --no-fund --no-audit --loglevel=error
|
||||||
|
COPY DorkNet.Server/site/ ./
|
||||||
|
RUN mkdir -p /wwwroot/site \
|
||||||
|
&& npm run build \
|
||||||
|
&& ls -la /wwwroot/site
|
||||||
|
|
||||||
|
# ── build stage ────────────────────────────────────────────────────
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy csproj files first so `dotnet restore` is cached when only
|
||||||
|
# source files change. Web SDK pulls in transitive packages from the
|
||||||
|
# DorkNet.Models project ref, so its csproj needs to be present too.
|
||||||
|
COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/
|
||||||
|
COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/
|
||||||
|
RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj
|
||||||
|
|
||||||
|
# Now copy the rest of the .NET source, then drop the prebuilt admin
|
||||||
|
# SPA into wwwroot/admin so the publish step ships it as static content.
|
||||||
|
COPY DorkNet.Server/ DorkNet.Server/
|
||||||
|
COPY DorkNet.Models/ DorkNet.Models/
|
||||||
|
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
|
||||||
|
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
|
||||||
|
|
||||||
|
# Publish. SkipAdminUIBuild=true bypasses the csproj target that would
|
||||||
|
# otherwise try to invoke npm (which this image doesn't have).
|
||||||
|
RUN dotnet publish DorkNet.Server/DorkNet.Server.csproj \
|
||||||
|
-c Release \
|
||||||
|
--no-restore \
|
||||||
|
-p:SkipAdminUIBuild=true \
|
||||||
|
-o /app/publish
|
||||||
|
|
||||||
|
# ── runtime stage ──────────────────────────────────────────────────
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
|
||||||
|
# its own probe regardless of what the image's HEALTHCHECK uses, so
|
||||||
|
# both have to be present. The aspnet:10.0 base image strips both by
|
||||||
|
# default to keep the surface area small.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
|
||||||
|
# Coolify mounts persistent volumes with write permission for this UID.
|
||||||
|
USER app
|
||||||
|
|
||||||
|
COPY --from=build --chown=app:app /app/publish .
|
||||||
|
|
||||||
|
# Postgres provider is selected by appsettings.Production.json.
|
||||||
|
# ASPNETCORE_ENVIRONMENT defaults to Production in the aspnet image
|
||||||
|
# but we set it explicitly for clarity.
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
ASPNETCORE_URLS=http://0.0.0.0:8080 \
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER=true \
|
||||||
|
DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# /healthz returns 503 until the migration + seed pass complete on
|
||||||
|
# the singleton holding the pg_advisory_xact_lock; other replicas
|
||||||
|
# block on the lock during the same window. Coolify's default 30s
|
||||||
|
# probe interval is fine.
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=3 \
|
||||||
|
CMD curl --fail --silent http://localhost:8080/healthz || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["dotnet", "DorkNet.Server.dll"]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
ARG DOTNET_VERSION=10.0
|
||||||
|
ARG NODE_VERSION=20
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
||||||
|
WORKDIR /ui
|
||||||
|
COPY DorkNet.Server/admin-ui/package.json DorkNet.Server/admin-ui/package-lock.json* ./
|
||||||
|
RUN npm install --no-fund --no-audit --loglevel=error
|
||||||
|
COPY DorkNet.Server/admin-ui/ ./
|
||||||
|
RUN mkdir -p /wwwroot/admin \
|
||||||
|
&& npm run build
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS site
|
||||||
|
WORKDIR /ui
|
||||||
|
COPY DorkNet.Server/site/package.json DorkNet.Server/site/package-lock.json* ./
|
||||||
|
RUN npm install --no-fund --no-audit --loglevel=error
|
||||||
|
COPY DorkNet.Server/site/ ./
|
||||||
|
RUN mkdir -p /wwwroot/site \
|
||||||
|
&& npm run build
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
|
||||||
|
ARG PROJECT_PATH
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
|
||||||
|
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
|
||||||
|
RUN dotnet publish "${PROJECT_PATH}" -c Release \
|
||||||
|
-p:SkipAdminUIBuild=true \
|
||||||
|
-p:DorkNetServiceContainerPublish=true \
|
||||||
|
-o /app/publish
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime
|
||||||
|
ARG APP_DLL
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
|
COPY --from=build --chown=app:app /app/publish .
|
||||||
|
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
ASPNETCORE_URLS=http://0.0.0.0:8080 \
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER=true \
|
||||||
|
DOTNET_PRINT_TELEMETRY_MESSAGE=false \
|
||||||
|
APP_DLL=${APP_DLL}
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD curl --fail --silent http://localhost:8080/healthz || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["sh", "-c", "dotnet \"$APP_DLL\""]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using DorkNet.AdminMobile.Design;
|
||||||
|
using DorkNet.AdminMobile.Pages;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile;
|
||||||
|
|
||||||
|
public class App : Application
|
||||||
|
{
|
||||||
|
public App(LoginPage loginPage)
|
||||||
|
{
|
||||||
|
UserAppTheme = AppTheme.Dark;
|
||||||
|
MainPage = new NavigationPage(loginPage)
|
||||||
|
{
|
||||||
|
BarBackgroundColor = AppDesign.Canvas,
|
||||||
|
BarTextColor = AppDesign.Text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ShowAdmin()
|
||||||
|
{
|
||||||
|
Current!.MainPage = new NavigationPage(
|
||||||
|
AppServices.Services.GetRequiredService<AdminTabbedPage>())
|
||||||
|
{
|
||||||
|
BarBackgroundColor = AppDesign.Canvas,
|
||||||
|
BarTextColor = AppDesign.Text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ShowLogin()
|
||||||
|
{
|
||||||
|
Current!.MainPage = new NavigationPage(
|
||||||
|
AppServices.Services.GetRequiredService<LoginPage>())
|
||||||
|
{
|
||||||
|
BarBackgroundColor = AppDesign.Canvas,
|
||||||
|
BarTextColor = AppDesign.Text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using Microsoft.Maui.Controls.Shapes;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile.Design;
|
||||||
|
|
||||||
|
public static class AppDesign
|
||||||
|
{
|
||||||
|
public static readonly Color Canvas = Color.FromArgb("#0f1115");
|
||||||
|
public static readonly Color Surface = Color.FromArgb("#171a20");
|
||||||
|
public static readonly Color SurfaceLifted = Color.FromArgb("#20252d");
|
||||||
|
public static readonly Color RowSurface = Color.FromArgb("#14171d");
|
||||||
|
public static readonly Color Stroke = Color.FromArgb("#2a3039");
|
||||||
|
public static readonly Color Text = Color.FromArgb("#f4f6fa");
|
||||||
|
public static readonly Color Muted = Color.FromArgb("#aeb7c6");
|
||||||
|
public static readonly Color Subtle = Color.FromArgb("#778294");
|
||||||
|
public static readonly Color Accent = Color.FromArgb("#64b5ff");
|
||||||
|
public static readonly Color AndroidAccent = Color.FromArgb("#b7dd72");
|
||||||
|
public static readonly Color Danger = Color.FromArgb("#ff6f82");
|
||||||
|
|
||||||
|
public static Brush PageBackground => new SolidColorBrush(Canvas);
|
||||||
|
|
||||||
|
public static Color PlatformAccent =>
|
||||||
|
#if ANDROID
|
||||||
|
AndroidAccent;
|
||||||
|
#else
|
||||||
|
Accent;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public static Label Title(string text) => new()
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
TextColor = Text,
|
||||||
|
FontSize = 22,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
LineBreakMode = LineBreakMode.TailTruncation,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Label Section(string text) => new()
|
||||||
|
{
|
||||||
|
Text = text.ToUpperInvariant(),
|
||||||
|
TextColor = Subtle,
|
||||||
|
FontSize = 12,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
Margin = new Thickness(0, 12, 0, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Border GlassCard(View content, Thickness? padding = null) => new()
|
||||||
|
{
|
||||||
|
Padding = padding ?? new Thickness(14),
|
||||||
|
Stroke = Stroke,
|
||||||
|
StrokeThickness = 1,
|
||||||
|
BackgroundColor = Surface,
|
||||||
|
StrokeShape = new RoundRectangle { CornerRadius = 10 },
|
||||||
|
Content = content,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Button PrimaryButton(string text) => new()
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
BackgroundColor = PlatformAccent,
|
||||||
|
TextColor = Colors.Black,
|
||||||
|
CornerRadius = 10,
|
||||||
|
HeightRequest = 44,
|
||||||
|
FontAttributes = FontAttributes.Bold,
|
||||||
|
Padding = new Thickness(14, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Button SecondaryButton(string text) => new()
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
BackgroundColor = SurfaceLifted,
|
||||||
|
TextColor = Text,
|
||||||
|
CornerRadius = 10,
|
||||||
|
HeightRequest = 42,
|
||||||
|
Padding = new Thickness(14, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Button DangerButton(string text) => new()
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
BackgroundColor = Color.FromArgb("#4c1d28"),
|
||||||
|
TextColor = Color.FromArgb("#ffd5dc"),
|
||||||
|
CornerRadius = 10,
|
||||||
|
HeightRequest = 42,
|
||||||
|
Padding = new Thickness(12, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Entry Entry(string placeholder, Keyboard? keyboard = null, bool secret = false) => new()
|
||||||
|
{
|
||||||
|
Placeholder = placeholder,
|
||||||
|
IsPassword = secret,
|
||||||
|
TextColor = Text,
|
||||||
|
PlaceholderColor = Subtle,
|
||||||
|
BackgroundColor = Color.FromArgb("#19202b"),
|
||||||
|
ClearButtonVisibility = ClearButtonVisibility.WhileEditing,
|
||||||
|
Keyboard = keyboard ?? Keyboard.Text,
|
||||||
|
HeightRequest = 44,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Label Caption(string text) => new()
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
TextColor = Muted,
|
||||||
|
FontSize = 13,
|
||||||
|
LineBreakMode = LineBreakMode.WordWrap,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static BoxView Divider() => new()
|
||||||
|
{
|
||||||
|
HeightRequest = 1,
|
||||||
|
Color = Stroke,
|
||||||
|
Opacity = 0.7,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Label StatusLabel() => new()
|
||||||
|
{
|
||||||
|
TextColor = Muted,
|
||||||
|
FontSize = 13,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net9.0-android36.0;net9.0-ios26.0</TargetFrameworks>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<RootNamespace>DorkNet.AdminMobile</RootNamespace>
|
||||||
|
<UseMaui>true</UseMaui>
|
||||||
|
<SingleProject>true</SingleProject>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ApplicationTitle>DorkNet Admin</ApplicationTitle>
|
||||||
|
<ApplicationId>net.dorknet.adminmobile</ApplicationId>
|
||||||
|
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||||
|
<ApplicationVersion>3</ApplicationVersion>
|
||||||
|
<AndroidTargetSdkVersion>36</AndroidTargetSdkVersion>
|
||||||
|
<RuntimeIdentifiers>android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers>
|
||||||
|
<PublishTrimmed Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">false</PublishTrimmed>
|
||||||
|
<RunAOTCompilation Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">false</RunAOTCompilation>
|
||||||
|
<AndroidLinkMode Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">None</AndroidLinkMode>
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">23.0</SupportedOSPlatformVersion>
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">14.2</SupportedOSPlatformVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#0B0F14" />
|
||||||
|
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#0B0F14" BaseSize="256,256" />
|
||||||
|
<MauiImage Include="Resources\Brand\dorknet.svg" BaseSize="256,256" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Maui.Controls" Version="9.0.120" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Maui.Controls.Hosting;
|
||||||
|
using Microsoft.Maui.Hosting;
|
||||||
|
using DorkNet.AdminMobile.Pages;
|
||||||
|
using DorkNet.AdminMobile.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile;
|
||||||
|
|
||||||
|
public static class MauiProgram
|
||||||
|
{
|
||||||
|
public static MauiApp CreateMauiApp()
|
||||||
|
{
|
||||||
|
var builder = MauiApp.CreateBuilder();
|
||||||
|
builder.UseMauiApp<App>();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<SecureAdminSettings>();
|
||||||
|
builder.Services.AddSingleton<AdminApiClient>();
|
||||||
|
builder.Services.AddTransient<LoginPage>();
|
||||||
|
builder.Services.AddTransient<AdminTabbedPage>();
|
||||||
|
builder.Services.AddTransient<DashboardPage>();
|
||||||
|
builder.Services.AddTransient<PlayersPage>();
|
||||||
|
builder.Services.AddTransient<RoomsPage>();
|
||||||
|
builder.Services.AddTransient<SettingsPage>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
AppServices.Services = app.Services;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AppServices
|
||||||
|
{
|
||||||
|
public static IServiceProvider Services { get; set; } = default!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile.Models;
|
||||||
|
|
||||||
|
public sealed class AdminLoginResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("access_token")]
|
||||||
|
public string AccessToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
public string RefreshToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("account_id")]
|
||||||
|
public long AccountId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("username")]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("display_name")]
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlayerSummary
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public bool IsAdmin { get; set; }
|
||||||
|
public bool IsDeveloper { get; set; }
|
||||||
|
public bool IsVerified { get; set; }
|
||||||
|
public bool IsJunior { get; set; }
|
||||||
|
public bool Online { get; set; }
|
||||||
|
public int Level { get; set; }
|
||||||
|
public int XP { get; set; }
|
||||||
|
public DateTime? LastSeenAt { get; set; }
|
||||||
|
public DateTime? BannedUntil { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RoomSummary
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public long CreatorPlayerId { get; set; }
|
||||||
|
public int BlobCount { get; set; }
|
||||||
|
public bool IsAGRoom { get; set; }
|
||||||
|
public bool IsDormRoom { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class InstanceSummary
|
||||||
|
{
|
||||||
|
public long RoomInstanceId { get; set; }
|
||||||
|
public long RoomId { get; set; }
|
||||||
|
public string RoomName { get; set; } = string.Empty;
|
||||||
|
public string PhotonRoomId { get; set; } = string.Empty;
|
||||||
|
public string PhotonRegionId { get; set; } = string.Empty;
|
||||||
|
public int MaxCapacity { get; set; }
|
||||||
|
public bool IsPrivate { get; set; }
|
||||||
|
public List<InstanceParticipant> Participants { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class InstanceParticipant
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public bool IsMaster { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ServerSettingsDto
|
||||||
|
{
|
||||||
|
public bool SignupsDisabled { get; set; }
|
||||||
|
public bool GlobalFriendsEnabled { get; set; }
|
||||||
|
public bool WeeklyChallengesCompletedRequired { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminStats
|
||||||
|
{
|
||||||
|
public PlayerStats Players { get; set; } = new();
|
||||||
|
public RoomStats Rooms { get; set; } = new();
|
||||||
|
public int Inventions { get; set; }
|
||||||
|
public PhotoStats Photos { get; set; } = new();
|
||||||
|
public ModerationStats Moderation { get; set; } = new();
|
||||||
|
public DateTime ServerTime { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlayerStats
|
||||||
|
{
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int OnlineNow { get; set; }
|
||||||
|
public int BannedNow { get; set; }
|
||||||
|
public int NewToday { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RoomStats
|
||||||
|
{
|
||||||
|
public int Total { get; set; }
|
||||||
|
public long TotalVisits { get; set; }
|
||||||
|
public long TotalCheers { get; set; }
|
||||||
|
public int ActiveSessionCount { get; set; }
|
||||||
|
public int InGamePlayerCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PhotoStats
|
||||||
|
{
|
||||||
|
public int Today { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ModerationStats
|
||||||
|
{
|
||||||
|
public int OpenReports { get; set; }
|
||||||
|
public int ActiveIpBans { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using DorkNet.AdminMobile.Design;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile.Pages;
|
||||||
|
|
||||||
|
public sealed class AdminTabbedPage : TabbedPage
|
||||||
|
{
|
||||||
|
public AdminTabbedPage(
|
||||||
|
DashboardPage dashboard,
|
||||||
|
PlayersPage players,
|
||||||
|
RoomsPage rooms,
|
||||||
|
SettingsPage settings)
|
||||||
|
{
|
||||||
|
BarBackgroundColor = AppDesign.Canvas;
|
||||||
|
BarTextColor = AppDesign.Text;
|
||||||
|
SelectedTabColor = AppDesign.PlatformAccent;
|
||||||
|
UnselectedTabColor = AppDesign.Subtle;
|
||||||
|
Children.Add(dashboard);
|
||||||
|
Children.Add(players);
|
||||||
|
Children.Add(rooms);
|
||||||
|
Children.Add(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using DorkNet.AdminMobile.Design;
|
||||||
|
using DorkNet.AdminMobile.Models;
|
||||||
|
using DorkNet.AdminMobile.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile.Pages;
|
||||||
|
|
||||||
|
public sealed class DashboardPage : ContentPage
|
||||||
|
{
|
||||||
|
private readonly AdminApiClient api;
|
||||||
|
private readonly Label status = AppDesign.StatusLabel();
|
||||||
|
private readonly Grid metrics = new() { ColumnSpacing = 10, RowSpacing = 10 };
|
||||||
|
|
||||||
|
public DashboardPage(AdminApiClient api)
|
||||||
|
{
|
||||||
|
this.api = api;
|
||||||
|
Title = "Dashboard";
|
||||||
|
Background = AppDesign.PageBackground;
|
||||||
|
|
||||||
|
var refresh = AppDesign.SecondaryButton("Refresh");
|
||||||
|
refresh.Clicked += async (_, _) => await LoadAsync();
|
||||||
|
|
||||||
|
Content = new ScrollView
|
||||||
|
{
|
||||||
|
Content = new VerticalStackLayout
|
||||||
|
{
|
||||||
|
Padding = 16,
|
||||||
|
Spacing = 14,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
AppDesign.Title("Operations"),
|
||||||
|
refresh,
|
||||||
|
metrics,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async void OnAppearing()
|
||||||
|
{
|
||||||
|
base.OnAppearing();
|
||||||
|
if (metrics.Children.Count == 0) await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
status.Text = "Loading...";
|
||||||
|
var s = await api.GetStatsAsync();
|
||||||
|
DrawMetrics(s);
|
||||||
|
status.Text = $"Server time {s.ServerTime:g}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
status.Text = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawMetrics(AdminStats s)
|
||||||
|
{
|
||||||
|
metrics.Children.Clear();
|
||||||
|
metrics.RowDefinitions.Clear();
|
||||||
|
metrics.ColumnDefinitions.Clear();
|
||||||
|
metrics.ColumnDefinitions.Add(new ColumnDefinition());
|
||||||
|
metrics.ColumnDefinitions.Add(new ColumnDefinition());
|
||||||
|
AddMetric(0, 0, "Players", $"{s.Players.Total}", $"{s.Players.OnlineNow} online");
|
||||||
|
AddMetric(0, 1, "Rooms", $"{s.Rooms.Total}", $"{s.Rooms.ActiveSessionCount} active sessions");
|
||||||
|
AddMetric(1, 0, "Moderation", $"{s.Moderation.OpenReports}", "open reports");
|
||||||
|
AddMetric(1, 1, "Traffic", $"{s.Rooms.InGamePlayerCount}", $"{s.Rooms.TotalVisits:n0} visits");
|
||||||
|
AddMetric(2, 0, "New today", $"{s.Players.NewToday}", "players");
|
||||||
|
AddMetric(2, 1, "Photos", $"{s.Photos.Today}", "last 24h");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddMetric(int row, int col, string label, string value, string caption)
|
||||||
|
{
|
||||||
|
while (metrics.RowDefinitions.Count <= row) metrics.RowDefinitions.Add(new RowDefinition());
|
||||||
|
var tile = AppDesign.GlassCard(new VerticalStackLayout
|
||||||
|
{
|
||||||
|
Spacing = 4,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new Label { Text = label, TextColor = AppDesign.Subtle, FontSize = 12 },
|
||||||
|
new Label { Text = value, TextColor = AppDesign.Text, FontSize = 28, FontAttributes = FontAttributes.Bold },
|
||||||
|
new Label { Text = caption, TextColor = AppDesign.Muted, FontSize = 12 },
|
||||||
|
},
|
||||||
|
}, new Thickness(14));
|
||||||
|
metrics.Add(tile, col, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using DorkNet.AdminMobile.Design;
|
||||||
|
using DorkNet.AdminMobile.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile.Pages;
|
||||||
|
|
||||||
|
public sealed class LoginPage : ContentPage
|
||||||
|
{
|
||||||
|
private readonly AdminApiClient api;
|
||||||
|
private readonly SecureAdminSettings settings;
|
||||||
|
private readonly Entry baseUrl = AppDesign.Entry("https://admin.example.com", Keyboard.Url);
|
||||||
|
private readonly Entry cfClientId = AppDesign.Entry("Cloudflare Access Client ID");
|
||||||
|
private readonly Entry cfClientSecret = AppDesign.Entry("Cloudflare Access Client Secret", secret: true);
|
||||||
|
private readonly Entry cfJwt = AppDesign.Entry("Cloudflare Access JWT assertion", secret: true);
|
||||||
|
private readonly Entry username = AppDesign.Entry("Admin username");
|
||||||
|
private readonly Entry password = AppDesign.Entry("Password", secret: true);
|
||||||
|
private readonly Label status = AppDesign.StatusLabel();
|
||||||
|
private readonly ActivityIndicator busy = new() { IsVisible = false };
|
||||||
|
private bool loaded;
|
||||||
|
|
||||||
|
public LoginPage(AdminApiClient api, SecureAdminSettings settings)
|
||||||
|
{
|
||||||
|
this.api = api;
|
||||||
|
this.settings = settings;
|
||||||
|
Title = "DorkNet Admin";
|
||||||
|
Background = AppDesign.PageBackground;
|
||||||
|
|
||||||
|
var login = AppDesign.PrimaryButton("Sign in");
|
||||||
|
login.Clicked += OnLogin;
|
||||||
|
|
||||||
|
var continueSaved = AppDesign.SecondaryButton("Continue saved session");
|
||||||
|
continueSaved.Clicked += async (_, _) =>
|
||||||
|
{
|
||||||
|
if (await settings.GetJwtAsync() is { Length: > 0 }) App.ShowAdmin();
|
||||||
|
else status.Text = "No saved DorkNet admin session is stored.";
|
||||||
|
};
|
||||||
|
|
||||||
|
Content = new ScrollView
|
||||||
|
{
|
||||||
|
Content = new VerticalStackLayout
|
||||||
|
{
|
||||||
|
Padding = new Thickness(20, 28),
|
||||||
|
Spacing = 14,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new Image
|
||||||
|
{
|
||||||
|
Source = "dorknet.svg",
|
||||||
|
WidthRequest = 96,
|
||||||
|
HeightRequest = 96,
|
||||||
|
HorizontalOptions = LayoutOptions.Start,
|
||||||
|
},
|
||||||
|
AppDesign.Title("Admin mobile"),
|
||||||
|
new Label
|
||||||
|
{
|
||||||
|
Text = "Connect to a DorkNet admin host. Cloudflare Access service tokens are sent with every request when configured.",
|
||||||
|
TextColor = AppDesign.Muted,
|
||||||
|
FontSize = 14,
|
||||||
|
},
|
||||||
|
AppDesign.GlassCard(new VerticalStackLayout
|
||||||
|
{
|
||||||
|
Spacing = 10,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
AppDesign.Section("Connection"),
|
||||||
|
baseUrl,
|
||||||
|
cfClientId,
|
||||||
|
cfClientSecret,
|
||||||
|
cfJwt,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
AppDesign.GlassCard(new VerticalStackLayout
|
||||||
|
{
|
||||||
|
Spacing = 10,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
AppDesign.Section("DorkNet admin login"),
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
login,
|
||||||
|
continueSaved,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
busy,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async void OnAppearing()
|
||||||
|
{
|
||||||
|
base.OnAppearing();
|
||||||
|
if (loaded) return;
|
||||||
|
loaded = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var saved = await settings.LoadConnectionAsync();
|
||||||
|
baseUrl.Text = saved.BaseUrl;
|
||||||
|
cfClientId.Text = saved.CloudflareAccessClientId;
|
||||||
|
cfClientSecret.Text = saved.CloudflareAccessClientSecret;
|
||||||
|
cfJwt.Text = saved.CloudflareAccessJwt;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
baseUrl.Text = "https://admin.rec.net";
|
||||||
|
status.Text = $"Secure storage was reset: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnLogin(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
busy.IsVisible = true;
|
||||||
|
busy.IsRunning = true;
|
||||||
|
status.Text = string.Empty;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await settings.SaveConnectionAsync(new AdminConnectionSettings(
|
||||||
|
baseUrl.Text ?? string.Empty,
|
||||||
|
cfClientId.Text ?? string.Empty,
|
||||||
|
cfClientSecret.Text ?? string.Empty,
|
||||||
|
cfJwt.Text ?? string.Empty));
|
||||||
|
await api.LoginAsync(username.Text ?? string.Empty, password.Text ?? string.Empty);
|
||||||
|
App.ShowAdmin();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
status.Text = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
busy.IsRunning = false;
|
||||||
|
busy.IsVisible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using DorkNet.AdminMobile.Design;
|
||||||
|
using DorkNet.AdminMobile.Models;
|
||||||
|
using DorkNet.AdminMobile.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile.Pages;
|
||||||
|
|
||||||
|
public sealed class PlayersPage : ContentPage
|
||||||
|
{
|
||||||
|
private readonly AdminApiClient api;
|
||||||
|
private readonly Entry search = AppDesign.Entry("Search players");
|
||||||
|
private readonly CollectionView list = new();
|
||||||
|
private readonly Label status = AppDesign.StatusLabel();
|
||||||
|
private readonly Button refresh = AppDesign.SecondaryButton("Refresh");
|
||||||
|
private List<PlayerSummary> players = [];
|
||||||
|
|
||||||
|
public PlayersPage(AdminApiClient api)
|
||||||
|
{
|
||||||
|
this.api = api;
|
||||||
|
Title = "Players";
|
||||||
|
Background = AppDesign.PageBackground;
|
||||||
|
|
||||||
|
refresh.Clicked += async (_, _) => await LoadAsync();
|
||||||
|
search.Completed += async (_, _) => await LoadAsync();
|
||||||
|
|
||||||
|
list.SelectionMode = SelectionMode.Single;
|
||||||
|
list.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
|
||||||
|
list.SelectionChanged += async (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.CurrentSelection.FirstOrDefault() is PlayerSummary player)
|
||||||
|
{
|
||||||
|
list.SelectedItem = null;
|
||||||
|
await PlayerActionsAsync(player);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
list.EmptyView = new Label
|
||||||
|
{
|
||||||
|
Text = "No players loaded",
|
||||||
|
TextColor = AppDesign.Subtle,
|
||||||
|
HorizontalTextAlignment = TextAlignment.Center,
|
||||||
|
VerticalTextAlignment = TextAlignment.Center,
|
||||||
|
};
|
||||||
|
list.ItemTemplate = new DataTemplate(() =>
|
||||||
|
{
|
||||||
|
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 16 };
|
||||||
|
name.SetBinding(Label.TextProperty, nameof(PlayerSummary.DisplayName));
|
||||||
|
var handle = new Label { TextColor = AppDesign.Muted, FontSize = 13 };
|
||||||
|
handle.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Username), stringFormat: "@{0}"));
|
||||||
|
var detail = new Label { TextColor = AppDesign.Subtle, FontSize = 12 };
|
||||||
|
detail.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Level), stringFormat: "Level {0}"));
|
||||||
|
var online = new Label { TextColor = AppDesign.AndroidAccent, FontSize = 12, FontAttributes = FontAttributes.Bold };
|
||||||
|
online.SetBinding(Label.IsVisibleProperty, nameof(PlayerSummary.Online));
|
||||||
|
online.Text = "ONLINE";
|
||||||
|
var chevron = new Label
|
||||||
|
{
|
||||||
|
Text = ">",
|
||||||
|
TextColor = AppDesign.Subtle,
|
||||||
|
FontSize = 18,
|
||||||
|
VerticalTextAlignment = TextAlignment.Center,
|
||||||
|
};
|
||||||
|
var row = new Grid
|
||||||
|
{
|
||||||
|
RowDefinitions =
|
||||||
|
{
|
||||||
|
new RowDefinition { Height = GridLength.Auto },
|
||||||
|
new RowDefinition { Height = GridLength.Auto },
|
||||||
|
new RowDefinition { Height = GridLength.Auto },
|
||||||
|
},
|
||||||
|
ColumnDefinitions =
|
||||||
|
{
|
||||||
|
new ColumnDefinition(),
|
||||||
|
new ColumnDefinition { Width = GridLength.Auto },
|
||||||
|
new ColumnDefinition { Width = GridLength.Auto },
|
||||||
|
},
|
||||||
|
Padding = new Thickness(12, 10),
|
||||||
|
RowSpacing = 2,
|
||||||
|
ColumnSpacing = 10,
|
||||||
|
BackgroundColor = AppDesign.RowSurface,
|
||||||
|
};
|
||||||
|
row.Add(name, 0, 0);
|
||||||
|
row.Add(online, 1, 0);
|
||||||
|
row.Add(handle, 0, 1);
|
||||||
|
row.Add(detail, 0, 2);
|
||||||
|
row.Add(chevron, 2, 0);
|
||||||
|
Grid.SetRowSpan(chevron, 3);
|
||||||
|
|
||||||
|
return new VerticalStackLayout
|
||||||
|
{
|
||||||
|
Spacing = 0,
|
||||||
|
Children = { row, AppDesign.Divider() },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
var searchRow = new Grid
|
||||||
|
{
|
||||||
|
ColumnSpacing = 8,
|
||||||
|
ColumnDefinitions =
|
||||||
|
{
|
||||||
|
new ColumnDefinition(),
|
||||||
|
new ColumnDefinition { Width = GridLength.Auto },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
searchRow.Add(search, 0, 0);
|
||||||
|
searchRow.Add(refresh, 1, 0);
|
||||||
|
|
||||||
|
var grid = new Grid
|
||||||
|
{
|
||||||
|
Padding = 16,
|
||||||
|
RowSpacing = 12,
|
||||||
|
RowDefinitions =
|
||||||
|
{
|
||||||
|
new RowDefinition { Height = GridLength.Auto },
|
||||||
|
new RowDefinition { Height = GridLength.Auto },
|
||||||
|
new RowDefinition(),
|
||||||
|
new RowDefinition { Height = GridLength.Auto },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
grid.Add(AppDesign.Title("Players"), 0, 0);
|
||||||
|
grid.Add(searchRow, 0, 1);
|
||||||
|
grid.Add(list, 0, 2);
|
||||||
|
grid.Add(status, 0, 3);
|
||||||
|
Content = grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async void OnAppearing()
|
||||||
|
{
|
||||||
|
base.OnAppearing();
|
||||||
|
if (players.Count == 0) await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
refresh.IsEnabled = false;
|
||||||
|
status.Text = "Loading...";
|
||||||
|
players = await api.GetPlayersAsync(search.Text);
|
||||||
|
list.ItemsSource = players;
|
||||||
|
status.Text = $"{players.Count} players loaded";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
status.Text = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
refresh.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PlayerActionsAsync(PlayerSummary player)
|
||||||
|
{
|
||||||
|
var action = await DisplayActionSheet(
|
||||||
|
$"@{player.Username}",
|
||||||
|
"Cancel",
|
||||||
|
"Remove account",
|
||||||
|
"Kick",
|
||||||
|
"Reset avatar");
|
||||||
|
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case "Kick":
|
||||||
|
await ConfirmAction($"Kick @{player.Username}?", () => api.KickPlayerAsync(player.Id, "Kicked from mobile admin."));
|
||||||
|
break;
|
||||||
|
case "Reset avatar":
|
||||||
|
await ConfirmAction($"Reset @{player.Username}'s avatar?", () => api.ResetAvatarAsync(player.Id));
|
||||||
|
break;
|
||||||
|
case "Remove account":
|
||||||
|
await RemoveAccountAsync(player);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConfirmAction(string message, Func<Task> action)
|
||||||
|
{
|
||||||
|
if (!await DisplayAlert("Confirm", message, "Run", "Cancel")) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await action();
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await DisplayAlert("Request failed", ex.Message, "OK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveAccountAsync(PlayerSummary player)
|
||||||
|
{
|
||||||
|
var username = await DisplayPromptAsync(
|
||||||
|
"Verify username",
|
||||||
|
$"Type {player.Username} exactly to remove @{player.Username}.",
|
||||||
|
"Next",
|
||||||
|
"Cancel",
|
||||||
|
maxLength: Math.Max(32, player.Username.Length),
|
||||||
|
keyboard: Keyboard.Text);
|
||||||
|
if (!string.Equals(username?.Trim(), player.Username, StringComparison.Ordinal)) return;
|
||||||
|
|
||||||
|
var phrase = $"DELETE {player.Id}";
|
||||||
|
var typedPhrase = await DisplayPromptAsync(
|
||||||
|
"Verify removal",
|
||||||
|
$"Type {phrase} exactly. This cannot be undone.",
|
||||||
|
"Remove",
|
||||||
|
"Cancel",
|
||||||
|
maxLength: phrase.Length,
|
||||||
|
keyboard: Keyboard.Text);
|
||||||
|
if (!string.Equals(typedPhrase?.Trim(), phrase, StringComparison.Ordinal)) return;
|
||||||
|
|
||||||
|
if (!await DisplayAlert("Remove account", $"Permanently remove @{player.Username}?", "Remove", "Cancel")) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
status.Text = $"Removing @{player.Username}...";
|
||||||
|
await api.DeletePlayerAsync(player.Id, player.Username, phrase, "Removed from mobile admin.");
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await DisplayAlert("Removal failed", ex.Message, "OK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using DorkNet.AdminMobile.Design;
|
||||||
|
using DorkNet.AdminMobile.Models;
|
||||||
|
using DorkNet.AdminMobile.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile.Pages;
|
||||||
|
|
||||||
|
public sealed class RoomsPage : ContentPage
|
||||||
|
{
|
||||||
|
private readonly AdminApiClient api;
|
||||||
|
private readonly CollectionView rooms = new();
|
||||||
|
private readonly CollectionView instances = new();
|
||||||
|
private readonly Label status = AppDesign.StatusLabel();
|
||||||
|
private readonly Button refresh = AppDesign.SecondaryButton("Refresh");
|
||||||
|
private bool loaded;
|
||||||
|
|
||||||
|
public RoomsPage(AdminApiClient api)
|
||||||
|
{
|
||||||
|
this.api = api;
|
||||||
|
Title = "Rooms";
|
||||||
|
Background = AppDesign.PageBackground;
|
||||||
|
|
||||||
|
rooms.SelectionMode = SelectionMode.None;
|
||||||
|
rooms.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
|
||||||
|
rooms.EmptyView = EmptyList("No recent rooms");
|
||||||
|
rooms.ItemTemplate = new DataTemplate(() =>
|
||||||
|
{
|
||||||
|
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 };
|
||||||
|
name.SetBinding(Label.TextProperty, nameof(RoomSummary.Name));
|
||||||
|
var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 };
|
||||||
|
meta.SetBinding(Label.TextProperty, new Binding(nameof(RoomSummary.Id), stringFormat: "Room #{0}"));
|
||||||
|
var row = new VerticalStackLayout
|
||||||
|
{
|
||||||
|
Padding = new Thickness(12, 9),
|
||||||
|
Spacing = 2,
|
||||||
|
BackgroundColor = AppDesign.RowSurface,
|
||||||
|
Children = { name, meta },
|
||||||
|
};
|
||||||
|
return new VerticalStackLayout { Spacing = 0, Children = { row, AppDesign.Divider() } };
|
||||||
|
});
|
||||||
|
|
||||||
|
instances.SelectionMode = SelectionMode.None;
|
||||||
|
instances.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
|
||||||
|
instances.EmptyView = EmptyList("No active instances");
|
||||||
|
instances.ItemTemplate = new DataTemplate(() =>
|
||||||
|
{
|
||||||
|
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 };
|
||||||
|
name.SetBinding(Label.TextProperty, nameof(InstanceSummary.RoomName));
|
||||||
|
var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 };
|
||||||
|
meta.SetBinding(Label.TextProperty, new Binding(nameof(InstanceSummary.PhotonRegionId), stringFormat: "Region {0}"));
|
||||||
|
var count = new Label { TextColor = AppDesign.Subtle, FontSize = 12 };
|
||||||
|
count.SetBinding(Label.TextProperty, new Binding("Participants.Count", stringFormat: "{0} participants"));
|
||||||
|
var row = new VerticalStackLayout
|
||||||
|
{
|
||||||
|
Padding = new Thickness(12, 9),
|
||||||
|
Spacing = 2,
|
||||||
|
BackgroundColor = AppDesign.RowSurface,
|
||||||
|
Children = { name, meta, count },
|
||||||
|
};
|
||||||
|
return new VerticalStackLayout { Spacing = 0, Children = { row, AppDesign.Divider() } };
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh.Clicked += async (_, _) => await LoadAsync();
|
||||||
|
|
||||||
|
var grid = new Grid
|
||||||
|
{
|
||||||
|
Padding = 16,
|
||||||
|
RowSpacing = 10,
|
||||||
|
RowDefinitions =
|
||||||
|
{
|
||||||
|
new RowDefinition { Height = GridLength.Auto },
|
||||||
|
new RowDefinition { Height = GridLength.Auto },
|
||||||
|
new RowDefinition { Height = GridLength.Auto },
|
||||||
|
new RowDefinition { Height = new GridLength(1, GridUnitType.Star) },
|
||||||
|
new RowDefinition { Height = GridLength.Auto },
|
||||||
|
new RowDefinition { Height = new GridLength(2, GridUnitType.Star) },
|
||||||
|
new RowDefinition { Height = GridLength.Auto },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
grid.Add(AppDesign.Title("Rooms"), 0, 0);
|
||||||
|
grid.Add(refresh, 0, 1);
|
||||||
|
grid.Add(new Label { Text = "Live instances", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, 0, 2);
|
||||||
|
grid.Add(instances, 0, 3);
|
||||||
|
grid.Add(new Label { Text = "Recent rooms", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, 0, 4);
|
||||||
|
grid.Add(rooms, 0, 5);
|
||||||
|
grid.Add(status, 0, 6);
|
||||||
|
Content = grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Label EmptyList(string text) => new()
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
TextColor = AppDesign.Subtle,
|
||||||
|
HorizontalTextAlignment = TextAlignment.Center,
|
||||||
|
VerticalTextAlignment = TextAlignment.Center,
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override async void OnAppearing()
|
||||||
|
{
|
||||||
|
base.OnAppearing();
|
||||||
|
if (!loaded)
|
||||||
|
{
|
||||||
|
loaded = true;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
refresh.IsEnabled = false;
|
||||||
|
status.Text = "Loading...";
|
||||||
|
var instanceRows = await api.GetInstancesAsync();
|
||||||
|
var roomRows = await api.GetRoomsAsync();
|
||||||
|
instances.ItemsSource = instanceRows;
|
||||||
|
rooms.ItemsSource = roomRows;
|
||||||
|
status.Text = $"{instanceRows.Count} active instances, {roomRows.Count} rooms loaded";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
status.Text = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
refresh.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
using Microsoft.Maui.Controls;
|
||||||
|
using DorkNet.AdminMobile.Design;
|
||||||
|
using DorkNet.AdminMobile.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile.Pages;
|
||||||
|
|
||||||
|
public sealed class SettingsPage : ContentPage
|
||||||
|
{
|
||||||
|
private readonly AdminApiClient api;
|
||||||
|
private readonly SecureAdminSettings settings;
|
||||||
|
private readonly Switch signupsDisabled = new();
|
||||||
|
private readonly Switch globalFriends = new();
|
||||||
|
private readonly Label status = AppDesign.StatusLabel();
|
||||||
|
private bool loading;
|
||||||
|
|
||||||
|
public SettingsPage(AdminApiClient api, SecureAdminSettings settings)
|
||||||
|
{
|
||||||
|
this.api = api;
|
||||||
|
this.settings = settings;
|
||||||
|
Title = "Settings";
|
||||||
|
Background = AppDesign.PageBackground;
|
||||||
|
|
||||||
|
signupsDisabled.Toggled += async (_, e) =>
|
||||||
|
{
|
||||||
|
if (!loading) await SaveToggleAsync(() => api.SetSignupsDisabledAsync(e.Value));
|
||||||
|
};
|
||||||
|
globalFriends.Toggled += async (_, e) =>
|
||||||
|
{
|
||||||
|
if (!loading) await SaveToggleAsync(() => api.SetGlobalFriendsAsync(e.Value));
|
||||||
|
};
|
||||||
|
|
||||||
|
var refresh = AppDesign.SecondaryButton("Refresh");
|
||||||
|
refresh.Clicked += async (_, _) => await LoadAsync();
|
||||||
|
var logout = AppDesign.DangerButton("Clear saved session");
|
||||||
|
logout.Clicked += (_, _) =>
|
||||||
|
{
|
||||||
|
settings.ClearSession();
|
||||||
|
App.ShowLogin();
|
||||||
|
};
|
||||||
|
|
||||||
|
Content = new VerticalStackLayout
|
||||||
|
{
|
||||||
|
Padding = 16,
|
||||||
|
Spacing = 14,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
AppDesign.Title("Settings"),
|
||||||
|
refresh,
|
||||||
|
Row("Disable new account creation", signupsDisabled),
|
||||||
|
Row("Global friends mode", globalFriends),
|
||||||
|
logout,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async void OnAppearing()
|
||||||
|
{
|
||||||
|
base.OnAppearing();
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loading = true;
|
||||||
|
status.Text = "Loading...";
|
||||||
|
var s = await api.GetSettingsAsync();
|
||||||
|
signupsDisabled.IsToggled = s.SignupsDisabled;
|
||||||
|
globalFriends.IsToggled = s.GlobalFriendsEnabled;
|
||||||
|
status.Text = $"Updated {s.UpdatedAt:g}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
status.Text = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveToggleAsync(Func<Task> save)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
status.Text = "Saving...";
|
||||||
|
await save();
|
||||||
|
status.Text = "Saved";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
status.Text = ex.Message;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static View Row(string label, View control)
|
||||||
|
{
|
||||||
|
var grid = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions =
|
||||||
|
{
|
||||||
|
new ColumnDefinition(),
|
||||||
|
new ColumnDefinition { Width = GridLength.Auto },
|
||||||
|
},
|
||||||
|
Padding = new Thickness(12, 10),
|
||||||
|
};
|
||||||
|
grid.Add(new Label
|
||||||
|
{
|
||||||
|
Text = label,
|
||||||
|
TextColor = AppDesign.Text,
|
||||||
|
VerticalTextAlignment = TextAlignment.Center,
|
||||||
|
}, 0, 0);
|
||||||
|
grid.Add(control, 1, 0);
|
||||||
|
return AppDesign.GlassCard(grid, new Thickness(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/appicon"
|
||||||
|
android:roundIcon="@mipmap/appicon_round"
|
||||||
|
android:label="DorkNet Admin"
|
||||||
|
android:supportsRtl="true" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Android.App;
|
||||||
|
using Android.Content.PM;
|
||||||
|
using Microsoft.Maui;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile;
|
||||||
|
|
||||||
|
[Activity(
|
||||||
|
Label = "DorkNet Admin",
|
||||||
|
Icon = "@mipmap/appicon",
|
||||||
|
RoundIcon = "@mipmap/appicon_round",
|
||||||
|
MainLauncher = true,
|
||||||
|
Exported = true,
|
||||||
|
Theme = "@style/Maui.SplashTheme",
|
||||||
|
ConfigurationChanges =
|
||||||
|
ConfigChanges.ScreenSize |
|
||||||
|
ConfigChanges.Orientation |
|
||||||
|
ConfigChanges.UiMode |
|
||||||
|
ConfigChanges.ScreenLayout |
|
||||||
|
ConfigChanges.SmallestScreenSize |
|
||||||
|
ConfigChanges.Density)]
|
||||||
|
public class MainActivity : MauiAppCompatActivity
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using Android.App;
|
||||||
|
using Android.Runtime;
|
||||||
|
using Microsoft.Maui;
|
||||||
|
using Microsoft.Maui.Hosting;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile;
|
||||||
|
|
||||||
|
[Application]
|
||||||
|
public class MainApplication(nint handle, JniHandleOwnership ownership)
|
||||||
|
: MauiApplication(handle, ownership)
|
||||||
|
{
|
||||||
|
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Foundation;
|
||||||
|
using Microsoft.Maui;
|
||||||
|
using Microsoft.Maui.Hosting;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile;
|
||||||
|
|
||||||
|
[Register("AppDelegate")]
|
||||||
|
public class AppDelegate : MauiUIApplicationDelegate
|
||||||
|
{
|
||||||
|
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>DorkNet Admin</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>net.dorknet.adminmobile</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>MinimumOSVersion</key>
|
||||||
|
<string>14.2</string>
|
||||||
|
<key>UIDeviceFamily</key>
|
||||||
|
<array>
|
||||||
|
<integer>1</integer>
|
||||||
|
<integer>2</integer>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using UIKit;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile;
|
||||||
|
|
||||||
|
public static class Program
|
||||||
|
{
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
UIApplication.Main(args, null, typeof(AppDelegate));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# DorkNet Admin Mobile
|
||||||
|
|
||||||
|
Native Android/iOS admin app for DorkNet, built with .NET MAUI.
|
||||||
|
|
||||||
|
In the microservices deployment, point the app at the public admin host
|
||||||
|
for the stack, usually `https://admin.<domain>`. Cloudflare and
|
||||||
|
`DorkNet.Gateway` keep the public URL stable. Requests made through the
|
||||||
|
admin host are handled by the `web` service's same-origin admin route;
|
||||||
|
the `/api/admin/*` path family is also exposed by the moderation slice
|
||||||
|
for non-admin-host routing.
|
||||||
|
|
||||||
|
The browser admin page, route map, and troubleshooting flow are
|
||||||
|
documented in [`../docs/admin.md`](../docs/admin.md).
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The app stores sensitive values in platform secure storage:
|
||||||
|
|
||||||
|
- DorkNet admin JWT from `POST /api/admin/v1/login`
|
||||||
|
- Cloudflare Access service-token client id
|
||||||
|
- Cloudflare Access service-token client secret
|
||||||
|
- Optional Cloudflare Access JWT assertion
|
||||||
|
|
||||||
|
Every API request includes:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <dorknet-admin-jwt>
|
||||||
|
CF-Access-Client-Id: <service-token-client-id>
|
||||||
|
CF-Access-Client-Secret: <service-token-client-secret>
|
||||||
|
CF-Access-Jwt-Assertion: <optional-jwt>
|
||||||
|
```
|
||||||
|
|
||||||
|
The Cloudflare headers are optional, but required when the admin host is behind Cloudflare Access.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Install the .NET MAUI workloads, then build per platform:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet workload install maui
|
||||||
|
dotnet build DorkNet.AdminMobile\DorkNet.AdminMobile.csproj -f net9.0-android36.0
|
||||||
|
dotnet build DorkNet.AdminMobile\DorkNet.AdminMobile.csproj -f net9.0-ios26.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The project targets Android 16 / API 36 and iOS 26 SDK. iOS device/archive builds still require Xcode 26 on macOS.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
|
||||||
|
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
|
||||||
|
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
|
||||||
|
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
|
||||||
|
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||||
|
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
<clipPath id="_clip2">
|
||||||
|
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#_clip2)">
|
||||||
|
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||||
|
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||||
|
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
|
||||||
|
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||||
|
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
|
||||||
|
<clipPath id="_clip4">
|
||||||
|
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#_clip4)">
|
||||||
|
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||||
|
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||||
|
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
|
||||||
|
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
|
||||||
|
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||||
|
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||||
|
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||||
|
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
|
||||||
|
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
|
||||||
|
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
|
||||||
|
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||||
|
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
|
||||||
|
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
|
||||||
|
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
|
||||||
|
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
|
||||||
|
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||||
|
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
<clipPath id="_clip2">
|
||||||
|
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#_clip2)">
|
||||||
|
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||||
|
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||||
|
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
|
||||||
|
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||||
|
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
|
||||||
|
<clipPath id="_clip4">
|
||||||
|
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#_clip4)">
|
||||||
|
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||||
|
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||||
|
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
|
||||||
|
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
|
||||||
|
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||||
|
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||||
|
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||||
|
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
|
||||||
|
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
|
||||||
|
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
|
||||||
|
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||||
|
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
|
||||||
|
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
|
||||||
|
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
|
||||||
|
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
|
||||||
|
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||||
|
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
<clipPath id="_clip2">
|
||||||
|
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#_clip2)">
|
||||||
|
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||||
|
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||||
|
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
|
||||||
|
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||||
|
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
|
||||||
|
<clipPath id="_clip4">
|
||||||
|
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#_clip4)">
|
||||||
|
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||||
|
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||||
|
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
|
||||||
|
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
|
||||||
|
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||||
|
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||||
|
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||||
|
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
|
||||||
|
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
|
||||||
|
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
|
||||||
|
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||||
|
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
|
||||||
|
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
|
||||||
|
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
|
||||||
|
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
|
||||||
|
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||||
|
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
<clipPath id="_clip2">
|
||||||
|
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#_clip2)">
|
||||||
|
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||||
|
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
|
||||||
|
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
|
||||||
|
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
|
||||||
|
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
|
||||||
|
<clipPath id="_clip4">
|
||||||
|
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#_clip4)">
|
||||||
|
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||||
|
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
|
||||||
|
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
|
||||||
|
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
|
||||||
|
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||||
|
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||||
|
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
|
||||||
|
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
|
||||||
|
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
|
||||||
|
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
|
||||||
|
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||||
|
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,147 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.AdminMobile.Models;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile.Services;
|
||||||
|
|
||||||
|
public sealed class AdminApiClient(SecureAdminSettings settings)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Json = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly HttpClient http = new()
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(20),
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<AdminLoginResponse> LoginAsync(string username, string password)
|
||||||
|
{
|
||||||
|
var result = await SendAsync<AdminLoginResponse>(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"login",
|
||||||
|
new { Username = username, Password = password },
|
||||||
|
includeJwt: false);
|
||||||
|
|
||||||
|
await settings.SaveSessionAsync(result.AccessToken, result.RefreshToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AdminStats> GetStatsAsync() =>
|
||||||
|
SendAsync<AdminStats>(HttpMethod.Get, "stats");
|
||||||
|
|
||||||
|
public Task<List<PlayerSummary>> GetPlayersAsync(string? query = null)
|
||||||
|
{
|
||||||
|
var suffix = string.IsNullOrWhiteSpace(query)
|
||||||
|
? "players?take=100"
|
||||||
|
: $"players?take=100&query={Uri.EscapeDataString(query)}";
|
||||||
|
return SendAsync<List<PlayerSummary>>(HttpMethod.Get, suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<RoomSummary>> GetRoomsAsync() =>
|
||||||
|
SendAsync<List<RoomSummary>>(HttpMethod.Get, "rooms?take=100");
|
||||||
|
|
||||||
|
public Task<List<InstanceSummary>> GetInstancesAsync() =>
|
||||||
|
SendAsync<List<InstanceSummary>>(HttpMethod.Get, "instances");
|
||||||
|
|
||||||
|
public Task<ServerSettingsDto> GetSettingsAsync() =>
|
||||||
|
SendAsync<ServerSettingsDto>(HttpMethod.Get, "settings");
|
||||||
|
|
||||||
|
public Task<ServerSettingsDto> SetSignupsDisabledAsync(bool disabled) =>
|
||||||
|
SendAsync<ServerSettingsDto>(HttpMethod.Post, "settings/signups", new { Disabled = disabled });
|
||||||
|
|
||||||
|
public Task<ServerSettingsDto> SetGlobalFriendsAsync(bool enabled) =>
|
||||||
|
SendAsync<ServerSettingsDto>(HttpMethod.Post, "settings/global-friends", new { Enabled = enabled });
|
||||||
|
|
||||||
|
public Task KickPlayerAsync(long playerId, string reason) =>
|
||||||
|
SendAsync<object>(HttpMethod.Post, $"players/{playerId}/kick", new { Reason = reason });
|
||||||
|
|
||||||
|
public Task ResetAvatarAsync(long playerId) =>
|
||||||
|
SendAsync<object>(HttpMethod.Post, $"players/{playerId}/avatar/reset");
|
||||||
|
|
||||||
|
public Task DeletePlayerAsync(long playerId, string username, string phrase, string? reason) =>
|
||||||
|
SendAsync<object>(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"players/{playerId}",
|
||||||
|
new { ConfirmUsername = username, ConfirmPhrase = phrase, Reason = reason });
|
||||||
|
|
||||||
|
private async Task<T> SendAsync<T>(
|
||||||
|
HttpMethod method,
|
||||||
|
string path,
|
||||||
|
object? body = null,
|
||||||
|
bool includeJwt = true)
|
||||||
|
{
|
||||||
|
var connection = await settings.LoadConnectionAsync();
|
||||||
|
var url = $"{connection.BaseUrl.TrimEnd('/')}/api/admin/v1/{path.TrimStart('/')}";
|
||||||
|
using var request = new HttpRequestMessage(method, url);
|
||||||
|
AddCloudflareAccessHeaders(request, connection);
|
||||||
|
|
||||||
|
if (includeJwt)
|
||||||
|
{
|
||||||
|
var jwt = await settings.GetJwtAsync();
|
||||||
|
if (!string.IsNullOrWhiteSpace(jwt))
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body is not null)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(body, Json);
|
||||||
|
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await http.SendAsync(request);
|
||||||
|
var text = await response.Content.ReadAsStringAsync();
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new AdminApiException((int)response.StatusCode, ExtractError(text, response.ReasonPhrase));
|
||||||
|
|
||||||
|
if (typeof(T) == typeof(object) || string.IsNullOrWhiteSpace(text))
|
||||||
|
return default!;
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<T>(text, Json)
|
||||||
|
?? throw new AdminApiException(0, "Server returned an empty response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddCloudflareAccessHeaders(HttpRequestMessage request, AdminConnectionSettings connection)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(connection.CloudflareAccessClientId) &&
|
||||||
|
!string.IsNullOrWhiteSpace(connection.CloudflareAccessClientSecret))
|
||||||
|
{
|
||||||
|
request.Headers.SetOrReplace("CF-Access-Client-Id", connection.CloudflareAccessClientId);
|
||||||
|
request.Headers.SetOrReplace("CF-Access-Client-Secret", connection.CloudflareAccessClientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(connection.CloudflareAccessJwt))
|
||||||
|
request.Headers.SetOrReplace("CF-Access-Jwt-Assertion", connection.CloudflareAccessJwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractError(string text, string? fallback)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return fallback ?? "Request failed.";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(text);
|
||||||
|
if (doc.RootElement.TryGetProperty("error", out var error))
|
||||||
|
return error.GetString() ?? fallback ?? "Request failed.";
|
||||||
|
if (doc.RootElement.TryGetProperty("message", out var message))
|
||||||
|
return message.GetString() ?? fallback ?? "Request failed.";
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return text.Length > 300 ? text[..300] : text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminApiException(int statusCode, string message) : Exception(message)
|
||||||
|
{
|
||||||
|
public int StatusCode { get; } = statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class HeaderExtensions
|
||||||
|
{
|
||||||
|
public static void SetOrReplace(this HttpRequestHeaders headers, string name, string value)
|
||||||
|
{
|
||||||
|
headers.Remove(name);
|
||||||
|
headers.Add(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Microsoft.Maui.Storage;
|
||||||
|
|
||||||
|
namespace DorkNet.AdminMobile.Services;
|
||||||
|
|
||||||
|
public sealed record AdminConnectionSettings(
|
||||||
|
string BaseUrl,
|
||||||
|
string CloudflareAccessClientId,
|
||||||
|
string CloudflareAccessClientSecret,
|
||||||
|
string CloudflareAccessJwt);
|
||||||
|
|
||||||
|
public sealed class SecureAdminSettings
|
||||||
|
{
|
||||||
|
private const string BaseUrlKey = "admin.baseUrl";
|
||||||
|
private const string JwtKey = "admin.jwt";
|
||||||
|
private const string RefreshKey = "admin.refresh";
|
||||||
|
private const string CfClientIdKey = "cf.access.clientId";
|
||||||
|
private const string CfClientSecretKey = "cf.access.clientSecret";
|
||||||
|
private const string CfJwtKey = "cf.access.jwt";
|
||||||
|
|
||||||
|
public async Task<AdminConnectionSettings> LoadConnectionAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var baseUrl = await SecureStorage.GetAsync(BaseUrlKey) ?? "https://admin.rec.net";
|
||||||
|
var cfId = await SecureStorage.GetAsync(CfClientIdKey) ?? string.Empty;
|
||||||
|
var cfSecret = await SecureStorage.GetAsync(CfClientSecretKey) ?? string.Empty;
|
||||||
|
var cfJwt = await SecureStorage.GetAsync(CfJwtKey) ?? string.Empty;
|
||||||
|
return new AdminConnectionSettings(baseUrl, cfId, cfSecret, cfJwt);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
SecureStorage.Remove(BaseUrlKey);
|
||||||
|
SecureStorage.Remove(CfClientIdKey);
|
||||||
|
SecureStorage.Remove(CfClientSecretKey);
|
||||||
|
SecureStorage.Remove(CfJwtKey);
|
||||||
|
return new AdminConnectionSettings("https://admin.rec.net", string.Empty, string.Empty, string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveConnectionAsync(AdminConnectionSettings settings)
|
||||||
|
{
|
||||||
|
await SecureStorage.SetAsync(BaseUrlKey, settings.BaseUrl.TrimEnd('/'));
|
||||||
|
await SecureStorage.SetAsync(CfClientIdKey, settings.CloudflareAccessClientId.Trim());
|
||||||
|
await SecureStorage.SetAsync(CfClientSecretKey, settings.CloudflareAccessClientSecret.Trim());
|
||||||
|
await SecureStorage.SetAsync(CfJwtKey, settings.CloudflareAccessJwt.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string?> GetJwtAsync() => SecureStorage.GetAsync(JwtKey);
|
||||||
|
|
||||||
|
public async Task SaveSessionAsync(string accessToken, string refreshToken)
|
||||||
|
{
|
||||||
|
await SecureStorage.SetAsync(JwtKey, accessToken);
|
||||||
|
await SecureStorage.SetAsync(RefreshKey, refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearSession()
|
||||||
|
{
|
||||||
|
SecureStorage.Remove(JwtKey);
|
||||||
|
SecureStorage.Remove(RefreshKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework Condition="'$(TargetFramework)' == '' and '$(TargetFrameworks)' == ''">net6.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>DorkNet.ClientMod</RootNamespace>
|
||||||
|
<AssemblyName>DorkNet.ClientMod</AssemblyName>
|
||||||
|
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' == ''">false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' != ''">true</AppendTargetFrameworkToOutputPath>
|
||||||
|
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||||
|
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
MelonLoader 0.6.x build of the DorkNet client patches. Resolves
|
||||||
|
every game-side type at runtime via AccessTools.TypeByName, so
|
||||||
|
Rec Room's IL2CPP-derived interop assemblies (Assembly-CSharp,
|
||||||
|
Photon3Unity3D, UnityEngine.*) are NOT needed at compile time —
|
||||||
|
they would be derivative works of the game anyway, so we never
|
||||||
|
reference them. Only MelonLoader's own DLLs are needed.
|
||||||
|
|
||||||
|
By default the project downloads the pinned desktop MelonLoader release
|
||||||
|
into DorkNet.ClientMod/build/ and references its net6 DLLs. For Quest /
|
||||||
|
LemonLoader builds, pass:
|
||||||
|
|
||||||
|
-p:TargetFrameworks=net8.0
|
||||||
|
-p:MelonLoaderDir=<path-to-LemonLoader-melon_data/MelonLoader>
|
||||||
|
|
||||||
|
The reference folder follows the target framework unless explicitly
|
||||||
|
overridden with -p:MelonLoaderReferenceTfm=...
|
||||||
|
-->
|
||||||
|
<PropertyGroup>
|
||||||
|
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||||
|
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot>
|
||||||
|
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||||
|
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||||
|
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
|
||||||
|
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Target Name="EnsureMelonLoaderReferences"
|
||||||
|
BeforeTargets="ResolveAssemblyReferences"
|
||||||
|
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')">
|
||||||
|
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||||
|
<DownloadFile
|
||||||
|
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||||
|
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||||
|
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
|
||||||
|
SkipUnchangedFiles="true" />
|
||||||
|
<Unzip SourceFiles="$(MelonLoaderZip)"
|
||||||
|
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||||
|
OverwriteReadOnlyFiles="true" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="MelonLoader">
|
||||||
|
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="0Harmony">
|
||||||
|
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Il2CppInterop.Runtime">
|
||||||
|
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Il2CppInterop.Common">
|
||||||
|
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,4457 @@
|
|||||||
|
// DorkNet client mod (MelonLoader IL2CPP). Applies the minimum set of
|
||||||
|
// Harmony patches needed for the 2020 watch to connect to a DorkNet
|
||||||
|
// server:
|
||||||
|
//
|
||||||
|
// 1. URI rewrite — `.rec.net` → user-configured host
|
||||||
|
// 2. TLS trust bypass — make BouncyCastle never reject server certs
|
||||||
|
//
|
||||||
|
// Config lives in <MelonLoader>\UserData\dorknet-clientmod.json. The
|
||||||
|
// install-melon.ps1 wrapper writes that file on install; you can also
|
||||||
|
// edit it by hand and relaunch the client.
|
||||||
|
//
|
||||||
|
// Why string-based AccessTools lookups instead of typeof(Il2Cpp...):
|
||||||
|
// MelonLoader's Il2CppInterop adds an `Il2Cpp` prefix to game-side
|
||||||
|
// namespaces (so `RecNet.Core` becomes `Il2CppRecNet.Core`) but the
|
||||||
|
// exact policy for globally-namespaced types like `PhotonNetwork` and
|
||||||
|
// for nested types varies across MelonLoader minor versions. Looking
|
||||||
|
// types up by their original game-side name via AccessTools.TypeByName
|
||||||
|
// (which searches every loaded assembly) keeps this file working
|
||||||
|
// regardless of the prefix scheme, and avoids needing a verified
|
||||||
|
// Il2CppAssemblies directory just to write the source.
|
||||||
|
|
||||||
|
using HarmonyLib;
|
||||||
|
using Il2CppInterop.Runtime;
|
||||||
|
using Il2CppInterop.Runtime.InteropTypes;
|
||||||
|
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
||||||
|
using MelonLoader;
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
|
||||||
|
[assembly: MelonGame(null, null)]
|
||||||
|
|
||||||
|
namespace DorkNet.ClientMod;
|
||||||
|
|
||||||
|
public class Mod : MelonMod
|
||||||
|
{
|
||||||
|
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
|
||||||
|
internal static Mod? Instance;
|
||||||
|
internal static string? DiagnosticsLogPath;
|
||||||
|
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||||
|
private readonly HashSet<string> _diagnosticPatchLabels = new();
|
||||||
|
private bool _networkPatchesRegistered;
|
||||||
|
private bool _httpDiagnosticsRegistered;
|
||||||
|
private bool _joinTracePatchesRegistered;
|
||||||
|
private bool _quitTraceCoreRegistered;
|
||||||
|
private bool _quitTraceGameComplete;
|
||||||
|
private int _quitTraceRetryFrame;
|
||||||
|
private bool _antiTamperCallbackPatchesComplete;
|
||||||
|
private int _antiTamperCallbackRetryFrame;
|
||||||
|
private bool _toxModPatchesComplete;
|
||||||
|
private int _toxModRetryFrame;
|
||||||
|
private bool _diagnosticCoreRegistered;
|
||||||
|
private bool _diagnosticGameComplete;
|
||||||
|
private bool _firstUpdateLogged;
|
||||||
|
private bool _processExceptionHandlersRegistered;
|
||||||
|
|
||||||
|
// Config backed by a plain JSON file under MelonLoader/UserData.
|
||||||
|
// The defaults below match the recommended install, so dropping this
|
||||||
|
// mod in without writing a config file still gives working behaviour.
|
||||||
|
public static class Cfg
|
||||||
|
{
|
||||||
|
public static string ServerHost = "localhost";
|
||||||
|
public static bool EnableTlsTrustBypass = true;
|
||||||
|
// Log-only trace for the stuck first-dorm "Change Username?"
|
||||||
|
// dialog. When true, RegistrationPatches hooks the dorm
|
||||||
|
// registration-prompt flow + the confirm-dialog buttons and writes
|
||||||
|
// what fires to MelonLoader/UserData/dorknet-diagnostics.log, so a
|
||||||
|
// brand-new account that reproduces the freeze tells us exactly
|
||||||
|
// where it breaks (does the click reach Button_Affirmative? does
|
||||||
|
// the prompt even run?). No behaviour change — purely diagnostic.
|
||||||
|
// See RegistrationPatches and the registration site in
|
||||||
|
// OnLateInitializeMelon.
|
||||||
|
public static bool TraceRegistrationDialog = true;
|
||||||
|
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
|
||||||
|
// (normally dev-gated) and silence CheatManager's tamper detectors
|
||||||
|
// so movement/time commands (SetTimeScale, Fly, Teleport, …) don't
|
||||||
|
// drop you to the dorm. Default OFF — purely opt-in. The toggle key
|
||||||
|
// is a UnityEngine.KeyCode name; BackQuote is the `~` key.
|
||||||
|
public static bool EnableDebugConsole = false;
|
||||||
|
public static string DebugConsoleToggleKey = "BackQuote";
|
||||||
|
public static string[] DevCommands = Array.Empty<string>();
|
||||||
|
public static bool DiagnoseDevMenu = false;
|
||||||
|
public static bool EnableBuiltInDevDebugTools = false;
|
||||||
|
// Master switch for the verbose, non-essential DIAGNOSTIC patches
|
||||||
|
// (HTTP header tracing, join/quit traces, studio traces, the deep
|
||||||
|
// RegisterDiagnostics path). OFF by default so the shipping client
|
||||||
|
// mod installs only the patches needed to connect + survive
|
||||||
|
// anti-cheat — the safest possible footprint. Turned ON automatically
|
||||||
|
// when DorkNet.DebugMod.dll is present in the Mods folder (drop it in
|
||||||
|
// to debug, remove it to ship), or by "EnableDiagnostics": true in
|
||||||
|
// the config. Core connectivity/anti-cheat patches are NEVER gated.
|
||||||
|
public static bool EnableDiagnostics = false;
|
||||||
|
|
||||||
|
// ── Desktop Screen Sharing gadget FPS ──
|
||||||
|
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts
|
||||||
|
// the shared desktop image. Its cadence is a baked [SerializeField]
|
||||||
|
// float screenShareImageRefreshFrequency — the broadcast tick gates
|
||||||
|
// on (Time.time - lastSend >= 1f / frequency), verified at
|
||||||
|
// DesktopScreenSharingDisplay.txt:697-703, so the field IS the FPS
|
||||||
|
// and its editor-only [Range] is NOT enforced at runtime. Set a
|
||||||
|
// target here to override it (default prefab value is low, ~5).
|
||||||
|
// 0 = leave the prefab value alone (patch not even registered).
|
||||||
|
public static float DesktopScreenShareFps = 0f;
|
||||||
|
// The captured frame ships via the gadget's IPunObservable
|
||||||
|
// OnPhotonSerializeView, which only fires at
|
||||||
|
// PhotonNetwork.SerializationRate (default 10 Hz) — so 30 fps capture
|
||||||
|
// still delivers ~10 unless we raise it. This lifts SendRate +
|
||||||
|
// SerializationRate to >= the FPS target. GLOBAL: affects every
|
||||||
|
// PhotonView's sync traffic, so turn off if it lags a busy room.
|
||||||
|
public static bool DesktopScreenShareRaisePhotonRate = true;
|
||||||
|
// Optional per-frame size knobs (0 = leave the prefab value). Higher
|
||||||
|
// FPS needs smaller frames to fit the Photon serialization budget;
|
||||||
|
// lower these if 30 fps saturates bandwidth (chunked frames stall).
|
||||||
|
public static int DesktopScreenShareResolution = 0; // horizontal px
|
||||||
|
public static int DesktopScreenShareQuality = 0; // JPEG quality
|
||||||
|
|
||||||
|
// Raise RRO quest party size past the baked 4. The cap lives in each
|
||||||
|
// quest GameConfigurationAsset.TeamConfigurations[].MaxTeamSize
|
||||||
|
// ScriptableObject (baked in the client bundle; no server lever). The
|
||||||
|
// quest scoreboard + party HUD build rows dynamically, so they
|
||||||
|
// auto-scale once the team cap is raised. 0 = off (leave at 4).
|
||||||
|
// Only single-team, quest-named configs are touched, so PvP team
|
||||||
|
// sizes (paintball/laser tag) are never changed. See QuestTeamSize.
|
||||||
|
public static int QuestMaxTeamSize = 0;
|
||||||
|
// Only these RRO quest GameConfigurationAssets get their team size
|
||||||
|
// bumped (matched loosely: case/space-insensitive, filler words like
|
||||||
|
// "the/of/for/quest" ignored, so "IsleOfLostSkulls" matches the
|
||||||
|
// in-game "Isle of the Lost Skulls"). Empty = bump nothing. The
|
||||||
|
// generic "Quest" config is intentionally NOT in the default list.
|
||||||
|
public static string[] QuestMaxTeamSizeRooms =
|
||||||
|
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnInitializeMelon()
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
Log.Msg("=== DorkNet ClientMod loading ===");
|
||||||
|
LoadConfig();
|
||||||
|
RegisterNetworkPatches();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnLateInitializeMelon()
|
||||||
|
{
|
||||||
|
Log.Msg("=== Registering client patches ===");
|
||||||
|
RegisterNetworkPatches();
|
||||||
|
TryPatchByName("RecRoom.AntiCheat.EACManager",
|
||||||
|
"GenerateChallengeResponse",
|
||||||
|
args: new[] { typeof(string) },
|
||||||
|
prefix: nameof(AntiCheatPatches.GenerateChallengeResponse_Prefix));
|
||||||
|
TryPatchByName("LBNJFPOLCDL", "JHLKGBGJKKK",
|
||||||
|
args: new[] { typeof(string), typeof(string), typeof(string) },
|
||||||
|
prefix: nameof(AuthPatches.CaptureTokenArgs_Prefix));
|
||||||
|
// TLS cert-validation bypass — gated behind Cfg.EnableTlsTrustBypass.
|
||||||
|
// Even when the prefix's return value is "let original run", the
|
||||||
|
// detour itself still has to be JIT-installed when BouncyCastle
|
||||||
|
// does its first handshake, and MonoMod's CompileMethodHook
|
||||||
|
// fatal-CLRs (0x80131506) installing trampolines into these
|
||||||
|
// third-party interface implementations. Only register the
|
||||||
|
// detours if the bypass is actually wanted; on a setup with
|
||||||
|
// valid certs (the default) skip the patch entirely.
|
||||||
|
if (Cfg.EnableTlsTrustBypass)
|
||||||
|
{
|
||||||
|
TryPatchByName("BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication",
|
||||||
|
"NotifyServerCertificate",
|
||||||
|
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||||
|
TryPatchByName("Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication",
|
||||||
|
"NotifyServerCertificate",
|
||||||
|
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||||
|
}
|
||||||
|
// Anti-tamper / ToxMod / file-hash-checker are anti-cheat BYPASS
|
||||||
|
// patches — core, always on, or the modded client gets kicked.
|
||||||
|
// Quit-trace is pure diagnostics, so it's gated.
|
||||||
|
if (Cfg.EnableDiagnostics)
|
||||||
|
RegisterQuitTracePatches(logMisses: true);
|
||||||
|
RegisterAntiTamperCallbackPatches(logMisses: true);
|
||||||
|
RegisterToxModPatches(logMisses: true);
|
||||||
|
PatchFileHashCheckerCallback();
|
||||||
|
// Desktop Screen Sharing FPS override. Only register when a target
|
||||||
|
// is set; the postfix on the gadget's Awake rewrites the baked
|
||||||
|
// refresh-frequency field (and, if enabled, raises the Photon
|
||||||
|
// serialization rate so the higher capture rate actually transmits).
|
||||||
|
if (Cfg.DesktopScreenShareFps > 0f)
|
||||||
|
{
|
||||||
|
TryPatchByName("RecRoom.Tools.Productivity.DesktopScreenSharingDisplay",
|
||||||
|
"Awake", args: Type.EmptyTypes,
|
||||||
|
postfix: nameof(ScreenSharePatches.DisplayAwake_Postfix));
|
||||||
|
Log.Msg($"[screenshare] override armed: target {Cfg.DesktopScreenShareFps} fps " +
|
||||||
|
$"(raisePhotonRate={Cfg.DesktopScreenShareRaisePhotonRate})");
|
||||||
|
}
|
||||||
|
|
||||||
|
// RRO quest party-size bump. Prefix the runtime config-apply path so
|
||||||
|
// we can read the config's Name (scope to the allowlisted quests) and
|
||||||
|
// bump GameConfigurationData.TeamConfigurations[].TeamSize before the
|
||||||
|
// team manager / pre-game roster reads it. Only when a target is set.
|
||||||
|
if (Cfg.QuestMaxTeamSize > 0)
|
||||||
|
{
|
||||||
|
// The runtime team cap is built from the baked GameConfigurationAsset
|
||||||
|
// (a readable ScriptableObject) by GameConfigurationAsset.GDECPANDBHJ()
|
||||||
|
// — it reads asset.TeamConfigurations[i].MaxTeamSize (a typed int
|
||||||
|
// struct-array) into the protobuf TeamSize. Prefix that builder and
|
||||||
|
// bump the source array IN PLACE before it converts, scoped by
|
||||||
|
// asset.Name. This avoids the obfuscated protobuf entirely (its
|
||||||
|
// generic RepeatedField getter isn't emitted by Il2CppInterop).
|
||||||
|
// Host-authoritative: the quest host must run the mod. See QuestTeamSize.
|
||||||
|
TryPatchByName("RecRoom.Core.GameManagement.GameConfigurationAsset", "GDECPANDBHJ",
|
||||||
|
prefix: nameof(QuestTeamSize.Build_Prefix));
|
||||||
|
QuestTeamSize.TryPatchSpawnIndexNormalizers(HarmonyInstance);
|
||||||
|
TryPatchByName("GameSpawnManager", "FKEICBPCMPJ",
|
||||||
|
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
||||||
|
Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Cfg.EnableDiagnostics)
|
||||||
|
RegisterStudioTracePatches();
|
||||||
|
|
||||||
|
if (Cfg.EnableBuiltInDevDebugTools)
|
||||||
|
RegisterBuiltInDevDebugTools();
|
||||||
|
|
||||||
|
Log.Msg("=== Client patches registered ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterBuiltInDevDebugTools()
|
||||||
|
{
|
||||||
|
Log.Msg("=== Registering built-in Dev Debug Tools hooks ===");
|
||||||
|
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDeveloperImpl",
|
||||||
|
"DGFIJKPCBLK", args: Type.EmptyTypes,
|
||||||
|
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
|
||||||
|
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDeveloperAndNotDebugBuildImpl",
|
||||||
|
"DGFIJKPCBLK", args: Type.EmptyTypes,
|
||||||
|
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
|
||||||
|
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDebugBuildImpl",
|
||||||
|
"DGFIJKPCBLK", args: Type.EmptyTypes,
|
||||||
|
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
|
||||||
|
TryPatchByName("RRUI.Data.DevDebugBuildInformationModel",
|
||||||
|
"get_BuildInfoFormatted", args: Type.EmptyTypes,
|
||||||
|
postfix: nameof(BuiltInDevDebugToolsPatches.BuildInfoFormatted_Postfix));
|
||||||
|
Log.Msg("=== Built-in Dev Debug Tools hooks registered ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Room-join + watch-button trace ────────────────────────────────
|
||||||
|
// Traces the full "press the Dorm Room button → join the room" chain
|
||||||
|
// so a stuck join (e.g. the watch's Dorm Room button doing nothing)
|
||||||
|
// is debuggable from the log alone. The chain, verified against the
|
||||||
|
// 2020.12.18 IL2CPP dump:
|
||||||
|
//
|
||||||
|
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() (button)
|
||||||
|
// → SessionManager.RunJoinRoom("DormRoom", …) (join entry)
|
||||||
|
// → OJMCBOKJFOF.NHBPIIGDAJP(name) → GET /goto/room/{name}
|
||||||
|
// rejects with "No such room" (OJMCBOKJFOF.txt:958-1019)
|
||||||
|
// → .Then(SessionManager.RunJoinRoom(RoomInstance, …))
|
||||||
|
// → on reject: SessionManager+<>c.<RunJoinRoom>b__165_1(error)
|
||||||
|
// → NotificationManager.Play("An error occured", "…happyfox…")
|
||||||
|
// (the "contact recroom.happyfox.com" toast)
|
||||||
|
//
|
||||||
|
// We can't express the obfuscated RunJoinRoom param types as a Type[]
|
||||||
|
// for AccessTools, so every RunJoinRoom overload + every /goto overload
|
||||||
|
// is patched by iterating declared methods by name, and the prefixes
|
||||||
|
// read arguments generically via Harmony's `object[] __args`.
|
||||||
|
private void RegisterJoinTracePatches()
|
||||||
|
{
|
||||||
|
if (_joinTracePatchesRegistered) return;
|
||||||
|
Log.Msg("=== Registering room-join trace ===");
|
||||||
|
|
||||||
|
// Watch "Dorm Room" button. Void, no params, unique name —
|
||||||
|
// HomeScreenFlow.txt:4791, calls RunJoinRoom("DormRoom",…) at :4848.
|
||||||
|
TryPatchByName("AGUI.StackedUI.HomeScreenFlow", "Button_DormRoom",
|
||||||
|
args: Type.EmptyTypes,
|
||||||
|
prefix: nameof(JoinPatches.ButtonDormRoom_Prefix));
|
||||||
|
|
||||||
|
// Every SessionManager.RunJoinRoom overload (string-name entry +
|
||||||
|
// the RoomInstance inner legs). Logs the method + its arguments.
|
||||||
|
PatchAllOverloads("SessionManager", "RunJoinRoom",
|
||||||
|
nameof(JoinPatches.RunJoinRoom_Prefix));
|
||||||
|
|
||||||
|
// The /goto/room/{name|id} promise builders — both the string
|
||||||
|
// (room-name) and Int64 (room-id) overloads of NHBPIIGDAJP.
|
||||||
|
PatchAllOverloads("OJMCBOKJFOF", "NHBPIIGDAJP",
|
||||||
|
nameof(JoinPatches.Goto_Prefix));
|
||||||
|
|
||||||
|
// The RunJoinRoom promise-chain callbacks compiled onto the
|
||||||
|
// SessionManager <>c display class — most importantly the reject
|
||||||
|
// handler b__165_1(string error) that raises the happyfox toast.
|
||||||
|
// Match by the (de-mangled) substring "RunJoinRoom" + the lambda
|
||||||
|
// marker "b__" so we hit the callbacks but not the RunJoinRoom
|
||||||
|
// state-machine MoveNext.
|
||||||
|
PatchNestedLambdas("SessionManager", "RunJoinRoom",
|
||||||
|
nameof(JoinPatches.JoinCallback_Prefix));
|
||||||
|
|
||||||
|
// Additionally capture the REJECT REASON string on b__165_1 (the
|
||||||
|
// happyfox-toast handler). Matches only that one lambda; it ends
|
||||||
|
// up with two prefixes (flow + error string), which is harmless.
|
||||||
|
PatchNestedLambdas("SessionManager", "RunJoinRoom_b__165_1",
|
||||||
|
nameof(JoinPatches.JoinError_Prefix));
|
||||||
|
|
||||||
|
_joinTracePatchesRegistered = true;
|
||||||
|
Log.Msg("=== Room-join trace registered ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch every declared method on `typeName` named `methodName`,
|
||||||
|
// regardless of signature, with a single prefix. Used for obfuscated
|
||||||
|
// overload sets whose param types we can't name in a Type[].
|
||||||
|
private bool PatchAllOverloads(string typeName, string methodName, string prefix, bool logMiss = true)
|
||||||
|
{
|
||||||
|
var type = ResolveType(typeName);
|
||||||
|
if (type is null)
|
||||||
|
{
|
||||||
|
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): type not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var prefixMethod = GetPatchMethod(prefix);
|
||||||
|
var patched = 0;
|
||||||
|
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||||
|
{
|
||||||
|
if (method.Name != methodName) continue;
|
||||||
|
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-fail] {typeName}.{methodName} overload: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patched == 0)
|
||||||
|
{
|
||||||
|
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): no overloads matched");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Log.Msg($"[patch-ok] {typeName}.{methodName}(*) x{patched}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch compiler-generated lambda methods (names containing "b__")
|
||||||
|
// on the nested display classes of `typeName` whose (de-mangled) name
|
||||||
|
// also contains `nameContains`. Il2CppInterop mangles the angle
|
||||||
|
// brackets of `<RunJoinRoom>b__165_1` to `_003C…_003E` but leaves the
|
||||||
|
// method name itself intact, so a plain substring match still works.
|
||||||
|
private bool PatchNestedLambdas(string typeName, string nameContains, string prefix, bool logMiss = true)
|
||||||
|
{
|
||||||
|
var type = ResolveType(typeName);
|
||||||
|
if (type is null)
|
||||||
|
{
|
||||||
|
if (logMiss) Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): type not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var prefixMethod = GetPatchMethod(prefix);
|
||||||
|
var patched = 0;
|
||||||
|
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
|
||||||
|
{
|
||||||
|
foreach (var method in AccessTools.GetDeclaredMethods(nested))
|
||||||
|
{
|
||||||
|
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||||
|
if (method.Name.IndexOf("b__", StringComparison.Ordinal) < 0) continue;
|
||||||
|
if (method.Name.IndexOf(nameContains, StringComparison.Ordinal) < 0) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||||
|
patched++;
|
||||||
|
Log.Msg($"[patch-ok] {typeName}+{nested.Name}.{method.Name}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-fail] {typeName}+{nested.Name}.{method.Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patched == 0 && logMiss)
|
||||||
|
Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): none matched");
|
||||||
|
return patched > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool PatchFileHashCheckerCallback()
|
||||||
|
{
|
||||||
|
var prefixMethod = GetPatchMethod(nameof(FileHashCheckerPatches.InitializeCallback_Prefix));
|
||||||
|
var patched = 0;
|
||||||
|
|
||||||
|
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
|
{
|
||||||
|
foreach (var type in GetLoadableTypes(asm))
|
||||||
|
{
|
||||||
|
if (!MatchesFileHashCheckerCallbackType(type)) continue;
|
||||||
|
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||||
|
{
|
||||||
|
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||||
|
if (!MatchesFileHashCheckerCallbackMethod(method)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||||
|
patched++;
|
||||||
|
Log.Msg($"[patch-ok] file hash checker callback {type.FullName}.{method.Name}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-fail] file hash checker callback {type.FullName}.{method.Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patched == 0)
|
||||||
|
{
|
||||||
|
Log.Warning("[patch-miss] file hash checker callback");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterAntiTamperCallbackPatches(bool logMisses)
|
||||||
|
{
|
||||||
|
var complete = true;
|
||||||
|
complete &= PatchCallbackMethod(
|
||||||
|
"hile warning callback",
|
||||||
|
type => TypeNameContains(type, "FPIBGPIAOBI") && TypeNameContains(type, "KHIIHOIFDMP"),
|
||||||
|
method => MethodNameContains(method, "CreateHileWarning") && MethodNameContains(method, "b__0"),
|
||||||
|
nameof(AntiTamperPatches.CreateHileWarningCallback_Prefix),
|
||||||
|
logMisses);
|
||||||
|
complete &= PatchCallbackMethod(
|
||||||
|
"unknown DLL callback",
|
||||||
|
type => TypeNameContains(type, "CheatManager") && TypeNameContains(type, "PNHDGEKCJCH"),
|
||||||
|
method => MethodNameContains(method, "OnUnknownDLLDetected") && MethodNameContains(method, "b__0"),
|
||||||
|
nameof(AntiTamperPatches.UnknownDllDetectedCallback_Prefix),
|
||||||
|
logMisses);
|
||||||
|
_antiTamperCallbackPatchesComplete = complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterToxModPatches(bool logMisses)
|
||||||
|
{
|
||||||
|
var complete = true;
|
||||||
|
complete &= TryPatchDiagnosticByName("ToxMod.ToxModVoiceComponent",
|
||||||
|
"CanInitializeToxMod",
|
||||||
|
args: Type.EmptyTypes,
|
||||||
|
prefix: nameof(ToxModPatches.CanInitializeToxMod_Prefix),
|
||||||
|
logMiss: logMisses);
|
||||||
|
_toxModPatchesComplete = complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool PatchCallbackMethod(string label,
|
||||||
|
Func<Type, bool> typePredicate,
|
||||||
|
Func<MethodInfo, bool> methodPredicate,
|
||||||
|
string prefix,
|
||||||
|
bool logMiss)
|
||||||
|
{
|
||||||
|
if (_diagnosticPatchLabels.Contains(label))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var prefixMethod = GetPatchMethod(prefix);
|
||||||
|
var patched = 0;
|
||||||
|
|
||||||
|
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
|
{
|
||||||
|
foreach (var type in GetLoadableTypes(asm))
|
||||||
|
{
|
||||||
|
if (!typePredicate(type)) continue;
|
||||||
|
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||||
|
{
|
||||||
|
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||||
|
if (!methodPredicate(method)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
|
||||||
|
patched++;
|
||||||
|
Log.Msg($"[patch-ok] {label} {type.FullName}.{method.Name}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-fail] {label} {type.FullName}.{method.Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patched == 0)
|
||||||
|
{
|
||||||
|
if (logMiss) Log.Warning($"[patch-miss] {label}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_diagnosticPatchLabels.Add(label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TypeNameContains(Type type, string value)
|
||||||
|
{
|
||||||
|
var fullName = type.FullName ?? type.Name;
|
||||||
|
return fullName.Contains(value, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MethodNameContains(MethodInfo method, string value)
|
||||||
|
=> method.Name.Contains(value, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static bool MatchesFileHashCheckerCallbackType(Type type)
|
||||||
|
{
|
||||||
|
var fullName = type.FullName ?? type.Name;
|
||||||
|
return fullName.Contains("BEPEAHOOLDB", StringComparison.Ordinal)
|
||||||
|
&& fullName.Contains("PBENKLNHBHL", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesFileHashCheckerCallbackMethod(MethodInfo method)
|
||||||
|
{
|
||||||
|
if (method.ReturnType != typeof(void)) return false;
|
||||||
|
var parameters = method.GetParameters();
|
||||||
|
if (parameters.Length != 1) return false;
|
||||||
|
|
||||||
|
var name = method.Name;
|
||||||
|
if (name == "<InitializeFileHashChecker>b__0") return true;
|
||||||
|
if (name == "_InitializeFileHashChecker_b__0") return true;
|
||||||
|
return name.Contains("InitializeFileHashChecker", StringComparison.Ordinal)
|
||||||
|
&& name.Contains("b__0", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterNetworkPatches()
|
||||||
|
{
|
||||||
|
if (_networkPatchesRegistered) return;
|
||||||
|
|
||||||
|
var complete = true;
|
||||||
|
complete &= TryPatch("Uri ctor (string)", typeof(Uri), "ctor",
|
||||||
|
new[] { typeof(string) },
|
||||||
|
prefix: nameof(UriPatches.UriStringCtor_Prefix));
|
||||||
|
complete &= TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor",
|
||||||
|
new[] { typeof(string), typeof(UriKind) },
|
||||||
|
prefix: nameof(UriPatches.UriStringCtor_Prefix));
|
||||||
|
complete &= TryPatchHttpRequestUriConstructors();
|
||||||
|
complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send",
|
||||||
|
prefix: nameof(UriPatches.HttpRequestSend_Prefix));
|
||||||
|
complete &= TryPatchHttpManagerStringSendRequestOverloads();
|
||||||
|
complete &= TryPatchByName("BestHTTP.HTTPManager", "SendRequest",
|
||||||
|
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
|
||||||
|
prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix));
|
||||||
|
|
||||||
|
// HTTP header/callback tracing is pure diagnostics (and the noisiest
|
||||||
|
// thing in the log) — only install it when diagnostics are enabled.
|
||||||
|
if (Cfg.EnableDiagnostics)
|
||||||
|
RegisterHttpDiagnostics();
|
||||||
|
_networkPatchesRegistered = complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterHttpDiagnostics()
|
||||||
|
{
|
||||||
|
if (_httpDiagnosticsRegistered) return;
|
||||||
|
|
||||||
|
var complete = true;
|
||||||
|
complete &= TryPatchBndRequestBuilderDiagnostics();
|
||||||
|
complete &= TryPatchDiagnosticAllOverloads("BestHTTP.HTTPRequest", "CallCallback",
|
||||||
|
postfix: nameof(HttpTracePatches.CallCallback_Postfix),
|
||||||
|
finalizer: nameof(HttpTracePatches.CallCallback_Finalizer));
|
||||||
|
complete &= TryPatchByName("BestHTTP.HTTPRequest", "AddHeader",
|
||||||
|
args: new[] { typeof(string), typeof(string) },
|
||||||
|
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||||
|
complete &= TryPatchByName("BestHTTP.HTTPRequest", "SetHeader",
|
||||||
|
args: new[] { typeof(string), typeof(string) },
|
||||||
|
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||||
|
|
||||||
|
_httpDiagnosticsRegistered = complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryPatchBndRequestBuilderDiagnostics()
|
||||||
|
{
|
||||||
|
const string label = "BNDIAONDFFF.request-builder";
|
||||||
|
if (_diagnosticPatchLabels.Contains(label))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var type = ResolveType("BNDIAONDFFF");
|
||||||
|
if (type is null)
|
||||||
|
{
|
||||||
|
Log.Warning("[patch-miss] BNDIAONDFFF request-builder: type not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prefix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix)));
|
||||||
|
var postfix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Postfix)));
|
||||||
|
var patched = 0;
|
||||||
|
|
||||||
|
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||||
|
{
|
||||||
|
var parameters = ctor.GetParameters();
|
||||||
|
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HarmonyInstance.Patch(ctor, prefix: prefix, postfix: postfix);
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-fail] BNDIAONDFFF..ctor({FormatParameterTypes(parameters)}): {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||||
|
{
|
||||||
|
if (method.Name != "FILJELOLBKK") continue;
|
||||||
|
var parameters = method.GetParameters();
|
||||||
|
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HarmonyInstance.Patch(method, prefix: prefix, postfix: postfix);
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-fail] BNDIAONDFFF.FILJELOLBKK({FormatParameterTypes(parameters)}): {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patched == 0)
|
||||||
|
{
|
||||||
|
Log.Warning("[patch-miss] BNDIAONDFFF request-builder overloads");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_diagnosticPatchLabels.Add(label);
|
||||||
|
Log.Msg($"[patch-ok] BNDIAONDFFF request-builder x{patched}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryPatchHttpRequestUriConstructors()
|
||||||
|
{
|
||||||
|
var type = ResolveType("BestHTTP.HTTPRequest");
|
||||||
|
if (type is null)
|
||||||
|
{
|
||||||
|
Log.Warning("[patch-miss] BestHTTP.HTTPRequest..ctor(Uri): type not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prefix = GetPatchMethod(nameof(UriPatches.HttpRequestCtorUri_Prefix));
|
||||||
|
var patched = 0;
|
||||||
|
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||||
|
{
|
||||||
|
var parameters = ctor.GetParameters();
|
||||||
|
if (parameters.Length == 0 || !IsUriType(parameters[0].ParameterType)) continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HarmonyInstance.Patch(ctor, prefix: new HarmonyMethod(prefix));
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-fail] BestHTTP.HTTPRequest..ctor({FormatParameterTypes(parameters)}): {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patched == 0)
|
||||||
|
{
|
||||||
|
Log.Warning("[patch-miss] BestHTTP.HTTPRequest..ctor(Uri)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Msg($"[patch-ok] BestHTTP.HTTPRequest..ctor(Uri first) overloads x{patched}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryPatchHttpManagerStringSendRequestOverloads()
|
||||||
|
{
|
||||||
|
var type = ResolveType("BestHTTP.HTTPManager");
|
||||||
|
if (type is null)
|
||||||
|
{
|
||||||
|
Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string): type not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prefix = GetPatchMethod(nameof(UriPatches.HttpManagerSendRequestString_Prefix));
|
||||||
|
var patched = 0;
|
||||||
|
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||||
|
{
|
||||||
|
if (method.Name != "SendRequest") continue;
|
||||||
|
var parameters = method.GetParameters();
|
||||||
|
if (parameters.Length == 0 || parameters[0].ParameterType != typeof(string)) continue;
|
||||||
|
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefix));
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patched == 0)
|
||||||
|
{
|
||||||
|
Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Msg($"[patch-ok] BestHTTP.HTTPManager.SendRequest(string) overloads x{patched}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsUriType(Type type)
|
||||||
|
{
|
||||||
|
return type == typeof(Uri) || type.FullName == typeof(Uri).FullName || type.Name == nameof(Uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatParameterTypes(ParameterInfo[] parameters)
|
||||||
|
{
|
||||||
|
var names = new List<string>();
|
||||||
|
foreach (var parameter in parameters)
|
||||||
|
names.Add(parameter.ParameterType.Name);
|
||||||
|
return string.Join(",", names);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnUpdate()
|
||||||
|
{
|
||||||
|
if (!_firstUpdateLogged)
|
||||||
|
{
|
||||||
|
_firstUpdateLogged = true;
|
||||||
|
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Cfg.EnableDiagnostics && !_quitTraceGameComplete && ++_quitTraceRetryFrame >= 300)
|
||||||
|
{
|
||||||
|
_quitTraceRetryFrame = 0;
|
||||||
|
RegisterQuitTracePatches(logMisses: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
|
||||||
|
{
|
||||||
|
_antiTamperCallbackRetryFrame = 0;
|
||||||
|
RegisterAntiTamperCallbackPatches(logMisses: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_toxModPatchesComplete && ++_toxModRetryFrame >= 300)
|
||||||
|
{
|
||||||
|
_toxModRetryFrame = 0;
|
||||||
|
RegisterToxModPatches(logMisses: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Cfg.EnableDebugConsole)
|
||||||
|
DebugConsolePatches.PollToggleKey();
|
||||||
|
|
||||||
|
if (Cfg.DiagnoseDevMenu)
|
||||||
|
DevMenuProbe.Tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnApplicationQuit()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config ────────────────────────────────────────────────────────
|
||||||
|
private static void LoadConfig()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userData = ResolveUserDataDirectory();
|
||||||
|
Directory.CreateDirectory(userData);
|
||||||
|
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
|
||||||
|
|
||||||
|
// Auto-enable diagnostics when the separate DorkNet.DebugMod.dll is
|
||||||
|
// dropped in alongside this mod. Done before the config read (and
|
||||||
|
// before the no-config-file early return) so presence alone is
|
||||||
|
// enough — no config edit required to debug.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var modsDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
|
||||||
|
if (modsDir != null && File.Exists(Path.Combine(modsDir, "DorkNet.DebugMod.dll")))
|
||||||
|
{
|
||||||
|
Cfg.EnableDiagnostics = true;
|
||||||
|
Log.Msg("[config] DorkNet.DebugMod.dll present → diagnostics ENABLED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* presence detection is best-effort */ }
|
||||||
|
|
||||||
|
var path = Path.Combine(userData, "dorknet-clientmod.json");
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
Log.Msg($"[config] no file at {path}; using defaults (ServerHost={Cfg.ServerHost})");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||||
|
var r = doc.RootElement;
|
||||||
|
if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
|
||||||
|
if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
|
||||||
|
if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
|
||||||
|
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||||
|
if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||||
|
if (TryGetConfigValue(r, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
|
||||||
|
if (TryGetConfigValue(r, "EnableBuiltInDevDebugTools", out v)) Cfg.EnableBuiltInDevDebugTools = v.GetBoolean();
|
||||||
|
// Config can also enable diagnostics; OR with the debug-mod
|
||||||
|
// presence check above so either path turns them on.
|
||||||
|
if (TryGetConfigValue(r, "EnableDiagnostics", out v)) Cfg.EnableDiagnostics |= v.GetBoolean();
|
||||||
|
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
var commands = new List<string>();
|
||||||
|
foreach (var e in v.EnumerateArray())
|
||||||
|
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
|
||||||
|
commands.Add(e.GetString()!);
|
||||||
|
Cfg.DevCommands = commands.ToArray();
|
||||||
|
}
|
||||||
|
if (TryGetConfigValue(r, "DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble();
|
||||||
|
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
||||||
|
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
||||||
|
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
|
||||||
|
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
||||||
|
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
var rooms = new List<string>();
|
||||||
|
foreach (var e in v.EnumerateArray())
|
||||||
|
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
|
||||||
|
rooms.Add(e.GetString()!);
|
||||||
|
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
|
||||||
|
}
|
||||||
|
Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, " +
|
||||||
|
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveUserDataDirectory()
|
||||||
|
{
|
||||||
|
foreach (var typeName in new[]
|
||||||
|
{
|
||||||
|
"MelonLoader.MelonEnvironment, MelonLoader",
|
||||||
|
"MelonLoader.MelonUtils, MelonLoader",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var type = Type.GetType(typeName);
|
||||||
|
var prop = type?.GetProperty("UserDataDirectory", BindingFlags.Public | BindingFlags.Static);
|
||||||
|
if (prop?.GetValue(null) is string value && !string.IsNullOrWhiteSpace(value))
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var modDll = Assembly.GetExecutingAssembly().Location;
|
||||||
|
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
|
||||||
|
var gameDir = Path.GetDirectoryName(modsDir) ?? AppContext.BaseDirectory;
|
||||||
|
return Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetConfigValue(JsonElement root, string name, out JsonElement value)
|
||||||
|
{
|
||||||
|
if (root.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var prop in root.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
value = prop.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Patch dispatch helpers ────────────────────────────────────────
|
||||||
|
private bool TryPatch(string label, Type target, string methodName,
|
||||||
|
Type[]? args, string? prefix = null, string? postfix = null,
|
||||||
|
bool logMiss = true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var method = methodName == "ctor"
|
||||||
|
? (MethodBase)target.GetConstructor(args ?? Type.EmptyTypes)!
|
||||||
|
: (args is null ? AccessTools.Method(target, methodName)
|
||||||
|
: AccessTools.Method(target, methodName, args));
|
||||||
|
if (method is null)
|
||||||
|
{
|
||||||
|
// `logMiss` is throttled by the retry-loop caller — once per
|
||||||
|
// 5 seconds in OnUpdate, every time in the one-shot initial
|
||||||
|
// RegisterDiagnostics. Without the gate the per-second retry
|
||||||
|
// loop would spam `[patch-miss]` lines forever for any method
|
||||||
|
// whose name drifted in a newer game build.
|
||||||
|
if (logMiss) Log.Warning($"[patch-miss] {label}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
HarmonyInstance.Patch(method,
|
||||||
|
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||||
|
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||||
|
Log.Msg($"[patch-ok] {label}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error($"[patch-fail] {label}: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the game-side type by its ORIGINAL name (no Il2Cpp prefix)
|
||||||
|
// by walking every loaded assembly. Tries the exact name first, then
|
||||||
|
// the same name with an `Il2Cpp` prefix, then a substring scan as a
|
||||||
|
// last resort — covers all the IL2CPP interop naming schemes the user
|
||||||
|
// might end up with depending on the MelonLoader / Il2CppInterop
|
||||||
|
// version installed.
|
||||||
|
private bool TryPatchByName(string typeName, string methodName,
|
||||||
|
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||||
|
bool logMiss = true)
|
||||||
|
{
|
||||||
|
var label = FormatPatchLabel(typeName, methodName, args);
|
||||||
|
var type = ResolveType(typeName);
|
||||||
|
if (type is null)
|
||||||
|
{
|
||||||
|
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryPatchDeclaredMethodByReturnType(string typeName, string returnTypeName,
|
||||||
|
string fallbackMethodName,
|
||||||
|
string? prefix = null, string? postfix = null,
|
||||||
|
bool logMiss = true)
|
||||||
|
{
|
||||||
|
var type = ResolveType(typeName);
|
||||||
|
var label = $"{typeName}.<returns {returnTypeName}>";
|
||||||
|
if (type is null)
|
||||||
|
{
|
||||||
|
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||||
|
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches = new List<MethodInfo>();
|
||||||
|
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||||
|
{
|
||||||
|
if (method.IsAbstract || method.ContainsGenericParameters || method.IsSpecialName) continue;
|
||||||
|
if (ReturnTypeMatches(method.ReturnType, returnTypeName)) matches.Add(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.Count == 1)
|
||||||
|
{
|
||||||
|
var method = matches[0];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HarmonyInstance.Patch(method,
|
||||||
|
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||||
|
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
|
||||||
|
Log.Msg($"[patch-ok] {typeName}.{method.Name} (returns {returnTypeName})");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error($"[patch-fail] {typeName}.{method.Name} (returns {returnTypeName}): {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.Count > 1)
|
||||||
|
{
|
||||||
|
var names = new List<string>();
|
||||||
|
foreach (var method in matches) names.Add(method.Name);
|
||||||
|
Log.Warning($"[patch-ambiguous] {label}: {string.Join(", ", names)}; falling back to {fallbackMethodName}");
|
||||||
|
}
|
||||||
|
else if (logMiss)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-miss] {label}; falling back to {fallbackMethodName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Type? ResolveType(string name)
|
||||||
|
{
|
||||||
|
if (ResolvedTypeCache.TryGetValue(name, out var cached))
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var t = asm.GetType(name, throwOnError: false);
|
||||||
|
if (t is not null) return CacheResolvedType(name, t);
|
||||||
|
var dottedPrefixed = asm.GetType("Il2Cpp." + name, throwOnError: false);
|
||||||
|
if (dottedPrefixed is not null) return CacheResolvedType(name, dottedPrefixed);
|
||||||
|
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
|
||||||
|
if (prefixed is not null) return CacheResolvedType(name, prefixed);
|
||||||
|
// For namespaced types like "RecNet.Core", also try with
|
||||||
|
// the Il2Cpp prefix on the namespace (i.e. "Il2CppRecNet.Core").
|
||||||
|
var dot = name.IndexOf('.');
|
||||||
|
if (dot > 0)
|
||||||
|
{
|
||||||
|
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
|
||||||
|
if (nsPrefixed is not null) return CacheResolvedType(name, nsPrefixed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* unloaded / dynamic asm — skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
|
{
|
||||||
|
foreach (var t in GetLoadableTypes(asm))
|
||||||
|
{
|
||||||
|
if (MatchesRequestedType(t, name))
|
||||||
|
return CacheResolvedType(name, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type CacheResolvedType(string name, Type type)
|
||||||
|
{
|
||||||
|
ResolvedTypeCache[name] = type;
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return asm.GetTypes();
|
||||||
|
}
|
||||||
|
catch (ReflectionTypeLoadException ex)
|
||||||
|
{
|
||||||
|
var types = new List<Type>();
|
||||||
|
foreach (var t in ex.Types)
|
||||||
|
{
|
||||||
|
if (t is not null) types.Add(t);
|
||||||
|
}
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Array.Empty<Type>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesRequestedType(Type type, string requested)
|
||||||
|
{
|
||||||
|
var prefixed = "Il2Cpp" + requested;
|
||||||
|
var dottedPrefixed = "Il2Cpp." + requested;
|
||||||
|
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true;
|
||||||
|
if (type.Name == requested || type.Name == prefixed) return true;
|
||||||
|
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||||
|
type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true ||
|
||||||
|
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ReturnTypeMatches(Type type, string requested)
|
||||||
|
{
|
||||||
|
var prefixed = "Il2Cpp" + requested;
|
||||||
|
var dottedPrefixed = "Il2Cpp." + requested;
|
||||||
|
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true;
|
||||||
|
if (type.Name == requested || type.Name == prefixed) return true;
|
||||||
|
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||||
|
type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true ||
|
||||||
|
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
|
||||||
|
{
|
||||||
|
if (args is null) return $"{typeName}.{methodName}";
|
||||||
|
var names = new List<string>();
|
||||||
|
foreach (var arg in args)
|
||||||
|
names.Add(arg.Name);
|
||||||
|
return $"{typeName}.{methodName}({string.Join(",", names)})";
|
||||||
|
}
|
||||||
|
|
||||||
|
private MethodInfo GetPatchMethod(string name)
|
||||||
|
{
|
||||||
|
// Look in all three patch holder classes — small enough that a
|
||||||
|
// linear scan is cheaper than per-class lookups.
|
||||||
|
foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(BuiltInDevDebugToolsPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
|
||||||
|
{
|
||||||
|
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||||
|
if (m is not null) return m;
|
||||||
|
}
|
||||||
|
throw new MissingMethodException($"patch method '{name}' not found on any holder");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterQuitTracePatches(bool logMisses)
|
||||||
|
{
|
||||||
|
if (!_quitTraceCoreRegistered)
|
||||||
|
{
|
||||||
|
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||||
|
prefix: nameof(QuitTracePatches.ApplicationQuit_Prefix),
|
||||||
|
logMiss: logMisses);
|
||||||
|
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||||
|
prefix: nameof(QuitTracePatches.ApplicationQuitInt_Prefix),
|
||||||
|
logMiss: logMisses);
|
||||||
|
_quitTraceCoreRegistered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var complete = true;
|
||||||
|
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "GDNAOLMIDLM",
|
||||||
|
prefix: nameof(QuitTracePatches.ApplicationWantsToQuit_Prefix),
|
||||||
|
logMiss: logMisses);
|
||||||
|
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "DGHJGMBNHCL",
|
||||||
|
prefix: nameof(QuitTracePatches.CreateExitTask_Prefix),
|
||||||
|
logMiss: logMisses);
|
||||||
|
|
||||||
|
_quitTraceGameComplete = complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterDiagnostics()
|
||||||
|
{
|
||||||
|
Log.Msg("=== Registering diagnostics ===");
|
||||||
|
if (!_diagnosticCoreRegistered)
|
||||||
|
{
|
||||||
|
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
|
||||||
|
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
|
||||||
|
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
|
||||||
|
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
|
||||||
|
_diagnosticCoreRegistered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
|
||||||
|
Log.Msg("=== Diagnostics registered ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool RegisterGameDiagnostics(bool logMisses)
|
||||||
|
{
|
||||||
|
// CheatManager fires these when its anti-cheat heuristics trip —
|
||||||
|
// logging them surfaces "client thinks it's been tampered with",
|
||||||
|
// a common cause of silent dorm-drops / kicks on a modded client.
|
||||||
|
// Names verified in CheatManager.txt for 2020.12.18; the older
|
||||||
|
// build's "AnalyticsHelper.<X>Cheat" names don't exist here, and
|
||||||
|
// there's no DeveloperFlag/StreamingAsset callback nor a
|
||||||
|
// BootSequence.RegisterError sink in this build — those legacy
|
||||||
|
// hooks were removed (they only ever logged [patch-miss] forever).
|
||||||
|
foreach (var method in new[]
|
||||||
|
{
|
||||||
|
"OnTimeCheatDetected",
|
||||||
|
"OnObscuredTypeCheatDetected",
|
||||||
|
"OnHeightCheatDetected",
|
||||||
|
"OnAdvancedMovementCheatDetected",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
TryPatchDiagnosticByName("CheatManager", method,
|
||||||
|
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
|
||||||
|
logMiss: logMisses);
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterDeepRoomDiagnostics(logMisses);
|
||||||
|
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
|
||||||
|
// pure-logging diagnostics that fired during Unity's very first
|
||||||
|
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam
|
||||||
|
// methods) MonoMod's lazy CompileMethodHook on the trampoline
|
||||||
|
// crashes the CLR with 0x80131506 the instant Awake JITs. The
|
||||||
|
// hooks gave us no behavioral leverage — only a `[call] …` log
|
||||||
|
// line — so they're disabled. If anyone needs Steam-side visibility
|
||||||
|
// later, route it through the game's own log lines instead.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryPatchDiagnosticByName(string typeName, string methodName,
|
||||||
|
Type[]? args = null, string? prefix = null, string? postfix = null,
|
||||||
|
bool logMiss = true)
|
||||||
|
{
|
||||||
|
var label = FormatPatchLabel(typeName, methodName, args);
|
||||||
|
if (_diagnosticPatchLabels.Contains(label))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (!TryPatchByName(typeName, methodName, args, prefix, postfix, logMiss))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_diagnosticPatchLabels.Add(label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryPatchDiagnosticAllOverloads(string typeName, string methodName,
|
||||||
|
string? prefix = null, string? postfix = null,
|
||||||
|
string? finalizer = null, bool logMiss = true)
|
||||||
|
{
|
||||||
|
var label = $"{typeName}.{methodName}(*)";
|
||||||
|
if (_diagnosticPatchLabels.Contains(label))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var type = ResolveType(typeName);
|
||||||
|
if (type is null)
|
||||||
|
{
|
||||||
|
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var patched = 0;
|
||||||
|
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||||
|
{
|
||||||
|
if (method.Name != methodName) continue;
|
||||||
|
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HarmonyInstance.Patch(method,
|
||||||
|
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||||
|
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)),
|
||||||
|
finalizer: finalizer is null ? null : new HarmonyMethod(GetPatchMethod(finalizer)));
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-fail] {type.FullName}.{method.Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patched == 0)
|
||||||
|
{
|
||||||
|
if (logMiss) Log.Warning($"[patch-miss] {label}: no overloads matched");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_diagnosticPatchLabels.Add(label);
|
||||||
|
Log.Msg($"[patch-ok] {label} x{patched}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool RegisterDeepRoomDiagnostics(bool logMisses)
|
||||||
|
{
|
||||||
|
var complete = true;
|
||||||
|
|
||||||
|
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogError",
|
||||||
|
args: new[] { typeof(object) },
|
||||||
|
prefix: nameof(RoomLoadDiagnostics.LogError_Prefix),
|
||||||
|
logMiss: logMisses);
|
||||||
|
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogException",
|
||||||
|
args: new[] { typeof(Exception) },
|
||||||
|
prefix: nameof(RoomLoadDiagnostics.LogException_Prefix),
|
||||||
|
logMiss: logMisses);
|
||||||
|
|
||||||
|
foreach (var method in new[]
|
||||||
|
{
|
||||||
|
"ConnectUsingSettings",
|
||||||
|
"ConnectToRegion",
|
||||||
|
"JoinRoom",
|
||||||
|
"JoinOrCreateRoom",
|
||||||
|
"CreateRoom",
|
||||||
|
"LeaveRoom",
|
||||||
|
"Disconnect",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
complete &= TryPatchDiagnosticAllOverloads("PhotonNetwork", method,
|
||||||
|
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
|
||||||
|
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
|
||||||
|
logMiss: logMisses);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in new (string Type, string Method)[]
|
||||||
|
{
|
||||||
|
("CMMIJKCDKPL", "CDNPLAGEDBK"),
|
||||||
|
("KGHEDLNPANK", "CDNPLAGEDBK"),
|
||||||
|
("HMPNINFEDFB", "IJIOJAIBPKE"),
|
||||||
|
("PhotonNetwork", "Disconnect"),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
complete &= TryPatchDiagnosticAllOverloads(item.Type, item.Method,
|
||||||
|
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
|
||||||
|
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
|
||||||
|
logMiss: logMisses);
|
||||||
|
}
|
||||||
|
|
||||||
|
complete &= PatchKnownRoomLoadMoveNext(logMisses);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Studio baked-asset load trace. Registered directly from the
|
||||||
|
/// live client-patch flow (the deep diagnostics path
|
||||||
|
/// <see cref="RegisterDiagnostics"/> is not wired into init, so probes
|
||||||
|
/// placed there never run). Pinpoints where the baked-Studio-scene chain
|
||||||
|
/// breaks: the unity-asset getter (does the client ever request a
|
||||||
|
/// bundle), prefab registration (does a bundle ever load), and the prefab
|
||||||
|
/// lookups that fail with "Expected a prefab, but found none".
|
||||||
|
/// RecRoomObjectPrefabManager is name-preserved; the getter is the
|
||||||
|
/// obfuscated NLDBPDCNNCF.FKLDMPMFLBD. Each is a single overload.</summary>
|
||||||
|
private void RegisterStudioTracePatches()
|
||||||
|
{
|
||||||
|
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "RegisterPrefabs",
|
||||||
|
postfix: nameof(RoomLoadDiagnostics.StudioRegisterPrefabs_Postfix));
|
||||||
|
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "TryGetPrefab",
|
||||||
|
postfix: nameof(RoomLoadDiagnostics.StudioTryGetPrefab_Postfix));
|
||||||
|
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "IsKnownPrefab",
|
||||||
|
postfix: nameof(RoomLoadDiagnostics.StudioIsKnownPrefab_Postfix));
|
||||||
|
TryPatchByName("NLDBPDCNNCF", "FKLDMPMFLBD",
|
||||||
|
prefix: nameof(RoomLoadDiagnostics.StudioUnityAssetFetch_Prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool PatchKnownRoomLoadMoveNext(bool logMiss)
|
||||||
|
{
|
||||||
|
const string label = "room-load-state-machines.MoveNext";
|
||||||
|
if (_diagnosticPatchLabels.Contains(label))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var nameNeedles = new[]
|
||||||
|
{
|
||||||
|
"OMJKHJLFOCO", "AFMPFLKGABO",
|
||||||
|
"AOEBBIFGIKI", "FINJCCNPHEP", "FDDFOKNJIFL", "GMNHJNAHAAI",
|
||||||
|
"MHDOLJJPEAD", "LFGLDJCMJDN", "PFDIKJHMBDI", "ICAGCHKPIOM",
|
||||||
|
"JEBACILNLJI", "DINBAFNHLPJ", "HLHPNABABME", "PAPLNIPKAMG",
|
||||||
|
"IBEOONPEELF", "JHPHMNGOFLB", "NLDBPDCNNCF", "LONOBOBHMJL",
|
||||||
|
"BNDIAONDFFF", "HOONLPFFADG", "PHODPEHLHGM",
|
||||||
|
};
|
||||||
|
|
||||||
|
var patched = 0;
|
||||||
|
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
|
{
|
||||||
|
foreach (var type in GetLoadableTypes(asm))
|
||||||
|
{
|
||||||
|
var fullName = type.FullName ?? type.Name;
|
||||||
|
var match = false;
|
||||||
|
foreach (var needle in nameNeedles)
|
||||||
|
{
|
||||||
|
if (fullName.IndexOf(needle, StringComparison.Ordinal) >= 0)
|
||||||
|
{
|
||||||
|
match = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
IEnumerable<MethodInfo> methods;
|
||||||
|
try { methods = AccessTools.GetDeclaredMethods(type); }
|
||||||
|
catch { continue; }
|
||||||
|
|
||||||
|
foreach (var method in methods)
|
||||||
|
{
|
||||||
|
if (method.Name != "MoveNext") continue;
|
||||||
|
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HarmonyInstance.Patch(method,
|
||||||
|
finalizer: new HarmonyMethod(GetPatchMethod(nameof(RoomLoadDiagnostics.MoveNext_Finalizer))));
|
||||||
|
patched++;
|
||||||
|
Log.Msg($"[patch-ok] room-load MoveNext {type.FullName}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-fail] room-load MoveNext {type.FullName}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patched == 0)
|
||||||
|
{
|
||||||
|
if (logMiss) Log.Warning("[patch-miss] room-load state-machine MoveNext hooks");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_diagnosticPatchLabels.Add(label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterProcessExceptionHandlers()
|
||||||
|
{
|
||||||
|
if (_processExceptionHandlersRegistered) return;
|
||||||
|
_processExceptionHandlersRegistered = true;
|
||||||
|
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ex = e.ExceptionObject as Exception;
|
||||||
|
DiagnosticPatches.Write($"[process-exception] unhandled terminating={e.IsTerminating} {RoomLoadDiagnostics.FormatException(ex, e.ExceptionObject)}");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
};
|
||||||
|
|
||||||
|
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write($"[process-exception] unobserved-task {RoomLoadDiagnostics.FormatException(e.Exception, e.Exception)}");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Patch holders ─────────────────────────────────────────────────────
|
||||||
|
// Each holder is a static class with public static Prefix methods that
|
||||||
|
// Harmony invokes. Kept separate from Mod for readability and so the
|
||||||
|
// reflection lookup in GetPatchMethod stays simple.
|
||||||
|
|
||||||
|
internal static class RoomLoadDiagnostics
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, int> Counts = new();
|
||||||
|
private static int _globalCount;
|
||||||
|
|
||||||
|
public static void LogError_Prefix(object? message)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write($"[unity-error] LogError {Sanitize(message?.ToString() ?? "<null>", 1600)}");
|
||||||
|
WriteStack("unity-error");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void LogException_Prefix(Exception exception)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write($"[unity-exception] {FormatException(exception, exception)}");
|
||||||
|
WriteStack("unity-exception");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void NamedCall_Prefix(MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
var name = FullMethodName(__originalMethod);
|
||||||
|
if (!ShouldLog("call:" + name, 80)) return;
|
||||||
|
|
||||||
|
DiagnosticPatches.Write($"[runtime-call] ENTER {name} [{Signature(__originalMethod)}]");
|
||||||
|
if (IsImportantCall(name))
|
||||||
|
WriteStack("runtime-call");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Exception? NamedCall_Finalizer(Exception? __exception, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
if (__exception is not null)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write($"[runtime-call] EXCEPTION {FullMethodName(__originalMethod)} {FormatException(__exception, __exception)}");
|
||||||
|
WriteStack("runtime-call-exception");
|
||||||
|
}
|
||||||
|
return __exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void MoveNext_Prefix(object __instance, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
var name = FullMethodName(__originalMethod);
|
||||||
|
if (!ShouldLog("enter:" + name, 120)) return;
|
||||||
|
DiagnosticPatches.Write($"[room-load] ENTER {name} state={DumpInstanceFields(__instance, 28)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void MoveNext_Postfix(object __instance, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
var name = FullMethodName(__originalMethod);
|
||||||
|
if (!ShouldLog("exit:" + name, 120)) return;
|
||||||
|
DiagnosticPatches.Write($"[room-load] EXIT {name} state={DumpInstanceFields(__instance, 28)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Exception? MoveNext_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
if (__exception is not null)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[room-load] EXCEPTION {FullMethodName(__originalMethod)} " +
|
||||||
|
$"{FormatException(__exception, __exception)} state={DumpInstanceFields(__instance, 40)}");
|
||||||
|
WriteStack("room-load-exception");
|
||||||
|
}
|
||||||
|
return __exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Studio baked-asset load trace ──────────────────────────────────
|
||||||
|
// Pinpoints where the baked-Studio-scene load chain breaks. The room
|
||||||
|
// joins but renders no custom geometry and logs "Expected a prefab,
|
||||||
|
// but found none"; the server serves everything but the client never
|
||||||
|
// requests a bundle. These probes answer: (1) does the client ever
|
||||||
|
// call the RecNet "get unity asset" API (FKLDMPMFLBD); (2) does it
|
||||||
|
// ever register baked prefabs; (3) which prefab GUIDs does it look up
|
||||||
|
// and miss.
|
||||||
|
|
||||||
|
/// <summary>RecNet unity-asset getter
|
||||||
|
/// <c>NLDBPDCNNCF.FKLDMPMFLBD(string unityAssetId, byte target, int version, CancellationToken)</c>
|
||||||
|
/// — the call that builds <c>unity_assets/{id}/{target}/{version}</c>. If
|
||||||
|
/// this never fires, the client decided not to fetch the bundle at all
|
||||||
|
/// (trigger missing upstream); if it fires, the URL/host/response is the
|
||||||
|
/// problem.</summary>
|
||||||
|
public static void StudioUnityAssetFetch_Prefix(object? __0, object? __1, object? __2)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[studio] UNITY-ASSET FETCH id={__0?.ToString() ?? "<null>"} " +
|
||||||
|
$"target={__1?.ToString() ?? "?"} version={__2?.ToString() ?? "?"}");
|
||||||
|
WriteStack("studio-assetfetch");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>RecRoomObjectPrefabManager.RegisterPrefabs</c> — baked
|
||||||
|
/// prefabs being registered after a bundle loads. If this never fires,
|
||||||
|
/// no bundle was loaded.</summary>
|
||||||
|
public static void StudioRegisterPrefabs_Postfix(object? __0)
|
||||||
|
{
|
||||||
|
int count = -1;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (__0 is not null)
|
||||||
|
{
|
||||||
|
var p = __0.GetType().GetProperty("Count");
|
||||||
|
if (p?.GetValue(__0) is int c) count = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* best-effort count */ }
|
||||||
|
DiagnosticPatches.Write($"[studio] RegisterPrefabs count={count}");
|
||||||
|
WriteStack("studio-registerprefabs");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>RecRoomObjectPrefabManager.TryGetPrefab(Guid, out GameObject)</c>
|
||||||
|
/// — the lookup that fails with "Expected a prefab, but found none".
|
||||||
|
/// Logs the GUID + whether it resolved.</summary>
|
||||||
|
public static void StudioTryGetPrefab_Postfix(object? __0, bool __result)
|
||||||
|
{
|
||||||
|
if (!ShouldLog("studio-trygetprefab", 200)) return;
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[studio] TryGetPrefab guid={__0?.ToString() ?? "<null>"} found={__result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>RecRoomObjectPrefabManager.IsKnownPrefab(Guid)</c>.</summary>
|
||||||
|
public static void StudioIsKnownPrefab_Postfix(object? __0, bool __result)
|
||||||
|
{
|
||||||
|
if (!ShouldLog("studio-isknownprefab", 200)) return;
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[studio] IsKnownPrefab guid={__0?.ToString() ?? "<null>"} known={__result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatException(Exception? ex, object? fallback)
|
||||||
|
{
|
||||||
|
if (ex is null) return fallback?.ToString() ?? "<null>";
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
AppendException(builder, ex, 0);
|
||||||
|
return Sanitize(builder.ToString(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendException(StringBuilder builder, Exception ex, int depth)
|
||||||
|
{
|
||||||
|
if (depth > 4) return;
|
||||||
|
if (builder.Length > 0) builder.Append(" | ");
|
||||||
|
builder.Append(ex.GetType().FullName).Append(": ").Append(ex.Message);
|
||||||
|
if (!string.IsNullOrWhiteSpace(ex.StackTrace))
|
||||||
|
builder.Append(" stack=").Append(ex.StackTrace);
|
||||||
|
if (ex is AggregateException aggregate)
|
||||||
|
{
|
||||||
|
foreach (var inner in aggregate.InnerExceptions)
|
||||||
|
AppendException(builder, inner, depth + 1);
|
||||||
|
}
|
||||||
|
else if (ex.InnerException is not null)
|
||||||
|
{
|
||||||
|
AppendException(builder, ex.InnerException, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldLog(string key, int perKeyLimit)
|
||||||
|
{
|
||||||
|
lock (Counts)
|
||||||
|
{
|
||||||
|
_globalCount++;
|
||||||
|
if (_globalCount > 12000)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
Counts.TryGetValue(key, out var count);
|
||||||
|
if (count >= perKeyLimit)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
Counts[key] = count + 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DumpInstanceFields(object? instance, int maxFields)
|
||||||
|
{
|
||||||
|
if (instance is null) return "<null>";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var type = instance.GetType();
|
||||||
|
var parts = new List<string>();
|
||||||
|
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||||
|
foreach (var field in type.GetFields(flags))
|
||||||
|
{
|
||||||
|
if (parts.Count >= maxFields)
|
||||||
|
{
|
||||||
|
parts.Add("...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
object? value;
|
||||||
|
try { value = field.GetValue(instance); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var described = DescribeValue(value, 400);
|
||||||
|
if (ShouldExpandField(field, value))
|
||||||
|
described += " " + DumpNestedFields(value, 16);
|
||||||
|
parts.Add($"{field.Name}={described}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"<dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 400)}>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldExpandField(FieldInfo field, object? value)
|
||||||
|
{
|
||||||
|
if (value is null) return false;
|
||||||
|
if (value is string) return false;
|
||||||
|
|
||||||
|
var valueType = value.GetType();
|
||||||
|
if (valueType.IsPrimitive || valueType.IsEnum || value is decimal)
|
||||||
|
return false;
|
||||||
|
if (value is IEnumerable)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var fieldName = field.Name ?? string.Empty;
|
||||||
|
var typeName = valueType.FullName ?? valueType.Name;
|
||||||
|
return fieldName.IndexOf("this", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||||
|
typeName.IndexOf("OMJKHJLFOCO", StringComparison.Ordinal) >= 0 ||
|
||||||
|
typeName.IndexOf("RoomPermissions", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DumpNestedFields(object value, int maxFields)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var type = value.GetType();
|
||||||
|
var parts = new List<string>();
|
||||||
|
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||||
|
foreach (var field in type.GetFields(flags))
|
||||||
|
{
|
||||||
|
if (parts.Count >= maxFields)
|
||||||
|
{
|
||||||
|
parts.Add("...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
object? nested;
|
||||||
|
try { nested = field.GetValue(value); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.Add($"{field.Name}={DescribeValue(nested, 220)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{nested " + string.Join("; ", parts) + "}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"{{nested dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 220)}}}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeValue(object? value, int limit)
|
||||||
|
{
|
||||||
|
if (value is null) return "null";
|
||||||
|
|
||||||
|
var type = value.GetType();
|
||||||
|
if (value is string text) return "\"" + Sanitize(text, limit) + "\"";
|
||||||
|
if (value is Exception ex) return FormatException(ex, ex);
|
||||||
|
if (type.IsPrimitive || type.IsEnum || value is decimal)
|
||||||
|
return Sanitize(value.ToString() ?? "<null>", limit);
|
||||||
|
if (value is IEnumerable enumerable && value is not string)
|
||||||
|
return DescribeEnumerable(enumerable, type, limit);
|
||||||
|
|
||||||
|
var rendered = value.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(rendered) || rendered == type.FullName)
|
||||||
|
return "<" + type.FullName + ">";
|
||||||
|
return "<" + type.FullName + " " + Sanitize(rendered, limit) + ">";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeEnumerable(IEnumerable enumerable, Type type, int limit)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
var count = 0;
|
||||||
|
foreach (var item in enumerable)
|
||||||
|
{
|
||||||
|
if (count++ >= 6)
|
||||||
|
{
|
||||||
|
parts.Add("...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parts.Add(DescribeValue(item, 120));
|
||||||
|
}
|
||||||
|
return "<" + type.FullName + " [" + Sanitize(string.Join(", ", parts), limit) + "]>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Signature(MethodBase method)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parameters = method.GetParameters();
|
||||||
|
var parts = new List<string>(parameters.Length);
|
||||||
|
foreach (var parameter in parameters)
|
||||||
|
parts.Add(parameter.ParameterType.FullName ?? parameter.ParameterType.Name);
|
||||||
|
return string.Join(", ", parts);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "<signature unread>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FullMethodName(MethodBase method) =>
|
||||||
|
$"{method.DeclaringType?.FullName ?? "<null>"}.{method.Name}";
|
||||||
|
|
||||||
|
private static bool IsImportantCall(string name) =>
|
||||||
|
name.IndexOf("Disconnect", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||||
|
name.IndexOf("Join", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||||
|
name.IndexOf("Photon", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||||
|
name.IndexOf("CDNPLAGEDBK", StringComparison.Ordinal) >= 0 ||
|
||||||
|
name.IndexOf("IJIOJAIBPKE", StringComparison.Ordinal) >= 0;
|
||||||
|
|
||||||
|
private static void WriteStack(string tag)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write($"[{tag}-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write($"[{tag}-stack] failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Sanitize(string value, int limit)
|
||||||
|
{
|
||||||
|
var redacted = Regex.Replace(value, @"(?i)(Bearer\s+)[-._~+/=A-Za-z0-9]+", "$1<redacted>");
|
||||||
|
redacted = Regex.Replace(redacted, @"(?i)(access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)", "$1=<redacted>");
|
||||||
|
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||||
|
return redacted.Length <= limit ? redacted : redacted[..limit] + "...<truncated>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Debug console access ───────────────────────────────────────────────
|
||||||
|
// Built-in watch Dev Debug Tools hooks. The 2023 RRUI page already exists,
|
||||||
|
// but retail rows are hidden by GlobalModelController hide gates. We keep
|
||||||
|
// those rows visible and append the debug-console command table to the
|
||||||
|
// built-in Build Information text so the watch page reflects the real client
|
||||||
|
// command config.
|
||||||
|
internal static class BuiltInDevDebugToolsPatches
|
||||||
|
{
|
||||||
|
public static bool KeepVisible_Prefix() => false;
|
||||||
|
|
||||||
|
public static void BuildInfoFormatted_Postfix(ref string __result)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var commands = DebugConsolePatches.BuildCommandReferenceText("Debug Console Commands", maxCommands: 60);
|
||||||
|
__result = string.IsNullOrWhiteSpace(__result)
|
||||||
|
? commands
|
||||||
|
: __result.TrimEnd() + "\n\n" + commands;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[devtools] failed to append command list: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game
|
||||||
|
// dev console (text input + a DebugConsoleCommandConfig command table:
|
||||||
|
// SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily,
|
||||||
|
// …). Retail gates it behind a developer flag. This holder force-toggles
|
||||||
|
// its UI on a hotkey and (via the OnLateInitializeMelon patch above) skips
|
||||||
|
// CheatManager's tamper detectors so movement/time commands don't drop you
|
||||||
|
// to the dorm.
|
||||||
|
//
|
||||||
|
// Everything is resolved by reflection at runtime so the SAME code works on
|
||||||
|
// both client builds despite their differing obfuscation:
|
||||||
|
// * The show/hide toggle is the one private instance void(bool) on
|
||||||
|
// DebugConsole — ShowInputField on 2020.03, MCNBJFECHLJ on 2020.12,
|
||||||
|
// KIAMDEDKFBC on 2023.03. We try the known names, then fall back to
|
||||||
|
// a structural match.
|
||||||
|
// * The live component is found via Resources.FindObjectsOfTypeAll
|
||||||
|
// using an Il2CppSystem.Type argument; this interop build does not
|
||||||
|
// expose UnityEngine.Object.FindObjectOfType(System.Type).
|
||||||
|
// * Execute(string) keeps its readable name on both builds (kept here as
|
||||||
|
// a scripted-command entry point even though the hotkey drives the UI).
|
||||||
|
internal static class DebugConsolePatches
|
||||||
|
{
|
||||||
|
private static bool _consoleVisible;
|
||||||
|
private static bool _pollErrorLogged;
|
||||||
|
private static int _probeFrame;
|
||||||
|
private static bool _probeDone;
|
||||||
|
|
||||||
|
private static bool _inputResolved;
|
||||||
|
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
|
||||||
|
private static object? _toggleKeyValue; // boxed KeyCode enum value
|
||||||
|
|
||||||
|
private static bool _consoleResolved;
|
||||||
|
private static Type? _consoleType;
|
||||||
|
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
|
||||||
|
private static MethodInfo? _setConsoleText; // DebugConsole.SetConsoleText(string)
|
||||||
|
private static List<ConfiguredCommand>? _commandCache;
|
||||||
|
|
||||||
|
// Harmony prefix returning false → skips the original CheatManager
|
||||||
|
// detector, so a tripped heuristic never runs its punish/drop path.
|
||||||
|
public static bool SuppressCheatDetected_Prefix() => false;
|
||||||
|
|
||||||
|
public static string FormatConfiguredCommandsForLog()
|
||||||
|
{
|
||||||
|
var commands = GetAvailableCommands();
|
||||||
|
if (commands.Count == 0) return "<none>";
|
||||||
|
|
||||||
|
var parts = new List<string>();
|
||||||
|
foreach (var command in commands)
|
||||||
|
parts.Add($"{command.Label}={command.Command}");
|
||||||
|
return string.Join(", ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polled every frame from Mod.OnUpdate while EnableDebugConsole is set.
|
||||||
|
public static void PollToggleKey()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_inputResolved) ResolveInput();
|
||||||
|
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
|
||||||
|
// console object actually exists, so we learn that even if the
|
||||||
|
// hotkey never fires.
|
||||||
|
if (!_probeDone && _probeFrame++ > 180)
|
||||||
|
{
|
||||||
|
_probeDone = true;
|
||||||
|
ProbeConsole();
|
||||||
|
}
|
||||||
|
if (_getKeyDown is null || _toggleKeyValue is null) return;
|
||||||
|
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
|
||||||
|
{
|
||||||
|
Mod.Log.Msg($"[debugconsole] toggle key '{Mod.Cfg.DebugConsoleToggleKey}' detected");
|
||||||
|
Toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_pollErrorLogged) { _pollErrorLogged = true; Mod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostic: reports whether a live DebugConsole component exists and
|
||||||
|
// whether the show/hide method resolved — without needing a keypress.
|
||||||
|
private static void ProbeConsole()
|
||||||
|
{
|
||||||
|
if (!_consoleResolved) ResolveConsole();
|
||||||
|
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var active = FindConsoleInstance();
|
||||||
|
Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs DebugConsole.Execute(command) directly — bypasses the UI/gate
|
||||||
|
// entirely. Handy for scripted one-shots; the hotkey path uses the UI.
|
||||||
|
public static void ExecuteCommand(string command)
|
||||||
|
{
|
||||||
|
if (!_consoleResolved) ResolveConsole();
|
||||||
|
if (_consoleType is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var execute = AccessTools.Method(_consoleType, "Execute", new[] { typeof(string) });
|
||||||
|
if (execute is null) { Mod.Log.Warning("[debugconsole] Execute(string) not found"); return; }
|
||||||
|
execute.Invoke(null, new object[] { command });
|
||||||
|
Mod.Log.Msg($"[debugconsole] executed: {command}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] execute '{command}' failed: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ResolveInput()
|
||||||
|
{
|
||||||
|
_inputResolved = true;
|
||||||
|
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
|
||||||
|
var inputType = Mod.ResolveType("UnityEngine.Input");
|
||||||
|
if (keyCodeType is null || inputType is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { _toggleKeyValue = Enum.Parse(keyCodeType, Mod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[debugconsole] '{Mod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
|
||||||
|
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
|
||||||
|
}
|
||||||
|
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
|
||||||
|
if (_getKeyDown is null) Mod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
|
||||||
|
else Mod.Log.Msg($"[debugconsole] hotkey armed: '{Mod.Cfg.DebugConsoleToggleKey}' (poll live)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ResolveConsole()
|
||||||
|
{
|
||||||
|
_consoleResolved = true;
|
||||||
|
_consoleType = Mod.ResolveType("RecRoom.Debugging.DebugConsole");
|
||||||
|
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
|
||||||
|
|
||||||
|
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
|
||||||
|
// name, then a structural fallback that fits both builds.
|
||||||
|
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
|
||||||
|
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||||
|
?? AccessTools.Method(_consoleType, "KIAMDEDKFBC", new[] { typeof(bool) })
|
||||||
|
?? FindToggleByShape(_consoleType);
|
||||||
|
if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The console's show/hide is the single private, instance, void method
|
||||||
|
// taking exactly one bool, excluding compiler-generated members (the
|
||||||
|
// property backing setter on 2020.12 is also void(bool) but is marked
|
||||||
|
// [CompilerGenerated]).
|
||||||
|
private static MethodInfo? FindToggleByShape(Type t)
|
||||||
|
{
|
||||||
|
MethodInfo? found = null;
|
||||||
|
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||||
|
{
|
||||||
|
if (m.ReturnType != typeof(void)) continue;
|
||||||
|
var ps = m.GetParameters();
|
||||||
|
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
|
||||||
|
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||||
|
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
|
||||||
|
if (found is not null) return null; // ambiguous — don't guess
|
||||||
|
found = m;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Toggle()
|
||||||
|
{
|
||||||
|
if (!_consoleResolved) ResolveConsole();
|
||||||
|
if (_consoleType is null || _toggleMethod is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var instance = FindConsoleInstance();
|
||||||
|
if (instance is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Best-effort: flip the static "show button" enable so the
|
||||||
|
// game's own Update keeps the console available instead of
|
||||||
|
// re-hiding it. Failures are ignored (obfuscated builds).
|
||||||
|
EnableShowButton();
|
||||||
|
_consoleVisible = !_consoleVisible;
|
||||||
|
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
|
||||||
|
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||||
|
if (_consoleVisible)
|
||||||
|
ShowConfiguredCommands(instance);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? FindConsoleInstance()
|
||||||
|
{
|
||||||
|
if (_consoleType is null) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var il2cppType = ToIl2CppType(_consoleType);
|
||||||
|
if (il2cppType is null) return null;
|
||||||
|
|
||||||
|
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||||
|
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||||
|
var count = ArrLen(all);
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var item = ArrItem(all, i);
|
||||||
|
if (item is not null) return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
var objType = Mod.ResolveType("UnityEngine.Object");
|
||||||
|
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
|
||||||
|
return findOne?.Invoke(null, new[] { il2cppType });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[debugconsole] FindConsoleInstance failed: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowConfiguredCommands(object instance)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_consoleType is null) return;
|
||||||
|
_setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) });
|
||||||
|
if (_setConsoleText is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[debugconsole] SetConsoleText(string) not found; configured commands: {FormatConfiguredCommandsForLog()}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setConsoleText.Invoke(instance, new object[] { BuildConfiguredCommandText() });
|
||||||
|
Mod.Log.Msg($"[debugconsole] displayed configured commands: {FormatConfiguredCommandsForLog()}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[debugconsole] failed to display configured commands: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildConfiguredCommandText()
|
||||||
|
{
|
||||||
|
return BuildCommandReferenceText("DorkNet debug commands", maxCommands: 80) + "\n\nType a command and press Enter.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BuildCommandReferenceText(string header, int maxCommands = int.MaxValue)
|
||||||
|
{
|
||||||
|
var commands = GetAvailableCommands();
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine(header);
|
||||||
|
sb.AppendLine();
|
||||||
|
if (commands.Count == 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine("No DebugConsoleCommandConfig commands found.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var visible = Math.Min(commands.Count, maxCommands);
|
||||||
|
for (var i = 0; i < visible; i++)
|
||||||
|
sb.AppendLine($"{i + 1}. {commands[i].Label}: {commands[i].Command}");
|
||||||
|
if (visible < commands.Count)
|
||||||
|
{
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"+ {commands.Count - visible} more commands available through the debug console.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static List<ConfiguredCommand> GetAvailableCommands()
|
||||||
|
{
|
||||||
|
if (_commandCache is not null) return _commandCache;
|
||||||
|
|
||||||
|
var commands = new List<ConfiguredCommand>();
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
AddConfiguredCommands(commands, seen);
|
||||||
|
AddRuntimeCommands(commands, seen);
|
||||||
|
commands.Sort((a, b) => string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase));
|
||||||
|
_commandCache = commands;
|
||||||
|
Mod.Log.Msg($"[debugconsole] command table loaded: {commands.Count} command(s)");
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddConfiguredCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||||
|
{
|
||||||
|
foreach (var entry in Mod.Cfg.DevCommands)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(entry)) continue;
|
||||||
|
var split = entry.IndexOf('=');
|
||||||
|
var label = split >= 0 ? entry[..split].Trim() : entry.Trim();
|
||||||
|
var command = split >= 0 ? entry[(split + 1)..].Trim() : entry.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(command)) continue;
|
||||||
|
if (string.IsNullOrWhiteSpace(label)) label = command;
|
||||||
|
AddCommand(commands, seen, label, command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddRuntimeCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cfgType = Mod.ResolveType("RecRoom.Debugging.DebugConsoleCommandConfig");
|
||||||
|
if (cfgType is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[debugconsole] DebugConsoleCommandConfig type not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var method in cfgType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (method.GetParameters().Length != 0) continue;
|
||||||
|
if (!cfgType.IsAssignableFrom(method.ReturnType)) continue;
|
||||||
|
try { AddCommandConfig(method.Invoke(null, Array.Empty<object>()), commands, seen); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
var il2cppType = ToIl2CppType(cfgType);
|
||||||
|
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||||
|
if (il2cppType is not null && resources is not null)
|
||||||
|
{
|
||||||
|
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||||
|
var count = ArrLen(all);
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
AddCommandConfig(ArrItem(all, i), commands, seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[debugconsole] runtime command scan failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddCommandConfig(object? config, List<ConfiguredCommand> commands, HashSet<string> seen)
|
||||||
|
{
|
||||||
|
if (config is null) return;
|
||||||
|
var metas = GetMemberValue(config, "Metas");
|
||||||
|
var count = ArrLen(metas);
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var meta = ArrItem(metas, i);
|
||||||
|
if (meta is null) continue;
|
||||||
|
var methodName = ReadStringMember(meta, "MethodName");
|
||||||
|
if (string.IsNullOrWhiteSpace(methodName)) continue;
|
||||||
|
var declaringType = ReadStringMember(meta, "DeclaringType");
|
||||||
|
var label = string.IsNullOrWhiteSpace(declaringType)
|
||||||
|
? methodName
|
||||||
|
: $"{ShortTypeName(declaringType)}.{methodName}";
|
||||||
|
AddCommand(commands, seen, label, methodName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddCommand(List<ConfiguredCommand> commands, HashSet<string> seen, string label, string command)
|
||||||
|
{
|
||||||
|
label = CleanLabel(label);
|
||||||
|
command = (command ?? string.Empty).Trim();
|
||||||
|
if (command.Length == 0 || !seen.Add(command)) return;
|
||||||
|
commands.Add(new ConfiguredCommand(label, command));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? GetMemberValue(object instance, string name)
|
||||||
|
{
|
||||||
|
var type = instance.GetType();
|
||||||
|
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
|
||||||
|
{
|
||||||
|
try { return prop.GetValue(instance); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
var field = type.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
if (field is not null)
|
||||||
|
{
|
||||||
|
try { return field.GetValue(instance); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadStringMember(object instance, string name)
|
||||||
|
{
|
||||||
|
return GetMemberValue(instance, name)?.ToString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CleanLabel(string label)
|
||||||
|
{
|
||||||
|
label = (label ?? string.Empty).Trim();
|
||||||
|
if (label.Length == 0) return "Command";
|
||||||
|
|
||||||
|
var cleaned = new char[label.Length];
|
||||||
|
for (var i = 0; i < label.Length; i++)
|
||||||
|
{
|
||||||
|
var ch = label[i];
|
||||||
|
cleaned[i] = char.IsLetterOrDigit(ch) || ch is '.' or '_' or '-' or ' ' ? ch : '_';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ShortTypeName(string typeName)
|
||||||
|
{
|
||||||
|
var idx = typeName.LastIndexOf('.');
|
||||||
|
return idx >= 0 && idx < typeName.Length - 1 ? typeName[(idx + 1)..] : typeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly struct ConfiguredCommand
|
||||||
|
{
|
||||||
|
public ConfiguredCommand(string label, string command)
|
||||||
|
{
|
||||||
|
Label = label;
|
||||||
|
Command = command;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Label { get; }
|
||||||
|
public string Command { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnableShowButton()
|
||||||
|
{
|
||||||
|
if (_consoleType is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
|
||||||
|
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
|
||||||
|
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
|
||||||
|
{
|
||||||
|
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
|
||||||
|
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* best-effort — the toggle still works without it */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ToIl2CppType(Type managed)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var il2cppTypeType = Mod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
|
||||||
|
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
|
||||||
|
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
|
||||||
|
if (from is null) return null;
|
||||||
|
var args = from.GetParameters().Length == 2
|
||||||
|
? new object[] { managed, true }
|
||||||
|
: new object[] { managed };
|
||||||
|
return from.Invoke(null, args);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MethodInfo? OneArgMethod(Type? type, string name, object arg)
|
||||||
|
{
|
||||||
|
if (type is null) return null;
|
||||||
|
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (method.Name != name) continue;
|
||||||
|
var parameters = method.GetParameters();
|
||||||
|
if (parameters.Length == 1 && parameters[0].ParameterType.IsInstanceOfType(arg))
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ArrLen(object? array)
|
||||||
|
{
|
||||||
|
if (array is null) return 0;
|
||||||
|
if (array is Array managedArray) return managedArray.Length;
|
||||||
|
var type = array.GetType();
|
||||||
|
var length = type.GetProperty("Length") ?? type.GetProperty("Count");
|
||||||
|
return (length?.GetValue(array) as int?) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ArrItem(object? array, int index)
|
||||||
|
{
|
||||||
|
if (array is null) return null;
|
||||||
|
if (array is Array managedArray) return managedArray.GetValue(index);
|
||||||
|
var getItem = array.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||||
|
return getItem?.Invoke(array, new object[] { index });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Dev-menu runtime probe (one-shot diagnostic) ────────────────────────
|
||||||
|
// Answers stripped-vs-gated for the watch developer menu. With a real dev
|
||||||
|
// account + forced flag the menu still doesn't appear, so the gate isn't the
|
||||||
|
// account flag — either the native dev-menu prefab content is stripped from
|
||||||
|
// this retail build, or a build-level flag skips its init. This logs the
|
||||||
|
// actual runtime state once ~5s after load (no behaviour change) so we know
|
||||||
|
// whether to un-gate the game's own dev menu or build our own watch entry.
|
||||||
|
// Called from OnUpdate when Cfg.DiagnoseDevMenu is set; self-gates to run
|
||||||
|
// once. Not a Harmony holder — invoked directly, so it's not registered in
|
||||||
|
// GetPatchMethod.
|
||||||
|
internal static class DevMenuProbe
|
||||||
|
{
|
||||||
|
private static int _frame;
|
||||||
|
private static bool _done;
|
||||||
|
|
||||||
|
public static void Tick()
|
||||||
|
{
|
||||||
|
if (_done) return;
|
||||||
|
_frame++;
|
||||||
|
if (_frame < 120) return; // ~2s — let MelonLoader/game boot
|
||||||
|
if ((_frame % 60) != 0) return; // then check ~once per second
|
||||||
|
// Only fire once a REAL, in-scene HomeScreenFlow exists with its
|
||||||
|
// UIBase wired (it gets assigned when the watch StackedUI initialises
|
||||||
|
// in the dorm). The earlier version fired during boot on the loaded
|
||||||
|
// PREFAB (Resources.FindObjectsOfTypeAll returns prefabs/inactive,
|
||||||
|
// whose runtime UIBase is null) and, being one-shot, captured nothing
|
||||||
|
// and never retried. NO timeout now — we wait until the watch is up.
|
||||||
|
var homeType = Mod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? Mod.ResolveType("HomeScreenFlow");
|
||||||
|
var home = homeType != null ? FindLiveHome(homeType) : null;
|
||||||
|
if (home == null)
|
||||||
|
{
|
||||||
|
if ((_frame % 600) == 0)
|
||||||
|
Mod.Log.Msg("[devmenu-probe] waiting for an in-scene HomeScreenFlow — enter your dorm / open the watch…");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_done = true;
|
||||||
|
try { Run(homeType!, home); }
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] failed: {ex}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The live HomeScreenFlow (UIBase wired), skipping the prefab/inactive
|
||||||
|
// copies FindObjectsOfTypeAll also returns.
|
||||||
|
private static object? FindLiveHome(Type homeType)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var il2 = ToIl2CppType(homeType);
|
||||||
|
if (il2 is null) return null;
|
||||||
|
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||||
|
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2)?.Invoke(null, new[] { il2 });
|
||||||
|
int n = ArrLen(all);
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var o = ArrItem(all, i);
|
||||||
|
if (o is null) continue;
|
||||||
|
if (GetMemberValue(homeType, o, "UIBase", "get_UIBase") != null) return o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Run(Type homeType, object homeInst)
|
||||||
|
{
|
||||||
|
Mod.Log.Msg("=== [devmenu-probe] runtime dev-UI state ===");
|
||||||
|
ProbeType("RecRoom.Core.WatchUI", "WatchUI", DumpWatchUi);
|
||||||
|
Mod.Log.Msg($"[devmenu-probe] HomeScreenFlow: type={homeType.FullName}, liveInstance=FOUND (UIBase wired)");
|
||||||
|
try { DumpHomeScreen(homeType, homeInst); }
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] HomeScreenFlow dump failed: {ex.Message}"); }
|
||||||
|
ProbeType("RecRoom.Debugging.DebugConsole", "DebugConsole", DumpDebugConsole);
|
||||||
|
Mod.Log.Msg("=== [devmenu-probe] done ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ProbeType(string fullName, string shortName, Action<Type, object?> dump)
|
||||||
|
{
|
||||||
|
var t = Mod.ResolveType(fullName) ?? Mod.ResolveType(shortName);
|
||||||
|
if (t is null) { Mod.Log.Msg($"[devmenu-probe] {shortName}: TYPE NOT FOUND"); return; }
|
||||||
|
var inst = FindOne(t);
|
||||||
|
Mod.Log.Msg($"[devmenu-probe] {shortName}: type={t.FullName}, liveInstance={(inst is null ? "null" : "FOUND")}");
|
||||||
|
try { dump(t, inst); } catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] {shortName} dump failed: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DumpHomeScreen(Type t, object? inst)
|
||||||
|
{
|
||||||
|
if (inst is null) { Mod.Log.Msg(" (no HomeScreenFlow instance live yet — open the watch, then re-probe)"); return; }
|
||||||
|
// Il2CppInterop exposes IL2CPP instance fields as PROPERTIES on the
|
||||||
|
// managed proxy, so a plain AccessTools.Field misses them (the earlier
|
||||||
|
// "field not found" was a false negative). Try property first, then
|
||||||
|
// field. NULL => content stripped from the prefab (can't un-gate);
|
||||||
|
// present => only gated.
|
||||||
|
foreach (var name in new[] { "devMenuPanel", "devButtonPrefab", "reportBugButtonSpacer" })
|
||||||
|
{
|
||||||
|
var (found, mt, v) = GetMember(t, inst, name);
|
||||||
|
if (!found) { Mod.Log.Msg($" {name}: <not found as property or field>"); continue; }
|
||||||
|
var extra = "";
|
||||||
|
if (v != null && name == "devMenuPanel")
|
||||||
|
{
|
||||||
|
try { var cc = v.GetType().GetProperty("childCount")?.GetValue(v); if (cc != null) extra = $" childCount={cc}"; } catch { }
|
||||||
|
}
|
||||||
|
Mod.Log.Msg($" {name} ({mt.Name}): {(v is null ? "NULL <-- stripped/unset" : "present")}{extra}");
|
||||||
|
}
|
||||||
|
// Calibration: are ordinary serialized field names exposed at all on
|
||||||
|
// the runtime proxy? If 'profileButton' (a non-dev [SerializeField])
|
||||||
|
// IS found but devMenuPanel isn't, the dev fields are genuinely absent
|
||||||
|
// in this build. If profileButton is ALSO not found, Il2CppInterop
|
||||||
|
// isn't exposing these fields by name (need a different access path).
|
||||||
|
foreach (var cal in new[] { "profileButton", "settingsButton" })
|
||||||
|
{
|
||||||
|
var (cf, cmt, cv) = GetMember(t, inst, cal);
|
||||||
|
Mod.Log.Msg($" [calib] {cal}: {(cf ? $"FOUND ({cmt.Name}, {(cv is null ? "null" : "present")})" : "not found")}");
|
||||||
|
}
|
||||||
|
// Named lookups miss → dump the proxy's actual member names to see
|
||||||
|
// Il2CppInterop's scheme, then enumerate Button3D children directly
|
||||||
|
// (no field names needed) — those are our clone sources for injection.
|
||||||
|
var fs = t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
var ps = t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
var fn = new List<string>(); foreach (var f in fs) fn.Add(f.Name);
|
||||||
|
var pn = new List<string>(); foreach (var p in ps) pn.Add(p.Name);
|
||||||
|
Mod.Log.Msg($" [members] fields={fn.Count}, props={pn.Count}");
|
||||||
|
if (fn.Count > 0) Mod.Log.Msg($" [fieldnames] {string.Join(", ", fn.GetRange(0, Math.Min(40, fn.Count)))}");
|
||||||
|
if (pn.Count > 0) Mod.Log.Msg($" [propnames] {string.Join(", ", pn.GetRange(0, Math.Min(40, pn.Count)))}");
|
||||||
|
DumpWatchButtons(t, inst);
|
||||||
|
DumpCollections(t, inst, "HomeScreenFlow");
|
||||||
|
DumpStackedUi(t, inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk HomeScreenFlow -> UIBase (StackedUIBase) and dump the data we need
|
||||||
|
// to build a CUSTOM watch page by cloning a real flow:
|
||||||
|
// * flowResources catalog — the TypeName of every PUSHABLE flow; these
|
||||||
|
// are our clone-target candidates (pick a simple self-contained one).
|
||||||
|
// * the Type->flow cache — what's already instantiated (do NOT mutate
|
||||||
|
// these cached singletons; clone an independent copy instead).
|
||||||
|
// * ScreenHeader presence — proves a cloned flow brings its own title
|
||||||
|
// bar + back button for free.
|
||||||
|
// Names are obfuscated, so we match by SHAPE (array-of-prefab-ref,
|
||||||
|
// Dictionary<Type,flow>) rather than by field name.
|
||||||
|
private static void DumpStackedUi(Type homeType, object homeInst)
|
||||||
|
{
|
||||||
|
var ui = GetMemberValue(homeType, homeInst, "UIBase", "get_UIBase");
|
||||||
|
if (ui is null) { Mod.Log.Msg(" [stackedui] UIBase: null (could not reach StackedUIBase)"); return; }
|
||||||
|
var uiType = ui.GetType();
|
||||||
|
Mod.Log.Msg($" [stackedui] UIBase type={uiType.FullName}");
|
||||||
|
|
||||||
|
var hdr = GetMemberValue(homeType, homeInst, "ScreenHeader", "get_ScreenHeader");
|
||||||
|
if (hdr != null)
|
||||||
|
{
|
||||||
|
var ht = hdr.GetType();
|
||||||
|
var hp = new List<string>(); foreach (var p in ht.GetProperties()) hp.Add(p.Name);
|
||||||
|
Mod.Log.Msg($" [stackedui] home ScreenHeader={ht.Name}; props={string.Join(",", hp.GetRange(0, Math.Min(30, hp.Count)))}");
|
||||||
|
}
|
||||||
|
else Mod.Log.Msg(" [stackedui] home ScreenHeader: null");
|
||||||
|
|
||||||
|
foreach (var f in uiType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||||
|
{
|
||||||
|
object? v; try { v = f.GetValue(ui); } catch { continue; }
|
||||||
|
if (v is null) continue;
|
||||||
|
int n = ArrLen(v);
|
||||||
|
// Flow-prefab catalog: a non-empty array whose elements expose a
|
||||||
|
// TypeName string (FlowPrefabResourceReference).
|
||||||
|
if (n > 0 && n <= 300 && (v is Array || v.GetType().Name.IndexOf("Array", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||||
|
{
|
||||||
|
var first = ArrItem(v, 0);
|
||||||
|
var et = first?.GetType().Name ?? "?";
|
||||||
|
if (et.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||||
|
et.IndexOf("Prefab", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||||
|
et.IndexOf("Resource", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||||
|
et.IndexOf("Reference", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||||
|
Mod.Log.Msg($" [stackedui] flow catalog '{f.Name}': {et}[{n}]");
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var e = ArrItem(v, i); if (e is null) continue;
|
||||||
|
Mod.Log.Msg($" [{i}] {ReadStringMember(e)}");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Flow cache: Dictionary<Type, StackedUIFlowBase>.
|
||||||
|
var vt = v.GetType();
|
||||||
|
if (vt.IsGenericType && vt.Name.StartsWith("Dictionary"))
|
||||||
|
{
|
||||||
|
var ga = vt.GetGenericArguments();
|
||||||
|
if (ga.Length == 2 && ga[1].Name.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
{
|
||||||
|
var cp = vt.GetProperty("Count");
|
||||||
|
Mod.Log.Msg($" [stackedui] flow cache '{f.Name}': Dictionary<{ga[0].Name},{ga[1].Name}> count={(cp?.GetValue(v) as int? ?? -1)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property by name, else parameterless getter method, else null.
|
||||||
|
private static object? GetMemberValue(Type t, object inst, string prop, string getter)
|
||||||
|
{
|
||||||
|
var (found, _, v) = GetMember(t, inst, prop);
|
||||||
|
if (found && v != null) return v;
|
||||||
|
var m = t.GetMethod(getter, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
|
||||||
|
null, Type.EmptyTypes, null);
|
||||||
|
if (m != null) { try { return m.Invoke(inst, null); } catch { } }
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First non-empty string field/property on an object — for obfuscated
|
||||||
|
// wrappers, that's the TypeName of a FlowPrefabResourceReference.
|
||||||
|
private static string ReadStringMember(object o)
|
||||||
|
{
|
||||||
|
var t = o.GetType();
|
||||||
|
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||||
|
if (p.PropertyType == typeof(string) && p.GetIndexParameters().Length == 0)
|
||||||
|
{ try { if (p.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
|
||||||
|
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||||
|
if (f.FieldType == typeof(string))
|
||||||
|
{ try { if (f.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
|
||||||
|
return "(no string member)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enumerate Button3D components under the HomeScreenFlow via Unity's
|
||||||
|
// GetComponentsInChildren(Type, includeInactive) — needs no field names.
|
||||||
|
// Confirms we can grab native buttons to clone for the dev entry.
|
||||||
|
private static void DumpWatchButtons(Type homeType, object homeInst)
|
||||||
|
{
|
||||||
|
var btnType = Mod.ResolveType("AGUI.StackedUI.Button3D");
|
||||||
|
if (btnType is null) { Mod.Log.Msg(" [buttons] Button3D type NOT FOUND"); return; }
|
||||||
|
var il2 = ToIl2CppType(btnType);
|
||||||
|
if (il2 is null) { Mod.Log.Msg(" [buttons] Button3D il2cppType null"); return; }
|
||||||
|
MethodInfo? m = null;
|
||||||
|
foreach (var mi in homeType.GetMethods(BindingFlags.Public | BindingFlags.Instance))
|
||||||
|
{
|
||||||
|
if (mi.Name != "GetComponentsInChildren") continue;
|
||||||
|
var mp = mi.GetParameters();
|
||||||
|
if (mp.Length == 2 && mp[0].ParameterType.IsInstanceOfType(il2) && mp[1].ParameterType == typeof(bool)) { m = mi; break; }
|
||||||
|
}
|
||||||
|
if (m is null) { Mod.Log.Msg(" [buttons] GetComponentsInChildren(Type,bool) not found"); return; }
|
||||||
|
object? arr;
|
||||||
|
try { arr = m.Invoke(homeInst, new object[] { il2, true }); }
|
||||||
|
catch (Exception ex) { Mod.Log.Msg($" [buttons] GetComponentsInChildren threw: {(ex.InnerException ?? ex).Message}"); return; }
|
||||||
|
int n = ArrLen(arr);
|
||||||
|
Mod.Log.Msg($" [buttons] Button3D children: {n}");
|
||||||
|
for (int i = 0; i < n && i < 20; i++)
|
||||||
|
{
|
||||||
|
var b = ArrItem(arr, i);
|
||||||
|
var nm = b?.GetType().GetProperty("name")?.GetValue(b)?.ToString() ?? "?";
|
||||||
|
Mod.Log.Msg($" [{i}] {nm}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ArrLen(object? arr)
|
||||||
|
{
|
||||||
|
if (arr is null) return 0;
|
||||||
|
if (arr is Array a) return a.Length;
|
||||||
|
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
|
||||||
|
return (lp?.GetValue(arr) as int?) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ArrItem(object? arr, int i)
|
||||||
|
{
|
||||||
|
if (arr is null) return null;
|
||||||
|
if (arr is Array a) return a.GetValue(i);
|
||||||
|
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||||
|
return gi?.Invoke(arr, new object[] { i });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (bool found, Type type, object? val) GetMember(Type t, object inst, string name)
|
||||||
|
{
|
||||||
|
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||||
|
{ try { return (true, p.PropertyType, p.GetValue(inst)); } catch { } }
|
||||||
|
var f = AccessTools.Field(t, name);
|
||||||
|
if (f != null) { try { return (true, f.FieldType, f.GetValue(inst)); } catch { } }
|
||||||
|
return (false, typeof(object), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DumpWatchUi(Type t, object? inst)
|
||||||
|
{
|
||||||
|
if (inst is null) { Mod.Log.Msg(" (no WatchUI instance live yet)"); return; }
|
||||||
|
DumpCollections(t, inst, "WatchUI");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DumpDebugConsole(Type t, object? inst)
|
||||||
|
{
|
||||||
|
foreach (var name in new[] { "GJIKMPIMDHH", "IEGCNLHPKFH", "ShowButton", "ShowErrorsInHeadset", "ShowUnityDevelopmentConsole" })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||||
|
if (p != null && p.CanRead) { Mod.Log.Msg($" static {name} = {p.GetValue(null)}"); continue; }
|
||||||
|
var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||||
|
if (f != null) Mod.Log.Msg($" static {name} = {f.GetValue(null)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Msg($" {name}: read failed ({ex.Message})"); }
|
||||||
|
}
|
||||||
|
// Does the static command path work without the console UI in-scene?
|
||||||
|
var exec = AccessTools.Method(t, "Execute", new[] { typeof(string) });
|
||||||
|
if (exec is null) { Mod.Log.Msg(" Execute(string): <not found>"); return; }
|
||||||
|
try { exec.Invoke(null, new object[] { "help" }); Mod.Log.Msg(" Execute(\"help\"): invoked OK — static command path is usable"); }
|
||||||
|
catch (Exception ex) { var e = ex.InnerException ?? ex; Mod.Log.Msg($" Execute(\"help\"): threw {e.GetType().Name} ({e.Message})"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log every array / List<> field whose element type mentions Flow/Watch,
|
||||||
|
// with its count — surfaces the dev-flow list (devOnlyFlows-equivalent)
|
||||||
|
// regardless of its obfuscated name, so we can see if dev flows exist.
|
||||||
|
private static void DumpCollections(Type t, object inst, string label)
|
||||||
|
{
|
||||||
|
var members = new List<(string name, object? val)>();
|
||||||
|
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||||
|
{ try { members.Add((f.Name, f.GetValue(inst))); } catch { } }
|
||||||
|
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||||
|
{ if (p.CanRead && p.GetIndexParameters().Length == 0) { try { members.Add((p.Name, p.GetValue(inst))); } catch { } } }
|
||||||
|
|
||||||
|
foreach (var (name, v) in members)
|
||||||
|
{
|
||||||
|
if (v is null) continue;
|
||||||
|
int? count = null; string elem = "";
|
||||||
|
if (v is Array arr) { count = arr.Length; elem = v.GetType().GetElementType()?.Name ?? "?"; }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var ft = v.GetType();
|
||||||
|
if (ft.IsGenericType && typeof(System.Collections.IEnumerable).IsAssignableFrom(ft))
|
||||||
|
{
|
||||||
|
var cp = ft.GetProperty("Count");
|
||||||
|
if (cp != null && cp.PropertyType == typeof(int))
|
||||||
|
{
|
||||||
|
count = (int?)cp.GetValue(v);
|
||||||
|
var ga = ft.GetGenericArguments();
|
||||||
|
elem = ga.Length > 0 ? ga[0].Name : "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count.HasValue &&
|
||||||
|
(elem.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||||
|
elem.IndexOf("Watch", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||||
|
Mod.Log.Msg($" {label}.{name}: {elem}[{count}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under Il2CppInterop, UnityEngine.Object.FindObjectOfType takes an
|
||||||
|
// Il2CppSystem.Type (NOT System.Type), so AccessTools.Method(...,
|
||||||
|
// typeof(Type)) misses — that's the "FindObjectOfType(Type) not found"
|
||||||
|
// in the log. Convert the managed Type to an Il2CppSystem.Type via
|
||||||
|
// Il2CppType.From, then use Resources.FindObjectsOfTypeAll (which also
|
||||||
|
// returns INACTIVE objects + loaded prefab instances — important, the
|
||||||
|
// watch UI is usually inactive until opened). Falls back to the
|
||||||
|
// active-only FindObjectOfType.
|
||||||
|
private static object? FindOne(Type t)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var il2cppType = ToIl2CppType(t);
|
||||||
|
if (il2cppType is null) return null;
|
||||||
|
|
||||||
|
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||||
|
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType);
|
||||||
|
var first = FirstOf(all?.Invoke(null, new[] { il2cppType }));
|
||||||
|
if (first != null) return first;
|
||||||
|
|
||||||
|
var objType = Mod.ResolveType("UnityEngine.Object");
|
||||||
|
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
|
||||||
|
return findOne?.Invoke(null, new[] { il2cppType });
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ToIl2CppType(Type managed)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var il2cppTypeType = Mod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
|
||||||
|
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
|
||||||
|
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
|
||||||
|
if (from is null) return null;
|
||||||
|
var args = from.GetParameters().Length == 2
|
||||||
|
? new object[] { managed, true } : new object[] { managed };
|
||||||
|
return from.Invoke(null, args);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a public static method `name` whose single parameter accepts `arg`.
|
||||||
|
private static MethodInfo? OneArgMethod(Type? t, string name, object arg)
|
||||||
|
{
|
||||||
|
if (t is null) return null;
|
||||||
|
foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (m.Name != name) continue;
|
||||||
|
var ps = m.GetParameters();
|
||||||
|
if (ps.Length == 1 && ps[0].ParameterType.IsInstanceOfType(arg)) return m;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First element of an Il2CppReferenceArray / Array / IList, or null.
|
||||||
|
private static object? FirstOf(object? arr)
|
||||||
|
{
|
||||||
|
if (arr is null) return null;
|
||||||
|
if (arr is Array a) return a.Length > 0 ? a.GetValue(0) : null;
|
||||||
|
var at = arr.GetType();
|
||||||
|
var lenProp = at.GetProperty("Length") ?? at.GetProperty("Count");
|
||||||
|
if (lenProp?.GetValue(arr) is int n && n > 0)
|
||||||
|
{
|
||||||
|
var getItem = at.GetMethod("get_Item", new[] { typeof(int) });
|
||||||
|
if (getItem != null) return getItem.Invoke(arr, new object[] { 0 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Desktop Screen Sharing FPS override ─────────────────────────────────
|
||||||
|
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts the
|
||||||
|
// shared desktop image. Capture cadence is the baked [SerializeField]
|
||||||
|
// float screenShareImageRefreshFrequency; the broadcast tick gates on
|
||||||
|
// if (Time.time - lastSend >= 1f / screenShareImageRefreshFrequency)
|
||||||
|
// (DesktopScreenSharingDisplay.txt:697-703) — so the field is literally the
|
||||||
|
// FPS, with no runtime clamp (the [Range] is editor-only). We rewrite it in
|
||||||
|
// an Awake postfix. The captured frame is transmitted via the component's
|
||||||
|
// IPunObservable OnPhotonSerializeView, which only fires at
|
||||||
|
// PhotonNetwork.SerializationRate (default 10 Hz), so we also lift the
|
||||||
|
// Photon send/serialization rate to cover the target — otherwise a 30 fps
|
||||||
|
// capture still only delivers ~10. Field names are un-obfuscated on both
|
||||||
|
// builds (confirmed in the 2020.12.18 dump), so a plain AccessTools.Field
|
||||||
|
// lookup works.
|
||||||
|
internal static class ScreenSharePatches
|
||||||
|
{
|
||||||
|
private static bool _photonRateApplied;
|
||||||
|
|
||||||
|
public static void DisplayAwake_Postfix(object __instance)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fps = Mod.Cfg.DesktopScreenShareFps;
|
||||||
|
if (fps <= 0f || __instance is null) return;
|
||||||
|
var t = __instance.GetType();
|
||||||
|
|
||||||
|
SetField(t, __instance, "screenShareImageRefreshFrequency", fps);
|
||||||
|
if (Mod.Cfg.DesktopScreenShareResolution > 0)
|
||||||
|
SetField(t, __instance, "screenShareImageHorizontalResolution", Mod.Cfg.DesktopScreenShareResolution);
|
||||||
|
if (Mod.Cfg.DesktopScreenShareQuality > 0)
|
||||||
|
SetField(t, __instance, "screenShareImageCompressionQuality", Mod.Cfg.DesktopScreenShareQuality);
|
||||||
|
|
||||||
|
Mod.Log.Msg($"[screenshare] {t.Name}: refreshFrequency→{fps}fps" +
|
||||||
|
(Mod.Cfg.DesktopScreenShareResolution > 0 ? $" res→{Mod.Cfg.DesktopScreenShareResolution}px" : "") +
|
||||||
|
(Mod.Cfg.DesktopScreenShareQuality > 0 ? $" quality→{Mod.Cfg.DesktopScreenShareQuality}" : ""));
|
||||||
|
|
||||||
|
if (Mod.Cfg.DesktopScreenShareRaisePhotonRate && !_photonRateApplied)
|
||||||
|
RaisePhotonRates((int)Math.Ceiling(fps));
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[screenshare] Awake postfix failed: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetField(Type t, object inst, string name, object value)
|
||||||
|
{
|
||||||
|
var f = AccessTools.Field(t, name);
|
||||||
|
if (f is null) { Mod.Log.Warning($"[screenshare] field '{name}' not found on {t.Name}"); return; }
|
||||||
|
f.SetValue(inst, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lift PhotonNetwork.SendRate + SerializationRate to >= hz. PUN requires
|
||||||
|
// SerializationRate <= SendRate, so SendRate is kept at the max of the
|
||||||
|
// two. Both are public static int members (property on some PUN builds,
|
||||||
|
// field on others) — handle either. GLOBAL: every PhotonView's
|
||||||
|
// OnPhotonSerializeView now fires at this rate.
|
||||||
|
private static void RaisePhotonRates(int hz)
|
||||||
|
{
|
||||||
|
var pn = Mod.ResolveType("PhotonNetwork");
|
||||||
|
if (pn is null) { Mod.Log.Warning("[screenshare] PhotonNetwork not found; can't raise serialization rate (delivered FPS will stay ~10)"); return; }
|
||||||
|
var send = ReadInt(pn, "SendRate");
|
||||||
|
var ser = ReadInt(pn, "SerializationRate");
|
||||||
|
var target = Math.Max(hz, 1);
|
||||||
|
var newSend = Math.Max(target, Math.Max(send, ser));
|
||||||
|
WriteInt(pn, "SendRate", newSend);
|
||||||
|
WriteInt(pn, "SerializationRate", target);
|
||||||
|
_photonRateApplied = true;
|
||||||
|
Mod.Log.Msg($"[screenshare] Photon rates raised (GLOBAL): SendRate {send}→{newSend}, SerializationRate {ser}→{target}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadInt(Type t, string name)
|
||||||
|
{
|
||||||
|
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
|
||||||
|
if (p is not null && p.CanRead && p.GetValue(null) is int pv) return pv;
|
||||||
|
var f = t.GetField(name, BindingFlags.Public | BindingFlags.Static);
|
||||||
|
if (f is not null && f.GetValue(null) is int fv) return fv;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteInt(Type t, string name, int value)
|
||||||
|
{
|
||||||
|
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
|
||||||
|
if (p is not null && p.CanWrite) { p.SetValue(null, value); return; }
|
||||||
|
var f = t.GetField(name, BindingFlags.Public | BindingFlags.Static);
|
||||||
|
if (f is not null) f.SetValue(null, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class MakerPenGiftPreviewPatches
|
||||||
|
{
|
||||||
|
private static bool _loggedMissingPointer;
|
||||||
|
private static bool _loggedMissingRestrictionFlag;
|
||||||
|
|
||||||
|
public static bool LaserPointerEnabled_Prefix(object __instance, bool ONGBFDACHHG)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (__instance is null) return true;
|
||||||
|
var t = __instance.GetType();
|
||||||
|
if (ReadField(__instance, t, "pointerLineRenderer") is null)
|
||||||
|
{
|
||||||
|
if (!_loggedMissingPointer)
|
||||||
|
{
|
||||||
|
_loggedMissingPointer = true;
|
||||||
|
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled on MakerPenVisuals with no pointerLineRenderer");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ONGBFDACHHG && ReadField(__instance, t, "EANJBIDHAMN") is null)
|
||||||
|
{
|
||||||
|
if (!_loggedMissingRestrictionFlag)
|
||||||
|
{
|
||||||
|
_loggedMissingRestrictionFlag = true;
|
||||||
|
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled=true on MakerPenVisuals with no laser restriction flag");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[makerpen-preview] guard failed, letting original run: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ReadField(object instance, Type type, string name)
|
||||||
|
{
|
||||||
|
var field = AccessTools.Field(type, name) ?? AccessTools.Field(type.BaseType, name);
|
||||||
|
return field?.GetValue(instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class AuthPatches
|
||||||
|
{
|
||||||
|
private static string? _latestAccessToken;
|
||||||
|
private static int _tokenLogCount;
|
||||||
|
|
||||||
|
public static void CaptureTokenArgs_Prefix(object? __0, object? __1, object? __2)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var accessToken = __0?.ToString();
|
||||||
|
if (LooksLikeJwt(accessToken))
|
||||||
|
_latestAccessToken = accessToken;
|
||||||
|
else if (string.IsNullOrWhiteSpace(__0?.ToString()) && string.IsNullOrWhiteSpace(__1?.ToString()))
|
||||||
|
_latestAccessToken = null;
|
||||||
|
|
||||||
|
if (_tokenLogCount++ < 3)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
"[auth-trace] token setter " +
|
||||||
|
$"access={Presence(__0)} refresh={Presence(__1)} key={Presence(__2)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[auth-trace] token capture failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool LooksLikeJwt(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||||
|
|
||||||
|
var dots = 0;
|
||||||
|
foreach (var ch in value)
|
||||||
|
if (ch == '.') dots++;
|
||||||
|
return dots == 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Presence(object? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value?.ToString()) ? "missing" : "present";
|
||||||
|
|
||||||
|
public static void ApplyBearerTokenHeader(object? request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (request is null || string.IsNullOrWhiteSpace(_latestAccessToken)) return;
|
||||||
|
var uri = TryReadUri(request);
|
||||||
|
if (uri is null || !IsDorkNetServiceHost(uri.Host)) return;
|
||||||
|
|
||||||
|
var type = request.GetType();
|
||||||
|
var setHeader = AccessTools.Method(type, "SetHeader", new[] { typeof(string), typeof(string) })
|
||||||
|
?? AccessTools.Method(type, "AddHeader", new[] { typeof(string), typeof(string) });
|
||||||
|
setHeader?.Invoke(request, new object[] { "Authorization", "Bearer " + _latestAccessToken });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (_tokenLogCount++ < 6)
|
||||||
|
Mod.Log.Warning($"[auth-trace] bearer propagation failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Uri? TryReadUri(object request)
|
||||||
|
{
|
||||||
|
var type = request.GetType();
|
||||||
|
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||||
|
|
||||||
|
if (TryGetUri(type.GetProperty("Uri", flags), request, out var uri)) return uri;
|
||||||
|
if (TryGetUri(type.GetField("<Uri>k__BackingField", flags), request, out uri)) return uri;
|
||||||
|
|
||||||
|
foreach (var prop in type.GetProperties(flags))
|
||||||
|
{
|
||||||
|
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||||
|
if (TryGetUri(prop, request, out uri)) return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var field in type.GetFields(flags))
|
||||||
|
{
|
||||||
|
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||||
|
if (TryGetUri(field, request, out uri)) return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetUri(PropertyInfo? prop, object request, out Uri? uri)
|
||||||
|
{
|
||||||
|
uri = null;
|
||||||
|
if (prop is null || !prop.CanRead || prop.GetIndexParameters().Length != 0) return false;
|
||||||
|
uri = prop.GetValue(request) as Uri;
|
||||||
|
return uri is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetUri(FieldInfo? field, object request, out Uri? uri)
|
||||||
|
{
|
||||||
|
uri = null;
|
||||||
|
if (field is null) return false;
|
||||||
|
uri = field.GetValue(request) as Uri;
|
||||||
|
return uri is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDorkNetServiceHost(string host)
|
||||||
|
{
|
||||||
|
var configuredHost = Mod.Cfg.ServerHost?.Trim().TrimStart('.');
|
||||||
|
if (string.IsNullOrWhiteSpace(configuredHost)) return false;
|
||||||
|
|
||||||
|
return host.Equals(configuredHost, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| host.EndsWith("." + configuredHost, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| host.EndsWith("-" + configuredHost, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class HttpTracePatches
|
||||||
|
{
|
||||||
|
private const int BodyPreviewBytes = 700;
|
||||||
|
private static readonly Regex SensitivePairRegex = new(
|
||||||
|
@"(?i)(password|client_secret|access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
private static readonly Regex BearerRegex = new(
|
||||||
|
@"(?i)Bearer\s+[-._~+/=A-Za-z0-9]+",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
private static readonly Regex SensitiveHeaderRegex = new(
|
||||||
|
@"(?im)^(Authorization|Cookie|Set-Cookie|X-RNSIG):\s*[^\r\n]+",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
private static readonly Regex SensitiveJsonStringRegex = new(
|
||||||
|
@"(?i)""([^""]*(?:access_token|accessToken|refresh_token|refreshToken|authorization|token|secret|client_secret|key)[^""]*)""\s*:\s*""[^""]*""",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
private static readonly Dictionary<IntPtr, string> RecNetRequestInfoByPtr = new();
|
||||||
|
private static int _requestId;
|
||||||
|
private static int _failureLogCount;
|
||||||
|
private static int _responseTupleLogCount;
|
||||||
|
private static int _builderSendLogCount;
|
||||||
|
private static int _callbackLogCount;
|
||||||
|
private static int _uriUnreadDumpCount;
|
||||||
|
|
||||||
|
public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
TraceRequestObject(__instance, $"{__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RecNetRequestBuilderCtor_Prefix(object? __0, object? __1, object? __2)
|
||||||
|
{
|
||||||
|
var id = NextRequestId();
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[http-trace] #{id} BNDIAONDFFF.ctor method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
|
||||||
|
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RecNetRequestBuilderCtor_Postfix(object __instance, object? __0, object? __1, object? __2)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ptr = TryGetIl2CppPtr(__instance);
|
||||||
|
if (ptr == IntPtr.Zero) return;
|
||||||
|
|
||||||
|
var info =
|
||||||
|
$"ptr=0x{ptr.ToInt64():X} method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
|
||||||
|
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}";
|
||||||
|
lock (RecNetRequestInfoByPtr)
|
||||||
|
{
|
||||||
|
RecNetRequestInfoByPtr[ptr] = info;
|
||||||
|
if (RecNetRequestInfoByPtr.Count > 256)
|
||||||
|
RecNetRequestInfoByPtr.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
DiagnosticPatches.Write($"[recnet-request-map] {info}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogFailure("recnet-request-map", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RecNetBuilderSend_Prefix(object __instance, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
if (System.Threading.Interlocked.Increment(ref _builderSendLogCount) > 120) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[recnet-send] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||||
|
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogFailure("recnet-send-enter", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RecNetBuilderSend_Postfix(object __instance, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
if (_builderSendLogCount > 120) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[recnet-send] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||||
|
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogFailure("recnet-send-exit", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RecNetResponseTuple_Prefix(object __instance, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
if (System.Threading.Interlocked.Increment(ref _responseTupleLogCount) > 80) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[recnet-response-map] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||||
|
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogFailure("recnet-response-map-enter", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RecNetResponseTuple_Postfix(object __instance, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
if (_responseTupleLogCount > 80) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[recnet-response-map] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||||
|
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogFailure("recnet-response-map-exit", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Exception? RecNetResponseTuple_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
if (__exception is not null)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[recnet-response-map] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||||
|
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
|
||||||
|
$"state={DumpObjectFields(__instance, 36, 1200)}");
|
||||||
|
}
|
||||||
|
return __exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void HeaderSet_Prefix(object __instance, object? __0, object? __1, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var name = __0?.ToString() ?? "<null>";
|
||||||
|
var value = RedactHeaderValue(name, __1?.ToString());
|
||||||
|
var uri = TryReadRequestUri(__instance)?.ToString() ?? "<uri unread>";
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[http-header] {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name} " +
|
||||||
|
$"{SanitizeInline(uri)} {name}: {value}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogFailure("header", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void CallCallback_Prefix(object __instance, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[http-callback] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||||
|
$"{DescribeRequestAndResponse(__instance)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogFailure("callback-enter", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void CallCallback_Postfix(object __instance, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!ShouldLogCallback(__instance)) return;
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[http-callback] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||||
|
$"{DescribeRequestAndResponse(__instance)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogFailure("callback-exit", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Exception? CallCallback_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
if (__exception is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[http-callback] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||||
|
$"{DescribeRequestAndResponse(__instance)} " +
|
||||||
|
$"{RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
|
||||||
|
$"state={DumpObjectFields(__instance, 32, 900)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogFailure("callback-finalizer", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return __exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void TraceUrl(string? url, string source)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url)) return;
|
||||||
|
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var id = NextRequestId();
|
||||||
|
DiagnosticPatches.Write($"[http-trace] #{id} {source} url={SanitizeInline(url)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void TraceRequestObject(object? request, string source)
|
||||||
|
{
|
||||||
|
if (request is null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = NextRequestId();
|
||||||
|
var uriValue = TryReadRequestUri(request);
|
||||||
|
var uri = uriValue?.ToString() ?? "<uri unread>";
|
||||||
|
var method = TryReadHttpMethod(request);
|
||||||
|
var headers = TryReadHeaders(request);
|
||||||
|
var body = TryReadBodyPreview(request);
|
||||||
|
var fallback = uriValue is null && System.Threading.Interlocked.Increment(ref _uriUnreadDumpCount) <= 40
|
||||||
|
? $" requestFields={DumpObjectFields(request, 24, 260)}"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[http-trace] #{id} {source} method={method} uri={SanitizeInline(uri)} " +
|
||||||
|
$"headers={headers} body={body}{fallback}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogFailure(source, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int NextRequestId() => System.Threading.Interlocked.Increment(ref _requestId);
|
||||||
|
|
||||||
|
private static Uri? TryReadRequestUri(object request)
|
||||||
|
{
|
||||||
|
if (TryInvokeUriMethod(request, "KPKNHFBHPHO", out var uri)) return uri;
|
||||||
|
if (TryInvokeUriMethod(request, "IBLHNHHBNNE", out uri)) return uri;
|
||||||
|
|
||||||
|
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||||
|
var type = request.GetType();
|
||||||
|
|
||||||
|
if (TryReadUriValue(type.GetProperty("Uri", flags)?.GetValue(request), out uri)) return uri;
|
||||||
|
if (TryReadUriValue(type.GetProperty("CurrentUri", flags)?.GetValue(request), out uri)) return uri;
|
||||||
|
if (TryReadUriValue(type.GetField("<Uri>k__BackingField", flags)?.GetValue(request), out uri)) return uri;
|
||||||
|
|
||||||
|
foreach (var prop in type.GetProperties(flags))
|
||||||
|
{
|
||||||
|
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||||
|
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (TryReadUriValue(prop.GetValue(request), out uri)) return uri;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var field in type.GetFields(flags))
|
||||||
|
{
|
||||||
|
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (TryReadUriValue(field.GetValue(request), out uri)) return uri;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryInvokeUriMethod(object request, string methodName, out Uri? uri)
|
||||||
|
{
|
||||||
|
uri = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
|
||||||
|
if (method is null) return false;
|
||||||
|
return TryReadUriValue(method.Invoke(request, Array.Empty<object>()), out uri);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadUriValue(object? value, out Uri? uri)
|
||||||
|
{
|
||||||
|
uri = null;
|
||||||
|
if (value is null) return false;
|
||||||
|
if (value is Uri direct)
|
||||||
|
{
|
||||||
|
uri = direct;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = value.ToString();
|
||||||
|
if (Uri.TryCreate(text, UriKind.Absolute, out var parsed))
|
||||||
|
{
|
||||||
|
uri = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TryReadHttpMethod(object request)
|
||||||
|
{
|
||||||
|
foreach (var name in new[] { "MethodType", "Method", "LNJILMKOBIP" })
|
||||||
|
{
|
||||||
|
if (TryReadMemberValue(request, name, out var value))
|
||||||
|
return SanitizeInline(value?.ToString() ?? "<null>");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<method unread>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TryReadHeaders(object request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dump = AccessTools.Method(request.GetType(), "DumpHeaders", Type.EmptyTypes);
|
||||||
|
if (dump?.Invoke(request, Array.Empty<object>()) is object rawDump)
|
||||||
|
return SanitizeInline(rawDump.ToString() ?? "<empty>");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
var known = new List<string>();
|
||||||
|
foreach (var name in new[]
|
||||||
|
{
|
||||||
|
"Authorization", "Cookie", "X-RNSIG", "Accept-Language",
|
||||||
|
"Content-Type", "User-Agent"
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var value = TryReadHeaderValue(request, name);
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
known.Add($"{name}:{RedactHeaderValue(name, value)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return known.Count == 0 ? "<headers unread>" : string.Join("; ", known);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? TryReadHeaderValue(object request, string name)
|
||||||
|
{
|
||||||
|
foreach (var methodName in new[] { "GetFirstHeaderValue", "FKHEHIMHHBP" })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var method = AccessTools.Method(request.GetType(), methodName, new[] { typeof(string) });
|
||||||
|
var value = method?.Invoke(request, new object[] { name })?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TryReadBodyPreview(object request)
|
||||||
|
{
|
||||||
|
foreach (var methodName in new[] { "BGMFJOKNJAJ", "GetEntityBody" })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
|
||||||
|
if (method is null) continue;
|
||||||
|
var body = method.Invoke(request, Array.Empty<object>());
|
||||||
|
var bytes = TryCopyBytes(body, BodyPreviewBytes + 1);
|
||||||
|
if (bytes is null) continue;
|
||||||
|
if (bytes.Length == 0) return "<empty>";
|
||||||
|
|
||||||
|
var shown = Math.Min(bytes.Length, BodyPreviewBytes);
|
||||||
|
var text = Encoding.UTF8.GetString(bytes, 0, shown);
|
||||||
|
var suffix = bytes.Length > BodyPreviewBytes ? "...<truncated>" : "";
|
||||||
|
return $"{bytes.Length} bytes \"{SanitizeInline(text)}{suffix}\"";
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<body unread>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldLogCallback(object request)
|
||||||
|
{
|
||||||
|
var response = TryReadResponseObject(request);
|
||||||
|
var status = response is null ? null : TryReadStatusCode(response);
|
||||||
|
if (status >= 400) return true;
|
||||||
|
return System.Threading.Interlocked.Increment(ref _callbackLogCount) <= 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeRequestAndResponse(object request)
|
||||||
|
{
|
||||||
|
var uri = TryReadRequestUri(request)?.ToString() ?? "<uri unread>";
|
||||||
|
var method = TryReadHttpMethod(request);
|
||||||
|
var response = TryReadResponseObject(request);
|
||||||
|
var responseText = response is null ? "<response unread>" : DescribeResponseObject(response);
|
||||||
|
var requestState = DescribeKnownMembers(request, "State", "Exception", "Error", "IsCancellationRequested");
|
||||||
|
return $"method={method} uri={SanitizeInline(uri)} request={requestState} response={responseText}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? TryReadResponseObject(object request)
|
||||||
|
{
|
||||||
|
foreach (var name in new[] { "Response", "response", "_response", "<Response>k__BackingField" })
|
||||||
|
{
|
||||||
|
if (TryReadMemberValue(request, name, out var value) && value is not null)
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||||
|
foreach (var prop in request.GetType().GetProperties(flags))
|
||||||
|
{
|
||||||
|
if (prop.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||||
|
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var value = prop.GetValue(request);
|
||||||
|
if (value is not null) return value;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var field in request.GetType().GetFields(flags))
|
||||||
|
{
|
||||||
|
if (field.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var value = field.GetValue(request);
|
||||||
|
if (value is not null) return value;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? TryReadStatusCode(object response)
|
||||||
|
{
|
||||||
|
if (TryReadMemberValue(response, "StatusCode", out var value))
|
||||||
|
{
|
||||||
|
try { return Convert.ToInt32(value); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeResponseObject(object response)
|
||||||
|
{
|
||||||
|
var known = DescribeKnownMembers(response,
|
||||||
|
"StatusCode", "Message", "IsSuccess", "DataAsText", "Data", "Exception", "Error");
|
||||||
|
return $"{response.GetType().FullName} {{{known}}}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeKnownMembers(object obj, params string[] names)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (!TryReadMemberValue(obj, name, out var value)) continue;
|
||||||
|
parts.Add($"{name}={FormatValue(value, 700)}");
|
||||||
|
}
|
||||||
|
return parts.Count == 0 ? "<no known members>" : string.Join("; ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatValue(object? value, int limit)
|
||||||
|
{
|
||||||
|
if (value is null) return "null";
|
||||||
|
if (value is byte[] bytes)
|
||||||
|
return $"byte[{bytes.Length}]";
|
||||||
|
if (value is IEnumerable enumerable && value is not string)
|
||||||
|
return FormatEnumerable(enumerable, value.GetType(), limit);
|
||||||
|
var text = value is Exception ex
|
||||||
|
? $"{ex.GetType().Name}: {ex.Message}"
|
||||||
|
: value.ToString() ?? "<null>";
|
||||||
|
text = SanitizeInline(text);
|
||||||
|
return text.Length <= limit ? text : text[..limit] + "...<truncated>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatEnumerable(IEnumerable enumerable, Type type, int limit)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
var countText = TryReadEnumerableCount(enumerable, out var count) ? count.ToString() : "?";
|
||||||
|
var shown = 0;
|
||||||
|
|
||||||
|
foreach (var item in enumerable)
|
||||||
|
{
|
||||||
|
if (shown++ >= 6)
|
||||||
|
{
|
||||||
|
parts.Add("...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.Add(FormatValue(item, 180));
|
||||||
|
}
|
||||||
|
|
||||||
|
var rendered = $"{type.FullName} count={countText} [{string.Join(", ", parts)}]";
|
||||||
|
return rendered.Length <= limit ? rendered : rendered[..limit] + "...<truncated>";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"{type.FullName} <enumeration failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadEnumerableCount(IEnumerable enumerable, out int count)
|
||||||
|
{
|
||||||
|
count = 0;
|
||||||
|
if (enumerable is ICollection collection)
|
||||||
|
{
|
||||||
|
count = collection.Count;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var prop = enumerable.GetType().GetProperty("Count", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
if (prop?.GetValue(enumerable) is int value)
|
||||||
|
{
|
||||||
|
count = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[]? TryCopyBytes(object? value, int limit)
|
||||||
|
{
|
||||||
|
if (value is null) return null;
|
||||||
|
if (value is byte[] direct)
|
||||||
|
return CopyLimited(direct, limit);
|
||||||
|
|
||||||
|
if (value is IEnumerable enumerable)
|
||||||
|
{
|
||||||
|
var bytes = new List<byte>();
|
||||||
|
foreach (var item in enumerable)
|
||||||
|
{
|
||||||
|
if (item is null) continue;
|
||||||
|
try { bytes.Add(Convert.ToByte(item)); }
|
||||||
|
catch { return null; }
|
||||||
|
if (bytes.Count >= limit) break;
|
||||||
|
}
|
||||||
|
return bytes.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] CopyLimited(byte[] source, int limit)
|
||||||
|
{
|
||||||
|
var count = Math.Min(source.Length, limit);
|
||||||
|
var copy = new byte[count];
|
||||||
|
Array.Copy(source, copy, count);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadMemberValue(object request, string name, out object? value)
|
||||||
|
{
|
||||||
|
value = null;
|
||||||
|
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||||
|
var type = request.GetType();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var prop = type.GetProperty(name, flags);
|
||||||
|
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
|
||||||
|
{
|
||||||
|
value = prop.GetValue(request);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var field = type.GetField(name, flags);
|
||||||
|
if (field is not null)
|
||||||
|
{
|
||||||
|
value = field.GetValue(request);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var method = AccessTools.Method(type, name, Type.EmptyTypes);
|
||||||
|
if (method is not null)
|
||||||
|
{
|
||||||
|
value = method.Invoke(request, Array.Empty<object>());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DumpObjectFields(object? instance, int maxFields, int valueLimit)
|
||||||
|
{
|
||||||
|
if (instance is null) return "<null>";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var type = instance.GetType();
|
||||||
|
var parts = new List<string>();
|
||||||
|
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||||
|
|
||||||
|
foreach (var field in type.GetFields(flags))
|
||||||
|
{
|
||||||
|
if (parts.Count >= maxFields)
|
||||||
|
{
|
||||||
|
parts.Add("...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
object? value;
|
||||||
|
try { value = field.GetValue(instance); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.Add($"{field.Name}={FormatValue(value, valueLimit)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"<field dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LookupRecNetRequestInfo(object instance)
|
||||||
|
{
|
||||||
|
var ptr = TryGetIl2CppPtr(instance);
|
||||||
|
if (ptr == IntPtr.Zero) return "ptr=<unread>";
|
||||||
|
|
||||||
|
lock (RecNetRequestInfoByPtr)
|
||||||
|
{
|
||||||
|
return RecNetRequestInfoByPtr.TryGetValue(ptr, out var info)
|
||||||
|
? info
|
||||||
|
: $"ptr=0x{ptr.ToInt64():X} request=<unmapped>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DumpBndNativeFields(object? instance)
|
||||||
|
{
|
||||||
|
var ptr = TryGetIl2CppPtr(instance);
|
||||||
|
if (ptr == IntPtr.Zero) return "native=<unread>";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var method = Marshal.ReadByte(ptr, 16);
|
||||||
|
var service = Marshal.ReadInt32(ptr, 20);
|
||||||
|
var pathPtr = Marshal.ReadIntPtr(ptr, 24);
|
||||||
|
var fieldsPtr = Marshal.ReadIntPtr(ptr, 32);
|
||||||
|
var filesPtr = Marshal.ReadIntPtr(ptr, 40);
|
||||||
|
var objectFieldsPtr = Marshal.ReadIntPtr(ptr, 48);
|
||||||
|
var bodyKind = Marshal.ReadInt32(ptr, 56);
|
||||||
|
var callbackPtr = Marshal.ReadIntPtr(ptr, 64);
|
||||||
|
var path = ReadIl2CppString(pathPtr);
|
||||||
|
|
||||||
|
return
|
||||||
|
"native={" +
|
||||||
|
$"method={method}; service={service}; path={SanitizeInline(path)}; " +
|
||||||
|
$"pathPtr=0x{pathPtr.ToInt64():X}; fieldsPtr=0x{fieldsPtr.ToInt64():X}; " +
|
||||||
|
$"filesPtr=0x{filesPtr.ToInt64():X}; objectFieldsPtr=0x{objectFieldsPtr.ToInt64():X}; " +
|
||||||
|
$"bodyKind={bodyKind}; callbackPtr=0x{callbackPtr.ToInt64():X}" +
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"native=<dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadIl2CppString(IntPtr ptr)
|
||||||
|
{
|
||||||
|
if (ptr == IntPtr.Zero) return "<null>";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return IL2CPP.Il2CppStringToManaged(ptr) ?? "<null>";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"<string read failed 0x{ptr.ToInt64():X} {ex.GetType().Name}>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IntPtr TryGetIl2CppPtr(object? instance)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return instance is Il2CppObjectBase il2CppObject
|
||||||
|
? IL2CPP.Il2CppObjectBaseToPtrNotNull(il2CppObject)
|
||||||
|
: IntPtr.Zero;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RedactHeaderValue(string name, string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return "<empty>";
|
||||||
|
if (name.Equals("Authorization", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
name.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
name.Equals("X-RNSIG", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "<redacted>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return SanitizeInline(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeInline(string value)
|
||||||
|
{
|
||||||
|
var redacted = SensitivePairRegex.Replace(value, "$1=<redacted>");
|
||||||
|
redacted = BearerRegex.Replace(redacted, "Bearer <redacted>");
|
||||||
|
redacted = SensitiveHeaderRegex.Replace(redacted, "$1: <redacted>");
|
||||||
|
redacted = SensitiveJsonStringRegex.Replace(redacted, "\"$1\":\"<redacted>\"");
|
||||||
|
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||||
|
return redacted.Length <= 1200 ? redacted : redacted[..1200] + "...<truncated>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LogFailure(string source, Exception ex)
|
||||||
|
{
|
||||||
|
if (_failureLogCount++ < 10)
|
||||||
|
Mod.Log.Warning($"[http-trace] {source} failed: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class UriPatches
|
||||||
|
{
|
||||||
|
public static void HttpRequestCtorUri_Prefix(object __0)
|
||||||
|
{
|
||||||
|
HttpTracePatches.TraceUrl(__0?.ToString(), "BestHTTP.HTTPRequest..ctor(Uri)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void HttpRequestSetUri_Prefix(ref Uri __0)
|
||||||
|
{
|
||||||
|
RewriteUriArg(ref __0, "httprequest-seturi-rewrite");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void HttpRequestPrepareUri_Prefix(ref Uri __0)
|
||||||
|
{
|
||||||
|
RewriteUriArg(ref __0, "httprequest-prepareuri-rewrite");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void HttpManagerSendRequestString_Prefix(ref string url)
|
||||||
|
{
|
||||||
|
RewriteString(ref url, "httpmanager-rewrite");
|
||||||
|
HttpTracePatches.TraceUrl(url, "BestHTTP.HTTPManager.SendRequest(string)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void HttpManagerSendRequestObject_Prefix(object request)
|
||||||
|
{
|
||||||
|
RewriteRequestUri(request, "httpmanager-request-rewrite");
|
||||||
|
AuthPatches.ApplyBearerTokenHeader(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void UriStringCtor_Prefix(ref string uriString)
|
||||||
|
{
|
||||||
|
RewriteString(ref uriString, "uri-rewrite");
|
||||||
|
HttpTracePatches.TraceUrl(uriString, "System.Uri..ctor(string)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void HttpRequestSend_Prefix(object __instance)
|
||||||
|
{
|
||||||
|
RewriteRequestUri(__instance, "http-rewrite");
|
||||||
|
AuthPatches.ApplyBearerTokenHeader(__instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RewriteRequestUri(object request, string label)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (request is null) return;
|
||||||
|
var type = request.GetType();
|
||||||
|
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||||
|
|
||||||
|
if (TryRewriteUriProperty(request, type.GetProperty("Uri", flags), label)) return;
|
||||||
|
if (TryRewriteUriField(request, type.GetField("<Uri>k__BackingField", flags), label)) return;
|
||||||
|
|
||||||
|
foreach (var prop in type.GetProperties(flags))
|
||||||
|
{
|
||||||
|
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||||
|
if (TryRewriteUriProperty(request, prop, label)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var field in type.GetFields(flags))
|
||||||
|
{
|
||||||
|
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||||
|
if (TryRewriteUriField(request, field, label)) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[{label}] failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RewriteUriArg(ref Uri uri, string label)
|
||||||
|
{
|
||||||
|
if (uri is null) return;
|
||||||
|
var rewritten = RewriteUri(uri);
|
||||||
|
if (rewritten is null) return;
|
||||||
|
|
||||||
|
Mod.Log.Msg($"[{label}] {uri} -> {rewritten}");
|
||||||
|
uri = rewritten;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryRewriteUriProperty(object request, PropertyInfo? prop, string label)
|
||||||
|
{
|
||||||
|
if (prop is null || !prop.CanRead || !prop.CanWrite) return false;
|
||||||
|
if (prop.GetIndexParameters().Length != 0) return false;
|
||||||
|
if (prop.GetValue(request) is not Uri uri) return false;
|
||||||
|
|
||||||
|
var rewritten = RewriteUri(uri);
|
||||||
|
if (rewritten is null) return false;
|
||||||
|
|
||||||
|
prop.SetValue(request, rewritten);
|
||||||
|
Mod.Log.Msg($"[{label}] {uri} -> {rewritten}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryRewriteUriField(object request, FieldInfo? field, string label)
|
||||||
|
{
|
||||||
|
if (field is null || field.IsInitOnly) return false;
|
||||||
|
if (field.GetValue(request) is not Uri uri) return false;
|
||||||
|
|
||||||
|
var rewritten = RewriteUri(uri);
|
||||||
|
if (rewritten is null) return false;
|
||||||
|
|
||||||
|
field.SetValue(request, rewritten);
|
||||||
|
Mod.Log.Msg($"[{label}] {uri} -> {rewritten}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RewriteString(ref string uriString, string label)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(uriString)) return;
|
||||||
|
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
|
||||||
|
if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri)) return;
|
||||||
|
|
||||||
|
var rewrittenUri = RewriteUri(uri);
|
||||||
|
if (rewrittenUri is null) return;
|
||||||
|
|
||||||
|
var rewritten = rewrittenUri.ToString();
|
||||||
|
if (rewritten == uriString) return;
|
||||||
|
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
|
||||||
|
uriString = rewritten;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Uri? RewriteUri(Uri uri)
|
||||||
|
{
|
||||||
|
var host = Mod.Cfg.ServerHost;
|
||||||
|
if (string.IsNullOrEmpty(host)) return null;
|
||||||
|
if (!uri.Host.EndsWith(".rec.net", StringComparison.OrdinalIgnoreCase)) return null;
|
||||||
|
|
||||||
|
var service = uri.Host[..^".rec.net".Length];
|
||||||
|
if (string.IsNullOrWhiteSpace(service)) return null;
|
||||||
|
|
||||||
|
return new UriBuilder(uri)
|
||||||
|
{
|
||||||
|
Host = service + "." + host,
|
||||||
|
}.Uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class SavePatches
|
||||||
|
{
|
||||||
|
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
|
||||||
|
// our currently-loaded room. Captured BEFORE the watch's caching
|
||||||
|
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
|
||||||
|
// (roomId, subRoomId) tuple — which is every post-save push for a
|
||||||
|
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
|
||||||
|
// would-be no-op setter call into a real transition.
|
||||||
|
private static volatile string? s_lastPushedBlobName;
|
||||||
|
private static long s_lastPushedRoomId;
|
||||||
|
private static long s_lastPushedSubRoomId;
|
||||||
|
|
||||||
|
// Hook on Notifications.OnNotificationReceived(string json). Fires for
|
||||||
|
// every SignalR message before any handler dispatch. Parses the JSON
|
||||||
|
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
|
||||||
|
public static void OnNotificationReceived_Prefix(string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(message)) return;
|
||||||
|
// Fast reject: only parse JSON if it could plausibly be a
|
||||||
|
// SubscriptionUpdateRoom payload. Saves cycles on the (very
|
||||||
|
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
|
||||||
|
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
|
||||||
|
return;
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(message);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||||
|
if (!root.TryGetProperty("Id", out var idEl) ||
|
||||||
|
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
|
||||||
|
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
|
||||||
|
return;
|
||||||
|
if (!root.TryGetProperty("Msg", out var msg) ||
|
||||||
|
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||||
|
if (!msg.TryGetProperty("Room", out var room) ||
|
||||||
|
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
|
||||||
|
long roomId = 0;
|
||||||
|
if (room.TryGetProperty("RoomId", out var ridEl) &&
|
||||||
|
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||||
|
roomId = ridEl.GetInt64();
|
||||||
|
if (!msg.TryGetProperty("Scenes", out var scenes) ||
|
||||||
|
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
|
||||||
|
scenes.GetArrayLength() == 0) return;
|
||||||
|
var first = scenes[0];
|
||||||
|
long subRoomId = 0;
|
||||||
|
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
|
||||||
|
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||||
|
subRoomId = sidEl.GetInt64();
|
||||||
|
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
|
||||||
|
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
|
||||||
|
var blob = blobEl.GetString();
|
||||||
|
if (string.IsNullOrEmpty(blob)) return;
|
||||||
|
s_lastPushedRoomId = roomId;
|
||||||
|
s_lastPushedSubRoomId = subRoomId;
|
||||||
|
s_lastPushedBlobName = blob;
|
||||||
|
Mod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OnSubscriptionUpdateRoom_Prefix(object message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (message is null) return;
|
||||||
|
var dict = message;
|
||||||
|
|
||||||
|
// The watch parses this dict as Dictionary<string, object>
|
||||||
|
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
|
||||||
|
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
|
||||||
|
// get_Item(key) — call it via reflection.
|
||||||
|
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||||
|
if (getItem is null) return;
|
||||||
|
|
||||||
|
// The push wire shape from server (BuildRoomDetails) is
|
||||||
|
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
|
||||||
|
// on the Room sub-object; the scene's blob on Scenes[0].
|
||||||
|
object? roomObj = TryGet(dict, getItem, "Room");
|
||||||
|
object? scenesObj = TryGet(dict, getItem, "Scenes");
|
||||||
|
if (roomObj is null || scenesObj is null) return;
|
||||||
|
|
||||||
|
long roomId = 0;
|
||||||
|
long subRoomId = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||||
|
if (roomItem is not null)
|
||||||
|
{
|
||||||
|
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
|
||||||
|
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* roomId stays 0 — we'll log it and bail later */ }
|
||||||
|
|
||||||
|
// Scenes is an IL2CPP List<RoomScene>. Take the first
|
||||||
|
// (current dorm has one scene; multi-scene rooms target
|
||||||
|
// Scenes[currentSubRoomId] specifically, but the post-save
|
||||||
|
// push always carries the scene that was just saved).
|
||||||
|
object? firstScene = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||||
|
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
|
||||||
|
}
|
||||||
|
catch { /* leave firstScene null */ }
|
||||||
|
if (firstScene is null) return;
|
||||||
|
|
||||||
|
string? newBlob = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
|
||||||
|
if (sceneItem is not null)
|
||||||
|
{
|
||||||
|
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
|
||||||
|
newBlob = blob?.ToString();
|
||||||
|
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
|
||||||
|
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* leave newBlob null */ }
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(newBlob)) return;
|
||||||
|
|
||||||
|
s_lastPushedRoomId = roomId;
|
||||||
|
s_lastPushedSubRoomId = subRoomId;
|
||||||
|
s_lastPushedBlobName = newBlob;
|
||||||
|
Mod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? TryGet(object dict, MethodInfo getItem, string key)
|
||||||
|
{
|
||||||
|
try { return getItem.Invoke(dict, new object[] { key }); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
|
||||||
|
// path only when the new value differs from the cached field. The
|
||||||
|
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
|
||||||
|
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
|
||||||
|
// save it calls the setter with the OLD blob — a no-op that skips
|
||||||
|
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
|
||||||
|
// pointed at the boot-time bytes, and PostSaveReloading deserializes
|
||||||
|
// those over the live world — wiping any MakerPen object the user
|
||||||
|
// just placed.
|
||||||
|
//
|
||||||
|
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
|
||||||
|
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
|
||||||
|
// after b__4 returns True, even though the SubscriptionUpdateRoom
|
||||||
|
// push already carried Scenes[0].DataBlobName="v11".
|
||||||
|
//
|
||||||
|
// Strategy: intercept the setter. If the incoming value matches the
|
||||||
|
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
|
||||||
|
// newer version (the freshly-pushed one), substitute that into the
|
||||||
|
// value parameter so the real setter sees a transition and fires
|
||||||
|
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
|
||||||
|
// Falls through silently when no override is needed.
|
||||||
|
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
|
||||||
|
{
|
||||||
|
string currentForLog = "<unread>";
|
||||||
|
string sceneForLog = "<unread>";
|
||||||
|
string scenes0ForLog = "<unread>";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (__instance is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rpmType = __instance.GetType();
|
||||||
|
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
|
||||||
|
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
if (blobNameProp is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
|
||||||
|
|
||||||
|
var roomsType = FindGameType("RecNet.Rooms");
|
||||||
|
if (roomsType is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var localScene = roomsType.GetProperty("LocalRoomScene",
|
||||||
|
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||||
|
if (localScene is not null)
|
||||||
|
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
|
||||||
|
|
||||||
|
var localDetails = roomsType.GetProperty("LocalRoomDetails",
|
||||||
|
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||||
|
if (localDetails is not null)
|
||||||
|
{
|
||||||
|
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
|
||||||
|
if (scenes is not null)
|
||||||
|
{
|
||||||
|
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||||
|
var first = indexer?.Invoke(scenes, new object[] { 0 });
|
||||||
|
if (first is not null)
|
||||||
|
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unconditional trace of EVERYTHING we read so we can see in
|
||||||
|
// the log exactly why we did or didn't override.
|
||||||
|
Mod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
|
||||||
|
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
|
||||||
|
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
|
||||||
|
|
||||||
|
// Pick a candidate "newer" blob name. Priority order:
|
||||||
|
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
|
||||||
|
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
|
||||||
|
// cache gate drops the push).
|
||||||
|
// 2. LocalRoomScene.DataBlobName.
|
||||||
|
// 3. Scenes[0].DataBlobName.
|
||||||
|
string? pushedBlob = s_lastPushedBlobName;
|
||||||
|
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||||
|
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
|
||||||
|
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
|
||||||
|
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(pushedBlob)) return;
|
||||||
|
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
|
||||||
|
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
|
||||||
|
|
||||||
|
Mod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
|
||||||
|
value = pushedBlob;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
|
||||||
|
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type? FindGameType(string name) => Mod.ResolveType(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class AntiCheatPatches
|
||||||
|
{
|
||||||
|
public static bool GenerateChallengeResponse_Prefix(string FIMBPCACMKJ, ref string __result)
|
||||||
|
{
|
||||||
|
__result = "AAAAAAAAAAAAAAAAAAAAAAAA";
|
||||||
|
Mod.Log.Msg("[eac] using local challenge response for copied test client");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class AntiTamperPatches
|
||||||
|
{
|
||||||
|
public static bool CreateHileWarningCallback_Prefix(bool shouldQuit)
|
||||||
|
{
|
||||||
|
Mod.Log.Msg($"[hile] suppressed warning callback shouldQuit={shouldQuit}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool UnknownDllDetectedCallback_Prefix()
|
||||||
|
{
|
||||||
|
Mod.Log.Msg("[cheatmanager] suppressed unknown DLL fatal callback");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class FileHashCheckerPatches
|
||||||
|
{
|
||||||
|
public static bool InitializeCallback_Prefix(object? result)
|
||||||
|
{
|
||||||
|
Mod.Log.Msg($"[filecheck] suppressed verifier callback result={result?.GetType().FullName ?? "null"}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class ToxModPatches
|
||||||
|
{
|
||||||
|
public static bool CanInitializeToxMod_Prefix(ref bool __result)
|
||||||
|
{
|
||||||
|
__result = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class TlsPatches
|
||||||
|
{
|
||||||
|
// Both BouncyCastle TLS authenticators have the same `void
|
||||||
|
// NotifyServerCertificate(...)` signature whose body throws when the
|
||||||
|
// chain doesn't validate. Returning false from a Harmony prefix on a
|
||||||
|
// void method skips the original — the certificate is accepted by
|
||||||
|
// virtue of NotifyServerCertificate never throwing.
|
||||||
|
public static bool NotifyServerCertificate_Prefix()
|
||||||
|
{
|
||||||
|
return !Mod.Cfg.EnableTlsTrustBypass; // false skips original; true (bypass off) lets it run
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class QuitTracePatches
|
||||||
|
{
|
||||||
|
public static bool ApplicationQuit_Prefix()
|
||||||
|
{
|
||||||
|
Write("[quit-trace] UnityEngine.Application.Quit()");
|
||||||
|
WriteStack();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ApplicationQuitInt_Prefix(int exitCode)
|
||||||
|
{
|
||||||
|
Write($"[quit-trace] UnityEngine.Application.Quit({exitCode})");
|
||||||
|
WriteStack();
|
||||||
|
if (exitCode == 1550502249 || exitCode == 533223478)
|
||||||
|
{
|
||||||
|
Write($"[quit-blocked] UnityEngine.Application.Quit fatal client exit {exitCode}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
Write($"[quit-trace] {FormatOriginal(__originalMethod)}");
|
||||||
|
WriteStack();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP})");
|
||||||
|
WriteStack();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP}, {EPAIMCEIMPA})");
|
||||||
|
WriteStack();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ApplicationWantsToQuit_Prefix(MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
Write($"[quit-trace] {FormatOriginal(__originalMethod)} wants-to-quit handler entered");
|
||||||
|
WriteStack();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void CreateExitTask_Prefix(MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
Write($"[quit-trace] {FormatOriginal(__originalMethod)} creating exit task");
|
||||||
|
WriteStack();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatOriginal(MethodBase method)
|
||||||
|
{
|
||||||
|
return $"{method.DeclaringType?.FullName}.{method.Name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Write(string message)
|
||||||
|
{
|
||||||
|
try { Mod.Log.Msg(message); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteStack()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Write("[quit-trace-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Write("[quit-trace-stack] failed: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class DiagnosticPatches
|
||||||
|
{
|
||||||
|
public static void Write(string message)
|
||||||
|
{
|
||||||
|
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
|
||||||
|
try { Mod.Log.Msg(line); } catch { }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(Mod.DiagnosticsLogPath))
|
||||||
|
File.AppendAllText(Mod.DiagnosticsLogPath, line + Environment.NewLine);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ApplicationQuit_Prefix()
|
||||||
|
{
|
||||||
|
Write("[quit] UnityEngine.Application.Quit()");
|
||||||
|
WriteStack();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ApplicationQuitInt_Prefix(int exitCode)
|
||||||
|
{
|
||||||
|
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
|
||||||
|
WriteStack();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||||
|
WriteStack();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void NamedMethod_Prefix(MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||||
|
WriteStack();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
|
||||||
|
WriteStack();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP})");
|
||||||
|
WriteStack();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP}, {EPAIMCEIMPA})");
|
||||||
|
WriteStack();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteStack()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Write("[stack] failed: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class ChatPatches
|
||||||
|
{
|
||||||
|
private static bool s_logged;
|
||||||
|
|
||||||
|
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
|
||||||
|
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
|
||||||
|
// before raising the in-room Photon chat RPC; if it returns false
|
||||||
|
// the coroutine bails silently and the sender doesn't even see
|
||||||
|
// their own message echo.
|
||||||
|
public static void CanLocalPlayerChat_Postfix(ref bool __result)
|
||||||
|
{
|
||||||
|
if (!__result && !s_logged)
|
||||||
|
{
|
||||||
|
s_logged = true;
|
||||||
|
Mod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
|
||||||
|
}
|
||||||
|
__result = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the original. Returning false from a Harmony prefix stops
|
||||||
|
// the underlying call.
|
||||||
|
public static bool RemoveInvalidCharactersFromMessage_Prefix()
|
||||||
|
{
|
||||||
|
Mod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SendChatEmote_Prefix(string message)
|
||||||
|
{
|
||||||
|
Mod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
|
||||||
|
{
|
||||||
|
Mod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RpcChatEmote_Prefix(string message)
|
||||||
|
{
|
||||||
|
Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log-only diagnostics for the stuck first-dorm "Change Username?" dialog.
|
||||||
|
// See the registration site in OnLateInitializeMelon and
|
||||||
|
// Cfg.TraceRegistrationDialog. Nothing here changes game behaviour — every
|
||||||
|
// hook only writes to the diagnostics log.
|
||||||
|
internal static class RegistrationPatches
|
||||||
|
{
|
||||||
|
private static bool s_loggedFullyReg;
|
||||||
|
|
||||||
|
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered() — postfix reads
|
||||||
|
// (does NOT modify) the return value. False ⇒ the dorm raises the
|
||||||
|
// "Change Username?" prompt; true ⇒ no prompt. Logged once so the file
|
||||||
|
// stays readable (it's polled from several call sites every boot).
|
||||||
|
public static void IsFullyRegistered_Postfix(bool __result)
|
||||||
|
{
|
||||||
|
if (!s_loggedFullyReg)
|
||||||
|
{
|
||||||
|
s_loggedFullyReg = true;
|
||||||
|
DiagnosticPatches.Write($"[reg-trace] RegistrationModel.IsFullyRegistered() = {__result} ({(__result ? "no prompt — fully registered" : "NOT registered → dorm will raise the Change Username? prompt")})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DormroomSceneManager.<PromptForRegistration>b__6_* lambdas. b__6_1 is
|
||||||
|
// the dialog-response handler (a dialog was shown). Names reach the
|
||||||
|
// prefix via __originalMethod so we can tell which branch ran.
|
||||||
|
public static void PromptLambda_Prefix(MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write($"[reg-trace] DormroomSceneManager prompt lambda fired: {__originalMethod.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmUIDialog button handlers. If one of these fires when the user
|
||||||
|
// taps a dialog button but the modal stays open, the break is in the
|
||||||
|
// resolve/hide completer (UIDialog`1.Respond's close path) — not input
|
||||||
|
// routing. "Okay" on the screenshot is Button_Affirmative.
|
||||||
|
public static bool ButtonAffirmative_Prefix(object __instance)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Affirmative pressed (this is 'Okay')");
|
||||||
|
|
||||||
|
if (!IsRegistrationWarningDialog(__instance))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryResolveAffirmativeWithoutClose(__instance))
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write("[registration-fix] resolved first-dorm registration warning without the broken close-before-resolve path");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DiagnosticPatches.Write("[registration-fix] registration warning matched, but reflection resolve failed; falling back to original button handler");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ButtonNegative_Prefix() =>
|
||||||
|
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Negative pressed");
|
||||||
|
public static void ButtonNotNow_Prefix() =>
|
||||||
|
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_NotNow pressed");
|
||||||
|
public static void ButtonCancel_Prefix() =>
|
||||||
|
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Cancel pressed");
|
||||||
|
|
||||||
|
private static bool IsRegistrationWarningDialog(object instance)
|
||||||
|
{
|
||||||
|
var title = ReadTextField(instance, "titleText");
|
||||||
|
var body = ReadTextField(instance, "bodyText");
|
||||||
|
|
||||||
|
return Contains(title, "Change Username")
|
||||||
|
&& Contains(body, "permanently lost")
|
||||||
|
&& Contains(body, "password");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryResolveAffirmativeWithoutClose(object instance)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var respond = AccessTools.Method(instance.GetType(), "Respond");
|
||||||
|
if (respond is null)
|
||||||
|
{
|
||||||
|
respond = AccessTools.Method(instance.GetType().BaseType, "Respond");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (respond is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parameters = respond.GetParameters();
|
||||||
|
if (parameters.Length != 2)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = CreateAffirmativeResponse(parameters[0].ParameterType);
|
||||||
|
respond.Invoke(instance, new[] { response, (object)false });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write($"[registration-fix] resolve failed: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object CreateAffirmativeResponse(Type responseType)
|
||||||
|
{
|
||||||
|
if (responseType.IsEnum)
|
||||||
|
{
|
||||||
|
return Enum.ToObject(responseType, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Convert.ChangeType(1, responseType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ReadTextField(object instance, string fieldName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var field = AccessTools.Field(instance.GetType(), fieldName);
|
||||||
|
var textObject = field?.GetValue(instance);
|
||||||
|
if (textObject is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var property = AccessTools.Property(textObject.GetType(), "text");
|
||||||
|
if (property?.GetValue(textObject) is string text)
|
||||||
|
{
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
var getter = AccessTools.Method(textObject.GetType(), "get_text");
|
||||||
|
return getter?.Invoke(textObject, Array.Empty<object>()) as string;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write($"[registration-fix] could not read {fieldName}: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Contains(string? value, string fragment)
|
||||||
|
{
|
||||||
|
return value?.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trace hooks for the watch's room-join flow. Registered by
|
||||||
|
// Mod.RegisterJoinTracePatches — see that method for the verified call
|
||||||
|
// chain. Everything here is log-only; no prefix returns false, so the
|
||||||
|
// game's own join logic runs unchanged.
|
||||||
|
internal static class JoinPatches
|
||||||
|
{
|
||||||
|
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() — the watch's
|
||||||
|
// "Dorm Room" button. If this never fires, the button's UnityEvent
|
||||||
|
// binding is the problem, not the join chain.
|
||||||
|
public static void ButtonDormRoom_Prefix()
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write("[join-trace] >>> watch DORM ROOM button pressed (HomeScreenFlow.Button_DormRoom)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any SessionManager.RunJoinRoom overload. We DON'T read argument
|
||||||
|
// values here: Harmony's `object[] __args` forces a trampoline that
|
||||||
|
// boxes the obfuscated IL2CPP reference-type params (KMKPEOGJDFK,
|
||||||
|
// RoomInstance, …), and MonoMod fatal-CLRs (0x80131506) compiling it
|
||||||
|
// when the boot-time dorm auto-join first calls this method. Logging
|
||||||
|
// the overload's parameter *types* (pure reflection metadata, no value
|
||||||
|
// boxing) is enough to tell which overload fired — the entry overload
|
||||||
|
// (String,…) is the "DormRoom" press; the others are inner legs.
|
||||||
|
public static void RunJoinRoom_Prefix(MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write($"[join-trace] SessionManager.RunJoinRoom [{Sig(__originalMethod)}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// OJMCBOKJFOF.NHBPIIGDAJP(name|id) — the get-room-by-name / by-id
|
||||||
|
// resolver that feeds the join. __0 is the single first param: a room
|
||||||
|
// NAME string (String overload) or room ID (Int64 overload). Reading
|
||||||
|
// it via `object __0` is safe — it's a string/long, not one of the
|
||||||
|
// obfuscated ref-types that crashed `object[] __args`. This is the
|
||||||
|
// value we need to compare boot (works) vs button (fails).
|
||||||
|
public static void Goto_Prefix(MethodBase __originalMethod, object __0)
|
||||||
|
{
|
||||||
|
var arg = __0?.ToString() ?? "<null>";
|
||||||
|
DiagnosticPatches.Write($"[join-trace] /goto resolve {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}(\"{arg}\") [{Sig(__originalMethod)}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunJoinRoom promise-chain callbacks (SessionManager <>c lambdas).
|
||||||
|
// b__165_1 is the reject handler that raises the "An error occured /
|
||||||
|
// contact recroom.happyfox.com" toast — so if THIS name appears, the
|
||||||
|
// join failed; the success legs (other b__*) firing means it advanced.
|
||||||
|
// (Same __args/0x80131506 constraint as above — name only.)
|
||||||
|
public static void JoinCallback_Prefix(MethodBase __originalMethod)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write($"[join-trace] RunJoinRoom callback {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedicated capture for the reject handler b__165_1(string error) —
|
||||||
|
// the answer to "why did the join fail". Single string param, so
|
||||||
|
// `object __0` is safe (same as OnCustomAuthenticationFailed). This
|
||||||
|
// is the string that becomes the happyfox toast.
|
||||||
|
public static void JoinError_Prefix(object __0)
|
||||||
|
{
|
||||||
|
var err = __0?.ToString() ?? "<null>";
|
||||||
|
DiagnosticPatches.Write($"[join-trace] !!! RunJoinRoom REJECTED — error=\"{err}\" (raises the happyfox toast)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameter-type list from reflection metadata only — never reads
|
||||||
|
// instance values, so it can't trigger the MonoMod trampoline crash.
|
||||||
|
private static string Sig(MethodBase m)
|
||||||
|
{
|
||||||
|
var ps = m.GetParameters();
|
||||||
|
var names = new List<string>(ps.Length);
|
||||||
|
foreach (var p in ps) names.Add(p.ParameterType.Name);
|
||||||
|
return $"{ps.Length} args: {string.Join(",", names)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
// Raise RRO quest party size past the baked 4 — precisely, only for the
|
||||||
|
// allowlisted quests (Cfg.QuestMaxTeamSizeRooms).
|
||||||
|
//
|
||||||
|
// HOW THE CAP IS REALLY BUILT (cracked from the December dump/ISIL):
|
||||||
|
// GameConfigurationTool.Awake()
|
||||||
|
// → reads its baked `defaultGameConfiguration` (GameConfigurationAsset, the
|
||||||
|
// Unity ScriptableObject @0x58)
|
||||||
|
// → calls GameConfigurationAsset.GDECPANDBHJ() ← builds the runtime
|
||||||
|
// protobuf GameConfigurationData (PJPAPHKPKGM) FROM the asset
|
||||||
|
// → stores it @0x88 and applies it (OAHJOLJOKGI) to the team manager.
|
||||||
|
// Inside GDECPANDBHJ the build loop reads asset.TeamConfigurations[i]
|
||||||
|
// .MaxTeamSize (a typed int struct-array: [arr+0x20 + i*4]) and writes each
|
||||||
|
// straight into a new GameTeamConfigurationData.TeamSize (HFBCEJLLDLI @0x10).
|
||||||
|
//
|
||||||
|
// So the runtime cap originates from the asset's `TeamConfiguration[]` — a
|
||||||
|
// READABLE, typed struct array — NOT from anything that needs the obfuscated
|
||||||
|
// protobuf. Earlier attempts failed because:
|
||||||
|
// • bumping the asset on a 2s poll happened AFTER GDECPANDBHJ already built &
|
||||||
|
// cached the protobuf (timing, not the wrong field);
|
||||||
|
// • patching the protobuf getters (get_FNJLAGHPMFL / get_FBOCAPLIIDH) is
|
||||||
|
// impossible — Il2CppInterop doesn't emit the generic RepeatedField<T>
|
||||||
|
// getter, and the simple int getter wasn't emitted either.
|
||||||
|
//
|
||||||
|
// THE FIX: Harmony-PREFIX GameConfigurationAsset.GDECPANDBHJ() and bump the
|
||||||
|
// asset's TeamConfigurations[i].MaxTeamSize to the target IN PLACE, right
|
||||||
|
// before it builds the protobuf. The built config then carries the new cap and
|
||||||
|
// the team manager / pre-game roster / open-slots all honour it. Scoped by
|
||||||
|
// asset.Name to the allowlist (PvP/sandbox configs are left alone).
|
||||||
|
//
|
||||||
|
// HOST-AUTHORITATIVE: the quest host builds the config it networks to joiners
|
||||||
|
// (RpcDeserializeConfig), so the host must run this mod for everyone to see the
|
||||||
|
// raised cap. (A joiner can't override — that would need the unreachable
|
||||||
|
// protobuf path.)
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Reflection;
|
||||||
|
using HarmonyLib;
|
||||||
|
|
||||||
|
namespace DorkNet.ClientMod;
|
||||||
|
|
||||||
|
internal static class QuestTeamSize
|
||||||
|
{
|
||||||
|
private const BindingFlags BF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||||
|
|
||||||
|
private static readonly HashSet<string> _seen = new(StringComparer.Ordinal);
|
||||||
|
private static bool _loggedBump;
|
||||||
|
private static bool _activeAllowedQuestConfig;
|
||||||
|
private static int _teamIndexNormalizeLogCount;
|
||||||
|
private static int _spawnModeDataNormalizeLogCount;
|
||||||
|
private static int _requiredSpawnIndexNormalizeLogCount;
|
||||||
|
private static int _spawnRelaxLogCount;
|
||||||
|
|
||||||
|
public static bool TryPatchSpawnIndexNormalizers(HarmonyLib.Harmony harmony)
|
||||||
|
{
|
||||||
|
var ok = true;
|
||||||
|
ok &= TryPatchTeamIndexNormalizer(harmony);
|
||||||
|
ok &= TryPatchSpawnModeDataNormalizer(harmony);
|
||||||
|
ok &= TryPatchRequiredSpawnIndexNormalizer(harmony);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryPatchTeamIndexNormalizer(HarmonyLib.Harmony harmony)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var managerType = Mod.ResolveType("GameTeamManager");
|
||||||
|
var indexType = Mod.ResolveType("CCMLKDOHKJE");
|
||||||
|
if (managerType is null || indexType is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex: type not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = AccessTools.Method(managerType, "GetTeamPlayerIndex");
|
||||||
|
if (target is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var patch = typeof(QuestTeamSize)
|
||||||
|
.GetMethod(nameof(NormalizeTeamPlayerIndex_Postfix), BindingFlags.Public | BindingFlags.Static)!
|
||||||
|
.MakeGenericMethod(indexType);
|
||||||
|
harmony.Patch(target, postfix: new HarmonyMethod(patch));
|
||||||
|
Mod.Log.Msg("[patch-ok] GameTeamManager.GetTeamPlayerIndex");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Error($"[patch-fail] GameTeamManager.GetTeamPlayerIndex: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryPatchSpawnModeDataNormalizer(HarmonyLib.Harmony harmony)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var spawnType = Mod.ResolveType("GameSpawnManager");
|
||||||
|
var indexType = Mod.ResolveType("CCMLKDOHKJE");
|
||||||
|
if (spawnType is null || indexType is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ: type not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = AccessTools.Method(spawnType, "ALCADGJKKHJ");
|
||||||
|
if (target is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var patch = typeof(QuestTeamSize)
|
||||||
|
.GetMethod(nameof(NormalizeSpawnModeData_Postfix), BindingFlags.Public | BindingFlags.Static)!
|
||||||
|
.MakeGenericMethod(indexType);
|
||||||
|
harmony.Patch(target, postfix: new HarmonyMethod(patch));
|
||||||
|
Mod.Log.Msg("[patch-ok] GameSpawnManager.ALCADGJKKHJ");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Error($"[patch-fail] GameSpawnManager.ALCADGJKKHJ: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryPatchRequiredSpawnIndexNormalizer(HarmonyLib.Harmony harmony)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var spawnType = Mod.ResolveType("GameSpawnManager");
|
||||||
|
var indexType = Mod.ResolveType("CCMLKDOHKJE");
|
||||||
|
if (spawnType is null || indexType is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer: type not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = AccessTools.Method(spawnType, "FKEICBPCMPJ");
|
||||||
|
if (target is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var patch = typeof(QuestTeamSize)
|
||||||
|
.GetMethod(nameof(NormalizeRequiredSpawnIndex_Prefix), BindingFlags.Public | BindingFlags.Static)!
|
||||||
|
.MakeGenericMethod(indexType);
|
||||||
|
harmony.Patch(target, prefix: new HarmonyMethod(patch));
|
||||||
|
Mod.Log.Msg("[patch-ok] GameSpawnManager.FKEICBPCMPJ index normalizer");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Error($"[patch-fail] GameSpawnManager.FKEICBPCMPJ index normalizer: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POSTFIX on GameTeamManager.GetTeamPlayerIndex(Player). The RRO quest
|
||||||
|
// scenes only have spawn points for indexes 0-3. After raising the quest
|
||||||
|
// team cap, players 5+ legitimately receive INDEX_4+, but those values
|
||||||
|
// cannot match any baked quest spawn point. Normalize just the enum result
|
||||||
|
// that spawn code asks for; the actual team roster/cap remains raised.
|
||||||
|
public static void NormalizeTeamPlayerIndex_Postfix<T>(ref T __result) where T : struct
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
|
||||||
|
|
||||||
|
var value = Convert.ToInt32(__result);
|
||||||
|
if (!TryNormalizeIndex(value, out var normalized)) return;
|
||||||
|
|
||||||
|
__result = (T)Enum.ToObject(typeof(T), normalized);
|
||||||
|
|
||||||
|
if (_teamIndexNormalizeLogCount < 12)
|
||||||
|
{
|
||||||
|
_teamIndexNormalizeLogCount++;
|
||||||
|
Mod.Log.Msg($"[questsize] normalized team-player index {value} -> {normalized} for quest spawn lookup " +
|
||||||
|
$"activeQuest={_activeAllowedQuestConfig}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[questsize] team-player index normalize failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POSTFIX on GameSpawnManager.ALCADGJKKHJ(..., out CCMLKDOHKJE index, ...).
|
||||||
|
// FPBFJMMKPPJ calls this private helper immediately before FKEICBPCMPJ, so
|
||||||
|
// this is the exact spawn-path value seen in the "TeamIndex INDEX_4" log.
|
||||||
|
public static void NormalizeSpawnModeData_Postfix<T>(ref T __3) where T : struct
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
|
||||||
|
|
||||||
|
var value = Convert.ToInt32(__3);
|
||||||
|
if (!TryNormalizeIndex(value, out var normalized)) return;
|
||||||
|
|
||||||
|
__3 = (T)Enum.ToObject(typeof(T), normalized);
|
||||||
|
|
||||||
|
if (_spawnModeDataNormalizeLogCount < 12)
|
||||||
|
{
|
||||||
|
_spawnModeDataNormalizeLogCount++;
|
||||||
|
Mod.Log.Msg($"[questsize] normalized spawn-mode team-player index {value} -> {normalized} " +
|
||||||
|
$"activeQuest={_activeAllowedQuestConfig}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[questsize] spawn-mode index normalize failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PREFIX on GameSpawnManager.FKEICBPCMPJ(..., CCMLKDOHKJE requiredIndex, ...).
|
||||||
|
// This is the last guard before the spawn point field compare at +0x88.
|
||||||
|
public static void NormalizeRequiredSpawnIndex_Prefix<T>(ref T __2) where T : struct
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
|
||||||
|
|
||||||
|
var value = Convert.ToInt32(__2);
|
||||||
|
if (!TryNormalizeIndex(value, out var normalized)) return;
|
||||||
|
|
||||||
|
__2 = (T)Enum.ToObject(typeof(T), normalized);
|
||||||
|
|
||||||
|
if (_requiredSpawnIndexNormalizeLogCount < 12)
|
||||||
|
{
|
||||||
|
_requiredSpawnIndexNormalizeLogCount++;
|
||||||
|
Mod.Log.Msg($"[questsize] normalized required spawn index {value} -> {normalized} " +
|
||||||
|
$"activeQuest={_activeAllowedQuestConfig}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[questsize] required spawn index normalize failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryNormalizeIndex(int value, out int normalized)
|
||||||
|
{
|
||||||
|
normalized = value % 4;
|
||||||
|
return value >= 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PREFIX on GameConfigurationAsset.GDECPANDBHJ() (asset → protobuf builder).
|
||||||
|
public static void Build_Prefix(object __instance)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var target = Mod.Cfg.QuestMaxTeamSize;
|
||||||
|
if (target <= 0 || __instance is null) return;
|
||||||
|
|
||||||
|
var name = GetMember(__instance, "Name") as string ?? "";
|
||||||
|
var allowed = IsAllowed(name);
|
||||||
|
_activeAllowedQuestConfig = allowed;
|
||||||
|
if (_seen.Add(name))
|
||||||
|
Mod.Log.Msg($"[questsize] config build '{name}' allowed={allowed}");
|
||||||
|
if (!allowed) return;
|
||||||
|
|
||||||
|
var arr = GetMember(__instance, "TeamConfigurations");
|
||||||
|
int len = ArrLen(arr);
|
||||||
|
if (len <= 0) return;
|
||||||
|
|
||||||
|
int bumped = 0;
|
||||||
|
for (int i = 0; i < len; i++)
|
||||||
|
{
|
||||||
|
var elem = ArrItem(arr, i); // boxed TeamConfiguration struct (copy)
|
||||||
|
if (elem is null) continue;
|
||||||
|
int cur = ReadInt(elem, "MaxTeamSize");
|
||||||
|
if (cur > 0 && cur < target &&
|
||||||
|
WriteInt(elem, "MaxTeamSize", target) &&
|
||||||
|
SetArrItem(arr!, i, elem)) // write the mutated struct back
|
||||||
|
bumped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bumped > 0 && !_loggedBump)
|
||||||
|
{
|
||||||
|
_loggedBump = true;
|
||||||
|
Mod.Log.Msg($"[questsize] '{name}': bumped {bumped} team slot(s) → {target}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Mod.Log.Warning($"[questsize] build prefix failed: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// POSTFIX on GameSpawnManager.FKEICBPCMPJ(..., outputList). That method
|
||||||
|
// filters spawn points by state/game-mode/spectator/team/team-player-index/
|
||||||
|
// custom-tag before the spawn selector runs. The RRO quest prefabs only
|
||||||
|
// have indexed start points for players 0-3; once we raise the team size,
|
||||||
|
// player index 4+ can filter every point out before the built-in overlap
|
||||||
|
// fallback has a chance to help.
|
||||||
|
//
|
||||||
|
// December decompile: FPBFJMMKPPJ calls this filter, then if output.Count
|
||||||
|
// is still 0 it logs "due to spawn point requirement tests" and falls back
|
||||||
|
// to the unfiltered source list as an arbitrary spawn set. Filling the
|
||||||
|
// output list here keeps the normal overlap pass and spawn-selection mode
|
||||||
|
// alive instead of taking that bad arbitrary fallback.
|
||||||
|
//
|
||||||
|
// Harmony binds __0 and __7 by argument position: source list and output
|
||||||
|
// list. The obfuscated parameter names are not reliable in MelonLoader's
|
||||||
|
// generated IL2CPP metadata, which is why this intentionally avoids them.
|
||||||
|
// If the filtered output is empty, copy the already-selected candidate
|
||||||
|
// scene spawn list back into the output so extra players reuse baked quest
|
||||||
|
// spawn points instead of being left out in the void.
|
||||||
|
public static void RelaxEmptySpawnFilter_Postfix(object __0, object __7)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
|
||||||
|
if (Count(__7) > 0) return;
|
||||||
|
|
||||||
|
var sourceCount = Count(__0);
|
||||||
|
if (sourceCount <= 0) return;
|
||||||
|
|
||||||
|
var copied = CopyListItems(__0, __7);
|
||||||
|
if (_spawnRelaxLogCount < 12)
|
||||||
|
{
|
||||||
|
_spawnRelaxLogCount++;
|
||||||
|
Mod.Log.Msg($"[questsize] relaxed empty quest spawn filter; activeQuest={_activeAllowedQuestConfig} " +
|
||||||
|
$"source={sourceCount} copied={copied} " +
|
||||||
|
$"sourceType={TypeName(__0)} outputType={TypeName(__7)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[questsize] spawn filter relax failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── allowlist matching (normalize: lowercase-alnum, strip filler) ──────
|
||||||
|
private static List<string>? _wanted;
|
||||||
|
|
||||||
|
private static bool IsAllowed(string configName)
|
||||||
|
{
|
||||||
|
_wanted ??= BuildWanted();
|
||||||
|
if (_wanted.Count == 0) return false;
|
||||||
|
var have = Norm(configName);
|
||||||
|
foreach (var w in _wanted)
|
||||||
|
if (have.Contains(w)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> BuildWanted()
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
foreach (var entry in Mod.Cfg.QuestMaxTeamSizeRooms)
|
||||||
|
{
|
||||||
|
var nrm = Norm(entry);
|
||||||
|
if (nrm.Length > 0) list.Add(nrm);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Norm(string s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s)) return "";
|
||||||
|
var sb = new System.Text.StringBuilder(s.Length);
|
||||||
|
foreach (var c in s)
|
||||||
|
if (char.IsLetterOrDigit(c)) sb.Append(char.ToLowerInvariant(c));
|
||||||
|
return sb.ToString().Replace("the", "").Replace("for", "").Replace("of", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── reflection helpers (Il2CppInterop exposes il2cpp members as either a
|
||||||
|
// property or a field depending on version — try both) ────────────────
|
||||||
|
private static object? GetMember(object o, string name)
|
||||||
|
{
|
||||||
|
var t = o.GetType();
|
||||||
|
var p = t.GetProperty(name, BF);
|
||||||
|
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||||
|
{ try { return p.GetValue(o); } catch { } }
|
||||||
|
var f = t.GetField(name, BF);
|
||||||
|
if (f != null) { try { return f.GetValue(o); } catch { } }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadInt(object? o, string name)
|
||||||
|
{
|
||||||
|
if (o is null) return 0;
|
||||||
|
var v = GetMember(o, name);
|
||||||
|
if (v is int n) return n;
|
||||||
|
return v != null && int.TryParse(v.ToString(), out var p) ? p : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool WriteInt(object o, string name, int val)
|
||||||
|
{
|
||||||
|
var t = o.GetType();
|
||||||
|
var p = t.GetProperty(name, BF);
|
||||||
|
if (p != null && p.CanWrite) { try { p.SetValue(o, val); return true; } catch { } }
|
||||||
|
var f = t.GetField(name, BF);
|
||||||
|
if (f != null) { try { f.SetValue(o, val); return true; } catch { } }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ArrLen(object? arr)
|
||||||
|
{
|
||||||
|
if (arr is null) return 0;
|
||||||
|
if (arr is Array a) return a.Length;
|
||||||
|
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
|
||||||
|
return (lp?.GetValue(arr) as int?) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Count(object? collection) => ArrLen(collection);
|
||||||
|
|
||||||
|
private static object? ArrItem(object? arr, int i)
|
||||||
|
{
|
||||||
|
if (arr is null) return null;
|
||||||
|
if (arr is Array a) return a.GetValue(i);
|
||||||
|
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||||
|
return gi?.Invoke(arr, new object[] { i });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a (boxed) struct element back into an Il2CppStructArray via its
|
||||||
|
// set_Item(int, T) indexer. Reflection unboxes to T on invoke.
|
||||||
|
private static bool SetArrItem(object arr, int i, object val)
|
||||||
|
{
|
||||||
|
if (arr is Array a) { try { a.SetValue(val, i); return true; } catch { return false; } }
|
||||||
|
foreach (var m in arr.GetType().GetMethods())
|
||||||
|
{
|
||||||
|
if (m.Name != "set_Item") continue;
|
||||||
|
var ps = m.GetParameters();
|
||||||
|
if (ps.Length == 2 && ps[0].ParameterType == typeof(int))
|
||||||
|
{
|
||||||
|
try { m.Invoke(arr, new[] { i, val }); return true; } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CopyListItems(object? src, object? dst)
|
||||||
|
{
|
||||||
|
if (src is null || dst is null) return 0;
|
||||||
|
|
||||||
|
MethodInfo? add = null;
|
||||||
|
foreach (var method in dst.GetType().GetMethods())
|
||||||
|
{
|
||||||
|
if (method.Name != "Add") continue;
|
||||||
|
if (method.GetParameters().Length != 1) continue;
|
||||||
|
add = method;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (add is null) return 0;
|
||||||
|
|
||||||
|
var n = ArrLen(src);
|
||||||
|
var copied = 0;
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var item = ArrItem(src, i);
|
||||||
|
if (item is null) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
add.Invoke(dst, new[] { item });
|
||||||
|
copied++;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TypeName(object? value) =>
|
||||||
|
value?.GetType().FullName ?? "<null>";
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"ServerHost": "{HOST}",
|
||||||
|
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
||||||
|
"QuestMaxTeamSize": 0,
|
||||||
|
"QuestMaxTeamSizeRooms": [
|
||||||
|
"CrimsonCauldron",
|
||||||
|
"Crescendo",
|
||||||
|
"GoldenTrophy",
|
||||||
|
"IsleOfLostSkulls",
|
||||||
|
"TheRiseofJumbotron"
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
471710
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
namespace DorkNet.Contracts;
|
||||||
|
|
||||||
|
public static class DorkNetRouteOwnership
|
||||||
|
{
|
||||||
|
private const string AdminHost = "admin";
|
||||||
|
|
||||||
|
private static readonly DorkNetRouteGroup[] ServiceRouteGroups =
|
||||||
|
[
|
||||||
|
new(
|
||||||
|
ServiceNames.Identity,
|
||||||
|
["auth", "accounts"],
|
||||||
|
[
|
||||||
|
"/.well-known",
|
||||||
|
"/account",
|
||||||
|
"/api/account",
|
||||||
|
"/api/auth",
|
||||||
|
"/api/createaccount",
|
||||||
|
"/api/platformlogin",
|
||||||
|
"/api/playerreputation",
|
||||||
|
"/api/players",
|
||||||
|
"/api/relationships",
|
||||||
|
"/api/settings",
|
||||||
|
"/cachedlogin",
|
||||||
|
"/connect",
|
||||||
|
"/eac",
|
||||||
|
"/parentalcontrol",
|
||||||
|
"/photon",
|
||||||
|
"/profileimage",
|
||||||
|
"/settings",
|
||||||
|
]),
|
||||||
|
new(
|
||||||
|
ServiceNames.Rooms,
|
||||||
|
["rooms", "match", "discovery", "lists", "roomcomments", "roomieintegrations", "studio"],
|
||||||
|
[
|
||||||
|
"/api/activities",
|
||||||
|
"/api/curatedroomplaylists",
|
||||||
|
"/api/gameconfigs",
|
||||||
|
"/api/gameconfigurations",
|
||||||
|
"/api/gamesessions",
|
||||||
|
"/api/leaderboard",
|
||||||
|
"/api/playerelo",
|
||||||
|
"/api/playlists",
|
||||||
|
"/api/roomkeys",
|
||||||
|
"/api/rooms",
|
||||||
|
"/api/royale",
|
||||||
|
"/discover",
|
||||||
|
"/featuredrooms",
|
||||||
|
"/goto",
|
||||||
|
"/hot_rooms",
|
||||||
|
"/hot_roomsandplaylists",
|
||||||
|
"/invite",
|
||||||
|
"/playlists",
|
||||||
|
"/player",
|
||||||
|
"/room",
|
||||||
|
"/roominstance",
|
||||||
|
"/rooms",
|
||||||
|
"/roomsandplaylists",
|
||||||
|
"/roomserver",
|
||||||
|
"/search_rooms",
|
||||||
|
"/search_roomsandplaylists",
|
||||||
|
"/v1/find",
|
||||||
|
"/v1/join",
|
||||||
|
"/v1/leave",
|
||||||
|
"/v1/session",
|
||||||
|
]),
|
||||||
|
new(
|
||||||
|
ServiceNames.Notify,
|
||||||
|
["notify", "chat", "platformnotifications"],
|
||||||
|
[
|
||||||
|
"/api/messages",
|
||||||
|
"/api/notification",
|
||||||
|
"/api/offlineinvite",
|
||||||
|
"/hub",
|
||||||
|
"/notify",
|
||||||
|
"/thread",
|
||||||
|
]),
|
||||||
|
new(
|
||||||
|
ServiceNames.Content,
|
||||||
|
["cdn", "data", "img", "storage", "strings-cdn", "videos"],
|
||||||
|
[
|
||||||
|
"/api/images",
|
||||||
|
"/api/photos",
|
||||||
|
"/config",
|
||||||
|
"/data",
|
||||||
|
"/img",
|
||||||
|
"/invention",
|
||||||
|
"/upload",
|
||||||
|
"/video",
|
||||||
|
]),
|
||||||
|
new(
|
||||||
|
ServiceNames.Social,
|
||||||
|
["clubs"],
|
||||||
|
[
|
||||||
|
"/announcements",
|
||||||
|
"/api/announcement",
|
||||||
|
"/api/clubreporting",
|
||||||
|
"/api/communityboard",
|
||||||
|
"/api/groups",
|
||||||
|
"/api/playercheer",
|
||||||
|
"/api/playerevents",
|
||||||
|
"/api/playersubscriptions",
|
||||||
|
"/club",
|
||||||
|
"/members",
|
||||||
|
"/subscription",
|
||||||
|
]),
|
||||||
|
new(
|
||||||
|
ServiceNames.Commerce,
|
||||||
|
["commerce", "econ"],
|
||||||
|
[
|
||||||
|
"/api/avatar",
|
||||||
|
"/api/catalog",
|
||||||
|
"/api/compatibility",
|
||||||
|
"/api/consumables",
|
||||||
|
"/api/equipment",
|
||||||
|
"/api/gamerewards",
|
||||||
|
"/api/inventions",
|
||||||
|
"/api/purchase",
|
||||||
|
"/api/storefronts",
|
||||||
|
"/commerce",
|
||||||
|
"/econ",
|
||||||
|
]),
|
||||||
|
new(
|
||||||
|
ServiceNames.Platform,
|
||||||
|
["cards", "cms", "datacollection", "gamelogs", "geo", "leaderboard", "link", "ns", "playersettings", "strings", "thorn"],
|
||||||
|
[
|
||||||
|
"/api/cards",
|
||||||
|
"/api/config",
|
||||||
|
"/api/events",
|
||||||
|
"/api/quickplay",
|
||||||
|
"/api/role",
|
||||||
|
"/api/version",
|
||||||
|
"/api/versioncheck",
|
||||||
|
"/motd",
|
||||||
|
"/pageview",
|
||||||
|
"/ping",
|
||||||
|
"/role",
|
||||||
|
"/strings",
|
||||||
|
"/v1/ping",
|
||||||
|
"/v1/player",
|
||||||
|
"/v1/regions",
|
||||||
|
"/v1/services",
|
||||||
|
]),
|
||||||
|
new(
|
||||||
|
ServiceNames.Moderation,
|
||||||
|
["bugreporting", "moderation"],
|
||||||
|
[
|
||||||
|
"/api/admin",
|
||||||
|
"/api/banappeal",
|
||||||
|
"/api/bugreporting",
|
||||||
|
"/api/moderation",
|
||||||
|
"/api/playerreporting",
|
||||||
|
"/api/playersbanned",
|
||||||
|
"/api/sanitize",
|
||||||
|
"/api/testcasemanagement",
|
||||||
|
]),
|
||||||
|
new(
|
||||||
|
ServiceNames.Web,
|
||||||
|
["www", AdminHost, "feed"],
|
||||||
|
[
|
||||||
|
"/api/site",
|
||||||
|
],
|
||||||
|
IncludesApexHost: true),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IReadOnlyList<DorkNetRouteGroup> RouteGroups => ServiceRouteGroups;
|
||||||
|
|
||||||
|
public static bool IsInfrastructurePath(string path)
|
||||||
|
{
|
||||||
|
return path.Equals("/healthz", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.StartsWith("/internal/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ResolvePublicService(string? host, string path, string apexDomain)
|
||||||
|
{
|
||||||
|
var normalizedPath = NormalizePath(path);
|
||||||
|
var subdomain = Subdomain(host, apexDomain);
|
||||||
|
|
||||||
|
if (string.Equals(subdomain, AdminHost, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ServiceNames.Web;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in ServiceRouteGroups.Where(group => group.ServiceName != ServiceNames.Web))
|
||||||
|
{
|
||||||
|
if (MatchesHost(subdomain, group.HostSubdomains))
|
||||||
|
{
|
||||||
|
return group.ServiceName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in ServiceRouteGroups)
|
||||||
|
{
|
||||||
|
if (StartsWithAny(normalizedPath, group.PathPrefixes))
|
||||||
|
{
|
||||||
|
return group.ServiceName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var web = ServiceRouteGroups.First(group => group.ServiceName == ServiceNames.Web);
|
||||||
|
if ((subdomain is null && web.IncludesApexHost) || MatchesHost(subdomain, web.HostSubdomains))
|
||||||
|
{
|
||||||
|
return ServiceNames.Web;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ServiceNames.Monolith;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsOwnedBy(string serviceName, string? host, string path, string apexDomain)
|
||||||
|
{
|
||||||
|
if (IsInfrastructurePath(path))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Equals(
|
||||||
|
ResolvePublicService(host, path, apexDomain),
|
||||||
|
serviceName,
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Subdomain(string? host, string apexDomain)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(host))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedHost = host.Split(':', 2)[0].Trim().ToLowerInvariant();
|
||||||
|
var normalizedApex = apexDomain.Split(':', 2)[0].Trim().ToLowerInvariant();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedHost)
|
||||||
|
|| string.IsNullOrWhiteSpace(normalizedApex)
|
||||||
|
|| string.Equals(normalizedHost, normalizedApex, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var suffix = "." + normalizedApex;
|
||||||
|
if (normalizedHost.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return normalizedHost[..^suffix.Length];
|
||||||
|
}
|
||||||
|
|
||||||
|
var dot = normalizedHost.IndexOf('.');
|
||||||
|
return dot > 0 ? normalizedHost[..dot] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool StartsWithAny(string path, IEnumerable<string> prefixes)
|
||||||
|
{
|
||||||
|
foreach (var prefix in prefixes)
|
||||||
|
{
|
||||||
|
if (StartsWithPathSegment(path, prefix))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool StartsWithPathSegment(string path, string prefix)
|
||||||
|
{
|
||||||
|
var normalizedPath = NormalizePath(path);
|
||||||
|
var normalizedPrefix = NormalizePath(prefix).TrimEnd('/');
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedPrefix))
|
||||||
|
{
|
||||||
|
normalizedPrefix = "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedPath.Equals(normalizedPrefix, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| normalizedPath.StartsWith(normalizedPrefix + "/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesHost(string? subdomain, IEnumerable<string> candidates)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(subdomain))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.Contains(subdomain, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePath(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return path[0] == '/' ? path : "/" + path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DorkNetRouteGroup(
|
||||||
|
string ServiceName,
|
||||||
|
string[] HostSubdomains,
|
||||||
|
string[] PathPrefixes,
|
||||||
|
bool IncludesApexHost = false);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace DorkNet.Contracts;
|
||||||
|
|
||||||
|
public sealed class DorkNetServiceMapOptions
|
||||||
|
{
|
||||||
|
public string Identity { get; set; } = "http://localhost:8081";
|
||||||
|
public string Rooms { get; set; } = "http://localhost:8082";
|
||||||
|
public string Notify { get; set; } = "http://localhost:8083";
|
||||||
|
public string Content { get; set; } = "http://localhost:8084";
|
||||||
|
public string Social { get; set; } = "http://localhost:8085";
|
||||||
|
public string Commerce { get; set; } = "http://localhost:8086";
|
||||||
|
public string Platform { get; set; } = "http://localhost:8087";
|
||||||
|
public string Moderation { get; set; } = "http://localhost:8088";
|
||||||
|
public string Web { get; set; } = "http://localhost:8089";
|
||||||
|
public string Monolith { get; set; } = "http://localhost:8090";
|
||||||
|
|
||||||
|
public IReadOnlyList<DorkNetServiceEndpoint> Endpoints()
|
||||||
|
{
|
||||||
|
DorkNetServiceEndpoint[] endpoints =
|
||||||
|
[
|
||||||
|
new(ServiceNames.Identity, Identity),
|
||||||
|
new(ServiceNames.Rooms, Rooms),
|
||||||
|
new(ServiceNames.Notify, Notify),
|
||||||
|
new(ServiceNames.Content, Content),
|
||||||
|
new(ServiceNames.Social, Social),
|
||||||
|
new(ServiceNames.Commerce, Commerce),
|
||||||
|
new(ServiceNames.Platform, Platform),
|
||||||
|
new(ServiceNames.Moderation, Moderation),
|
||||||
|
new(ServiceNames.Web, Web),
|
||||||
|
new(ServiceNames.Monolith, Monolith),
|
||||||
|
];
|
||||||
|
|
||||||
|
return endpoints
|
||||||
|
.Where(endpoint => !string.IsNullOrWhiteSpace(endpoint.BaseUrl))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DorkNetServiceEndpoint(string Name, string BaseUrl);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace DorkNet.Contracts;
|
||||||
|
|
||||||
|
public sealed record ServiceHealthResponse(
|
||||||
|
string Service,
|
||||||
|
string Status,
|
||||||
|
DateTimeOffset CheckedAtUtc);
|
||||||
|
|
||||||
|
public sealed record ServiceCapabilityResponse(
|
||||||
|
string Service,
|
||||||
|
string[] Owns,
|
||||||
|
string[] PlannedPublicRoutes);
|
||||||
|
|
||||||
|
public sealed record ServiceProbeResponse(
|
||||||
|
string Service,
|
||||||
|
string BaseUrl,
|
||||||
|
string Status,
|
||||||
|
int? StatusCode,
|
||||||
|
string? Error,
|
||||||
|
DateTimeOffset CheckedAtUtc);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace DorkNet.Contracts;
|
||||||
|
|
||||||
|
public static class ServiceNames
|
||||||
|
{
|
||||||
|
public const string Gateway = "gateway";
|
||||||
|
public const string Identity = "identity";
|
||||||
|
public const string Rooms = "rooms";
|
||||||
|
public const string Notify = "notify";
|
||||||
|
public const string Content = "content";
|
||||||
|
public const string Social = "social";
|
||||||
|
public const string Commerce = "commerce";
|
||||||
|
public const string Platform = "platform";
|
||||||
|
public const string Moderation = "moderation";
|
||||||
|
public const string Web = "web";
|
||||||
|
public const string Monolith = "monolith";
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// DorkNet.DebugMod — opt-in debugging switch for DorkNet.ClientMod.
|
||||||
|
//
|
||||||
|
// This mod intentionally does almost nothing on its own. Its job is to
|
||||||
|
// EXIST: when its DLL sits in the Mods folder next to DorkNet.ClientMod.dll,
|
||||||
|
// the ClientMod detects it at startup (Cfg.EnableDiagnostics) and installs
|
||||||
|
// its verbose diagnostic patches — HTTP header/callback tracing, join/quit
|
||||||
|
// /studio traces, and the deep RegisterDiagnostics probes. Remove this DLL
|
||||||
|
// to ship a quiet, minimal client that only carries the connect + anti-cheat
|
||||||
|
// patches.
|
||||||
|
//
|
||||||
|
// Keeping the switch as a separate assembly (rather than a config flag baked
|
||||||
|
// into the shipping mod) means the diagnostics literally cannot turn
|
||||||
|
// themselves on in a normal install — you have to deliberately drop this
|
||||||
|
// file in. That is the "as safe as possible" default the main mod wants.
|
||||||
|
|
||||||
|
using MelonLoader;
|
||||||
|
|
||||||
|
[assembly: MelonInfo(typeof(DorkNet.DebugMod.DebugMod), "DorkNet DebugMod", "1.0.0", "Dork Squad")]
|
||||||
|
[assembly: MelonGame(null, null)]
|
||||||
|
|
||||||
|
namespace DorkNet.DebugMod;
|
||||||
|
|
||||||
|
public class DebugMod : MelonMod
|
||||||
|
{
|
||||||
|
public override void OnInitializeMelon()
|
||||||
|
{
|
||||||
|
LoggerInstance.Msg(
|
||||||
|
"=== DorkNet DebugMod present — DorkNet.ClientMod diagnostics are ENABLED. " +
|
||||||
|
"Remove DorkNet.DebugMod.dll from Mods to ship a quiet client. ===");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
|
||||||
|
|
||||||
|
The shipping ClientMod installs ONLY the patches needed to connect and
|
||||||
|
survive anti-cheat; all of its verbose diagnostic patches (HTTP header
|
||||||
|
tracing, join/quit/studio traces, the deep RegisterDiagnostics path) are
|
||||||
|
gated OFF by default. Dropping THIS dll into the Mods folder next to
|
||||||
|
DorkNet.ClientMod.dll flips Cfg.EnableDiagnostics on (the ClientMod
|
||||||
|
detects this dll's presence at startup), turning all of that tracing
|
||||||
|
back on. Remove it to ship a quiet, minimal client.
|
||||||
|
|
||||||
|
Build: dotnet build DorkNet.DebugMod/DorkNet.DebugMod.csproj -c Release
|
||||||
|
-->
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>DorkNet.DebugMod</RootNamespace>
|
||||||
|
<AssemblyName>DorkNet.DebugMod</AssemblyName>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||||
|
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Reuse the MelonLoader the ClientMod build already downloads. -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader\MelonLoader\net6</MelonLoaderDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="MelonLoader">
|
||||||
|
<HintPath>$(MelonLoaderDir)\MelonLoader.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
using HarmonyLib;
|
||||||
|
using Il2CppRecRoom.Debugging;
|
||||||
|
using MelonLoader;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using UnityEngine;
|
||||||
|
using UObject = UnityEngine.Object;
|
||||||
|
|
||||||
|
[assembly: MelonInfo(typeof(DorkNet.DevMenu.DevMenuMod), "DorkNet DevMenu", "1.0.0", "Dork Squad")]
|
||||||
|
[assembly: MelonGame(null, null)]
|
||||||
|
|
||||||
|
namespace DorkNet.DevMenu;
|
||||||
|
|
||||||
|
public class DevMenuMod : MelonMod
|
||||||
|
{
|
||||||
|
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
|
||||||
|
internal static DevMenuMod? Instance;
|
||||||
|
internal HarmonyLib.Harmony PatchHarmony => HarmonyInstance;
|
||||||
|
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
|
||||||
|
|
||||||
|
public static class Cfg
|
||||||
|
{
|
||||||
|
public static bool EnableDebugConsole = false;
|
||||||
|
public static string DebugConsoleToggleKey = "BackQuote";
|
||||||
|
public static bool DiagnoseDevMenu = false;
|
||||||
|
public static bool EnableDevWatchButton = false;
|
||||||
|
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnInitializeMelon()
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
Log.Msg("=== DorkNet DevMenu loading ===");
|
||||||
|
LoadConfig();
|
||||||
|
|
||||||
|
if (Cfg.EnableDebugConsole)
|
||||||
|
{
|
||||||
|
foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
|
||||||
|
TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
|
||||||
|
Log.Msg($"[debugconsole] enabled; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnUpdate()
|
||||||
|
{
|
||||||
|
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
|
||||||
|
if (Cfg.DiagnoseDevMenu) DevMenuProbe.Tick();
|
||||||
|
if (Cfg.EnableDevWatchButton) DevWatchButton.Tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LoadConfig()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var modDll = Assembly.GetExecutingAssembly().Location;
|
||||||
|
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
|
||||||
|
var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty;
|
||||||
|
var userData = Path.Combine(gameDir, "MelonLoader", "UserData");
|
||||||
|
Directory.CreateDirectory(userData);
|
||||||
|
|
||||||
|
var devPath = Path.Combine(userData, "dorknet-devmenu.json");
|
||||||
|
var legacyPath = Path.Combine(userData, "dorknet-clientmod.json");
|
||||||
|
var path = File.Exists(devPath) ? devPath : legacyPath;
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
Log.Msg($"[config] no dev config found; all dev menu features disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||||
|
var r = doc.RootElement;
|
||||||
|
if (r.TryGetProperty("EnableDebugConsole", out var v)) Cfg.EnableDebugConsole = v.GetBoolean();
|
||||||
|
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
|
||||||
|
if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
|
||||||
|
if (r.TryGetProperty("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
|
||||||
|
if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
foreach (var e in v.EnumerateArray())
|
||||||
|
{
|
||||||
|
var s = e.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(s)) list.Add(s!);
|
||||||
|
}
|
||||||
|
if (list.Count > 0) Cfg.DevCommands = list.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Msg($"[config] loaded {Path.GetFileName(path)}: debugConsole={Cfg.EnableDebugConsole}, devWatchButton={Cfg.EnableDevWatchButton}, diagnose={Cfg.DiagnoseDevMenu}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning($"[config] load failed, using disabled defaults: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryPatchByName(string typeName, string methodName, Type[]? args = null, string? prefix = null, string? postfix = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var type = ResolveType(typeName);
|
||||||
|
if (type is null)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-miss] {typeName}.{methodName}: type not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var method = args is null
|
||||||
|
? AccessTools.Method(type, methodName)
|
||||||
|
: AccessTools.Method(type, methodName, args);
|
||||||
|
if (method is null)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-miss] {typeName}.{methodName}: method not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
HarmonyMethod? pre = prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix));
|
||||||
|
HarmonyMethod? post = postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix));
|
||||||
|
Instance!.PatchHarmony.Patch(method, pre, post);
|
||||||
|
Log.Msg($"[patch-ok] {typeName}.{methodName}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning($"[patch-fail] {typeName}.{methodName}: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MethodInfo GetPatchMethod(string name)
|
||||||
|
{
|
||||||
|
foreach (var holder in new[] { typeof(DebugConsolePatches) })
|
||||||
|
{
|
||||||
|
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||||
|
if (m is not null) return m;
|
||||||
|
}
|
||||||
|
throw new MissingMethodException($"patch method '{name}' not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Type? ResolveType(string requested)
|
||||||
|
{
|
||||||
|
if (ResolvedTypeCache.TryGetValue(requested, out var cached)) return cached;
|
||||||
|
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
|
{
|
||||||
|
Type? found = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
found = asm.GetType(requested, throwOnError: false)
|
||||||
|
?? asm.GetType("Il2Cpp" + requested, throwOnError: false);
|
||||||
|
if (found is null)
|
||||||
|
{
|
||||||
|
foreach (var t in asm.GetTypes())
|
||||||
|
{
|
||||||
|
if (t.FullName == requested ||
|
||||||
|
t.Name == requested ||
|
||||||
|
t.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
|
||||||
|
t.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true)
|
||||||
|
{
|
||||||
|
found = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
if (found is null) continue;
|
||||||
|
ResolvedTypeCache[requested] = found;
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class DebugConsolePatches
|
||||||
|
{
|
||||||
|
private static bool _consoleVisible;
|
||||||
|
private static bool _warned;
|
||||||
|
private static KeyCode _toggleKey = KeyCode.BackQuote;
|
||||||
|
private static bool _keyParsed;
|
||||||
|
private static MethodInfo? _toggleMethod;
|
||||||
|
|
||||||
|
public static bool SuppressCheatDetected_Prefix() => false;
|
||||||
|
|
||||||
|
public static void PollToggleKey()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_keyParsed)
|
||||||
|
{
|
||||||
|
_keyParsed = true;
|
||||||
|
if (!Enum.TryParse(DevMenuMod.Cfg.DebugConsoleToggleKey, ignoreCase: true, out _toggleKey))
|
||||||
|
{
|
||||||
|
_toggleKey = KeyCode.BackQuote;
|
||||||
|
DevMenuMod.Log.Warning($"[debugconsole] invalid key '{DevMenuMod.Cfg.DebugConsoleToggleKey}', defaulting to BackQuote");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Input.GetKeyDown(_toggleKey))
|
||||||
|
{
|
||||||
|
Toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_warned)
|
||||||
|
{
|
||||||
|
_warned = true;
|
||||||
|
DevMenuMod.Log.Warning($"[debugconsole] poll failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Toggle()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var console = UObject.FindObjectOfType<DebugConsole>();
|
||||||
|
if (console == null)
|
||||||
|
{
|
||||||
|
DevMenuMod.Log.Warning("[debugconsole] no live DebugConsole in scene");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggleMethod ??= AccessTools.Method(typeof(DebugConsole), "ShowInputField", new[] { typeof(bool) })
|
||||||
|
?? AccessTools.Method(typeof(DebugConsole), "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||||
|
?? FindToggleByShape(typeof(DebugConsole));
|
||||||
|
if (_toggleMethod is null)
|
||||||
|
{
|
||||||
|
DevMenuMod.Log.Warning("[debugconsole] show/hide method not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_consoleVisible = !_consoleVisible;
|
||||||
|
_toggleMethod.Invoke(console, new object[] { _consoleVisible });
|
||||||
|
DevMenuMod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
DevMenuMod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MethodInfo? FindToggleByShape(Type t)
|
||||||
|
{
|
||||||
|
MethodInfo? found = null;
|
||||||
|
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||||
|
{
|
||||||
|
if (m.ReturnType != typeof(void)) continue;
|
||||||
|
var ps = m.GetParameters();
|
||||||
|
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
|
||||||
|
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||||
|
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
|
||||||
|
if (found is not null) return null;
|
||||||
|
found = m;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class DevMenuProbe
|
||||||
|
{
|
||||||
|
private static int _frame;
|
||||||
|
private static bool _done;
|
||||||
|
|
||||||
|
public static void Tick()
|
||||||
|
{
|
||||||
|
if (_done) return;
|
||||||
|
_frame++;
|
||||||
|
if (_frame < 180) return;
|
||||||
|
_done = true;
|
||||||
|
|
||||||
|
var homeType = DevMenuMod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? DevMenuMod.ResolveType("HomeScreenFlow");
|
||||||
|
var consoleType = DevMenuMod.ResolveType("RecRoom.Debugging.DebugConsole") ?? typeof(DebugConsole);
|
||||||
|
DevMenuMod.Log.Msg("=== [devmenu-probe] runtime dev-menu state ===");
|
||||||
|
DevMenuMod.Log.Msg($"[devmenu-probe] HomeScreenFlow type: {(homeType is null ? "NOT FOUND" : homeType.FullName)}");
|
||||||
|
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole type: {(consoleType is null ? "NOT FOUND" : consoleType.FullName)}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var console = UObject.FindObjectOfType<DebugConsole>();
|
||||||
|
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole live instance: {(console == null ? "null" : "FOUND")}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
DevMenuMod.Log.Warning($"[devmenu-probe] console instance probe failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
DevMenuMod.Log.Msg("=== [devmenu-probe] done ===");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Native watch "Dev Console" injection — typed against the per-install Il2Cpp
|
||||||
|
// interop assemblies (referenced in the csproj, dev-tools only, never shipped).
|
||||||
|
// Pure reflection couldn't drive this build's watch UI (FindObjectOfType /
|
||||||
|
// property-get / GetComponentsInChildren all failed under Il2CppInterop), so
|
||||||
|
// we use real types here: clone an existing watch Button3D, relabel it, and
|
||||||
|
// wire its OnClick to run console commands via DebugConsole.Execute (which
|
||||||
|
// works headless even though the console UI isn't in the scene). It's the
|
||||||
|
// game's own Button3D, so it's interactable with the VR laser AND the
|
||||||
|
// Screen-Mode mouse. Gated behind Cfg.EnableDevWatchButton.
|
||||||
|
using System;
|
||||||
|
using Il2CppInterop.Runtime;
|
||||||
|
using Il2CppAGUI.StackedUI;
|
||||||
|
using Il2CppRecRoom.Debugging;
|
||||||
|
using Il2CppTMPro;
|
||||||
|
using UnityEngine.Events;
|
||||||
|
using UObject = UnityEngine.Object;
|
||||||
|
|
||||||
|
namespace DorkNet.DevMenu;
|
||||||
|
|
||||||
|
internal static class DevWatchButton
|
||||||
|
{
|
||||||
|
private static int _frame;
|
||||||
|
private static bool _done;
|
||||||
|
|
||||||
|
// Polled from Mod.OnUpdate while Cfg.EnableDevWatchButton is set. Waits for
|
||||||
|
// the watch's HomeScreenFlow to exist, then injects once.
|
||||||
|
public static void Tick()
|
||||||
|
{
|
||||||
|
if (_done) return;
|
||||||
|
_frame++;
|
||||||
|
if (_frame < 120 || (_frame % 30) != 0) return; // settle, then ~2/s
|
||||||
|
var home = UObject.FindObjectOfType<HomeScreenFlow>();
|
||||||
|
if (home == null && _frame < 3600) return; // wait for the watch
|
||||||
|
_done = true;
|
||||||
|
if (home == null) { DevMenuMod.Log.Warning("[devbtn] HomeScreenFlow never appeared"); return; }
|
||||||
|
try { Inject(home); }
|
||||||
|
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] inject failed: {ex}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Inject(HomeScreenFlow home)
|
||||||
|
{
|
||||||
|
var buttons = home.GetComponentsInChildren<Button3D>(true);
|
||||||
|
if (buttons == null || buttons.Length == 0)
|
||||||
|
{
|
||||||
|
DevMenuMod.Log.Warning("[devbtn] no Button3D found under HomeScreenFlow");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer a source button that HAS a TMP label, so our clones can be
|
||||||
|
// relabeled (HomeButton etc. are icon-only — no text child).
|
||||||
|
Button3D? src = null;
|
||||||
|
for (int i = 0; i < buttons.Length; i++)
|
||||||
|
if (buttons[i].GetComponentInChildren<TextMeshProUGUI>(true) != null) { src = buttons[i]; break; }
|
||||||
|
bool labeled = src != null;
|
||||||
|
if (src == null) src = buttons[0]; // fall back: inject anyway, just no text
|
||||||
|
DevMenuMod.Log.Msg($"[devbtn] {buttons.Length} watch buttons; clone source='{src.name}' labeled={labeled}");
|
||||||
|
|
||||||
|
int n = 0;
|
||||||
|
foreach (var (label, cmd) in ParsePresets())
|
||||||
|
{
|
||||||
|
var clone = UObject.Instantiate(src, src.transform.parent);
|
||||||
|
clone.name = $"DorkNetDev_{label}";
|
||||||
|
clone.gameObject.SetActive(true);
|
||||||
|
clone.transform.localScale = src.transform.localScale;
|
||||||
|
|
||||||
|
var tmp = clone.GetComponentInChildren<TextMeshProUGUI>(true);
|
||||||
|
if (tmp != null) tmp.text = label;
|
||||||
|
|
||||||
|
var command = cmd; // capture per-button
|
||||||
|
clone.OnClick.RemoveAllListeners();
|
||||||
|
clone.OnClick.AddListener(DelegateSupport.ConvertDelegate<UnityAction>((Action)(() => Run(label, command))));
|
||||||
|
_clones.Add(clone);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
DevMenuMod.Log.Msg($"[devbtn] injected {n} dev command button(s) into the watch bar — open the watch to use them");
|
||||||
|
DumpCommands();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the real console command names from DebugConsoleCommandConfig.Metas
|
||||||
|
// so we can set DevCommands correctly (the asset loads even though the
|
||||||
|
// console component isn't in the scene).
|
||||||
|
private static void DumpCommands()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cfgs = UObject.FindObjectsOfTypeAll(Il2CppType.Of<DebugConsoleCommandConfig>());
|
||||||
|
DevMenuMod.Log.Msg($"[devcmds] {cfgs.Length} DebugConsoleCommandConfig asset(s) loaded");
|
||||||
|
for (int c = 0; c < cfgs.Length; c++)
|
||||||
|
{
|
||||||
|
var cfg = cfgs[c].TryCast<DebugConsoleCommandConfig>();
|
||||||
|
var metas = cfg?.Metas;
|
||||||
|
if (metas == null) continue;
|
||||||
|
DevMenuMod.Log.Msg($"[devcmds] {metas.Count} command(s):");
|
||||||
|
for (int i = 0; i < metas.Count; i++)
|
||||||
|
{
|
||||||
|
var m = metas[i];
|
||||||
|
DevMenuMod.Log.Msg($" {m.MethodName} ({m.DeclaringType})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { DevMenuMod.Log.Warning($"[devcmds] failed: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Run(string label, string command)
|
||||||
|
{
|
||||||
|
DevMenuMod.Log.Msg($"[devbtn] '{label}' -> DebugConsole.Execute(\"{command}\")");
|
||||||
|
try { DebugConsole.Execute(command); }
|
||||||
|
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] Execute('{command}') failed: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presets come from Cfg.DevCommands as "Label=command" strings.
|
||||||
|
private static System.Collections.Generic.IEnumerable<(string label, string cmd)> ParsePresets()
|
||||||
|
{
|
||||||
|
foreach (var entry in DevMenuMod.Cfg.DevCommands)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(entry)) continue;
|
||||||
|
var eq = entry.IndexOf('=');
|
||||||
|
if (eq > 0) yield return (entry.Substring(0, eq).Trim(), entry.Substring(eq + 1).Trim());
|
||||||
|
else yield return (entry.Trim(), entry.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly System.Collections.Generic.List<Button3D> _clones = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>DorkNet.DevMenu</RootNamespace>
|
||||||
|
<AssemblyName>DorkNet.DevMenu</AssemblyName>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||||
|
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||||
|
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader</MelonLoaderCacheRoot>
|
||||||
|
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||||
|
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Target Name="EnsureMelonLoaderReferences"
|
||||||
|
BeforeTargets="ResolveAssemblyReferences"
|
||||||
|
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
|
||||||
|
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||||
|
<DownloadFile
|
||||||
|
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||||
|
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||||
|
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
|
||||||
|
SkipUnchangedFiles="true" />
|
||||||
|
<Unzip SourceFiles="$(MelonLoaderZip)"
|
||||||
|
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||||
|
OverwriteReadOnlyFiles="true" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="MelonLoader">
|
||||||
|
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="0Harmony">
|
||||||
|
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Il2CppInterop.Runtime">
|
||||||
|
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Il2CppInterop.Common">
|
||||||
|
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<Il2CppAsmDir Condition="'$(Il2CppAsmDir)' == ''">C:\Users\Alexa\Downloads\DorkRoom-2020.12\MelonLoader\Il2CppAssemblies</Il2CppAsmDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup Condition="Exists('$(Il2CppAsmDir)')">
|
||||||
|
<Reference Include="Assembly-CSharp"><HintPath>$(Il2CppAsmDir)\Assembly-CSharp.dll</HintPath><Private>false</Private></Reference>
|
||||||
|
<Reference Include="Il2Cppmscorlib"><HintPath>$(Il2CppAsmDir)\Il2Cppmscorlib.dll</HintPath><Private>false</Private></Reference>
|
||||||
|
<Reference Include="UnityEngine.CoreModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
|
||||||
|
<Reference Include="UnityEngine.UI"><HintPath>$(Il2CppAsmDir)\UnityEngine.UI.dll</HintPath><Private>false</Private></Reference>
|
||||||
|
<Reference Include="UnityEngine.InputLegacyModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.InputLegacyModule.dll</HintPath><Private>false</Private></Reference>
|
||||||
|
<Reference Include="Unity.TextMeshPro"><HintPath>$(Il2CppAsmDir)\Unity.TextMeshPro.dll</HintPath><Private>false</Private></Reference>
|
||||||
|
<Reference Include="Il2CppRecRoom.Promises.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Promises.Runtime.dll</HintPath><Private>false</Private></Reference>
|
||||||
|
<Reference Include="Il2CppRecRoom.Datastructures.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Datastructures.Runtime.dll</HintPath><Private>false</Private></Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"EnableDebugConsole": false,
|
||||||
|
"DebugConsoleToggleKey": "BackQuote",
|
||||||
|
"DiagnoseDevMenu": false,
|
||||||
|
"EnableDevWatchButton": false,
|
||||||
|
"DevCommands": [
|
||||||
|
"Help=help",
|
||||||
|
"Fly=fly",
|
||||||
|
"NoClip=noclip"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
using DorkNet.Contracts;
|
||||||
|
using DorkNet.ServiceDefaults;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Yarp.ReverseProxy.Configuration;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
builder.Configuration.AddJsonFile("appsettings.Local.json",
|
||||||
|
optional: true, reloadOnChange: true);
|
||||||
|
|
||||||
|
builder.AddDorkNetServiceDefaults(ServiceNames.Gateway);
|
||||||
|
builder.Services.Configure<DorkNetServiceMapOptions>(
|
||||||
|
builder.Configuration.GetSection("DorkNet:Services"));
|
||||||
|
var serviceMap = builder.Configuration
|
||||||
|
.GetSection("DorkNet:Services")
|
||||||
|
.Get<DorkNetServiceMapOptions>() ?? new DorkNetServiceMapOptions();
|
||||||
|
var apexDomain = builder.Configuration["Domain:Apex"]
|
||||||
|
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
|
||||||
|
?? "localhost";
|
||||||
|
builder.Services
|
||||||
|
.AddReverseProxy()
|
||||||
|
.LoadFromMemory(
|
||||||
|
BuildRoutes(apexDomain, serviceMap),
|
||||||
|
BuildClusters(serviceMap));
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.MapDorkNetServiceDefaults();
|
||||||
|
|
||||||
|
app.MapGet("/internal/services", (IOptions<DorkNetServiceMapOptions> options) =>
|
||||||
|
Results.Ok(options.Value.Endpoints()));
|
||||||
|
app.MapGet("/internal/routes", () =>
|
||||||
|
Results.Ok(new
|
||||||
|
{
|
||||||
|
ApexDomain = apexDomain,
|
||||||
|
Routes = BuildRoutes(apexDomain, serviceMap).Select(r => new
|
||||||
|
{
|
||||||
|
r.RouteId,
|
||||||
|
r.ClusterId,
|
||||||
|
r.Match.Hosts,
|
||||||
|
r.Match.Path,
|
||||||
|
r.Order,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.MapGet("/internal/services/health", async (
|
||||||
|
IOptions<DorkNetServiceMapOptions> options,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var client = httpClientFactory.CreateClient();
|
||||||
|
var probes = new List<ServiceProbeResponse>();
|
||||||
|
|
||||||
|
foreach (var endpoint in options.Value.Endpoints())
|
||||||
|
{
|
||||||
|
probes.Add(await ProbeServiceAsync(client, endpoint, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(probes);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapReverseProxy();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
static async Task<ServiceProbeResponse> ProbeServiceAsync(
|
||||||
|
HttpClient client,
|
||||||
|
DorkNetServiceEndpoint endpoint,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var checkedAt = DateTimeOffset.UtcNow;
|
||||||
|
var baseUrl = endpoint.BaseUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await client.GetAsync($"{baseUrl}/internal/healthz", ct);
|
||||||
|
return new ServiceProbeResponse(
|
||||||
|
endpoint.Name,
|
||||||
|
endpoint.BaseUrl,
|
||||||
|
response.IsSuccessStatusCode ? "ok" : "unhealthy",
|
||||||
|
(int)response.StatusCode,
|
||||||
|
null,
|
||||||
|
checkedAt);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
return new ServiceProbeResponse(
|
||||||
|
endpoint.Name,
|
||||||
|
endpoint.BaseUrl,
|
||||||
|
"unreachable",
|
||||||
|
null,
|
||||||
|
ex.Message,
|
||||||
|
checkedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static IReadOnlyList<RouteConfig> BuildRoutes(
|
||||||
|
string apexDomain,
|
||||||
|
DorkNetServiceMapOptions services)
|
||||||
|
{
|
||||||
|
var routes = new List<RouteConfig>();
|
||||||
|
var order = 0;
|
||||||
|
var webGroup = DorkNetRouteOwnership.RouteGroups
|
||||||
|
.First(group => group.ServiceName == ServiceNames.Web);
|
||||||
|
|
||||||
|
AddHostRoute(
|
||||||
|
routes,
|
||||||
|
ref order,
|
||||||
|
webGroup.ServiceName,
|
||||||
|
BaseUrlFor(services, webGroup.ServiceName),
|
||||||
|
"web-admin-host",
|
||||||
|
[SubdomainHost("admin", apexDomain)]);
|
||||||
|
|
||||||
|
AddHostRoute(
|
||||||
|
routes,
|
||||||
|
ref order,
|
||||||
|
webGroup.ServiceName,
|
||||||
|
BaseUrlFor(services, webGroup.ServiceName),
|
||||||
|
"web-feed-host",
|
||||||
|
[SubdomainHost("feed", apexDomain)]);
|
||||||
|
|
||||||
|
AddHostPathRoutes(
|
||||||
|
routes,
|
||||||
|
ref order,
|
||||||
|
webGroup.ServiceName,
|
||||||
|
BaseUrlFor(services, webGroup.ServiceName),
|
||||||
|
WebBrowserHosts(apexDomain),
|
||||||
|
[
|
||||||
|
"/admin",
|
||||||
|
"/assets",
|
||||||
|
"/brand",
|
||||||
|
"/feed",
|
||||||
|
"/join",
|
||||||
|
"/p",
|
||||||
|
"/photo",
|
||||||
|
"/players",
|
||||||
|
"/rooms",
|
||||||
|
"/u",
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach (var group in DorkNetRouteOwnership.RouteGroups.Where(group => group.ServiceName != ServiceNames.Web))
|
||||||
|
{
|
||||||
|
AddHostRoute(
|
||||||
|
routes,
|
||||||
|
ref order,
|
||||||
|
group.ServiceName,
|
||||||
|
BaseUrlFor(services, group.ServiceName),
|
||||||
|
$"{group.ServiceName}-hosts",
|
||||||
|
group.HostSubdomains.Select(subdomain => SubdomainHost(subdomain, apexDomain)).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in DorkNetRouteOwnership.RouteGroups)
|
||||||
|
{
|
||||||
|
AddPathRoutes(
|
||||||
|
routes,
|
||||||
|
ref order,
|
||||||
|
group.ServiceName,
|
||||||
|
BaseUrlFor(services, group.ServiceName),
|
||||||
|
group.PathPrefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddHostRoute(
|
||||||
|
routes,
|
||||||
|
ref order,
|
||||||
|
webGroup.ServiceName,
|
||||||
|
BaseUrlFor(services, webGroup.ServiceName),
|
||||||
|
"web-hosts",
|
||||||
|
BuildWebHosts(webGroup, apexDomain));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(services.Monolith))
|
||||||
|
{
|
||||||
|
routes.Add(Route(ServiceNames.Monolith, "monolith-fallback", "/{**catch-all}", order++));
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static IReadOnlyList<ClusterConfig> BuildClusters(DorkNetServiceMapOptions services)
|
||||||
|
{
|
||||||
|
var clusters = new List<ClusterConfig>();
|
||||||
|
foreach (var endpoint in services.Endpoints())
|
||||||
|
{
|
||||||
|
AddCluster(clusters, endpoint.Name, endpoint.BaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string[] WebBrowserHosts(string apexDomain)
|
||||||
|
{
|
||||||
|
return [apexDomain, SubdomainHost("www", apexDomain)];
|
||||||
|
}
|
||||||
|
|
||||||
|
static void AddHostRoute(
|
||||||
|
List<RouteConfig> routes,
|
||||||
|
ref int order,
|
||||||
|
string serviceName,
|
||||||
|
string baseUrl,
|
||||||
|
string routeId,
|
||||||
|
string[] hosts)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
routes.Add(Route(
|
||||||
|
serviceName,
|
||||||
|
routeId,
|
||||||
|
"/{**catch-all}",
|
||||||
|
order++,
|
||||||
|
hosts));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void AddPathRoutes(
|
||||||
|
List<RouteConfig> routes,
|
||||||
|
ref int order,
|
||||||
|
string serviceName,
|
||||||
|
string baseUrl,
|
||||||
|
IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
var routeId = $"{serviceName}-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}";
|
||||||
|
var exactPath = NormalizeRoutePrefix(path);
|
||||||
|
routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++));
|
||||||
|
routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void AddHostPathRoutes(
|
||||||
|
List<RouteConfig> routes,
|
||||||
|
ref int order,
|
||||||
|
string serviceName,
|
||||||
|
string baseUrl,
|
||||||
|
string[] hosts,
|
||||||
|
IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
var routeId = $"{serviceName}-browser-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}";
|
||||||
|
var exactPath = NormalizeRoutePrefix(path);
|
||||||
|
routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++, hosts));
|
||||||
|
routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++, hosts));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string BaseUrlFor(DorkNetServiceMapOptions services, string serviceName)
|
||||||
|
{
|
||||||
|
return serviceName switch
|
||||||
|
{
|
||||||
|
ServiceNames.Identity => services.Identity,
|
||||||
|
ServiceNames.Rooms => services.Rooms,
|
||||||
|
ServiceNames.Notify => services.Notify,
|
||||||
|
ServiceNames.Content => services.Content,
|
||||||
|
ServiceNames.Social => services.Social,
|
||||||
|
ServiceNames.Commerce => services.Commerce,
|
||||||
|
ServiceNames.Platform => services.Platform,
|
||||||
|
ServiceNames.Moderation => services.Moderation,
|
||||||
|
ServiceNames.Web => services.Web,
|
||||||
|
ServiceNames.Monolith => services.Monolith,
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static string[] BuildWebHosts(DorkNetRouteGroup group, string apexDomain)
|
||||||
|
{
|
||||||
|
var hosts = new List<string>();
|
||||||
|
if (group.IncludesApexHost)
|
||||||
|
{
|
||||||
|
hosts.Add(apexDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts.AddRange(group.HostSubdomains
|
||||||
|
.Where(subdomain => !string.Equals(subdomain, "admin", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(subdomain => SubdomainHost(subdomain, apexDomain)));
|
||||||
|
|
||||||
|
return hosts.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string SubdomainHost(string subdomain, string apexDomain)
|
||||||
|
{
|
||||||
|
return $"{subdomain}.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
static string NormalizeRoutePrefix(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || path == "/")
|
||||||
|
{
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (path[0] == '/' ? path : "/" + path).TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
static RouteConfig Route(
|
||||||
|
string clusterId,
|
||||||
|
string routeId,
|
||||||
|
string path,
|
||||||
|
int order,
|
||||||
|
string[]? hosts = null)
|
||||||
|
{
|
||||||
|
return new RouteConfig
|
||||||
|
{
|
||||||
|
RouteId = routeId,
|
||||||
|
ClusterId = clusterId,
|
||||||
|
Order = order,
|
||||||
|
Match = new RouteMatch
|
||||||
|
{
|
||||||
|
Hosts = hosts,
|
||||||
|
Path = path,
|
||||||
|
},
|
||||||
|
Transforms =
|
||||||
|
[
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["RequestHeaderOriginalHost"] = "true",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static void AddCluster(
|
||||||
|
List<ClusterConfig> clusters,
|
||||||
|
string serviceName,
|
||||||
|
string baseUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var address = baseUrl.EndsWith('/') ? baseUrl : baseUrl + "/";
|
||||||
|
clusters.Add(new ClusterConfig
|
||||||
|
{
|
||||||
|
ClusterId = serviceName,
|
||||||
|
Destinations = new Dictionary<string, DestinationConfig>
|
||||||
|
{
|
||||||
|
["primary"] = new() { Address = address },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"System.Net.Http.HttpClient": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DorkNet": {
|
||||||
|
"Services": {
|
||||||
|
"Identity": "http://localhost:8081",
|
||||||
|
"Rooms": "http://localhost:8082",
|
||||||
|
"Notify": "http://localhost:8083",
|
||||||
|
"Content": "http://localhost:8084",
|
||||||
|
"Social": "http://localhost:8085",
|
||||||
|
"Commerce": "http://localhost:8086",
|
||||||
|
"Platform": "http://localhost:8087",
|
||||||
|
"Moderation": "http://localhost:8088",
|
||||||
|
"Web": "http://localhost:8089",
|
||||||
|
"Monolith": "http://localhost:8090"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Login.CachedLogin — dump.cs:578388, Deserialize at RVA 0x1447BD0.
|
||||||
|
///
|
||||||
|
/// Verified by disassembly: reads platform, platformId, accountId,
|
||||||
|
/// lastLoginTime (DateTime), requirePassword (default false). The Account
|
||||||
|
/// field on the C# class is NOT read from the JSON dict — it is set via
|
||||||
|
/// SetAccount(Account) after the fact, so the JSON shape doesn't need it.
|
||||||
|
///
|
||||||
|
/// Returned by:
|
||||||
|
/// GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
|
||||||
|
/// GET api.rec.net/api/platformlogin/cached
|
||||||
|
/// GET accounts.rec.net/account/v1/savedlogins
|
||||||
|
/// as a List<CachedLogin>. Empty list = no remembered accounts.
|
||||||
|
/// </summary>
|
||||||
|
public class CachedLogin
|
||||||
|
{
|
||||||
|
// PlatformManager.PlatformType (int)
|
||||||
|
[JsonPropertyName("platform")]
|
||||||
|
public int Platform { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("platformId")]
|
||||||
|
public string PlatformId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("accountId")]
|
||||||
|
public int AccountId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("lastLoginTime")]
|
||||||
|
public DateTime LastLoginTime { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[JsonPropertyName("requirePassword")]
|
||||||
|
public bool RequirePassword { get; set; } = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace DorkNet.Models.Auth;
|
||||||
|
|
||||||
|
public class LoginRequest
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public long BuildTimestamp { get; set; }
|
||||||
|
public int Platform { get; set; }
|
||||||
|
public string? DeviceId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Login.LoginResponse — dump.cs:578473 (private nested class),
|
||||||
|
/// Deserialize at RVA 0x1447D10.
|
||||||
|
///
|
||||||
|
/// Verified by disassembly: this is a standard OAuth 2.0 RFC 6749
|
||||||
|
/// password-grant token response. The Deserialize body has two branches:
|
||||||
|
///
|
||||||
|
/// error = dict["error"];
|
||||||
|
/// if (string.IsNullOrEmpty(error)) {
|
||||||
|
/// this.AccessToken = dict["access_token"];
|
||||||
|
/// this.RefreshToken = dict["refresh_token"];
|
||||||
|
/// } else {
|
||||||
|
/// this.ErrorDescription = dict["error_description"];
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// Returned by api/platformlogin/v5 (the 2020 build's login endpoint)
|
||||||
|
/// and api/platformlogin/refresh.
|
||||||
|
///
|
||||||
|
/// All four keys are snake_case, NOT PascalCase. This is the standard
|
||||||
|
/// OAuth response format — Rec Room conforms to RFC 6749 here.
|
||||||
|
/// </summary>
|
||||||
|
public class LoginResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("error")]
|
||||||
|
public string? Error { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("error_description")]
|
||||||
|
public string? ErrorDescription { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("access_token")]
|
||||||
|
public string AccessToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
public string RefreshToken { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Auth;
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// JSON keys verified by disassembling each .Deserialize method body
|
||||||
|
// directly from GameAssembly.dll. Each key here is the literal string
|
||||||
|
// passed to Util.GetKey / Util.GetKeyOrDefault inside the client. Util.GetKey
|
||||||
|
// uses Dictionary<string,object>.TryGetValue, which is case-sensitive — so
|
||||||
|
// the keys MUST match exactly.
|
||||||
|
//
|
||||||
|
// Convention summary (it's mixed):
|
||||||
|
// RecNetResult lowercase "success", "error"
|
||||||
|
// Account camelCase "accountId", "displayName", "isJunior"…
|
||||||
|
// SelfAccount camelCase "email", "birthday", "juniorState"…
|
||||||
|
// LoginResponse snake_case (OAuth) "access_token", "error_description"…
|
||||||
|
// RefreshLoginResp. lowercase "token"
|
||||||
|
// CachedLogin camelCase "platformId", "lastLoginTime"…
|
||||||
|
// CreateAccountResp uses "value" as the wrapper key for the Account dict
|
||||||
|
//
|
||||||
|
// NOTE: TreatAsJunior on the C# side maps to JSON key "isJunior" — they
|
||||||
|
// don't match. Account.Deserialize reads dict["isJunior"], not dict["treatAsJunior"].
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RecNet.RecNetResult — dump.cs:584325, Deserialize at RVA 0x1146340.
|
||||||
|
/// Reads "success" (required) and "error" (optional).
|
||||||
|
/// </summary>
|
||||||
|
public class RecNetResult
|
||||||
|
{
|
||||||
|
[JsonPropertyName("success")]
|
||||||
|
public bool Success { get; set; } = true;
|
||||||
|
|
||||||
|
[JsonPropertyName("error")]
|
||||||
|
public string? Error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RecNet.Account — dump.cs:567684, Deserialize at RVA 0xF9DE50.
|
||||||
|
/// JSON keys verified in the current 2020 IL2CPP build. Account.Deserialize
|
||||||
|
/// reads profileImage.
|
||||||
|
/// Other keys: accountId, username, displayName, isJunior, platforms. The C# Account class also has
|
||||||
|
/// RawUsername and HasBirthday properties but Deserialize does not read
|
||||||
|
/// them from JSON (they're set via MakeNameAdhereToPlatformRequirements
|
||||||
|
/// or other flows). We expose them with reasonable camelCase keys for
|
||||||
|
/// other endpoints that may consume them.
|
||||||
|
/// </summary>
|
||||||
|
public class RecNetAccount
|
||||||
|
{
|
||||||
|
private string profileImage = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("accountId")]
|
||||||
|
public int AccountId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("username")]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("displayName")]
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("profileImage")]
|
||||||
|
public string ProfileImage
|
||||||
|
{
|
||||||
|
get => profileImage;
|
||||||
|
set => profileImage = value ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account.Deserialize reads "isJunior" — NOT "treatAsJunior".
|
||||||
|
// The C# property name and the JSON key name differ.
|
||||||
|
[JsonPropertyName("isJunior")]
|
||||||
|
public bool TreatAsJunior { get; set; } = false;
|
||||||
|
|
||||||
|
// PlatformManager.PlatformMask: Steam=1, Oculus=2, etc.
|
||||||
|
[JsonPropertyName("platforms")]
|
||||||
|
public int Platforms { get; set; } = 1;
|
||||||
|
|
||||||
|
// Not in Account.Deserialize body but kept for forward-compat.
|
||||||
|
[JsonPropertyName("rawUsername")]
|
||||||
|
public string RawUsername { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Likewise.
|
||||||
|
[JsonPropertyName("hasBirthday")]
|
||||||
|
public bool HasBirthday { get; set; } = true;
|
||||||
|
|
||||||
|
// 2020.12 Account.Deserialize reads this key (verified via Cpp2IL ISIL
|
||||||
|
// dump of CCEOLAOLEKJ.PPGFHEDFBEA — the obfuscated Account class). The
|
||||||
|
// 2020.03 client doesn't read it, but emitting it is harmless there.
|
||||||
|
// Missing → LitJson throws and 2020.12 surfaces "Malformed Response".
|
||||||
|
[JsonPropertyName("createdAt")]
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RecNet.SelfAccount : Account — dump.cs:567797, Deserialize at RVA 0xAF4FB0.
|
||||||
|
/// Calls base Account.Deserialize, then reads:
|
||||||
|
/// email, phone, birthday, juniorState, parentAccountId.
|
||||||
|
/// </summary>
|
||||||
|
public class RecNetSelfAccount : RecNetAccount
|
||||||
|
{
|
||||||
|
[JsonPropertyName("email")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("phone")]
|
||||||
|
public string Phone { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("birthday")]
|
||||||
|
public DateTime? Birthday { get; set; } = new DateTime(2000, 1, 1);
|
||||||
|
|
||||||
|
// RecNet.JuniorState enum (int)
|
||||||
|
[JsonPropertyName("juniorState")]
|
||||||
|
public int JuniorState { get; set; } = 0;
|
||||||
|
|
||||||
|
[JsonPropertyName("parentAccountId")]
|
||||||
|
public int? ParentAccountId { get; set; } = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accounts.CreateAccountResponse : RecNetResult — dump.cs:568167,
|
||||||
|
/// Deserialize at RVA 0xFAA200.
|
||||||
|
/// Calls base RecNetResult.Deserialize (reads "success", "error"),
|
||||||
|
/// then reads the Account from dict["value"] — NOT dict["account"].
|
||||||
|
/// Verified by disassembly: the only string literal after the base
|
||||||
|
/// call is "value", passed to Util.GetObjectKey<object>.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateAccountResponse : RecNetResult
|
||||||
|
{
|
||||||
|
[JsonPropertyName("value")]
|
||||||
|
public RecNetAccount Account { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Login.RefreshLoginResponse — dump.cs:578533 (private nested class),
|
||||||
|
/// Deserialize at RVA 0x145B1E0.
|
||||||
|
///
|
||||||
|
/// Verified by disassembly: reads exactly one key — lowercase "token" —
|
||||||
|
/// via Util.GetKey<string>. Single-field response from
|
||||||
|
/// POST api/platformlogin/refresh: the new (rotated) bearer access token.
|
||||||
|
/// </summary>
|
||||||
|
public class RefreshLoginResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("token")]
|
||||||
|
public string Token { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Avatar;
|
||||||
|
|
||||||
|
public class AvatarData
|
||||||
|
{
|
||||||
|
[JsonPropertyName("AccountId")]
|
||||||
|
public long AccountId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("AvatarItemInstances")]
|
||||||
|
public List<AvatarItemInstance> AvatarItemInstances { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AvatarItemInstance
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Id")]
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("ItemId")]
|
||||||
|
public long ItemId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("IsEquipped")]
|
||||||
|
public bool IsEquipped { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Avatar;
|
||||||
|
|
||||||
|
public class AvatarItem
|
||||||
|
{
|
||||||
|
[JsonPropertyName("ItemId")]
|
||||||
|
public long ItemId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("Name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("Equipped")]
|
||||||
|
public bool Equipped { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Config;
|
||||||
|
|
||||||
|
// Required keys per RecNet.RecRoomConfig.Deserialize disassembly (RVA 0x1146FA0).
|
||||||
|
// Required-throw keys: ShareBaseUrl, LevelProgressionMaps[].Level, [].RequiredXp.
|
||||||
|
// Required-object keys (GetObjectKey, accept null): ServerMaintenance,
|
||||||
|
// AutoMicMutingConfig.
|
||||||
|
//
|
||||||
|
// NOTE: the level entry uses "RequiredXp" (lowercase 'p') per the IL string
|
||||||
|
// literal — NOT "RequiredXP". A capital P here would throw KeyNotFoundException.
|
||||||
|
public class RecRoomConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("MessageOfTheDay")]
|
||||||
|
public string MessageOfTheDay { get; set; } = "Welcome to the private server!";
|
||||||
|
|
||||||
|
[JsonPropertyName("CdnBaseUri")]
|
||||||
|
public string CdnBaseUri { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Required by client (string, GetKey). Used for share-link generation.
|
||||||
|
[JsonPropertyName("ShareBaseUrl")]
|
||||||
|
public string ShareBaseUrl { get; set; } = "https://rec.net/";
|
||||||
|
|
||||||
|
// GetObjectKey<ServerMaintenanceDTO>. Required key. The deserializer
|
||||||
|
// accepts null without throwing, but the C# property getter on the
|
||||||
|
// client doesn't null-check before use during boot. Send a real DTO
|
||||||
|
// with StartsInMinutes far in the future = "no upcoming maintenance".
|
||||||
|
[JsonPropertyName("ServerMaintenance")]
|
||||||
|
public ServerMaintenanceDTO? ServerMaintenance { get; set; } = new();
|
||||||
|
|
||||||
|
// GetObjectKey<AutoMicMutingConfig>. Required by the client AND
|
||||||
|
// dereferenced by AudioManager.Initialize during PostLogin boot —
|
||||||
|
// null causes:
|
||||||
|
// NullReferenceException at RecNet.Config.get_AutoMicMutingConfig
|
||||||
|
// at AudioManager.Initialize
|
||||||
|
// which kills the boot sequence with "Error initializing Core Systems".
|
||||||
|
// Must be a real object with all 8 float fields populated.
|
||||||
|
[JsonPropertyName("AutoMicMutingConfig")]
|
||||||
|
public AutoMicMutingConfig AutoMicMutingConfig { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("PhotonConfig")]
|
||||||
|
public PhotonConfig PhotonConfig { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("MatchmakingParams")]
|
||||||
|
public MatchmakingParams MatchmakingParams { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("LevelProgressionMaps")]
|
||||||
|
public List<LevelProgressionEntry> LevelProgressionMaps { get; set; } = LevelProgressionEntry.Default();
|
||||||
|
|
||||||
|
[JsonPropertyName("DailyObjectives")]
|
||||||
|
public List<List<DailyObjective>> DailyObjectives { get; set; } = DailyObjective.DefaultSets();
|
||||||
|
|
||||||
|
[JsonPropertyName("ConfigTable")]
|
||||||
|
public Dictionary<string, string> ConfigTable { get; set; } = [];
|
||||||
|
|
||||||
|
[JsonPropertyName("ServiceUrls")]
|
||||||
|
public Dictionary<string, string> ServiceUrls { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PhotonConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("AppId")]
|
||||||
|
public string AppId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("VoiceAppId")]
|
||||||
|
public string VoiceAppId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("CloudRegion")]
|
||||||
|
public string CloudRegion { get; set; } = "us";
|
||||||
|
|
||||||
|
[JsonPropertyName("CrcCheckEnabled")]
|
||||||
|
public bool CrcCheckEnabled { get; set; } = false;
|
||||||
|
|
||||||
|
[JsonPropertyName("EnableServerTracingAfterDisconnect")]
|
||||||
|
public bool EnableServerTracingAfterDisconnect { get; set; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MatchmakingParams
|
||||||
|
{
|
||||||
|
[JsonPropertyName("MaxPlayersPerRoom")]
|
||||||
|
public int MaxPlayersPerRoom { get; set; } = 8;
|
||||||
|
|
||||||
|
[JsonPropertyName("MinPlayersToStart")]
|
||||||
|
public int MinPlayersToStart { get; set; } = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LevelProgressionEntry
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Level")]
|
||||||
|
public int Level { get; set; }
|
||||||
|
|
||||||
|
// Note the lowercase 'p' — the client's RecRoomConfig.Deserialize calls
|
||||||
|
// Util.GetKey<int>("RequiredXp", ...). PascalCase XP would miss.
|
||||||
|
[JsonPropertyName("RequiredXp")]
|
||||||
|
public int RequiredXp { get; set; }
|
||||||
|
|
||||||
|
public static List<LevelProgressionEntry> Default()
|
||||||
|
{
|
||||||
|
var entries = new List<LevelProgressionEntry>();
|
||||||
|
for (int i = 0; i <= 30; i++)
|
||||||
|
entries.Add(new LevelProgressionEntry { Level = i, RequiredXp = i * 500 });
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner item shape for RecRoomConfig.DailyObjectives, verified by the
|
||||||
|
// disassembled iteration body — only "type" and "score" are read. Other
|
||||||
|
// fields the client doesn't read get ignored, so we only send these two.
|
||||||
|
public class DailyObjective
|
||||||
|
{
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public int Type { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("score")]
|
||||||
|
public int Score { get; set; }
|
||||||
|
|
||||||
|
public static List<List<DailyObjective>> DefaultSets()
|
||||||
|
{
|
||||||
|
var sets = new List<List<DailyObjective>>();
|
||||||
|
for (int day = 0; day < 7; day++)
|
||||||
|
{
|
||||||
|
sets.Add(
|
||||||
|
[
|
||||||
|
new DailyObjective { Type = 1, Score = 50 },
|
||||||
|
new DailyObjective { Type = 2, Score = 30 },
|
||||||
|
new DailyObjective { Type = 3, Score = 20 },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return sets;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RecNet.AutoMicMutingConfig — dump.cs class, Deserialize at RVA 0xFA3AE0.
|
||||||
|
/// All 8 float fields are required (Util.GetKey<float>). Defaults are
|
||||||
|
/// "feature effectively disabled" — high thresholds, no force-mute.
|
||||||
|
/// </summary>
|
||||||
|
public class AutoMicMutingConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("MicSpamVolumeThreshold")]
|
||||||
|
public float MicSpamVolumeThreshold { get; set; } = 1.0f;
|
||||||
|
|
||||||
|
[JsonPropertyName("MicVolumeSampleInterval")]
|
||||||
|
public float MicVolumeSampleInterval { get; set; } = 0.1f;
|
||||||
|
|
||||||
|
[JsonPropertyName("MicVolumeSampleRollingWindowLength")]
|
||||||
|
public float MicVolumeSampleRollingWindowLength { get; set; } = 5.0f;
|
||||||
|
|
||||||
|
[JsonPropertyName("MicSpamSamplePercentageForWarning")]
|
||||||
|
public float MicSpamSamplePercentageForWarning { get; set; } = 1.0f;
|
||||||
|
|
||||||
|
[JsonPropertyName("MicSpamSamplePercentageForWarningToEnd")]
|
||||||
|
public float MicSpamSamplePercentageForWarningToEnd { get; set; } = 0.5f;
|
||||||
|
|
||||||
|
[JsonPropertyName("MicSpamSamplePercentageForForceMute")]
|
||||||
|
public float MicSpamSamplePercentageForForceMute { get; set; } = 1.0f;
|
||||||
|
|
||||||
|
[JsonPropertyName("MicSpamSamplePercentageForForceMuteToEnd")]
|
||||||
|
public float MicSpamSamplePercentageForForceMuteToEnd { get; set; } = 0.5f;
|
||||||
|
|
||||||
|
[JsonPropertyName("MicSpamWarningStateVolumeMultiplier")]
|
||||||
|
public float MicSpamWarningStateVolumeMultiplier { get; set; } = 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RecNet.ServerMaintenanceDTO — dump.cs, Deserialize at RVA 0xAF5450.
|
||||||
|
/// Single required key: StartsInMinutes (int). A large positive value
|
||||||
|
/// means "maintenance window is far away" = effectively never.
|
||||||
|
/// </summary>
|
||||||
|
public class ServerMaintenanceDTO
|
||||||
|
{
|
||||||
|
[JsonPropertyName("StartsInMinutes")]
|
||||||
|
public int StartsInMinutes { get; set; } = int.MaxValue;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
version=1
|
||||||
|
|
||||||
|
# This file caches language service data to improve the performance of C# Dev Kit.
|
||||||
|
# It is not intended for manual editing. It can safely be deleted and will be
|
||||||
|
# regenerated automatically. For more information, see https://aka.ms/lscache
|
||||||
|
#
|
||||||
|
# To control where cache files are stored, use the following VS Code setting:
|
||||||
|
# "dotnet.projectsystem.cacheInProjectFolder": true
|
||||||
|
|
||||||
|
[project]
|
||||||
|
language=C#
|
||||||
|
primary
|
||||||
|
lastDtbSucceeded
|
||||||
|
|
||||||
|
[properties]
|
||||||
|
AssemblyName=DorkNet.Models
|
||||||
|
CommandLineArgsForDesignTimeEvaluation=-langversion:13.0 -define:TRACE
|
||||||
|
CompilerGeneratedFilesOutputPath=
|
||||||
|
MaxSupportedLangVersion=13.0
|
||||||
|
ProjectAssetsFile=<PATH>obj/project.assets.json
|
||||||
|
RootNamespace=DorkNet.Models
|
||||||
|
RunAnalyzers=
|
||||||
|
RunAnalyzersDuringLiveAnalysis=
|
||||||
|
SolutionPath=<PATH>../DorkNet.sln
|
||||||
|
TargetFrameworkIdentifier=.NETCoreApp
|
||||||
|
TargetPath=<PATH>bin/Debug/net9.0/DorkNet.Models.dll
|
||||||
|
TargetRefPath=<PATH>obj/Debug/net9.0/ref/DorkNet.Models.dll
|
||||||
|
TemporaryDependencyNodeTargetIdentifier=net9.0
|
||||||
|
|
||||||
|
[commandLineArguments]
|
||||||
|
/noconfig
|
||||||
|
/unsafe-
|
||||||
|
/checked-
|
||||||
|
/nowarn:1701,1702,1701,1702
|
||||||
|
/fullpaths
|
||||||
|
/nostdlib+
|
||||||
|
/errorreport:prompt
|
||||||
|
/warn:9
|
||||||
|
/define:TRACE;DEBUG;NET;NET9_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER
|
||||||
|
/highentropyva+
|
||||||
|
/nullable:enable
|
||||||
|
/debug+
|
||||||
|
/debug:portable
|
||||||
|
/filealign:512
|
||||||
|
/optimize-
|
||||||
|
/out:obj\Debug\net9.0\DorkNet.Models.dll
|
||||||
|
/refout:obj\Debug\net9.0\refint\DorkNet.Models.dll
|
||||||
|
/target:library
|
||||||
|
/warnaserror-
|
||||||
|
/utf8output
|
||||||
|
/deterministic+
|
||||||
|
/langversion:13.0
|
||||||
|
/warnaserror+:NU1605,SYSLIB0011
|
||||||
|
|
||||||
|
[sourceFiles]
|
||||||
|
Auth/
|
||||||
|
CachedLogin.cs
|
||||||
|
LoginRequest.cs
|
||||||
|
LoginResponse.cs
|
||||||
|
RecNetResult.cs
|
||||||
|
RefreshLoginResponse.cs
|
||||||
|
Avatar/
|
||||||
|
AvatarData.cs
|
||||||
|
AvatarItem.cs
|
||||||
|
Config/RecRoomConfig.cs
|
||||||
|
GameSessions/GameSession.cs
|
||||||
|
Notification/PushNotification.cs
|
||||||
|
obj/Debug/net9.0/
|
||||||
|
.NETCoreApp,Version=v9.0.AssemblyAttributes.cs
|
||||||
|
DorkNet.Models.AssemblyInfo.cs
|
||||||
|
DorkNet.Models.GlobalUsings.g.cs
|
||||||
|
Players/
|
||||||
|
ModerationBlockDetail.cs
|
||||||
|
PlayerProfile.cs
|
||||||
|
Progression.cs
|
||||||
|
Reputation.cs
|
||||||
|
UpdateProfileRequest.cs
|
||||||
|
Relationships/Relationship.cs
|
||||||
|
|
||||||
|
[metadataReferences]
|
||||||
|
<DOTNET>/packs/Microsoft.NETCore.App.Ref/9.0.16/ref/net9.0/
|
||||||
|
Microsoft.CSharp.dll
|
||||||
|
Microsoft.VisualBasic.Core.dll
|
||||||
|
Microsoft.VisualBasic.dll
|
||||||
|
Microsoft.Win32.Primitives.dll
|
||||||
|
Microsoft.Win32.Registry.dll
|
||||||
|
mscorlib.dll
|
||||||
|
netstandard.dll
|
||||||
|
System.AppContext.dll
|
||||||
|
System.Buffers.dll
|
||||||
|
System.Collections.Concurrent.dll
|
||||||
|
System.Collections.dll
|
||||||
|
System.Collections.Immutable.dll
|
||||||
|
System.Collections.NonGeneric.dll
|
||||||
|
System.Collections.Specialized.dll
|
||||||
|
System.ComponentModel.Annotations.dll
|
||||||
|
System.ComponentModel.DataAnnotations.dll
|
||||||
|
System.ComponentModel.dll
|
||||||
|
System.ComponentModel.EventBasedAsync.dll
|
||||||
|
System.ComponentModel.Primitives.dll
|
||||||
|
System.ComponentModel.TypeConverter.dll
|
||||||
|
System.Configuration.dll
|
||||||
|
System.Console.dll
|
||||||
|
System.Core.dll
|
||||||
|
System.Data.Common.dll
|
||||||
|
System.Data.DataSetExtensions.dll
|
||||||
|
System.Data.dll
|
||||||
|
System.Diagnostics.Contracts.dll
|
||||||
|
System.Diagnostics.Debug.dll
|
||||||
|
System.Diagnostics.DiagnosticSource.dll
|
||||||
|
System.Diagnostics.FileVersionInfo.dll
|
||||||
|
System.Diagnostics.Process.dll
|
||||||
|
System.Diagnostics.StackTrace.dll
|
||||||
|
System.Diagnostics.TextWriterTraceListener.dll
|
||||||
|
System.Diagnostics.Tools.dll
|
||||||
|
System.Diagnostics.TraceSource.dll
|
||||||
|
System.Diagnostics.Tracing.dll
|
||||||
|
System.dll
|
||||||
|
System.Drawing.dll
|
||||||
|
System.Drawing.Primitives.dll
|
||||||
|
System.Dynamic.Runtime.dll
|
||||||
|
System.Formats.Asn1.dll
|
||||||
|
System.Formats.Tar.dll
|
||||||
|
System.Globalization.Calendars.dll
|
||||||
|
System.Globalization.dll
|
||||||
|
System.Globalization.Extensions.dll
|
||||||
|
System.IO.Compression.Brotli.dll
|
||||||
|
System.IO.Compression.dll
|
||||||
|
System.IO.Compression.FileSystem.dll
|
||||||
|
System.IO.Compression.ZipFile.dll
|
||||||
|
System.IO.dll
|
||||||
|
System.IO.FileSystem.AccessControl.dll
|
||||||
|
System.IO.FileSystem.dll
|
||||||
|
System.IO.FileSystem.DriveInfo.dll
|
||||||
|
System.IO.FileSystem.Primitives.dll
|
||||||
|
System.IO.FileSystem.Watcher.dll
|
||||||
|
System.IO.IsolatedStorage.dll
|
||||||
|
System.IO.MemoryMappedFiles.dll
|
||||||
|
System.IO.Pipelines.dll
|
||||||
|
System.IO.Pipes.AccessControl.dll
|
||||||
|
System.IO.Pipes.dll
|
||||||
|
System.IO.UnmanagedMemoryStream.dll
|
||||||
|
System.Linq.dll
|
||||||
|
System.Linq.Expressions.dll
|
||||||
|
System.Linq.Parallel.dll
|
||||||
|
System.Linq.Queryable.dll
|
||||||
|
System.Memory.dll
|
||||||
|
System.Net.dll
|
||||||
|
System.Net.Http.dll
|
||||||
|
System.Net.Http.Json.dll
|
||||||
|
System.Net.HttpListener.dll
|
||||||
|
System.Net.Mail.dll
|
||||||
|
System.Net.NameResolution.dll
|
||||||
|
System.Net.NetworkInformation.dll
|
||||||
|
System.Net.Ping.dll
|
||||||
|
System.Net.Primitives.dll
|
||||||
|
System.Net.Quic.dll
|
||||||
|
System.Net.Requests.dll
|
||||||
|
System.Net.Security.dll
|
||||||
|
System.Net.ServicePoint.dll
|
||||||
|
System.Net.Sockets.dll
|
||||||
|
System.Net.WebClient.dll
|
||||||
|
System.Net.WebHeaderCollection.dll
|
||||||
|
System.Net.WebProxy.dll
|
||||||
|
System.Net.WebSockets.Client.dll
|
||||||
|
System.Net.WebSockets.dll
|
||||||
|
System.Numerics.dll
|
||||||
|
System.Numerics.Vectors.dll
|
||||||
|
System.ObjectModel.dll
|
||||||
|
System.Reflection.DispatchProxy.dll
|
||||||
|
System.Reflection.dll
|
||||||
|
System.Reflection.Emit.dll
|
||||||
|
System.Reflection.Emit.ILGeneration.dll
|
||||||
|
System.Reflection.Emit.Lightweight.dll
|
||||||
|
System.Reflection.Extensions.dll
|
||||||
|
System.Reflection.Metadata.dll
|
||||||
|
System.Reflection.Primitives.dll
|
||||||
|
System.Reflection.TypeExtensions.dll
|
||||||
|
System.Resources.Reader.dll
|
||||||
|
System.Resources.ResourceManager.dll
|
||||||
|
System.Resources.Writer.dll
|
||||||
|
System.Runtime.CompilerServices.Unsafe.dll
|
||||||
|
System.Runtime.CompilerServices.VisualC.dll
|
||||||
|
System.Runtime.dll
|
||||||
|
System.Runtime.Extensions.dll
|
||||||
|
System.Runtime.Handles.dll
|
||||||
|
System.Runtime.InteropServices.dll
|
||||||
|
System.Runtime.InteropServices.JavaScript.dll
|
||||||
|
System.Runtime.InteropServices.RuntimeInformation.dll
|
||||||
|
System.Runtime.Intrinsics.dll
|
||||||
|
System.Runtime.Loader.dll
|
||||||
|
System.Runtime.Numerics.dll
|
||||||
|
System.Runtime.Serialization.dll
|
||||||
|
System.Runtime.Serialization.Formatters.dll
|
||||||
|
System.Runtime.Serialization.Json.dll
|
||||||
|
System.Runtime.Serialization.Primitives.dll
|
||||||
|
System.Runtime.Serialization.Xml.dll
|
||||||
|
System.Security.AccessControl.dll
|
||||||
|
System.Security.Claims.dll
|
||||||
|
System.Security.Cryptography.Algorithms.dll
|
||||||
|
System.Security.Cryptography.Cng.dll
|
||||||
|
System.Security.Cryptography.Csp.dll
|
||||||
|
System.Security.Cryptography.dll
|
||||||
|
System.Security.Cryptography.Encoding.dll
|
||||||
|
System.Security.Cryptography.OpenSsl.dll
|
||||||
|
System.Security.Cryptography.Primitives.dll
|
||||||
|
System.Security.Cryptography.X509Certificates.dll
|
||||||
|
System.Security.dll
|
||||||
|
System.Security.Principal.dll
|
||||||
|
System.Security.Principal.Windows.dll
|
||||||
|
System.Security.SecureString.dll
|
||||||
|
System.ServiceModel.Web.dll
|
||||||
|
System.ServiceProcess.dll
|
||||||
|
System.Text.Encoding.CodePages.dll
|
||||||
|
System.Text.Encoding.dll
|
||||||
|
System.Text.Encoding.Extensions.dll
|
||||||
|
System.Text.Encodings.Web.dll
|
||||||
|
System.Text.Json.dll
|
||||||
|
System.Text.RegularExpressions.dll
|
||||||
|
System.Threading.Channels.dll
|
||||||
|
System.Threading.dll
|
||||||
|
System.Threading.Overlapped.dll
|
||||||
|
System.Threading.Tasks.Dataflow.dll
|
||||||
|
System.Threading.Tasks.dll
|
||||||
|
System.Threading.Tasks.Extensions.dll
|
||||||
|
System.Threading.Tasks.Parallel.dll
|
||||||
|
System.Threading.Thread.dll
|
||||||
|
System.Threading.ThreadPool.dll
|
||||||
|
System.Threading.Timer.dll
|
||||||
|
System.Transactions.dll
|
||||||
|
System.Transactions.Local.dll
|
||||||
|
System.ValueTuple.dll
|
||||||
|
System.Web.dll
|
||||||
|
System.Web.HttpUtility.dll
|
||||||
|
System.Windows.dll
|
||||||
|
System.Xml.dll
|
||||||
|
System.Xml.Linq.dll
|
||||||
|
System.Xml.ReaderWriter.dll
|
||||||
|
System.Xml.Serialization.dll
|
||||||
|
System.Xml.XDocument.dll
|
||||||
|
System.Xml.XmlDocument.dll
|
||||||
|
System.Xml.XmlSerializer.dll
|
||||||
|
System.Xml.XPath.dll
|
||||||
|
System.Xml.XPath.XDocument.dll
|
||||||
|
WindowsBase.dll
|
||||||
|
|
||||||
|
[analyzerReferences]
|
||||||
|
<DOTNET>/packs/Microsoft.NETCore.App.Ref/9.0.16/analyzers/dotnet/cs/
|
||||||
|
Microsoft.Interop.ComInterfaceGenerator.dll
|
||||||
|
Microsoft.Interop.JavaScript.JSImportGenerator.dll
|
||||||
|
Microsoft.Interop.LibraryImportGenerator.dll
|
||||||
|
Microsoft.Interop.SourceGeneration.dll
|
||||||
|
System.Text.Json.SourceGeneration.dll
|
||||||
|
System.Text.RegularExpressions.Generator.dll
|
||||||
|
<DOTNET>/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/
|
||||||
|
Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll
|
||||||
|
Microsoft.CodeAnalysis.NetAnalyzers.dll
|
||||||
|
|
||||||
|
[analyzerConfigFiles]
|
||||||
|
<DOTNET>/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_9_default.globalconfig
|
||||||
|
obj/Debug/net9.0/DorkNet.Models.GeneratedMSBuildEditorConfig.editorconfig
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.GameSessions;
|
||||||
|
|
||||||
|
public class GameSession
|
||||||
|
{
|
||||||
|
[JsonPropertyName("GameSessionId")]
|
||||||
|
public long GameSessionId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("RegionId")]
|
||||||
|
public string RegionId { get; set; } = "us";
|
||||||
|
|
||||||
|
[JsonPropertyName("RoomId")]
|
||||||
|
public string RoomId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("RoomName")]
|
||||||
|
public string RoomName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("ActivityLevelId")]
|
||||||
|
public string ActivityLevelId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("EventId")]
|
||||||
|
public long? EventId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("Private")]
|
||||||
|
public bool Private { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("GameInProgress")]
|
||||||
|
public bool GameInProgress { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("MaxCapacity")]
|
||||||
|
public int MaxCapacity { get; set; } = 8;
|
||||||
|
|
||||||
|
[JsonPropertyName("IsFull")]
|
||||||
|
public bool IsFull { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("PlayerCount")]
|
||||||
|
public int PlayerCount { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("PhotonRoomName")]
|
||||||
|
public string PhotonRoomName { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class JoinRandomGameSessionRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("RoomId")]
|
||||||
|
public string? RoomId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("ActivityLevelId")]
|
||||||
|
public string? ActivityLevelId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("RegionId")]
|
||||||
|
public string RegionId { get; set; } = "us";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class JoinGameSessionResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Result")]
|
||||||
|
public JoinGameErrorCode Result { get; set; } = JoinGameErrorCode.Success;
|
||||||
|
|
||||||
|
[JsonPropertyName("GameSession")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public GameSession? GameSession { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum JoinGameErrorCode
|
||||||
|
{
|
||||||
|
Success = 0,
|
||||||
|
Full = 1,
|
||||||
|
NotFound = 2,
|
||||||
|
Banned = 3,
|
||||||
|
Error = 4,
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Notification;
|
||||||
|
|
||||||
|
public class PushNotification
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Id")]
|
||||||
|
public PushNotificationId Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("Msg")]
|
||||||
|
public object? Msg { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirrors the client's <c>RecNet.Notifications+PushNotificationId</c>
|
||||||
|
/// enum exactly (decompiled at
|
||||||
|
/// <c>Cpp2IL_CS/.../RecNet/Notifications.cs:52-89</c>). Values are
|
||||||
|
/// non-contiguous because the client allocates them in groups (1–6
|
||||||
|
/// core; 11–15 subscriptions; 20–25 moderation; 30–31 gifts;
|
||||||
|
/// 60–62 storefronts; 70–71 consumables; 80–85 events; 90 chat;
|
||||||
|
/// 95–96 community board; 100 inventions).
|
||||||
|
///
|
||||||
|
/// Server <c>NotifyAsync(playerId, id)</c> pushes raise an event with
|
||||||
|
/// this int value over the SignalR hub. If the value doesn't match
|
||||||
|
/// what the client expects for a given concept, the push is silently
|
||||||
|
/// dropped — match the client byte-for-byte.
|
||||||
|
/// </summary>
|
||||||
|
public enum PushNotificationId
|
||||||
|
{
|
||||||
|
RelationshipChanged = 1,
|
||||||
|
MessageReceived = 2,
|
||||||
|
MessageDeleted = 3,
|
||||||
|
PresenceHeartbeatResponse = 4,
|
||||||
|
RefreshLogin = 5,
|
||||||
|
Logout = 6,
|
||||||
|
SubscriptionUpdateProfile = 11,
|
||||||
|
SubscriptionUpdatePresence = 12,
|
||||||
|
SubscriptionUpdateGameSession = 13,
|
||||||
|
SubscriptionUpdateRoom = 15,
|
||||||
|
ModerationQuitGame = 20,
|
||||||
|
ModerationUpdateRequired = 21,
|
||||||
|
ModerationKick = 22,
|
||||||
|
ModerationKickAttemptFailed = 23,
|
||||||
|
ModerationRoomBan = 24,
|
||||||
|
ServerMaintenance = 25,
|
||||||
|
GiftPackageReceived = 30,
|
||||||
|
GiftPackageReceivedImmediate = 31,
|
||||||
|
ProfileJuniorStatusUpdate = 40,
|
||||||
|
RelationshipsInvalid = 50,
|
||||||
|
StorefrontBalanceAdd = 60,
|
||||||
|
StorefrontBalanceUpdate = 61,
|
||||||
|
StorefrontBalancePurchase = 62,
|
||||||
|
ConsumableMappingAdded = 70,
|
||||||
|
ConsumableMappingRemoved = 71,
|
||||||
|
PlayerEventCreated = 80,
|
||||||
|
PlayerEventUpdated = 81,
|
||||||
|
PlayerEventDeleted = 82,
|
||||||
|
PlayerEventResponseChanged = 83,
|
||||||
|
PlayerEventResponseDeleted = 84,
|
||||||
|
PlayerEventStateChanged = 85,
|
||||||
|
ChatMessageReceived = 90,
|
||||||
|
CommunityBoardUpdate = 95,
|
||||||
|
CommunityBoardAnnouncementUpdate = 96,
|
||||||
|
InventionModerationStateChanged = 100,
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Players;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RecNet.ModerationBlockDetail — dump.cs:583626, Deserialize at RVA 0x1450830.
|
||||||
|
///
|
||||||
|
/// Verified by disassembly. Required keys (Util.GetKey, throws if missing):
|
||||||
|
/// ReportCategory (int), Duration (int), GameSessionId (long), Message (string).
|
||||||
|
/// Optional keys (Util.GetKeyOrDefault):
|
||||||
|
/// IsHostKick (bool, default false), IsBan (bool, default false),
|
||||||
|
/// PlayerIdReporter (Nullable<int>, default null).
|
||||||
|
///
|
||||||
|
/// Returned by:
|
||||||
|
/// GET api.rec.net/api/PlayerReporting/v1/moderationBlockDetails
|
||||||
|
/// (Single object, NOT a list — the client method GetModerationBlockDetails
|
||||||
|
/// returns IPromise<ModerationBlockDetail>.)
|
||||||
|
///
|
||||||
|
/// To indicate "user is not blocked": ReportCategory=0, Duration=0,
|
||||||
|
/// GameSessionId=0, Message="", IsBan=false, IsHostKick=false,
|
||||||
|
/// PlayerIdReporter=null.
|
||||||
|
/// </summary>
|
||||||
|
public class ModerationBlockDetail
|
||||||
|
{
|
||||||
|
// PlayerReporting.ReportCategory enum (int)
|
||||||
|
[JsonPropertyName("ReportCategory")]
|
||||||
|
public int ReportCategory { get; set; } = 0;
|
||||||
|
|
||||||
|
[JsonPropertyName("Duration")]
|
||||||
|
public int Duration { get; set; } = 0;
|
||||||
|
|
||||||
|
[JsonPropertyName("GameSessionId")]
|
||||||
|
public long GameSessionId { get; set; } = 0;
|
||||||
|
|
||||||
|
[JsonPropertyName("Message")]
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("IsHostKick")]
|
||||||
|
public bool IsHostKick { get; set; } = false;
|
||||||
|
|
||||||
|
[JsonPropertyName("PlayerIdReporter")]
|
||||||
|
public int? PlayerIdReporter { get; set; } = null;
|
||||||
|
|
||||||
|
[JsonPropertyName("IsBan")]
|
||||||
|
public bool IsBan { get; set; } = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Players;
|
||||||
|
|
||||||
|
public class PlayerProfile
|
||||||
|
{
|
||||||
|
private string? profileImage;
|
||||||
|
|
||||||
|
[JsonPropertyName("AccountId")]
|
||||||
|
public long AccountId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("Username")]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("DisplayName")]
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("ProfileImage")]
|
||||||
|
public string? ProfileImage
|
||||||
|
{
|
||||||
|
get => profileImage;
|
||||||
|
set => profileImage = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonPropertyName("ProfileImageName")]
|
||||||
|
public string? ProfileImageName
|
||||||
|
{
|
||||||
|
get => profileImage;
|
||||||
|
set => profileImage = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonPropertyName("BannerImage")]
|
||||||
|
public string? BannerImage { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("IsJunior")]
|
||||||
|
public bool IsJunior { get; set; } = false;
|
||||||
|
|
||||||
|
[JsonPropertyName("Level")]
|
||||||
|
public int Level { get; set; } = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("XP")]
|
||||||
|
public int XP { get; set; } = 0;
|
||||||
|
|
||||||
|
[JsonPropertyName("Reputation")]
|
||||||
|
public int Reputation { get; set; } = 0;
|
||||||
|
|
||||||
|
[JsonPropertyName("Bio")]
|
||||||
|
public string Bio { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("IsVerified")]
|
||||||
|
public bool IsVerified { get; set; } = false;
|
||||||
|
|
||||||
|
[JsonPropertyName("IsDeveloper")]
|
||||||
|
public bool IsDeveloper { get; set; } = false;
|
||||||
|
|
||||||
|
[JsonPropertyName("IsCommunityTeam")]
|
||||||
|
public bool IsCommunityTeam { get; set; } = false;
|
||||||
|
|
||||||
|
[JsonPropertyName("CanReceiveInvites")]
|
||||||
|
public bool CanReceiveInvites { get; set; } = true;
|
||||||
|
|
||||||
|
[JsonPropertyName("CreatedAt")]
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Players;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RecNet.Progression — dump.cs:582273, Deserialize at RVA 0x1142BB0.
|
||||||
|
///
|
||||||
|
/// Verified by disassembly: reads "PlayerId", "Level", "XP" via
|
||||||
|
/// Util.GetKey<int>. All three keys are required (non-default) — missing
|
||||||
|
/// any of them throws KeyNotFoundException at the client.
|
||||||
|
///
|
||||||
|
/// Note: the JSON uses "PlayerId" not "AccountId" even though the C# property
|
||||||
|
/// is AccountId on most other types. Returned by:
|
||||||
|
/// GET api.rec.net/api/players/v1/progression/{accountId}
|
||||||
|
/// </summary>
|
||||||
|
public class Progression
|
||||||
|
{
|
||||||
|
[JsonPropertyName("PlayerId")]
|
||||||
|
public int PlayerId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("Level")]
|
||||||
|
public int Level { get; set; } = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("XP")]
|
||||||
|
public int XP { get; set; } = 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Players;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RecNet.Reputation — dump.cs:584087, Deserialize at RVA 0x114A830.
|
||||||
|
///
|
||||||
|
/// Verified by disassembly. ALL Cheer*/Subscriber* keys are required
|
||||||
|
/// (Util.GetKey<int>), missing any throws KeyNotFoundException.
|
||||||
|
/// "SelectedCheer" is the only optional one (Util.GetKeyOrDefault<Nullable<int>>).
|
||||||
|
///
|
||||||
|
/// CRITICAL TYPO: the JSON key is "Noteriety" — the dev mistyped "Notoriety".
|
||||||
|
/// The C# property is named Notoriety in dump.cs (line 584117) but the dict
|
||||||
|
/// lookup uses "Noteriety". Server response MUST match the typo.
|
||||||
|
///
|
||||||
|
/// Returned by:
|
||||||
|
/// GET api.rec.net/api/playerReputation/v1/{accountId}
|
||||||
|
/// </summary>
|
||||||
|
public class Reputation
|
||||||
|
{
|
||||||
|
[JsonPropertyName("AccountId")]
|
||||||
|
public int AccountId { get; set; }
|
||||||
|
|
||||||
|
// sic: see class XML doc — typo in the binary's Util.GetKey call.
|
||||||
|
[JsonPropertyName("Noteriety")]
|
||||||
|
public float Notoriety { get; set; } = 0f;
|
||||||
|
|
||||||
|
[JsonPropertyName("CheerGeneral")]
|
||||||
|
public int CheerGeneral { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("CheerHelpful")]
|
||||||
|
public int CheerHelpful { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("CheerGreatHost")]
|
||||||
|
public int CheerGreatHost { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("CheerSportsman")]
|
||||||
|
public int CheerSportsman { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("CheerCreative")]
|
||||||
|
public int CheerCreative { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("CheerCredit")]
|
||||||
|
public int CheerCredit { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("SubscriberCount")]
|
||||||
|
public int SubscriberCount { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("SubscribedCount")]
|
||||||
|
public int SubscribedCount { get; set; }
|
||||||
|
|
||||||
|
// PlayerCheering.CheerCategory enum (int) — Nullable; null means "no
|
||||||
|
// active cheer set". This is the only optional key.
|
||||||
|
[JsonPropertyName("SelectedCheer")]
|
||||||
|
public int? SelectedCheer { get; set; } = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Players;
|
||||||
|
|
||||||
|
public class UpdateProfileRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("DisplayName")]
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("Bio")]
|
||||||
|
public string? Bio { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Models.Relationships;
|
||||||
|
|
||||||
|
public class RelationshipData
|
||||||
|
{
|
||||||
|
[JsonPropertyName("AccountId")]
|
||||||
|
public long AccountId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("OtherAccountId")]
|
||||||
|
public long OtherAccountId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("Status")]
|
||||||
|
public RelationshipStatus Status { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("CreatedAt")]
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RelationshipStatus
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Friend = 1,
|
||||||
|
PendingSent = 2,
|
||||||
|
PendingReceived = 3,
|
||||||
|
Blocked = 4,
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Standalone MelonLoader IL2CPP mod whose ONLY job is to dump every
|
||||||
|
Google.Protobuf FileDescriptor that the running client has loaded.
|
||||||
|
|
||||||
|
It carries no Harmony patches, so it is safe to drop onto ANY Rec Room
|
||||||
|
build (2020, 2023, …) without the version-specific ClientMod patches
|
||||||
|
misfiring. Everything game-side is reached through plain
|
||||||
|
System.Reflection over the Il2CppInterop proxy assemblies, so no game
|
||||||
|
DLLs are referenced at compile time (they would be derivative works).
|
||||||
|
|
||||||
|
Build (desktop net6 MelonLoader, same pin as the ClientMod):
|
||||||
|
|
||||||
|
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
|
||||||
|
|
||||||
|
Drop bin\Release\DorkNet.ProtoDumper.dll into <RecRoom>\Mods\ on a
|
||||||
|
MelonLoader-patched build and launch. Output lands in
|
||||||
|
<RecRoom>\DorkNet-ProtoDump\.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>DorkNet.ProtoDumper</RootNamespace>
|
||||||
|
<AssemblyName>DorkNet.ProtoDumper</AssemblyName>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||||
|
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Reuse the MelonLoader DLLs the ClientMod project already downloads. -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||||
|
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader</MelonLoaderCacheRoot>
|
||||||
|
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||||
|
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||||
|
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
|
||||||
|
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Target Name="EnsureMelonLoaderReferences"
|
||||||
|
BeforeTargets="ResolveAssemblyReferences"
|
||||||
|
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')">
|
||||||
|
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||||
|
<DownloadFile
|
||||||
|
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||||
|
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||||
|
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
|
||||||
|
SkipUnchangedFiles="true" />
|
||||||
|
<Unzip SourceFiles="$(MelonLoaderZip)"
|
||||||
|
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||||
|
OverwriteReadOnlyFiles="true" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="MelonLoader">
|
||||||
|
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="0Harmony">
|
||||||
|
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Il2CppInterop.Runtime">
|
||||||
|
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Il2CppInterop.Common">
|
||||||
|
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// DorkNet ProtoDumper — runtime Google.Protobuf schema extractor.
|
||||||
|
//
|
||||||
|
// Rec Room compiles its room-save / creation / circuits data model from
|
||||||
|
// .proto files into Google.Protobuf C# classes. In the shipped IL2CPP build
|
||||||
|
// those classes are name-obfuscated and the descriptor base64 is split into
|
||||||
|
// scattered string-literal chunks, so static reconstruction from
|
||||||
|
// global-metadata.dat is unreliable.
|
||||||
|
//
|
||||||
|
// At RUNTIME every FileDescriptor still carries its original, un-obfuscated
|
||||||
|
// FileDescriptorProto (message names, field names, numbers, types intact).
|
||||||
|
// The catch: these descriptors are built LAZILY and in a specific dependency
|
||||||
|
// order by the game. Force-building them ourselves (by reading a message
|
||||||
|
// type's static descriptor property out of order) throws inside the type's
|
||||||
|
// static constructor and the failure is cached — poisoning the type. So we do
|
||||||
|
// NOT enumerate types. Instead we Harmony-postfix the one library method every
|
||||||
|
// generated cctor funnels through — Google.Protobuf.Reflection.FileDescriptor
|
||||||
|
// .BuildFrom / .FromGeneratedCode — and capture each descriptor as the game
|
||||||
|
// builds it. Completely passive: just play the client through the dorm, a
|
||||||
|
// created room, the Maker Pen and a circuits board, and every descriptor that
|
||||||
|
// loads is recorded.
|
||||||
|
//
|
||||||
|
// Output (under <RecRoom>\DorkNet-ProtoDump\):
|
||||||
|
// manifest.tsv — one "<proto path>\t<base64>" line per file
|
||||||
|
// files\<sanitised>.b64 — same base64, one file each
|
||||||
|
// dump.log — capture log
|
||||||
|
//
|
||||||
|
// Decode offline with decode_dump.py (see README.md).
|
||||||
|
|
||||||
|
using HarmonyLib;
|
||||||
|
using MelonLoader;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
[assembly: MelonInfo(typeof(DorkNet.ProtoDumper.ProtoDumper), "DorkNet ProtoDumper", "2.0.0", "Dork Squad")]
|
||||||
|
[assembly: MelonGame(null, null)]
|
||||||
|
|
||||||
|
namespace DorkNet.ProtoDumper;
|
||||||
|
|
||||||
|
public class ProtoDumper : MelonMod
|
||||||
|
{
|
||||||
|
private static MelonLogger.Instance? _log;
|
||||||
|
|
||||||
|
// proto file name -> descriptor base64 (accumulated as the game builds them)
|
||||||
|
private static readonly Dictionary<string, string> Files = new(StringComparer.Ordinal);
|
||||||
|
private static readonly object Gate = new();
|
||||||
|
private static string _outDir = "";
|
||||||
|
private static string _filesDir = "";
|
||||||
|
private static volatile bool _dirty;
|
||||||
|
private int _frame;
|
||||||
|
|
||||||
|
public override void OnInitializeMelon()
|
||||||
|
{
|
||||||
|
_log = LoggerInstance;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string baseDir = AppDomain.CurrentDomain.BaseDirectory ?? Directory.GetCurrentDirectory();
|
||||||
|
_outDir = Path.Combine(baseDir, "DorkNet-ProtoDump");
|
||||||
|
_filesDir = Path.Combine(_outDir, "files");
|
||||||
|
Directory.CreateDirectory(_filesDir);
|
||||||
|
_log.Msg($"ProtoDumper ready. Output dir: {_outDir}");
|
||||||
|
}
|
||||||
|
catch (Exception e) { _log?.Error($"ProtoDumper init failed: {e}"); }
|
||||||
|
|
||||||
|
InstallHooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InstallHooks()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Google.Protobuf library type — NOT obfuscated (it's the library,
|
||||||
|
// not game code), reached by its original name via every loaded
|
||||||
|
// assembly so we never reference the interop DLL at compile time.
|
||||||
|
Type? fd = ResolveType("Google.Protobuf.Reflection.FileDescriptor");
|
||||||
|
if (fd == null)
|
||||||
|
{
|
||||||
|
_log?.Error("ProtoDumper: FileDescriptor type not found — cannot hook.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var postfix = new HarmonyMethod(
|
||||||
|
typeof(ProtoDumper).GetMethod(nameof(Captured_Postfix),
|
||||||
|
BindingFlags.Public | BindingFlags.Static));
|
||||||
|
|
||||||
|
int patched = 0;
|
||||||
|
// BuildFrom is the lower-level builder every descriptor passes
|
||||||
|
// through; FromGeneratedCode is the generated-code entry point.
|
||||||
|
// Patch both (deduped downstream) so nothing is missed.
|
||||||
|
foreach (string name in new[] { "BuildFrom", "FromGeneratedCode" })
|
||||||
|
{
|
||||||
|
foreach (MethodInfo m in fd.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (m.Name != name) continue;
|
||||||
|
try { HarmonyInstance.Patch(m, postfix: postfix); patched++; }
|
||||||
|
catch (Exception e) { _log?.Warning($"ProtoDumper: patch {name} failed: {e.Message}"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_log?.Msg($"ProtoDumper: installed {patched} descriptor-build hooks. Play through your content; descriptors are captured as they load.");
|
||||||
|
}
|
||||||
|
catch (Exception e) { _log?.Error($"ProtoDumper hook install error: {e}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a type by its ORIGINAL (game-side) name, accounting for the
|
||||||
|
// Il2Cpp prefix Il2CppInterop puts on the namespace (so
|
||||||
|
// "Google.Protobuf.Reflection.FileDescriptor" ->
|
||||||
|
// "Il2CppGoogle.Protobuf.Reflection.FileDescriptor").
|
||||||
|
private static Type? ResolveType(string name)
|
||||||
|
{
|
||||||
|
foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var t = asm.GetType(name, false) ?? asm.GetType("Il2Cpp" + name, false);
|
||||||
|
if (t != null) return t;
|
||||||
|
int dot = name.IndexOf('.');
|
||||||
|
if (dot > 0)
|
||||||
|
{
|
||||||
|
var ns = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), false);
|
||||||
|
if (ns != null) return ns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Harmony postfix on FileDescriptor.BuildFrom / FromGeneratedCode.
|
||||||
|
// __result is the freshly-built FileDescriptor (Il2Cpp proxy, boxed as
|
||||||
|
// object — same pattern the ClientMod uses for il2cpp reference returns).
|
||||||
|
public static void Captured_Postfix(object? __result)
|
||||||
|
{
|
||||||
|
if (__result == null) return;
|
||||||
|
try { CollectFile(__result); }
|
||||||
|
catch { /* never let our hook disturb the game */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectFile(object file)
|
||||||
|
{
|
||||||
|
string? name = GetProp(file, "Name") as string;
|
||||||
|
if (string.IsNullOrEmpty(name)) return;
|
||||||
|
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
if (Files.ContainsKey(name!)) return;
|
||||||
|
|
||||||
|
string b64 = DescriptorBase64(file);
|
||||||
|
Files[name!] = b64;
|
||||||
|
_dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture import dependencies too (closure of well-known + leaf protos).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
object? deps = GetProp(file, "Dependencies");
|
||||||
|
if (deps != null)
|
||||||
|
{
|
||||||
|
var dt = deps.GetType();
|
||||||
|
var countP = dt.GetProperty("Count");
|
||||||
|
var itemM = dt.GetMethod("get_Item", new[] { typeof(int) });
|
||||||
|
if (countP != null && itemM != null)
|
||||||
|
{
|
||||||
|
int n = (int)(countP.GetValue(deps) ?? 0);
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
object? dep = itemM.Invoke(deps, new object[] { i });
|
||||||
|
if (dep != null) CollectFile(dep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescriptorBase64(object file)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// FileDescriptor exposes no public SerializedData property — only
|
||||||
|
// the Il2CppInterop backing field (a ByteString with the original
|
||||||
|
// descriptor bytes). Fall back to re-serialising the parsed Proto.
|
||||||
|
object? sd = GetProp(file, "_SerializedData_k__BackingField")
|
||||||
|
?? GetProp(file, "SerializedData");
|
||||||
|
if (sd != null)
|
||||||
|
{
|
||||||
|
string b = ByteStringToBase64(sd);
|
||||||
|
if (b.Length > 0) return b;
|
||||||
|
}
|
||||||
|
object? proto = GetProp(file, "Proto");
|
||||||
|
if (proto != null)
|
||||||
|
{
|
||||||
|
MethodInfo? toBs = proto.GetType().GetMethod("ToByteString", Type.EmptyTypes);
|
||||||
|
object? bs = toBs?.Invoke(proto, null);
|
||||||
|
if (bs != null) return ByteStringToBase64(bs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ByteStringToBase64(object byteString)
|
||||||
|
{
|
||||||
|
MethodInfo? toB64 = byteString.GetType().GetMethod("ToBase64", Type.EmptyTypes);
|
||||||
|
return toB64?.Invoke(byteString, null) as string ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? GetProp(object obj, string propName)
|
||||||
|
{
|
||||||
|
var p = obj.GetType().GetProperty(propName,
|
||||||
|
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
return p?.GetValue(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnUpdate()
|
||||||
|
{
|
||||||
|
// Flush to disk shortly after new descriptors are captured (every ~1s).
|
||||||
|
if (++_frame % 60 == 0 && _dirty)
|
||||||
|
Flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnApplicationQuit() => Flush();
|
||||||
|
|
||||||
|
private static void Flush()
|
||||||
|
{
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
if (!_dirty) return;
|
||||||
|
_dirty = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
int withData = 0;
|
||||||
|
foreach (var kv in Files)
|
||||||
|
{
|
||||||
|
if (kv.Value.Length == 0) continue;
|
||||||
|
withData++;
|
||||||
|
sb.Append(kv.Key).Append('\t').Append(kv.Value).Append('\n');
|
||||||
|
string fp = Path.Combine(_filesDir, Sanitize(kv.Key) + ".b64");
|
||||||
|
if (!File.Exists(fp)) File.WriteAllText(fp, kv.Value);
|
||||||
|
}
|
||||||
|
File.WriteAllText(Path.Combine(_outDir, "manifest.tsv"), sb.ToString());
|
||||||
|
string line = $"captured files={Files.Count} (with-data={withData})";
|
||||||
|
File.AppendAllText(Path.Combine(_outDir, "dump.log"), line + "\n");
|
||||||
|
_log?.Msg($"ProtoDumper: {line} -> {_outDir}");
|
||||||
|
}
|
||||||
|
catch (Exception e) { _log?.Error($"ProtoDumper flush error: {e}"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Sanitize(string protoPath)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(protoPath.Length);
|
||||||
|
foreach (char c in protoPath)
|
||||||
|
sb.Append(char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '.' ? c : '_');
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# DorkNet ProtoDumper
|
||||||
|
|
||||||
|
Runtime extractor for Rec Room's Google.Protobuf schema (room save /
|
||||||
|
creation / circuits data model). Produces clean `.proto` files for any
|
||||||
|
client build — including obfuscated ones like 2023.03.21 where static
|
||||||
|
reconstruction from `global-metadata.dat` is unreliable (the descriptor
|
||||||
|
base64 is split into scattered string-literal chunks whose concat order
|
||||||
|
only lives in IL).
|
||||||
|
|
||||||
|
At runtime every `FileDescriptor` still carries its original,
|
||||||
|
un-obfuscated `FileDescriptorProto` (real message names, field names,
|
||||||
|
numbers and types intact). The catch: Rec Room builds these descriptors
|
||||||
|
**lazily and in a specific dependency order** as content loads. Trying to
|
||||||
|
force-build them by reading a message type's static descriptor out of
|
||||||
|
order throws inside the type's static constructor, and the failure is
|
||||||
|
cached — *poisoning* the type (and crashing the client). So this mod does
|
||||||
|
**not** enumerate types.
|
||||||
|
|
||||||
|
Instead it Harmony-postfixes the one library method every generated cctor
|
||||||
|
funnels through —
|
||||||
|
`Google.Protobuf.Reflection.FileDescriptor.BuildFrom` /
|
||||||
|
`.FromGeneratedCode` — and records each descriptor as the game builds it.
|
||||||
|
Completely passive: **play the client through the content you care about**
|
||||||
|
and every descriptor that loads is captured.
|
||||||
|
|
||||||
|
## Pipeline
|
||||||
|
|
||||||
|
1. **Build the mod**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `DorkNet.ProtoDumper/bin/Release/DorkNet.ProtoDumper.dll`.
|
||||||
|
|
||||||
|
2. **Run it on a *runnable* 2023.03.21 client (MelonLoader 0.6.6)**
|
||||||
|
|
||||||
|
> The `GameAssembly.dll` + `il2cpp_data`-only copy under
|
||||||
|
> `Recnet-old/dist/RecRoom-2023.03.21-il2cpp/…/staging/` is an
|
||||||
|
> **offline-analysis payload, not a runnable build**. Use a full build
|
||||||
|
> with the exe + UnityPlayer + MelonLoader (e.g. the devdork package).
|
||||||
|
|
||||||
|
- Drop `DorkNet.ProtoDumper.dll` into `<RecRoom>\Mods\`. On launch it
|
||||||
|
installs the descriptor-build hooks (see `dump.log` / MelonLoader
|
||||||
|
console for "installed N descriptor-build hooks").
|
||||||
|
- **Log in and play through the content whose schema you want:** your
|
||||||
|
dorm, a created room, the Maker Pen, and a circuits (CV2) board. Each
|
||||||
|
subsystem builds its protos the first time it loads. Descriptors are
|
||||||
|
captured the instant they build — no scene/timer scanning, nothing is
|
||||||
|
force-built, so the client stays stable.
|
||||||
|
- Output appears under `<RecRoom>\DorkNet-ProtoDump\` and is flushed to
|
||||||
|
disk ~1 s after new descriptors load, and again on quit:
|
||||||
|
- `manifest.tsv` — `<proto path>\t<base64>` per file
|
||||||
|
- `files\*.b64` — same, one file each
|
||||||
|
- `dump.log` — running capture count
|
||||||
|
|
||||||
|
3. **Decode to .proto**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install protobuf
|
||||||
|
python DorkNet.ProtoDumper/decode_dump.py <path>/manifest.tsv proto_out
|
||||||
|
```
|
||||||
|
|
||||||
|
Emits one `.proto` per descriptor under `proto_out/`, plus a combined
|
||||||
|
`proto_out/recroom_2023.proto` (every rec_room/circuits message in one
|
||||||
|
file) for an easy diff against
|
||||||
|
`DorkNet.Server/Protos/recroom_2020.proto`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No game DLLs are referenced at compile time. `FileDescriptor` is the
|
||||||
|
Google.Protobuf *library* type (not obfuscated); it's reached by its
|
||||||
|
original name with the Il2Cpp namespace-prefix fallback, and its members
|
||||||
|
are read via reflection. The same DLL works across builds and stays clean
|
||||||
|
of derivative game code.
|
||||||
|
- The only Harmony patches are the two read-only descriptor-build
|
||||||
|
postfixes above — safe to run alongside (or without) `DorkNet.ClientMod`.
|
||||||
|
- The descriptor base64 is the same `FileDescriptorProto` wire format
|
||||||
|
Google's own tooling emits, so `decode_dump.py` (or `protoc
|
||||||
|
--decode=google.protobuf.FileDescriptorProto descriptor.proto`) reads it
|
||||||
|
directly.
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Decode a DorkNet ProtoDumper manifest into .proto text.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python decode_dump.py manifest.tsv [out_dir]
|
||||||
|
|
||||||
|
manifest.tsv has one "<proto path>\\t<base64 FileDescriptorProto>" line per
|
||||||
|
file (as written by the ProtoDumper MelonLoader mod). This emits one .proto
|
||||||
|
file per descriptor under out_dir/ (default: ./proto_out), plus a combined
|
||||||
|
recroom_2023.proto with every rec_room/circuits message in one file for an
|
||||||
|
easy diff against DorkNet.Server/Protos/recroom_2020.proto.
|
||||||
|
|
||||||
|
Requires: pip install protobuf
|
||||||
|
"""
|
||||||
|
import sys, os, base64, re
|
||||||
|
from google.protobuf import descriptor_pb2 as D
|
||||||
|
|
||||||
|
FDP = D.FileDescriptorProto
|
||||||
|
|
||||||
|
# proto3 scalar type names keyed by FieldDescriptorProto.Type
|
||||||
|
SCALAR = {
|
||||||
|
1: "double", 2: "float", 3: "int64", 4: "uint64", 5: "int32",
|
||||||
|
6: "fixed64", 7: "fixed32", 8: "bool", 9: "string", 12: "bytes",
|
||||||
|
13: "uint32", 15: "sfixed32", 16: "sfixed64", 17: "sint32", 18: "sint64",
|
||||||
|
}
|
||||||
|
LABEL_REPEATED = 3
|
||||||
|
|
||||||
|
|
||||||
|
def type_name(f):
|
||||||
|
if f.type in (11, 14): # message / enum -> use type_name (strip leading dot)
|
||||||
|
return f.type_name.lstrip(".")
|
||||||
|
return SCALAR.get(f.type, f"type{f.type}")
|
||||||
|
|
||||||
|
|
||||||
|
def emit_enum(e, indent):
|
||||||
|
pad = " " * indent
|
||||||
|
out = [f"{pad}enum {e.name} {{"]
|
||||||
|
for v in e.value:
|
||||||
|
out.append(f"{pad} {v.name} = {v.number};")
|
||||||
|
out.append(f"{pad}}}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def emit_message(m, indent=0):
|
||||||
|
pad = " " * indent
|
||||||
|
out = [f"{pad}message {m.name} {{"]
|
||||||
|
|
||||||
|
# map<> detection: synthetic nested *Entry types with map_entry option
|
||||||
|
map_entries = {}
|
||||||
|
for nt in m.nested_type:
|
||||||
|
if nt.options.map_entry:
|
||||||
|
kt = type_name(nt.field[0])
|
||||||
|
vt = type_name(nt.field[1])
|
||||||
|
map_entries[nt.name] = (kt, vt)
|
||||||
|
|
||||||
|
# which oneofs are real (not proto3 optional synthetic)
|
||||||
|
for nt in m.nested_type:
|
||||||
|
if nt.name in map_entries:
|
||||||
|
continue
|
||||||
|
out += emit_message(nt, indent + 1)
|
||||||
|
for en in m.enum_type:
|
||||||
|
out += emit_enum(en, indent + 1)
|
||||||
|
|
||||||
|
# group fields by oneof
|
||||||
|
printed_oneof = set()
|
||||||
|
for f in m.field:
|
||||||
|
# map field?
|
||||||
|
if f.type == 11 and f.type_name.split(".")[-1] in map_entries:
|
||||||
|
kt, vt = map_entries[f.type_name.split(".")[-1]]
|
||||||
|
out.append(f"{pad} map<{kt}, {vt}> {f.name} = {f.number};")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if f.HasField("oneof_index") and not f.proto3_optional:
|
||||||
|
idx = f.oneof_index
|
||||||
|
if idx not in printed_oneof:
|
||||||
|
printed_oneof.add(idx)
|
||||||
|
out.append(f"{pad} oneof {m.oneof_decl[idx].name} {{")
|
||||||
|
for g in m.field:
|
||||||
|
if g.HasField("oneof_index") and g.oneof_index == idx and not g.proto3_optional:
|
||||||
|
out.append(f"{pad} {type_name(g)} {g.name} = {g.number};")
|
||||||
|
out.append(f"{pad} }}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
label = "repeated " if f.label == LABEL_REPEATED else ""
|
||||||
|
opt = "optional " if f.proto3_optional else ""
|
||||||
|
out.append(f"{pad} {label}{opt}{type_name(f)} {f.name} = {f.number};")
|
||||||
|
|
||||||
|
out.append(f"{pad}}}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def render_file(fdp):
|
||||||
|
out = []
|
||||||
|
syntax = fdp.syntax or "proto2"
|
||||||
|
out.append(f'syntax = "{syntax}";')
|
||||||
|
out.append("")
|
||||||
|
if fdp.package:
|
||||||
|
out.append(f"package {fdp.package};")
|
||||||
|
for dep in fdp.dependency:
|
||||||
|
out.append(f'import "{dep}";')
|
||||||
|
if fdp.package or fdp.dependency:
|
||||||
|
out.append("")
|
||||||
|
for en in fdp.enum_type:
|
||||||
|
out += emit_enum(en, 0)
|
||||||
|
out.append("")
|
||||||
|
for m in fdp.message_type:
|
||||||
|
out += emit_message(m, 0)
|
||||||
|
out.append("")
|
||||||
|
return "\n".join(out).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(__doc__); sys.exit(1)
|
||||||
|
manifest = sys.argv[1]
|
||||||
|
out_dir = sys.argv[2] if len(sys.argv) > 2 else "proto_out"
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
|
||||||
|
files = []
|
||||||
|
with open(manifest, "r", encoding="utf-8") as fh:
|
||||||
|
for ln in fh:
|
||||||
|
ln = ln.rstrip("\n")
|
||||||
|
if not ln or "\t" not in ln:
|
||||||
|
continue
|
||||||
|
name, b64 = ln.split("\t", 1)
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(b64)
|
||||||
|
fdp = FDP(); fdp.ParseFromString(raw)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ! failed to parse {name}: {e}")
|
||||||
|
continue
|
||||||
|
files.append((name, fdp))
|
||||||
|
|
||||||
|
print(f"parsed {len(files)} descriptors")
|
||||||
|
|
||||||
|
# per-file .proto
|
||||||
|
for name, fdp in files:
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9_.\-]", "_", name)
|
||||||
|
with open(os.path.join(out_dir, safe), "w", encoding="utf-8") as w:
|
||||||
|
w.write(render_file(fdp))
|
||||||
|
|
||||||
|
# combined rec_room/circuits view (skip google/* well-known imports)
|
||||||
|
rr = [ (n,f) for n,f in files if not n.startswith("google/") ]
|
||||||
|
seen = set(); combo = ['syntax = "proto3";', "", "package recroom;", ""]
|
||||||
|
total_msgs = 0
|
||||||
|
for name, fdp in sorted(rr, key=lambda x: x[0]):
|
||||||
|
combo.append(f"// ===== {name} (package {fdp.package or '-'}) =====")
|
||||||
|
for m in fdp.message_type:
|
||||||
|
if m.name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(m.name)
|
||||||
|
combo += emit_message(m, 0)
|
||||||
|
combo.append("")
|
||||||
|
total_msgs += 1
|
||||||
|
for en in fdp.enum_type:
|
||||||
|
combo += emit_enum(en, 0)
|
||||||
|
combo.append("")
|
||||||
|
with open(os.path.join(out_dir, "recroom_2023.proto"), "w", encoding="utf-8") as w:
|
||||||
|
w.write("\n".join(combo).rstrip() + "\n")
|
||||||
|
print(f"wrote {out_dir}/recroom_2023.proto with {total_msgs} unique messages "
|
||||||
|
f"from {len(rr)} rec_room/circuits descriptors")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.HostFiltering;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
private const string JwtSecret = "endpoint-contract-secret-000000000000000000000000000000000000000000";
|
||||||
|
private readonly string _dataRoot;
|
||||||
|
|
||||||
|
public DorkNetServerFactory()
|
||||||
|
{
|
||||||
|
_dataRoot = Path.Combine(Path.GetTempPath(), "dorknet-server-tests", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_dataRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ApexDomain => "dork.test";
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.UseEnvironment("Development");
|
||||||
|
builder.UseSetting("Domain:Apex", ApexDomain);
|
||||||
|
builder.ConfigureAppConfiguration((_, config) =>
|
||||||
|
{
|
||||||
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AllowedHosts"] = "*",
|
||||||
|
["Database:Provider"] = "sqlite",
|
||||||
|
["Database:SqlitePath"] = Path.Combine(_dataRoot, "dorknet-test.db"),
|
||||||
|
["Domain:Apex"] = ApexDomain,
|
||||||
|
["Jwt:Secret"] = JwtSecret,
|
||||||
|
["S3:Endpoint"] = "",
|
||||||
|
["S3:AccessKey"] = "",
|
||||||
|
["S3:SecretKey"] = "",
|
||||||
|
["DorkNet:DefaultClientVersion"] = "march_2023_03_21",
|
||||||
|
["DorkNet:SupportedVersions:0"] = "march_2023_03_21",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.PostConfigure<HostFilteringOptions>(options =>
|
||||||
|
{
|
||||||
|
options.AllowedHosts =
|
||||||
|
[
|
||||||
|
ApexDomain,
|
||||||
|
$"*.{ApexDomain}",
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1",
|
||||||
|
];
|
||||||
|
options.AllowEmptyHosts = true;
|
||||||
|
options.IncludeFailureMessage = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
base.Dispose(disposing);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_dataRoot))
|
||||||
|
{
|
||||||
|
Directory.Delete(_dataRoot, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Test cleanup must not hide the original test failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Routing;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
public static partial class EndpointContractDiscovery
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<EndpointContract> Discover()
|
||||||
|
{
|
||||||
|
var serverAssembly = typeof(Program).Assembly;
|
||||||
|
var controllerBaseType = typeof(ControllerBase);
|
||||||
|
return serverAssembly.GetTypes()
|
||||||
|
.Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t))
|
||||||
|
.SelectMany(DiscoverControllerRoutes)
|
||||||
|
.OrderBy(route => route.Method, StringComparer.Ordinal)
|
||||||
|
.ThenBy(route => route.SamplePath, StringComparer.Ordinal)
|
||||||
|
.ThenBy(route => route.Source, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<EndpointContract> DiscoverControllerRoutes(Type controller)
|
||||||
|
{
|
||||||
|
var controllerName = ControllerName(controller);
|
||||||
|
var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true)
|
||||||
|
.OfType<IRouteTemplateProvider>()
|
||||||
|
.Select(a => a.Template)
|
||||||
|
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||||
|
.Cast<string>()
|
||||||
|
.DefaultIfEmpty("")
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
|
||||||
|
{
|
||||||
|
var httpAttrs = method.GetCustomAttributes(inherit: true)
|
||||||
|
.OfType<HttpMethodAttribute>()
|
||||||
|
.ToArray();
|
||||||
|
if (httpAttrs.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var attr in httpAttrs)
|
||||||
|
{
|
||||||
|
var attrMethods = attr.HttpMethods.Any()
|
||||||
|
? attr.HttpMethods
|
||||||
|
: new[] { "ANY" };
|
||||||
|
|
||||||
|
foreach (var httpMethod in attrMethods)
|
||||||
|
{
|
||||||
|
foreach (var controllerTemplate in controllerRouteTemplates)
|
||||||
|
{
|
||||||
|
var template = CombineTemplates(controllerTemplate, attr.Template);
|
||||||
|
template = ReplaceRouteTokens(template, controllerName, method.Name);
|
||||||
|
yield return new EndpointContract(
|
||||||
|
httpMethod.ToUpperInvariant(),
|
||||||
|
template,
|
||||||
|
ToSamplePath(template),
|
||||||
|
$"{controller.FullName}.{method.Name}",
|
||||||
|
RequiresAuthorization(controller, method),
|
||||||
|
IsAdminEndpoint(controller, method),
|
||||||
|
ConsumesContentTypes(controller, method));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RequiresAuthorization(Type controller, MethodInfo method)
|
||||||
|
{
|
||||||
|
var attributes = controller.GetCustomAttributes(inherit: true)
|
||||||
|
.Concat(method.GetCustomAttributes(inherit: true))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (attributes.OfType<IAllowAnonymous>().Any())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes.OfType<IAuthorizeData>().Any();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAdminEndpoint(Type controller, MethodInfo method)
|
||||||
|
{
|
||||||
|
return controller.FullName?.Contains(".Controllers.Admin.", StringComparison.Ordinal) == true
|
||||||
|
|| method.Name.Contains("Admin", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] ConsumesContentTypes(Type controller, MethodInfo method)
|
||||||
|
{
|
||||||
|
var consumes = method.GetCustomAttributes(inherit: true)
|
||||||
|
.Concat(controller.GetCustomAttributes(inherit: true))
|
||||||
|
.OfType<ConsumesAttribute>()
|
||||||
|
.SelectMany(a => a.ContentTypes)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return consumes.Length == 0
|
||||||
|
? ["application/json"]
|
||||||
|
: consumes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ControllerName(Type controller)
|
||||||
|
{
|
||||||
|
const string suffix = "Controller";
|
||||||
|
return controller.Name.EndsWith(suffix, StringComparison.Ordinal)
|
||||||
|
? controller.Name[..^suffix.Length]
|
||||||
|
: controller.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CombineTemplates(string? controllerTemplate, string? actionTemplate)
|
||||||
|
{
|
||||||
|
controllerTemplate ??= "";
|
||||||
|
actionTemplate ??= "";
|
||||||
|
|
||||||
|
if (actionTemplate.StartsWith("~/", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "/" + actionTemplate[2..].TrimStart('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionTemplate.StartsWith("/", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "/" + actionTemplate.TrimStart('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
var left = controllerTemplate.Trim('/');
|
||||||
|
var right = actionTemplate.Trim('/');
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right))
|
||||||
|
{
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(left))
|
||||||
|
{
|
||||||
|
return "/" + right;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(right))
|
||||||
|
{
|
||||||
|
return "/" + left;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/" + left + "/" + right;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReplaceRouteTokens(string template, string controllerName, string actionName)
|
||||||
|
{
|
||||||
|
return template
|
||||||
|
.Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToSamplePath(string template)
|
||||||
|
{
|
||||||
|
var path = RouteParameterRegex().Replace(template, match =>
|
||||||
|
{
|
||||||
|
var name = match.Groups["name"].Value.ToLowerInvariant();
|
||||||
|
var constraint = match.Groups["constraint"].Value.ToLowerInvariant();
|
||||||
|
|
||||||
|
if (name.Contains("playerid") || name.Contains("accountid") || name == "id")
|
||||||
|
{
|
||||||
|
return EndpointContract.TestPlayerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (constraint.Contains("long") || constraint.Contains("int") || name.EndsWith("id"))
|
||||||
|
{
|
||||||
|
return "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (constraint.Contains("bool"))
|
||||||
|
{
|
||||||
|
return "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (constraint.Contains("guid"))
|
||||||
|
{
|
||||||
|
return "00000000-0000-0000-0000-000000000001";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.Contains("roomname"))
|
||||||
|
{
|
||||||
|
return "DormRoom";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.Contains("locale"))
|
||||||
|
{
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "sample";
|
||||||
|
});
|
||||||
|
|
||||||
|
path = Regex.Replace(path, "/+", "/");
|
||||||
|
return path.StartsWith('/') ? path : "/" + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\{[*]?(?<name>[^}:=\?]+)(?<constraint>:[^}=\?]+)?(?<optional>\?)?(?<default>=[^}]+)?\}")]
|
||||||
|
private static partial Regex RouteParameterRegex();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record EndpointContract(
|
||||||
|
string Method,
|
||||||
|
string Template,
|
||||||
|
string SamplePath,
|
||||||
|
string Source,
|
||||||
|
bool RequiresAuthorization,
|
||||||
|
bool IsAdminEndpoint,
|
||||||
|
string[] Consumes)
|
||||||
|
{
|
||||||
|
public const string TestPlayerToken = "__test_player_id__";
|
||||||
|
|
||||||
|
public string PathFor(GameClientSession session)
|
||||||
|
{
|
||||||
|
return SamplePath.Replace(
|
||||||
|
TestPlayerToken,
|
||||||
|
session.PlayerId.ToString(),
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
public sealed class GameClientEndpointContractTests :
|
||||||
|
IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public GameClientEndpointContractTests(DorkNetServerFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Game_client_contract_probe_reaches_every_controller_route()
|
||||||
|
{
|
||||||
|
var routes = EndpointContractDiscovery.Discover();
|
||||||
|
Assert.NotEmpty(routes);
|
||||||
|
|
||||||
|
using var client = _factory.CreateClient(new()
|
||||||
|
{
|
||||||
|
AllowAutoRedirect = false,
|
||||||
|
});
|
||||||
|
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(20);
|
||||||
|
|
||||||
|
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||||
|
var failures = new List<string>();
|
||||||
|
|
||||||
|
foreach (var route in routes)
|
||||||
|
{
|
||||||
|
var result = await ProbeAsync(client, route, session, _factory.ApexDomain);
|
||||||
|
if (!result.Passed)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"{route.Method,-6} {result.Host}{result.Path} {route.Source} expected {result.Expected}, got {result.Observed}"
|
||||||
|
+ (string.IsNullOrWhiteSpace(result.ResponseExcerpt) ? "" : $" body={result.ResponseExcerpt}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
failures.Count == 0,
|
||||||
|
$"Endpoint contract failures ({failures.Count}/{routes.Count}):{Environment.NewLine}"
|
||||||
|
+ string.Join(Environment.NewLine, failures));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<EndpointProbeResult> ProbeAsync(
|
||||||
|
HttpClient client,
|
||||||
|
EndpointContract route,
|
||||||
|
GameClientSession session,
|
||||||
|
string apexDomain)
|
||||||
|
{
|
||||||
|
var path = route.PathFor(session);
|
||||||
|
path = AddClientQuery(path, route, session);
|
||||||
|
var host = HostFor(path, apexDomain);
|
||||||
|
using var request = new HttpRequestMessage(
|
||||||
|
ToHttpMethod(route.Method),
|
||||||
|
new Uri($"http://{host}{path}"));
|
||||||
|
request.Headers.Accept.ParseAdd("application/json");
|
||||||
|
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||||
|
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
|
||||||
|
if (NeedsRequestBody(request.Method))
|
||||||
|
{
|
||||||
|
request.Content = CreateContent(route, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await client.SendAsync(request);
|
||||||
|
var responseText = await response.Content.ReadAsStringAsync();
|
||||||
|
var statusCode = (int)response.StatusCode;
|
||||||
|
var passed = ResponseMatchesContract(route, response.StatusCode);
|
||||||
|
return new EndpointProbeResult(
|
||||||
|
route.Method,
|
||||||
|
host,
|
||||||
|
path,
|
||||||
|
ExpectedContract(route),
|
||||||
|
statusCode,
|
||||||
|
null,
|
||||||
|
Excerpt(responseText),
|
||||||
|
passed);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
return new EndpointProbeResult(
|
||||||
|
route.Method,
|
||||||
|
host,
|
||||||
|
path,
|
||||||
|
ExpectedContract(route),
|
||||||
|
null,
|
||||||
|
ex.Message,
|
||||||
|
null,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ResponseMatchesContract(
|
||||||
|
EndpointContract route,
|
||||||
|
HttpStatusCode statusCode)
|
||||||
|
{
|
||||||
|
if ((int)statusCode >= 500)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode is HttpStatusCode.MethodNotAllowed
|
||||||
|
or HttpStatusCode.NotImplemented
|
||||||
|
or HttpStatusCode.UpgradeRequired)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.RequiresAuthorization
|
||||||
|
&& statusCode is HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExpectedContract(EndpointContract route)
|
||||||
|
{
|
||||||
|
if (route.IsAdminEndpoint)
|
||||||
|
{
|
||||||
|
return "admin endpoint may reject non-admin test player, but must not 5xx/405/426";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.RequiresAuthorization)
|
||||||
|
{
|
||||||
|
return "authenticated test player should reach the action without 401/5xx/405/426; 403 is valid for owner/admin-only resources";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "game-client request should not 5xx/405/426";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpMethod ToHttpMethod(string method)
|
||||||
|
{
|
||||||
|
return string.Equals(method, "ANY", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? HttpMethod.Get
|
||||||
|
: new HttpMethod(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool NeedsRequestBody(HttpMethod method)
|
||||||
|
{
|
||||||
|
return method == HttpMethod.Post
|
||||||
|
|| method == HttpMethod.Put
|
||||||
|
|| method.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpContent CreateContent(
|
||||||
|
EndpointContract route,
|
||||||
|
GameClientSession session)
|
||||||
|
{
|
||||||
|
var consumes = route.Consumes.Select(c => c.ToLowerInvariant()).ToArray();
|
||||||
|
if (consumes.Any(c => c.Contains("application/x-www-form-urlencoded")))
|
||||||
|
{
|
||||||
|
return new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["account_id"] = session.PlayerId.ToString(),
|
||||||
|
["AccountId"] = session.PlayerId.ToString(),
|
||||||
|
["clubId"] = "1",
|
||||||
|
["ClubId"] = "1",
|
||||||
|
["description"] = "Endpoint contract description",
|
||||||
|
["deviceId"] = session.DeviceId,
|
||||||
|
["grant_type"] = "password",
|
||||||
|
["image"] = "endpoint-contract.png",
|
||||||
|
["imageName"] = "endpoint-contract.png",
|
||||||
|
["fileName"] = "endpoint-contract.png",
|
||||||
|
["message"] = "Endpoint contract message",
|
||||||
|
["name"] = "EndpointContract",
|
||||||
|
["newPassword"] = "endpoint-contract-password",
|
||||||
|
["oldPassword"] = "",
|
||||||
|
["password"] = "endpoint-contract-password",
|
||||||
|
["ping"] = "42",
|
||||||
|
["platformId"] = session.PlatformId,
|
||||||
|
["playerId"] = session.PlayerId.ToString(),
|
||||||
|
["playerIds"] = session.PlayerId.ToString(),
|
||||||
|
["query"] = "DormRoom",
|
||||||
|
["region"] = "us",
|
||||||
|
["targetPlayerId"] = session.PlayerId.ToString(),
|
||||||
|
["text"] = "Endpoint contract message",
|
||||||
|
["username"] = "EndpointContract",
|
||||||
|
["device_id"] = session.DeviceId,
|
||||||
|
["platform"] = "0",
|
||||||
|
["platform_id"] = session.PlatformId,
|
||||||
|
["Ids"] = session.PlayerId.ToString(),
|
||||||
|
["PlayerIds"] = session.PlayerId.ToString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consumes.Any(c => c.Contains("multipart/form-data")))
|
||||||
|
{
|
||||||
|
var content = new MultipartFormDataContent();
|
||||||
|
content.Add(new StringContent("endpoint-contract"), "name");
|
||||||
|
content.Add(new StringContent(session.PlayerId.ToString()), "playerId");
|
||||||
|
content.Add(new StringContent("endpoint-contract.png"), "imageName");
|
||||||
|
content.Add(new StringContent("endpoint-contract.png"), "fileName");
|
||||||
|
content.Add(new ByteArrayContent([]), "file", "endpoint-contract.bin");
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consumes.Any(c => c.Contains("text/plain")))
|
||||||
|
{
|
||||||
|
return new StringContent("", Encoding.UTF8, "text/plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
PlayerId = session.PlayerId,
|
||||||
|
AccountId = session.PlayerId,
|
||||||
|
ClubId = 1,
|
||||||
|
Description = "Endpoint contract description",
|
||||||
|
DeviceId = session.DeviceId,
|
||||||
|
FileName = "endpoint-contract.png",
|
||||||
|
Id = session.PlayerId,
|
||||||
|
Image = "endpoint-contract.png",
|
||||||
|
ImageName = "endpoint-contract.png",
|
||||||
|
Message = "Endpoint contract message",
|
||||||
|
Platform = 0,
|
||||||
|
PlatformId = session.PlatformId,
|
||||||
|
PlayerIds = new[] { session.PlayerId },
|
||||||
|
Name = "EndpointContract",
|
||||||
|
Password = "endpoint-contract-password",
|
||||||
|
Query = "DormRoom",
|
||||||
|
RoomName = "DormRoom",
|
||||||
|
TargetPlayerId = session.PlayerId,
|
||||||
|
Text = "Endpoint contract message",
|
||||||
|
});
|
||||||
|
return new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string AddClientQuery(
|
||||||
|
string path,
|
||||||
|
EndpointContract route,
|
||||||
|
GameClientSession session)
|
||||||
|
{
|
||||||
|
var separator = path.Contains('?', StringComparison.Ordinal) ? "&" : "?";
|
||||||
|
var lower = path.ToLowerInvariant();
|
||||||
|
var query = new List<string>();
|
||||||
|
|
||||||
|
if (lower.Contains("search", StringComparison.Ordinal)
|
||||||
|
|| lower.Contains("query", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
query.Add("query=EndpointContract");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower == "/members/bulk")
|
||||||
|
{
|
||||||
|
query.Add("clubId=1");
|
||||||
|
query.Add($"playerIds={session.PlayerId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower == "/player/photonregionpings")
|
||||||
|
{
|
||||||
|
query.Add("region=us");
|
||||||
|
query.Add("ping=42");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.Source.Contains("CachedLogins", StringComparison.Ordinal)
|
||||||
|
|| lower.Contains("platformlogin/cached", StringComparison.Ordinal)
|
||||||
|
|| lower.Contains("savedlogins", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
query.Add("platform=0");
|
||||||
|
query.Add($"platformId={Uri.EscapeDataString(session.PlatformId)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.Source.Contains("Bulk", StringComparison.Ordinal)
|
||||||
|
&& !query.Any(q => q.StartsWith("Ids=", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
query.Add($"Ids={session.PlayerId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.Count == 0)
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path + separator + string.Join("&", query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HostFor(string path, string apexDomain)
|
||||||
|
{
|
||||||
|
var lower = path.ToLowerInvariant();
|
||||||
|
|
||||||
|
if (lower.StartsWith("/connect/")
|
||||||
|
|| lower.StartsWith("/.well-known/")
|
||||||
|
|| lower.StartsWith("/eac/")
|
||||||
|
|| lower.StartsWith("/cachedlogin/")
|
||||||
|
|| lower.StartsWith("/photon/")
|
||||||
|
|| lower.StartsWith("/api/platformlogin/")
|
||||||
|
|| lower.StartsWith("/api/version")
|
||||||
|
|| lower.StartsWith("/api/auth/"))
|
||||||
|
{
|
||||||
|
return $"auth.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.StartsWith("/account/"))
|
||||||
|
{
|
||||||
|
return $"accounts.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.StartsWith("/roomserver/")
|
||||||
|
|| lower.StartsWith("/rooms")
|
||||||
|
|| lower.StartsWith("/playlists")
|
||||||
|
|| lower.StartsWith("/featuredrooms")
|
||||||
|
|| lower.StartsWith("/hot_rooms")
|
||||||
|
|| lower.StartsWith("/search_rooms")
|
||||||
|
|| lower.StartsWith("/api/rooms")
|
||||||
|
|| lower.StartsWith("/api/roomkeys")
|
||||||
|
|| lower.StartsWith("/api/gamesessions")
|
||||||
|
|| lower.StartsWith("/api/curatedroomplaylists"))
|
||||||
|
{
|
||||||
|
return $"rooms.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.StartsWith("/v1/find")
|
||||||
|
|| lower.StartsWith("/v1/join")
|
||||||
|
|| lower.StartsWith("/v1/leave")
|
||||||
|
|| lower.StartsWith("/v1/session")
|
||||||
|
|| lower.StartsWith("/roominstance/")
|
||||||
|
|| lower.StartsWith("/goto/")
|
||||||
|
|| lower.StartsWith("/player/"))
|
||||||
|
{
|
||||||
|
return $"match.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.StartsWith("/thread"))
|
||||||
|
{
|
||||||
|
return $"chat.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.StartsWith("/club")
|
||||||
|
|| lower.StartsWith("/announcements/")
|
||||||
|
|| lower.StartsWith("/subscription/")
|
||||||
|
|| lower.StartsWith("/members/")
|
||||||
|
|| lower.StartsWith("/api/clubreporting/"))
|
||||||
|
{
|
||||||
|
return $"clubs.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.StartsWith("/api/storefronts/")
|
||||||
|
|| lower.StartsWith("/api/purchase/")
|
||||||
|
|| lower.StartsWith("/api/subscription/"))
|
||||||
|
{
|
||||||
|
return $"commerce.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.StartsWith("/hub/") || lower.StartsWith("/api/notification/"))
|
||||||
|
{
|
||||||
|
return $"notify.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.StartsWith("/v1/regions"))
|
||||||
|
{
|
||||||
|
return $"geo.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.StartsWith("/v1/services") || lower.StartsWith("/v1/ping"))
|
||||||
|
{
|
||||||
|
return $"ns.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.StartsWith("/upload"))
|
||||||
|
{
|
||||||
|
return $"storage.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.StartsWith("/img/") || lower.StartsWith("/room/") || lower.StartsWith("/data/"))
|
||||||
|
{
|
||||||
|
return $"cdn.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.StartsWith("/api/admin/"))
|
||||||
|
{
|
||||||
|
return $"admin.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"api.{apexDomain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Excerpt(string? body)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var compact = body.ReplaceLineEndings(" ").Trim();
|
||||||
|
return compact.Length <= 240 ? compact : compact[..240] + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record EndpointProbeResult(
|
||||||
|
string Method,
|
||||||
|
string Host,
|
||||||
|
string Path,
|
||||||
|
string Expected,
|
||||||
|
int? StatusCode,
|
||||||
|
string? Error,
|
||||||
|
string? ResponseExcerpt,
|
||||||
|
bool Passed)
|
||||||
|
{
|
||||||
|
public string Observed => StatusCode is int code ? code.ToString() : Error ?? "no response";
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
public sealed record GameClientSession(
|
||||||
|
long PlayerId,
|
||||||
|
string AccessToken,
|
||||||
|
string DeviceId,
|
||||||
|
string PlatformId);
|
||||||
|
|
||||||
|
public static class GameClientSessionFactory
|
||||||
|
{
|
||||||
|
public static async Task<GameClientSession> CreateAsync(
|
||||||
|
HttpClient client,
|
||||||
|
string apexDomain)
|
||||||
|
{
|
||||||
|
var deviceId = $"endpoint-contract-device-{Guid.NewGuid():N}";
|
||||||
|
var platformId = $"endpoint-contract-platform-{Guid.NewGuid():N}";
|
||||||
|
var tokenRequest = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
new Uri($"http://auth.{apexDomain}/connect/token"));
|
||||||
|
tokenRequest.Headers.Accept.ParseAdd("application/json");
|
||||||
|
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||||
|
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||||
|
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "password",
|
||||||
|
["client_id"] = "recroom",
|
||||||
|
["client_secret"] = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2",
|
||||||
|
["username"] = "EndpointContract",
|
||||||
|
["device_id"] = deviceId,
|
||||||
|
["platform"] = "0",
|
||||||
|
["platform_id"] = platformId,
|
||||||
|
});
|
||||||
|
|
||||||
|
using var tokenResponse = await client.SendAsync(tokenRequest);
|
||||||
|
if (!tokenResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await tokenResponse.Content.ReadAsStringAsync();
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Test player token request failed with {(int)tokenResponse.StatusCode} {tokenResponse.StatusCode}: {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var tokenJson = await JsonDocument.ParseAsync(
|
||||||
|
await tokenResponse.Content.ReadAsStreamAsync());
|
||||||
|
var accessToken = tokenJson.RootElement.GetProperty("access_token").GetString()
|
||||||
|
?? throw new InvalidOperationException("Token response did not include access_token.");
|
||||||
|
|
||||||
|
var userInfoRequest = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
new Uri($"http://auth.{apexDomain}/connect/userinfo"));
|
||||||
|
userInfoRequest.Headers.Accept.ParseAdd("application/json");
|
||||||
|
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||||
|
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||||
|
userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||||
|
|
||||||
|
using var userInfoResponse = await client.SendAsync(userInfoRequest);
|
||||||
|
userInfoResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var userInfoJson = await JsonDocument.ParseAsync(
|
||||||
|
await userInfoResponse.Content.ReadAsStreamAsync());
|
||||||
|
var playerId = long.Parse(userInfoJson.RootElement.GetProperty("sub").GetString()!);
|
||||||
|
|
||||||
|
return new GameClientSession(playerId, accessToken, deviceId, platformId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Compat;
|
||||||
|
using DorkNet.Server.Controllers.Auth;
|
||||||
|
using DorkNet.Server.Versions.Late2020;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
public sealed class VersionCheckControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void March_2023_build_is_valid_with_minimal_service_config()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
|
||||||
|
var result = Assert.IsType<OkObjectResult>(controller.Check("20230321", "0"));
|
||||||
|
|
||||||
|
Assert.Equal(0, ReadVersionStatus(result.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_build_still_requires_update()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
|
||||||
|
var result = Assert.IsType<OkObjectResult>(controller.Check("unknown", "0"));
|
||||||
|
|
||||||
|
Assert.Equal(2, ReadVersionStatus(result.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VersionCheckController CreateController()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder().Build();
|
||||||
|
var registry = new VersionRegistry(
|
||||||
|
[new Late2020VersionPlugin()],
|
||||||
|
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"march_2023_03_21",
|
||||||
|
},
|
||||||
|
"march_2023_03_21");
|
||||||
|
|
||||||
|
return new VersionCheckController(
|
||||||
|
config,
|
||||||
|
registry,
|
||||||
|
NullLogger<VersionCheckController>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadVersionStatus(object? value)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(value);
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
return document.RootElement.GetProperty("VersionStatus").GetInt32();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Action / controller filter that 403s any request whose
|
||||||
|
/// JWT-resolved player doesn't have <c>IsAdmin == true</c>. Does not
|
||||||
|
/// itself authenticate — pair it with <c>[Authorize]</c> to ensure a
|
||||||
|
/// valid bearer is present before this runs.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// <code>
|
||||||
|
/// [Authorize]
|
||||||
|
/// [AdminOnly]
|
||||||
|
/// [Route("api/admin/v1")]
|
||||||
|
/// public class AdminController { ... }
|
||||||
|
/// </code>
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
|
||||||
|
public class AdminOnlyAttribute : Attribute, IAsyncAuthorizationFilter
|
||||||
|
{
|
||||||
|
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
|
||||||
|
{
|
||||||
|
// AuthorizationFilterContext.HttpContext is always populated and
|
||||||
|
// exposes the same ClaimsPrincipal a controller would see, so
|
||||||
|
// there's no reason to round-trip through IHttpContextAccessor
|
||||||
|
// (which isn't even registered in this app's DI container — the
|
||||||
|
// earlier lookup crashed every admin request with
|
||||||
|
// "No service for type IHttpContextAccessor has been registered").
|
||||||
|
if (ControllerBaseExtensions.CurrentPlayerId(context.HttpContext.User) is not long id)
|
||||||
|
{
|
||||||
|
// No JWT → defer to [Authorize]'s 401.
|
||||||
|
context.Result = new UnauthorizedResult();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var db = context.HttpContext.RequestServices
|
||||||
|
.GetRequiredService<DorkNetDbContext>();
|
||||||
|
var isAdmin = await db.Players
|
||||||
|
.Where(p => p.Id == id)
|
||||||
|
.Select(p => p.IsAdmin)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (!isAdmin)
|
||||||
|
context.Result = new ForbidResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Short-circuits any authenticated request whose JWT-resolved player has
|
||||||
|
/// a non-null <c>BannedUntil</c> in the future. Sits AFTER
|
||||||
|
/// <c>UseAuthentication</c>/<c>UseAuthorization</c> in the pipeline so the
|
||||||
|
/// claims principal is populated before we look at it.
|
||||||
|
///
|
||||||
|
/// Anonymous requests (no/invalid bearer) pass through unchanged — this
|
||||||
|
/// middleware never short-circuits them. Whether they're allowed is the
|
||||||
|
/// per-endpoint <c>[Authorize]</c> decision, not ours.
|
||||||
|
///
|
||||||
|
/// Lookup is one query per request against the Players table. Cached for
|
||||||
|
/// the request lifetime via the scoped DbContext, so repeated bearer
|
||||||
|
/// checks within one request hit the EF first-level cache.
|
||||||
|
/// </summary>
|
||||||
|
public class BanCheckMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext ctx, DorkNetDbContext db)
|
||||||
|
{
|
||||||
|
// Cheap early-out for anonymous traffic — no DB hit.
|
||||||
|
var sub = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (!long.TryParse(sub, out var playerId))
|
||||||
|
{
|
||||||
|
await next(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project just the BannedUntil column — no need to materialise the
|
||||||
|
// full player entity for every request.
|
||||||
|
var bannedUntil = await db.Players
|
||||||
|
.Where(p => p.Id == playerId)
|
||||||
|
.Select(p => p.BannedUntil)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (bannedUntil.HasValue && bannedUntil.Value > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
ctx.Response.ContentType = "application/json";
|
||||||
|
await ctx.Response.WriteAsync(JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
Error = "banned",
|
||||||
|
Until = bannedUntil.Value.ToString("o"),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single canonical place for "who is the bearer-resolved player on this
|
||||||
|
/// request." Replaces the per-controller copy-paste of:
|
||||||
|
/// <code>
|
||||||
|
/// private long CurrentPlayerId =>
|
||||||
|
/// long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
/// </code>
|
||||||
|
/// which historically existed in 10 controllers and silently NPE-bombed
|
||||||
|
/// the action when the bearer was missing instead of returning 401.
|
||||||
|
/// </summary>
|
||||||
|
public static class ControllerBaseExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the bearer-resolved player id, or null when the caller is
|
||||||
|
/// anonymous / the bearer is malformed. Use on optional-auth endpoints.
|
||||||
|
/// </summary>
|
||||||
|
public static long? CurrentPlayerId(this ControllerBase controller) =>
|
||||||
|
CurrentPlayerId(controller.User);
|
||||||
|
|
||||||
|
/// <summary>Overload for non-controller callers (filters,
|
||||||
|
/// middleware) that hold a <see cref="ClaimsPrincipal"/> directly.</summary>
|
||||||
|
public static long? CurrentPlayerId(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
var claim = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
return long.TryParse(claim, out var id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the bearer-resolved player id; throws
|
||||||
|
/// <see cref="UnauthorizedAccessException"/> when missing. Use on
|
||||||
|
/// <c>[Authorize]</c>-decorated endpoints where a missing id is a
|
||||||
|
/// programming error (the framework should have already 401'd).
|
||||||
|
/// </summary>
|
||||||
|
public static long RequireCurrentPlayerId(this ControllerBase controller) =>
|
||||||
|
controller.CurrentPlayerId()
|
||||||
|
?? throw new UnauthorizedAccessException(
|
||||||
|
"Bearer token claim 'NameIdentifier' is missing or non-numeric. " +
|
||||||
|
"If this fired on an [Authorize] endpoint, JWT validation upstream " +
|
||||||
|
"is misconfigured.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,785 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Open.IdentityServer;
|
||||||
|
using Open.IdentityServer.Models;
|
||||||
|
using Open.IdentityServer.ResponseHandling;
|
||||||
|
using Open.IdentityServer.Services;
|
||||||
|
using Open.IdentityServer.Stores;
|
||||||
|
using Open.IdentityServer.Validation;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Auth;
|
||||||
|
|
||||||
|
public sealed class IdentityServerSigningKeyProvider
|
||||||
|
{
|
||||||
|
private readonly string _legacySecret;
|
||||||
|
|
||||||
|
public IdentityServerSigningKeyProvider(IConfiguration config)
|
||||||
|
{
|
||||||
|
_legacySecret =
|
||||||
|
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||||
|
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||||
|
?? config["Jwt:Secret"]
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or Jwt:Secret in appsettings.Local.json.");
|
||||||
|
|
||||||
|
CertificatePath = ResolveCertificatePath(config);
|
||||||
|
var password = config["IdentityServer:SigningCertificatePassword"] ?? _legacySecret;
|
||||||
|
var certificateBase64 = config["IdentityServer:SigningCertificateBase64"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(certificateBase64))
|
||||||
|
{
|
||||||
|
LoadedFromBase64 = true;
|
||||||
|
Certificate = LoadCertificateFromBase64(certificateBase64, password);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Certificate = LoadOrCreateCertificate(CertificatePath, password);
|
||||||
|
}
|
||||||
|
SigningKey = new X509SecurityKey(Certificate);
|
||||||
|
LegacyJwtKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_legacySecret));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CertificatePath { get; }
|
||||||
|
public bool LoadedFromBase64 { get; }
|
||||||
|
public string CertificateSource => LoadedFromBase64
|
||||||
|
? "IdentityServer:SigningCertificateBase64"
|
||||||
|
: CertificatePath;
|
||||||
|
public X509Certificate2 Certificate { get; }
|
||||||
|
public SecurityKey SigningKey { get; }
|
||||||
|
public SecurityKey LegacyJwtKey { get; }
|
||||||
|
public SecurityKey[] ValidationKeys => new[] { SigningKey, LegacyJwtKey };
|
||||||
|
|
||||||
|
private static string ResolveCertificatePath(IConfiguration config)
|
||||||
|
{
|
||||||
|
var path = config["IdentityServer:SigningCertificatePath"];
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
path = Path.Combine(AppContext.BaseDirectory, "data", "identityserver-signing.pfx");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static X509Certificate2 LoadCertificateFromBase64(string certificateBase64, string password)
|
||||||
|
{
|
||||||
|
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
|
||||||
|
var compact = NormalizeCertificateBase64(certificateBase64);
|
||||||
|
byte[] bytes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = Convert.FromBase64String(compact);
|
||||||
|
}
|
||||||
|
catch (FormatException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"IdentityServer signing certificate base64 is invalid. " +
|
||||||
|
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64, " +
|
||||||
|
"without a file path or shell assignment text.",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new X509Certificate2(bytes, password, flags);
|
||||||
|
}
|
||||||
|
catch (CryptographicException ex)
|
||||||
|
{
|
||||||
|
if (TryLoadPemCertificate(bytes, password, flags, out var certificate))
|
||||||
|
return certificate;
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"IdentityServer signing certificate base64 decoded successfully, but it is not a valid PFX/PKCS#12 certificate. " +
|
||||||
|
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64 and make sure " +
|
||||||
|
"IdentityServer__SigningCertificatePassword matches the PFX password.",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryLoadPemCertificate(
|
||||||
|
byte[] bytes,
|
||||||
|
string password,
|
||||||
|
X509KeyStorageFlags flags,
|
||||||
|
out X509Certificate2 certificate)
|
||||||
|
{
|
||||||
|
certificate = null!;
|
||||||
|
|
||||||
|
string pem;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pem = Encoding.UTF8.GetString(bytes);
|
||||||
|
}
|
||||||
|
catch (DecoderFallbackException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pem.Contains("-----BEGIN", StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var pemCertificate = X509Certificate2.CreateFromPem(pem, pem);
|
||||||
|
if (!pemCertificate.HasPrivateKey)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var pfxBytes = pemCertificate.Export(X509ContentType.Pfx, password);
|
||||||
|
certificate = new X509Certificate2(pfxBytes, password, flags);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (CryptographicException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeCertificateBase64(string value)
|
||||||
|
{
|
||||||
|
var text = value.Trim().Trim('"', '\'');
|
||||||
|
var equals = text.IndexOf('=');
|
||||||
|
if (equals > 0)
|
||||||
|
{
|
||||||
|
var key = text[..equals].Trim();
|
||||||
|
if (key.Equals("IdentityServer__SigningCertificateBase64", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
key.Equals("IdentityServer:SigningCertificateBase64", StringComparison.OrdinalIgnoreCase))
|
||||||
|
text = text[(equals + 1)..].Trim().Trim('"', '\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var comma = text.IndexOf(',');
|
||||||
|
if (comma >= 0)
|
||||||
|
text = text[(comma + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
text = text["base64:".Length..];
|
||||||
|
|
||||||
|
var compact = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray())
|
||||||
|
.Replace('-', '+')
|
||||||
|
.Replace('_', '/');
|
||||||
|
return (compact.Length % 4) switch
|
||||||
|
{
|
||||||
|
2 => compact + "==",
|
||||||
|
3 => compact + "=",
|
||||||
|
_ => compact,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static X509Certificate2 LoadOrCreateCertificate(string path, string password)
|
||||||
|
{
|
||||||
|
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
|
||||||
|
if (File.Exists(path))
|
||||||
|
return new X509Certificate2(path, password, flags);
|
||||||
|
|
||||||
|
using var rsa = RSA.Create(2048);
|
||||||
|
var request = new CertificateRequest(
|
||||||
|
"CN=DorkNet IdentityServer Signing",
|
||||||
|
rsa,
|
||||||
|
HashAlgorithmName.SHA256,
|
||||||
|
RSASignaturePadding.Pkcs1);
|
||||||
|
request.CertificateExtensions.Add(
|
||||||
|
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: false));
|
||||||
|
|
||||||
|
using var created = request.CreateSelfSigned(
|
||||||
|
DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
|
DateTimeOffset.UtcNow.AddYears(10));
|
||||||
|
var bytes = created.Export(X509ContentType.Pfx, password);
|
||||||
|
File.WriteAllBytes(path, bytes);
|
||||||
|
return new X509Certificate2(bytes, password, flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RecRoomIdentityServerRegistration
|
||||||
|
{
|
||||||
|
public static void AddRecRoomIdentityServer(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration config,
|
||||||
|
DomainConfig domain,
|
||||||
|
IdentityServerSigningKeyProvider signingKeyProvider)
|
||||||
|
{
|
||||||
|
services
|
||||||
|
.AddIdentityServer(options =>
|
||||||
|
{
|
||||||
|
options.IssuerUri = domain.AuthIssuer.TrimEnd('/');
|
||||||
|
})
|
||||||
|
.AddInMemoryIdentityResources(RecRoomIdentityServerConfig.IdentityResources)
|
||||||
|
.AddInMemoryApiScopes(RecRoomIdentityServerConfig.ApiScopes)
|
||||||
|
.AddInMemoryClients(RecRoomIdentityServerConfig.Clients)
|
||||||
|
.AddSigningCredential(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256)
|
||||||
|
.AddResourceOwnerValidator<RecRoomPasswordValidator>()
|
||||||
|
.AddExtensionGrantValidator<CachedLoginGrantValidator>()
|
||||||
|
.AddProfileService<RecRoomProfileService>();
|
||||||
|
|
||||||
|
services.AddTransient<ITokenResponseGenerator, RecRoomTokenResponseGenerator>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class RecRoomIdentityServerConfig
|
||||||
|
{
|
||||||
|
public const string ClientId = "recroom";
|
||||||
|
public const string ClientSecret = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2";
|
||||||
|
public const string GameClientScope = "gameClient";
|
||||||
|
public const string CachedLoginGrantType = "cached_login";
|
||||||
|
|
||||||
|
public static readonly IdentityResource[] IdentityResources =
|
||||||
|
{
|
||||||
|
new IdentityResources.OpenId(),
|
||||||
|
new IdentityResources.Profile(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly ApiScope[] ApiScopes =
|
||||||
|
{
|
||||||
|
new(GameClientScope, "Game client")
|
||||||
|
{
|
||||||
|
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
|
||||||
|
},
|
||||||
|
new("api", "DorkNet API")
|
||||||
|
{
|
||||||
|
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly Client[] Clients =
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ClientId = ClientId,
|
||||||
|
ClientName = "Rec Room game client",
|
||||||
|
RequireClientSecret = true,
|
||||||
|
ClientSecrets = { new Secret(ClientSecret.Sha256()) },
|
||||||
|
AllowedGrantTypes = { GrantType.ResourceOwnerPassword, "refresh_token", CachedLoginGrantType },
|
||||||
|
AllowedScopes = RecRoomScopes(),
|
||||||
|
AllowOfflineAccess = true,
|
||||||
|
AccessTokenLifetime = 60 * 60 * 12,
|
||||||
|
AbsoluteRefreshTokenLifetime = 60 * 60 * 24 * 30,
|
||||||
|
SlidingRefreshTokenLifetime = 60 * 60 * 24 * 30,
|
||||||
|
RefreshTokenUsage = TokenUsage.ReUse,
|
||||||
|
RefreshTokenExpiration = TokenExpiration.Absolute,
|
||||||
|
UpdateAccessTokenClaimsOnRefresh = true,
|
||||||
|
AlwaysIncludeUserClaimsInIdToken = true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ICollection<string> RecRoomScopes() =>
|
||||||
|
[
|
||||||
|
IdentityServerConstants.StandardScopes.OpenId,
|
||||||
|
IdentityServerConstants.StandardScopes.Profile,
|
||||||
|
IdentityServerConstants.StandardScopes.OfflineAccess,
|
||||||
|
GameClientScope,
|
||||||
|
"api",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class IdentityServerGameTokenRequestMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
DomainConfig domain,
|
||||||
|
IConfiguration config,
|
||||||
|
ILogger<IdentityServerGameTokenRequestMiddleware> logger)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
if (!IsTokenRequest(context))
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = await context.Request.ReadFormAsync();
|
||||||
|
var values = form.ToDictionary(
|
||||||
|
kvp => kvp.Key,
|
||||||
|
kvp => kvp.Value,
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
if (IsDevicePasswordGrant(values))
|
||||||
|
{
|
||||||
|
if (!values.ContainsKey("username") || string.IsNullOrWhiteSpace(values["username"].ToString()))
|
||||||
|
{
|
||||||
|
values["username"] = First(
|
||||||
|
values.TryGetValue("deviceId", out var deviceId) ? deviceId.ToString() : null,
|
||||||
|
values.TryGetValue("device_id", out var deviceIdSnake) ? deviceIdSnake.ToString() : null,
|
||||||
|
values.TryGetValue("platformId", out var platformId) ? platformId.ToString() : null,
|
||||||
|
values.TryGetValue("platform_id", out var platformIdSnake) ? platformIdSnake.ToString() : null,
|
||||||
|
"anonymous");
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.ContainsKey("password") || string.IsNullOrWhiteSpace(values["password"].ToString()))
|
||||||
|
{
|
||||||
|
values["password"] = "__dorknet_device_login__";
|
||||||
|
values["dorknet_device_login"] = "true";
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var scopes = values.TryGetValue("scope", out var rawScope)
|
||||||
|
? rawScope.ToString()
|
||||||
|
: string.Empty;
|
||||||
|
var mergedScopes = MergeScopes(scopes);
|
||||||
|
if (!string.Equals(scopes, mergedScopes, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
values["scope"] = mergedScopes;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
context.Features.Set<IFormFeature>(new FormFeature(new FormCollection(values)));
|
||||||
|
|
||||||
|
await InvokeAndPersistAccessTokenCookieAsync(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InvokeAndPersistAccessTokenCookieAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var originalBody = context.Response.Body;
|
||||||
|
await using var bufferedBody = new MemoryStream();
|
||||||
|
context.Response.Body = bufferedBody;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
|
||||||
|
bufferedBody.Position = 0;
|
||||||
|
if (IsSuccessfulJsonResponse(context.Response))
|
||||||
|
TryPersistAccessTokenCookie(context, bufferedBody);
|
||||||
|
|
||||||
|
bufferedBody.Position = 0;
|
||||||
|
await bufferedBody.CopyToAsync(originalBody);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
context.Response.Body = originalBody;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSuccessfulJsonResponse(HttpResponse response) =>
|
||||||
|
response.StatusCode is >= 200 and < 300
|
||||||
|
&& response.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
|
||||||
|
private void TryPersistAccessTokenCookie(HttpContext context, Stream responseBody)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(responseBody);
|
||||||
|
if (!document.RootElement.TryGetProperty("access_token", out var accessTokenElement))
|
||||||
|
return;
|
||||||
|
var accessToken = accessTokenElement.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(accessToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var expires = DateTimeOffset.UtcNow.AddHours(12);
|
||||||
|
if (document.RootElement.TryGetProperty("expires_in", out var expiresInElement) &&
|
||||||
|
expiresInElement.TryGetInt32(out var expiresIn) &&
|
||||||
|
expiresIn > 0)
|
||||||
|
{
|
||||||
|
expires = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = string.Equals(domain.Scheme, "https", StringComparison.OrdinalIgnoreCase),
|
||||||
|
SameSite = SameSiteMode.None,
|
||||||
|
Expires = expires,
|
||||||
|
IsEssential = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var cookieDomain = ResolveCookieDomain(context.Request.Host.Host);
|
||||||
|
if (cookieDomain is not null)
|
||||||
|
options.Domain = cookieDomain;
|
||||||
|
|
||||||
|
context.Response.Cookies.Append(AuthService.AccessCookieName, accessToken, options);
|
||||||
|
logger.LogInformation(
|
||||||
|
"[auth-cookie] issued access cookie host={Host} domain={Domain} secure={Secure} sameSite={SameSite} essential={IsEssential}",
|
||||||
|
context.Request.Host.Host,
|
||||||
|
cookieDomain ?? "<host-only>",
|
||||||
|
options.Secure,
|
||||||
|
options.SameSite,
|
||||||
|
options.IsEssential);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Leave the token response untouched if the upstream payload is not JSON.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ResolveCookieDomain(string host)
|
||||||
|
{
|
||||||
|
var configuredCookieDomain =
|
||||||
|
Environment.GetEnvironmentVariable("DORKNET_AUTH_COOKIE_DOMAIN")
|
||||||
|
?? config["Auth:CookieDomain"]
|
||||||
|
?? config["IdentityServer:CookieDomain"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredCookieDomain))
|
||||||
|
return configuredCookieDomain.Trim().TrimStart('.') is { Length: > 0 } value ? "." + value : null;
|
||||||
|
|
||||||
|
var apex = domain.Apex.Trim().TrimStart('.');
|
||||||
|
if (string.IsNullOrWhiteSpace(apex) ||
|
||||||
|
apex.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
apex.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
apex.Equals("::1", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host.Equals(apex, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
host.EndsWith("." + apex, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "." + apex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain.UsesHyphenSubdomains)
|
||||||
|
{
|
||||||
|
var parent = HyphenCookieParent(apex, host);
|
||||||
|
if (parent is not null)
|
||||||
|
return "." + parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? HyphenCookieParent(string apex, string host)
|
||||||
|
{
|
||||||
|
var parts = apex.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length < 3)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var environmentLabel = parts[0];
|
||||||
|
var parent = string.Join('.', parts.Skip(1));
|
||||||
|
if (!host.EndsWith("." + parent, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var hostLabel = host[..^(parent.Length + 1)];
|
||||||
|
return hostLabel.EndsWith("-" + environmentLabel, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? parent
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTokenRequest(HttpContext context) =>
|
||||||
|
HttpMethods.IsPost(context.Request.Method)
|
||||||
|
&& context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& context.Request.HasFormContentType;
|
||||||
|
|
||||||
|
private static bool IsDevicePasswordGrant(Dictionary<string, StringValues> values)
|
||||||
|
{
|
||||||
|
if (!values.TryGetValue("grant_type", out var grantType) ||
|
||||||
|
!string.Equals(grantType.ToString(), GrantType.ResourceOwnerPassword, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
return !values.TryGetValue("password", out var password) ||
|
||||||
|
string.IsNullOrWhiteSpace(password.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string First(params string?[] values) =>
|
||||||
|
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "anonymous";
|
||||||
|
|
||||||
|
private static string MergeScopes(string existing)
|
||||||
|
{
|
||||||
|
var scopes = existing
|
||||||
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
scopes.Add(IdentityServerConstants.StandardScopes.OpenId);
|
||||||
|
scopes.Add(IdentityServerConstants.StandardScopes.Profile);
|
||||||
|
scopes.Add(IdentityServerConstants.StandardScopes.OfflineAccess);
|
||||||
|
scopes.Add(RecRoomIdentityServerConfig.GameClientScope);
|
||||||
|
return string.Join(' ', scopes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RecRoomTokenResponseGenerator(
|
||||||
|
TimeProvider clock,
|
||||||
|
ITokenService tokenService,
|
||||||
|
IRefreshTokenService refreshTokenService,
|
||||||
|
IScopeParser scopeParser,
|
||||||
|
IResourceStore resources,
|
||||||
|
IClientStore clients,
|
||||||
|
ILogger<TokenResponseGenerator> logger) : ITokenResponseGenerator
|
||||||
|
{
|
||||||
|
private readonly TokenResponseGenerator _inner = new(
|
||||||
|
clock,
|
||||||
|
tokenService,
|
||||||
|
refreshTokenService,
|
||||||
|
scopeParser,
|
||||||
|
resources,
|
||||||
|
clients,
|
||||||
|
logger);
|
||||||
|
|
||||||
|
public async Task<TokenResponse> ProcessAsync(TokenRequestValidationResult request)
|
||||||
|
{
|
||||||
|
var response = await _inner.ProcessAsync(request);
|
||||||
|
AddLegacyGameFields(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddLegacyGameFields(TokenResponse response)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(response.AccessToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
response.Custom ??= new Dictionary<string, object>(StringComparer.Ordinal);
|
||||||
|
AddIfMissing(response.Custom, "Access_token", response.AccessToken);
|
||||||
|
AddIfMissing(response.Custom, "accessToken", response.AccessToken);
|
||||||
|
AddIfMissing(response.Custom, "AccessToken", response.AccessToken);
|
||||||
|
AddIfMissing(response.Custom, "token", response.AccessToken);
|
||||||
|
AddIfMissing(response.Custom, "Token", response.AccessToken);
|
||||||
|
|
||||||
|
AddIfMissing(response.Custom, "tokenType", "Bearer");
|
||||||
|
AddIfMissing(response.Custom, "TokenType", "Bearer");
|
||||||
|
AddIfMissing(response.Custom, "expiresIn", response.AccessTokenLifetime);
|
||||||
|
AddIfMissing(response.Custom, "ExpiresIn", response.AccessTokenLifetime);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(response.RefreshToken))
|
||||||
|
{
|
||||||
|
AddIfMissing(response.Custom, "Refresh_token", response.RefreshToken);
|
||||||
|
AddIfMissing(response.Custom, "refreshToken", response.RefreshToken);
|
||||||
|
AddIfMissing(response.Custom, "RefreshToken", response.RefreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var signingKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
AddIfMissing(response.Custom, "key", signingKey);
|
||||||
|
AddIfMissing(response.Custom, "Key", signingKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddIfMissing(IDictionary<string, object> values, string key, object value)
|
||||||
|
{
|
||||||
|
if (!values.ContainsKey(key))
|
||||||
|
values[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RecRoomPasswordValidator(
|
||||||
|
PlayerService playerService,
|
||||||
|
LevelService level,
|
||||||
|
ServerSettingsService settings,
|
||||||
|
DorkNetDbContext db,
|
||||||
|
ILogger<RecRoomPasswordValidator> logger) : IResourceOwnerPasswordValidator
|
||||||
|
{
|
||||||
|
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
|
||||||
|
{
|
||||||
|
var raw = context.Request.Raw;
|
||||||
|
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
|
||||||
|
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
|
||||||
|
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
|
||||||
|
var deviceLogin = string.Equals(raw.Get("dorknet_device_login"), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!deviceLogin && !string.IsNullOrEmpty(context.Password) && !string.IsNullOrEmpty(context.UserName))
|
||||||
|
{
|
||||||
|
var byName = await playerService.GetByUsernameAsync(context.UserName);
|
||||||
|
if (byName is null || byName.PasswordHash is null)
|
||||||
|
{
|
||||||
|
context.Result = new GrantValidationResult(
|
||||||
|
TokenRequestErrors.InvalidGrant,
|
||||||
|
"unknown_user_or_no_password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BCrypt.Net.BCrypt.Verify(context.Password, byName.PasswordHash))
|
||||||
|
{
|
||||||
|
context.Result = new GrantValidationResult(
|
||||||
|
TokenRequestErrors.InvalidGrant,
|
||||||
|
"password_mismatch");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await playerService.TagPlatformAsync(byName.Id, platform, platformId);
|
||||||
|
await TryAwardLoginXpAsync(byName.Id);
|
||||||
|
context.Result = await CreateSuccessAsync(byName, "password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var effectiveDeviceId = !string.IsNullOrWhiteSpace(deviceId)
|
||||||
|
? deviceId
|
||||||
|
: $"legacy-{(context.UserName ?? "anonymous").ToLowerInvariant()}";
|
||||||
|
|
||||||
|
if (await settings.AreSignupsDisabledAsync())
|
||||||
|
{
|
||||||
|
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"[auth] IdentityServer password grant device fallback refused - signups disabled (device={Device})",
|
||||||
|
effectiveDeviceId);
|
||||||
|
context.Result = new GrantValidationResult(
|
||||||
|
TokenRequestErrors.InvalidGrant,
|
||||||
|
"signups_disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var player = await playerService.GetOrCreateByDeviceAsync(
|
||||||
|
deviceId: effectiveDeviceId,
|
||||||
|
platform: platform,
|
||||||
|
platformId: platformId,
|
||||||
|
displayName: context.UserName);
|
||||||
|
await TryAwardLoginXpAsync(player.Id);
|
||||||
|
context.Result = await CreateSuccessAsync(player, "device_password");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<GrantValidationResult> CreateSuccessAsync(PlayerEntity player, string authMethod) =>
|
||||||
|
new(
|
||||||
|
subject: player.Id.ToString(),
|
||||||
|
authenticationMethod: authMethod,
|
||||||
|
claims: await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
|
||||||
|
|
||||||
|
private async Task TryAwardLoginXpAsync(long playerId)
|
||||||
|
{
|
||||||
|
var lastSeen = await db.Players
|
||||||
|
.Where(p => p.Id == playerId)
|
||||||
|
.Select(p => p.LastSeenAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||||
|
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? First(params string?[] values) =>
|
||||||
|
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CachedLoginGrantValidator(
|
||||||
|
PlayerService playerService,
|
||||||
|
LevelService level,
|
||||||
|
OrphanAccountTracker orphans,
|
||||||
|
DorkNetDbContext db,
|
||||||
|
ILogger<CachedLoginGrantValidator> logger) : IExtensionGrantValidator
|
||||||
|
{
|
||||||
|
public string GrantType => RecRoomIdentityServerConfig.CachedLoginGrantType;
|
||||||
|
|
||||||
|
public async Task ValidateAsync(ExtensionGrantValidationContext context)
|
||||||
|
{
|
||||||
|
var raw = context.Request.Raw;
|
||||||
|
if (!long.TryParse(raw.Get("account_id"), out var pickedId))
|
||||||
|
{
|
||||||
|
context.Result = new GrantValidationResult(
|
||||||
|
TokenRequestErrors.InvalidGrant,
|
||||||
|
"missing account_id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var picked = await playerService.GetByIdAsync(pickedId);
|
||||||
|
if (picked is null)
|
||||||
|
{
|
||||||
|
context.Result = new GrantValidationResult(
|
||||||
|
TokenRequestErrors.InvalidGrant,
|
||||||
|
"unknown account_id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
|
||||||
|
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
|
||||||
|
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
|
||||||
|
|
||||||
|
var pendingId = orphans.PeekPending(deviceId, platformId);
|
||||||
|
if (pendingId is long orphanId && orphanId != pickedId)
|
||||||
|
{
|
||||||
|
await playerService.DeleteOrphanAsync(orphanId);
|
||||||
|
logger.LogInformation(
|
||||||
|
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
|
||||||
|
pickedId,
|
||||||
|
orphanId);
|
||||||
|
}
|
||||||
|
orphans.Clear(deviceId, platformId);
|
||||||
|
|
||||||
|
await TryAwardLoginXpAsync(picked.Id);
|
||||||
|
await playerService.TagPlatformAsync(picked.Id, platform, platformId);
|
||||||
|
context.Result = new GrantValidationResult(
|
||||||
|
subject: picked.Id.ToString(),
|
||||||
|
authenticationMethod: GrantType,
|
||||||
|
claims: await RecRoomIdentityClaims.CreateAsync(db, picked.Id, picked.Username));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TryAwardLoginXpAsync(long playerId)
|
||||||
|
{
|
||||||
|
var lastSeen = await db.Players
|
||||||
|
.Where(p => p.Id == playerId)
|
||||||
|
.Select(p => p.LastSeenAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||||
|
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? First(params string?[] values) =>
|
||||||
|
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RecRoomProfileService(PlayerService playerService, DorkNetDbContext db) : IProfileService
|
||||||
|
{
|
||||||
|
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
|
||||||
|
{
|
||||||
|
if (!TryGetSubjectId(context.Subject, out var playerId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var player = await playerService.GetByIdAsync(playerId);
|
||||||
|
if (player is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
context.IssuedClaims.AddRange(
|
||||||
|
await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task IsActiveAsync(IsActiveContext context)
|
||||||
|
{
|
||||||
|
context.IsActive =
|
||||||
|
TryGetSubjectId(context.Subject, out var playerId)
|
||||||
|
&& await playerService.GetByIdAsync(playerId) is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetSubjectId(ClaimsPrincipal subject, out long playerId)
|
||||||
|
{
|
||||||
|
var value = subject.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||||
|
?? subject.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
return long.TryParse(value, out playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RecRoomIdentityClaims
|
||||||
|
{
|
||||||
|
public static readonly List<string> UserClaimTypes =
|
||||||
|
[
|
||||||
|
JwtRegisteredClaimNames.Sub,
|
||||||
|
ClaimTypes.NameIdentifier,
|
||||||
|
"name",
|
||||||
|
"role",
|
||||||
|
ClaimTypes.Role,
|
||||||
|
"roles",
|
||||||
|
"accountId",
|
||||||
|
"account_id",
|
||||||
|
"accountid",
|
||||||
|
];
|
||||||
|
|
||||||
|
public static async Task<List<Claim>> CreateAsync(DorkNetDbContext db, long playerId, string? username = null)
|
||||||
|
{
|
||||||
|
var playerInfo = await db.Players
|
||||||
|
.Where(p => p.Id == playerId)
|
||||||
|
.Select(p => new { p.Username, IsDev = p.IsDeveloper || p.IsAdmin })
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
var name = username ?? playerInfo?.Username ?? playerId.ToString();
|
||||||
|
var isDev = playerInfo?.IsDev ?? false;
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.NameIdentifier, playerId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
|
||||||
|
new("name", name),
|
||||||
|
new("accountId", playerId.ToString()),
|
||||||
|
new("account_id", playerId.ToString()),
|
||||||
|
new("accountid", playerId.ToString()),
|
||||||
|
new(ClaimTypes.Role, "gameClient"),
|
||||||
|
new("role", "gameClient"),
|
||||||
|
new("roles", "gameClient"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDev)
|
||||||
|
{
|
||||||
|
claims.Add(new Claim(ClaimTypes.Role, "developer"));
|
||||||
|
claims.Add(new Claim("role", "developer"));
|
||||||
|
claims.Add(new Claim("roles", "developer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Short-circuits any incoming request whose remote IP falls under an
|
||||||
|
/// active <see cref="DorkNet.Server.Data.Entities.IpBanEntity"/> row
|
||||||
|
/// with 403. Sits BEFORE authentication so banned IPs can't spend a
|
||||||
|
/// round-trip negotiating a JWT — the connection terminates as early
|
||||||
|
/// as possible.
|
||||||
|
///
|
||||||
|
/// Lookup is one query against IpBans per request. The table is small
|
||||||
|
/// (a few rows in practice) and the simple "Cidr matches?" filter is
|
||||||
|
/// done in C# rather than SQL because CIDR maths in SQLite is awkward.
|
||||||
|
/// </summary>
|
||||||
|
public class IpBanCheckMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext ctx, DorkNetDbContext db)
|
||||||
|
{
|
||||||
|
var remote = ctx.Connection.RemoteIpAddress;
|
||||||
|
if (remote is null)
|
||||||
|
{
|
||||||
|
await next(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Materialise the active-ban list once per request. Could be
|
||||||
|
// cached for ~30s with a hosted service if it ever becomes a
|
||||||
|
// hot path, but right now this is unmeasurable.
|
||||||
|
var bans = await db.IpBans
|
||||||
|
.Where(b => b.Until == null || b.Until > DateTime.UtcNow)
|
||||||
|
.Select(b => b.Cidr)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var cidr in bans)
|
||||||
|
{
|
||||||
|
if (CidrMatches(cidr, remote))
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
await ctx.Response.WriteAsync("ip_banned");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Match a single IP against a CIDR-or-bare-IP string.
|
||||||
|
/// Bare IPs match exactly; CIDRs match if the prefix bits agree.
|
||||||
|
/// Falls back to false on any parse error.</summary>
|
||||||
|
private static bool CidrMatches(string cidr, IPAddress remote)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var slash = cidr.IndexOf('/');
|
||||||
|
if (slash < 0)
|
||||||
|
{
|
||||||
|
return IPAddress.TryParse(cidr, out var single) &&
|
||||||
|
single.Equals(remote);
|
||||||
|
}
|
||||||
|
|
||||||
|
var addrPart = cidr[..slash];
|
||||||
|
var prefixPart = cidr[(slash + 1)..];
|
||||||
|
if (!IPAddress.TryParse(addrPart, out var net) ||
|
||||||
|
!int.TryParse(prefixPart, out var prefix))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Only IPv4 networks for now — IPv6 CIDR matching is
|
||||||
|
// straightforward but we'd need to mask 128-bit values
|
||||||
|
// and the user's setup is single-host IPv4 anyway.
|
||||||
|
if (net.AddressFamily != AddressFamily.InterNetwork ||
|
||||||
|
remote.AddressFamily != AddressFamily.InterNetwork)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var netBytes = net.GetAddressBytes();
|
||||||
|
var remoteBytes = remote.GetAddressBytes();
|
||||||
|
int fullBytes = prefix / 8;
|
||||||
|
int extraBits = prefix % 8;
|
||||||
|
|
||||||
|
for (int i = 0; i < fullBytes; i++)
|
||||||
|
if (netBytes[i] != remoteBytes[i]) return false;
|
||||||
|
if (extraBits == 0) return true;
|
||||||
|
|
||||||
|
int mask = 0xFF << (8 - extraBits) & 0xFF;
|
||||||
|
return (netBytes[fullBytes] & mask) == (remoteBytes[fullBytes] & mask);
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace DorkNet.Server.Compat;
|
||||||
|
|
||||||
|
/// <summary>Identifies a specific Rec Room client build.
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Key"/> is the wire-format identifier the client mod
|
||||||
|
/// stamps on every request via the <c>X-DorkNet-Version</c> header.
|
||||||
|
/// Format is <c>{branch}_{semver}</c>: <c>march_2020_03_10</c>,
|
||||||
|
/// <c>december_2020_12_18</c>. Keep it lowercase, underscore-separated;
|
||||||
|
/// it shows up in admin logs and error responses.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Generation"/> is the wire-format-compatibility group:
|
||||||
|
/// every version inside the same generation shares the same wire
|
||||||
|
/// shapes (JSON DTO casing, route names, etc.) and therefore the same
|
||||||
|
/// strategy implementations. Adding a new build that's protocol-compatible
|
||||||
|
/// with an existing generation = a one-line registration; only a new
|
||||||
|
/// generation entails new strategy code.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ClientVersion(string Key, string Generation)
|
||||||
|
{
|
||||||
|
/// <summary>Sentinel returned when a request has no
|
||||||
|
/// <c>X-DorkNet-Version</c> header AND no
|
||||||
|
/// <c>DorkNet:DefaultClientVersion</c> is configured. Controllers
|
||||||
|
/// can branch on this to either fall through to a sensible default
|
||||||
|
/// or 426 the request.</summary>
|
||||||
|
public static readonly ClientVersion Unknown = new("unknown", "unknown");
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Compat;
|
||||||
|
|
||||||
|
/// <summary>Helpers to read the active <see cref="ClientVersion"/>
|
||||||
|
/// off the current request. The version is set by
|
||||||
|
/// <c>VersionDetectionMiddleware</c> early in the pipeline so any
|
||||||
|
/// downstream consumer (controllers, services, filters) can resolve
|
||||||
|
/// it without re-parsing the header.</summary>
|
||||||
|
public static class HttpContextExtensions
|
||||||
|
{
|
||||||
|
private const string ClientVersionKey = "DorkNet.ClientVersion";
|
||||||
|
|
||||||
|
/// <summary>Stamp the resolved version on the request. Called by
|
||||||
|
/// <c>VersionDetectionMiddleware</c>; not for general use.</summary>
|
||||||
|
internal static void SetClientVersion(this HttpContext ctx, ClientVersion v)
|
||||||
|
=> ctx.Items[ClientVersionKey] = v;
|
||||||
|
|
||||||
|
/// <summary>The client version for this request. Returns
|
||||||
|
/// <see cref="ClientVersion.Unknown"/> if the middleware hasn't
|
||||||
|
/// run (e.g. a unit test that calls a controller method directly)
|
||||||
|
/// rather than null, so callers can safely branch on
|
||||||
|
/// <c>Generation</c> without null-checks.</summary>
|
||||||
|
public static ClientVersion ClientVersion(this HttpContext ctx)
|
||||||
|
=> ctx.Items.TryGetValue(ClientVersionKey, out var v)
|
||||||
|
&& v is ClientVersion cv
|
||||||
|
? cv
|
||||||
|
: Compat.ClientVersion.Unknown;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
namespace DorkNet.Server.Compat;
|
||||||
|
|
||||||
|
/// <summary>One client-version-family plugin. Each implementation
|
||||||
|
/// declares which exact version <see cref="VersionKeys"/> it handles
|
||||||
|
/// and which protocol-compatibility <see cref="Generation"/> they map
|
||||||
|
/// to. The plugin's job at registration time is to also wire any
|
||||||
|
/// generation-specific strategy services into DI via
|
||||||
|
/// <see cref="RegisterStrategies"/> so controllers / services
|
||||||
|
/// downstream can resolve the right <c>IWireFormat</c>,
|
||||||
|
/// <c>IRouteOverride</c>, etc. for the active request's generation.
|
||||||
|
///
|
||||||
|
/// <para>Adding a new version with the SAME wire shapes as an existing
|
||||||
|
/// generation = just listing its key in that generation's plugin
|
||||||
|
/// <see cref="VersionKeys"/>. Only a new generation needs new
|
||||||
|
/// strategy classes.</para>
|
||||||
|
/// </summary>
|
||||||
|
public interface IVersionPlugin
|
||||||
|
{
|
||||||
|
/// <summary>All client-build keys this plugin handles. Multiple
|
||||||
|
/// keys can share one plugin when several Rec Room builds shipped
|
||||||
|
/// the same wire format. Convention: lowercase, underscore-separated,
|
||||||
|
/// matching the literal the client mod sends in
|
||||||
|
/// <c>X-DorkNet-Version</c>.</summary>
|
||||||
|
IReadOnlyCollection<string> VersionKeys { get; }
|
||||||
|
|
||||||
|
/// <summary>Wire-format-compatibility group this plugin implements.
|
||||||
|
/// Multiple plugins can share a generation (rare, but allowed when
|
||||||
|
/// e.g. a Quest build and a PC build of the same era are
|
||||||
|
/// protocol-identical and we just want separate
|
||||||
|
/// <see cref="VersionKeys"/> bookkeeping).</summary>
|
||||||
|
string Generation { get; }
|
||||||
|
|
||||||
|
/// <summary>Register any generation-specific strategy services
|
||||||
|
/// into DI. Called once at startup by the bootstrap code in
|
||||||
|
/// <c>Program.cs</c>. Use keyed registrations (keyed by
|
||||||
|
/// <see cref="Generation"/>) so requests for other generations
|
||||||
|
/// don't collide.</summary>
|
||||||
|
void RegisterStrategies(IServiceCollection services);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Compat;
|
||||||
|
|
||||||
|
/// <summary>Extracts the client version from <c>X-DorkNet-Version</c>
|
||||||
|
/// on every request, validates it against this deployment's allow-list,
|
||||||
|
/// and either:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Stamps <see cref="HttpContextExtensions.ClientVersion"/> with
|
||||||
|
/// the resolved <see cref="ClientVersion"/> and forwards the request, OR</item>
|
||||||
|
/// <item>Returns <c>HTTP 426 Upgrade Required</c> with a JSON body
|
||||||
|
/// describing the mismatch when the client's version isn't in
|
||||||
|
/// <see cref="VersionRegistry.SupportedVersionKeys"/>.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>Headerless requests fall back to
|
||||||
|
/// <see cref="VersionRegistry.DefaultVersionKey"/> when set. That's
|
||||||
|
/// the transition pathway — once every shipped client mod stamps the
|
||||||
|
/// header, set <c>DorkNet:DefaultClientVersion</c> to empty and
|
||||||
|
/// headerless requests get rejected too.</para>
|
||||||
|
///
|
||||||
|
/// <para>Some routes intentionally don't carry a client identity
|
||||||
|
/// (the public-facing site at the apex, the admin SPA at
|
||||||
|
/// <c>admin.{apex}</c>, health checks). Those are excluded by host
|
||||||
|
/// + path prefix below — they always skip the version gate and
|
||||||
|
/// receive <see cref="ClientVersion.Unknown"/>. The apex is whatever
|
||||||
|
/// <see cref="DomainConfig.Apex"/> resolved to at startup, so forks
|
||||||
|
/// running on a different domain get the same exemption automatically.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VersionDetectionMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
VersionRegistry registry,
|
||||||
|
DomainConfig domain,
|
||||||
|
ILogger<VersionDetectionMiddleware> logger)
|
||||||
|
{
|
||||||
|
private const string HeaderName = "X-DorkNet-Version";
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext ctx)
|
||||||
|
{
|
||||||
|
if (IsExempt(ctx))
|
||||||
|
{
|
||||||
|
ctx.SetClientVersion(ClientVersion.Unknown);
|
||||||
|
await next(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var headerVal = ctx.Request.Headers[HeaderName].ToString();
|
||||||
|
var versionKey = string.IsNullOrWhiteSpace(headerVal)
|
||||||
|
? registry.DefaultVersionKey
|
||||||
|
: headerVal.Trim();
|
||||||
|
|
||||||
|
// No header AND no default configured — reject. Once every
|
||||||
|
// client mod sends the header this is the path that catches
|
||||||
|
// probes / misconfigured clients.
|
||||||
|
if (string.IsNullOrWhiteSpace(versionKey))
|
||||||
|
{
|
||||||
|
await WriteMismatch(ctx, "client_version_missing", got: null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolved = registry.Resolve(versionKey);
|
||||||
|
if (resolved == ClientVersion.Unknown)
|
||||||
|
{
|
||||||
|
// The client claims a version but no plugin knows it. That
|
||||||
|
// means either a typo in the header or a build of Rec Room
|
||||||
|
// we haven't onboarded yet. Either way, reject — server
|
||||||
|
// can't reliably serve a version it doesn't have a
|
||||||
|
// strategy bundle for.
|
||||||
|
await WriteMismatch(ctx, "client_version_unknown", got: versionKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!registry.IsSupported(versionKey))
|
||||||
|
{
|
||||||
|
// Known version, but this specific deployment is
|
||||||
|
// configured to accept a different set. e.g. a March
|
||||||
|
// client hitting a December-only server.
|
||||||
|
await WriteMismatch(ctx, "client_version_not_accepted", got: versionKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SetClientVersion(resolved);
|
||||||
|
await next(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsExempt(HttpContext ctx)
|
||||||
|
{
|
||||||
|
// Hosts that don't serve the watch — these are humans on a
|
||||||
|
// browser or admin tooling, not the game client.
|
||||||
|
var host = ctx.Request.Host.Host.ToLowerInvariant();
|
||||||
|
var apex = domain.Apex.ToLowerInvariant();
|
||||||
|
if (host == apex || host == "www." + apex) return true;
|
||||||
|
if (host.StartsWith("admin.")) return true;
|
||||||
|
if (host.StartsWith("site.")) return true;
|
||||||
|
if (host.StartsWith("feed.")) return true;
|
||||||
|
|
||||||
|
// Server health / readiness probes.
|
||||||
|
var path = ctx.Request.Path.Value;
|
||||||
|
if (path is null) return false;
|
||||||
|
if (path.StartsWith("/health", StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
if (path.StartsWith("/api/admin/", StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
|
||||||
|
// The boot-time version gate itself — the whole POINT of this
|
||||||
|
// endpoint is to receive a mismatched-version client and reply
|
||||||
|
// with the watch's native "Update Required" payload. Gating it
|
||||||
|
// here would 426 the request before VersionCheckController
|
||||||
|
// could respond.
|
||||||
|
if (path.StartsWith("/api/versioncheck/", StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteMismatch(HttpContext ctx, string error, string? got)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"[version-gate] rejecting request path={Path} host={Host} reason={Reason} got={Got} supported=[{Supported}]",
|
||||||
|
ctx.Request.Path, ctx.Request.Host.Host, error, got ?? "(none)",
|
||||||
|
string.Join(",", registry.SupportedVersionKeys));
|
||||||
|
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status426UpgradeRequired;
|
||||||
|
ctx.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
var body = new
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
got,
|
||||||
|
supported = registry.SupportedVersionKeys.ToArray(),
|
||||||
|
message = error switch
|
||||||
|
{
|
||||||
|
"client_version_missing" =>
|
||||||
|
"Client did not send an X-DorkNet-Version header. Update your DorkNet client mod.",
|
||||||
|
"client_version_unknown" =>
|
||||||
|
$"Client claims version '{got}', which no version plugin in this server recognises.",
|
||||||
|
"client_version_not_accepted" =>
|
||||||
|
$"Client version '{got}' is known but this deployment is configured to accept only: " +
|
||||||
|
string.Join(", ", registry.SupportedVersionKeys) + ".",
|
||||||
|
_ => error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await JsonSerializer.SerializeAsync(ctx.Response.Body, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
namespace DorkNet.Server.Compat;
|
||||||
|
|
||||||
|
/// <summary>Resolves a client-build key (e.g. <c>december_2020_12_18</c>)
|
||||||
|
/// to its <see cref="ClientVersion"/>. Built once at startup from all
|
||||||
|
/// registered <see cref="IVersionPlugin"/> instances.
|
||||||
|
///
|
||||||
|
/// <para>Singleton — the version-key → generation mapping is fixed at
|
||||||
|
/// boot time, and the registry is read on every request by the
|
||||||
|
/// version-detection middleware.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VersionRegistry
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, ClientVersion> _byKey;
|
||||||
|
|
||||||
|
/// <summary>The set of version keys this deployment is willing to
|
||||||
|
/// serve. Comes from <c>DorkNet:SupportedVersions</c> in config.
|
||||||
|
/// Anything else gets a 426 from the middleware.</summary>
|
||||||
|
public IReadOnlySet<string> SupportedVersionKeys { get; }
|
||||||
|
|
||||||
|
/// <summary>Falls back to this when a request arrives without an
|
||||||
|
/// <c>X-DorkNet-Version</c> header — set via
|
||||||
|
/// <c>DorkNet:DefaultClientVersion</c> in config. Empty/null means
|
||||||
|
/// "no fallback; reject headerless requests". Set during the
|
||||||
|
/// transition window where the old client mod doesn't yet stamp
|
||||||
|
/// the header.</summary>
|
||||||
|
public string? DefaultVersionKey { get; }
|
||||||
|
|
||||||
|
public VersionRegistry(
|
||||||
|
IEnumerable<IVersionPlugin> plugins,
|
||||||
|
IReadOnlySet<string> supportedVersionKeys,
|
||||||
|
string? defaultVersionKey)
|
||||||
|
{
|
||||||
|
_byKey = new Dictionary<string, ClientVersion>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var plugin in plugins)
|
||||||
|
{
|
||||||
|
foreach (var key in plugin.VersionKeys)
|
||||||
|
{
|
||||||
|
if (_byKey.ContainsKey(key))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"VersionPlugin collision: '{key}' is claimed by more than one IVersionPlugin. " +
|
||||||
|
$"Each version key maps to exactly one plugin.");
|
||||||
|
_byKey[key] = new ClientVersion(key, plugin.Generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SupportedVersionKeys = supportedVersionKeys;
|
||||||
|
DefaultVersionKey = defaultVersionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolve a version key (case-insensitive) to its
|
||||||
|
/// <see cref="ClientVersion"/>, or <see cref="ClientVersion.Unknown"/>
|
||||||
|
/// if no plugin claims it. The middleware decides whether unknown
|
||||||
|
/// → reject or → fall back to <see cref="DefaultVersionKey"/>.</summary>
|
||||||
|
public ClientVersion Resolve(string? key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key)) return ClientVersion.Unknown;
|
||||||
|
return _byKey.TryGetValue(key, out var v) ? v : ClientVersion.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the given version key is in
|
||||||
|
/// <see cref="SupportedVersionKeys"/>. Used by the middleware to
|
||||||
|
/// 426 cross-version connection attempts (e.g. a March client
|
||||||
|
/// hitting a server configured for December only).</summary>
|
||||||
|
public bool IsSupported(string key)
|
||||||
|
=> SupportedVersionKeys.Contains(key);
|
||||||
|
|
||||||
|
/// <summary>Every known plugin-registered version key, for
|
||||||
|
/// diagnostic responses and admin tooling.</summary>
|
||||||
|
public IEnumerable<ClientVersion> AllKnown => _byKey.Values;
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using DorkNet.Models.Auth;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Controllers.API.Account;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// api.rec.net/api/account/* — same endpoints as accounts.rec.net but routed
|
||||||
|
/// through the API service. The Accounts class in dump.cs doesn't expose its
|
||||||
|
/// base URL constant so the game may use either host; we cover both.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
public class AccountApiController(
|
||||||
|
PlayerService playerService,
|
||||||
|
AuthService authService,
|
||||||
|
DorkNetDbContext db,
|
||||||
|
NotificationService notifications,
|
||||||
|
ServerSettingsService settings,
|
||||||
|
SignupCodeService signupCodes,
|
||||||
|
ILogger<AccountApiController> logger) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("api/account/v1/create")]
|
||||||
|
[HttpPost("api/account/v1")]
|
||||||
|
[HttpPost("api/account/v2/create")]
|
||||||
|
[HttpPost("api/createaccount")]
|
||||||
|
public async Task<IActionResult> CreateAccount(
|
||||||
|
[FromForm] int platform,
|
||||||
|
[FromForm] string? platformId,
|
||||||
|
[FromForm] string? deviceId,
|
||||||
|
[FromForm] string? Username,
|
||||||
|
[FromForm] string? DisplayName)
|
||||||
|
{
|
||||||
|
if (await settings.AreSignupsDisabledAsync())
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"[api-account] api/account/v1/create refused — signups disabled (device={Device} platform={Platform})",
|
||||||
|
deviceId, platform);
|
||||||
|
await signupCodes.RecordPendingDeviceAsync(
|
||||||
|
deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext));
|
||||||
|
return Ok(new CreateAccountResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "Account creation is currently disabled by the server admin.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var player = await playerService.GetOrCreateByDeviceAsync(
|
||||||
|
deviceId: deviceId,
|
||||||
|
platform: platform,
|
||||||
|
platformId: platformId,
|
||||||
|
displayName: DisplayName ?? Username);
|
||||||
|
|
||||||
|
return Ok(new CreateAccountResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Error = string.Empty,
|
||||||
|
Account = new RecNetAccount
|
||||||
|
{
|
||||||
|
AccountId = (int)player.Id,
|
||||||
|
RawUsername = player.Username,
|
||||||
|
Username = player.Username,
|
||||||
|
DisplayName = player.DisplayName ?? player.Username,
|
||||||
|
ProfileImage = string.Empty,
|
||||||
|
TreatAsJunior = false,
|
||||||
|
HasBirthday = true,
|
||||||
|
Platforms = 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/account/v1/me")]
|
||||||
|
[HttpGet("api/account/v2/me")]
|
||||||
|
public async Task<IActionResult> GetMe()
|
||||||
|
{
|
||||||
|
var player = await GetCurrentPlayerAsync();
|
||||||
|
if (player is null) return Unauthorized();
|
||||||
|
return Ok(new RecNetSelfAccount
|
||||||
|
{
|
||||||
|
AccountId = (int)player.Id,
|
||||||
|
RawUsername = player.Username,
|
||||||
|
Username = player.Username,
|
||||||
|
DisplayName = player.DisplayName ?? player.Username,
|
||||||
|
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||||
|
TreatAsJunior = false,
|
||||||
|
HasBirthday = true,
|
||||||
|
Platforms = 1,
|
||||||
|
Email = player.Email ?? string.Empty,
|
||||||
|
Phone = string.Empty,
|
||||||
|
Birthday = new DateTime(2000, 1, 1),
|
||||||
|
JuniorState = 0,
|
||||||
|
ParentAccountId = null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the player tied to the bearer token on the request. Returns
|
||||||
|
/// null when no/invalid bearer is supplied — caller decides whether
|
||||||
|
/// that's a 401 or a softer fallback. Anonymous-account creation was
|
||||||
|
/// previously the fallback path; deleted in Phase 1c because every
|
||||||
|
/// caller has gone through `/account/create` (deviceId-bound) before
|
||||||
|
/// reaching us.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
|
||||||
|
{
|
||||||
|
var auth = Request.Headers.Authorization.ToString();
|
||||||
|
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? auth["Bearer ".Length..].Trim()
|
||||||
|
: Request.Cookies[AuthService.AccessCookieName];
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
return null;
|
||||||
|
var id = authService.ValidateToken(token);
|
||||||
|
if (id is not long playerId) return null;
|
||||||
|
return await playerService.GetByIdAsync(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modification endpoints — POSTs land on api.* host with form body
|
||||||
|
// carrying the new value. AuthAccountController on auth.* handles
|
||||||
|
// the same shape with real persistence; we mirror that here so
|
||||||
|
// either host works.
|
||||||
|
|
||||||
|
[HttpPost("api/account/v1/displayname")]
|
||||||
|
public async Task<IActionResult> SetDisplayName([FromForm(Name = "displayName")] string? displayName,
|
||||||
|
[FromForm(Name = "value")] string? value)
|
||||||
|
{
|
||||||
|
var p = await GetCurrentPlayerAsync();
|
||||||
|
if (p is null) return Unauthorized();
|
||||||
|
var name = (displayName ?? value ?? string.Empty).Trim();
|
||||||
|
if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
|
||||||
|
await playerService.UpdateDisplayNameAsync(p.Id, name);
|
||||||
|
var updated = await playerService.GetByIdAsync(p.Id);
|
||||||
|
if (updated is not null) await notifications.AccountChanged(updated);
|
||||||
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/account/v1/bio")]
|
||||||
|
public async Task<IActionResult> SetBio([FromForm(Name = "bio")] string? bio,
|
||||||
|
[FromForm(Name = "value")] string? value)
|
||||||
|
{
|
||||||
|
var p = await GetCurrentPlayerAsync();
|
||||||
|
if (p is null) return Unauthorized();
|
||||||
|
await playerService.UpdateBioAsync(p.Id, (bio ?? value ?? string.Empty));
|
||||||
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/account/v1/username")]
|
||||||
|
public async Task<IActionResult> SetUsername([FromForm(Name = "username")] string? username,
|
||||||
|
[FromForm(Name = "value")] string? value)
|
||||||
|
{
|
||||||
|
var p = await GetCurrentPlayerAsync();
|
||||||
|
if (p is null) return Unauthorized();
|
||||||
|
var newName = (username ?? value ?? string.Empty).Trim();
|
||||||
|
if (newName.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
|
||||||
|
var taken = await db.Players.AnyAsync(x => x.Username == newName && x.Id != p.Id);
|
||||||
|
if (taken) return Ok(new RecNetResult { Success = false, Error = "username_taken" });
|
||||||
|
p.Username = newName;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await notifications.AccountChanged(p);
|
||||||
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/account/v1/birthday")]
|
||||||
|
public async Task<IActionResult> SetBirthday([FromForm(Name = "birthday")] string? birthday,
|
||||||
|
[FromForm(Name = "value")] string? value)
|
||||||
|
{
|
||||||
|
var p = await GetCurrentPlayerAsync();
|
||||||
|
if (p is null) return Unauthorized();
|
||||||
|
var raw = birthday ?? value ?? string.Empty;
|
||||||
|
if (DateTime.TryParse(raw, out var parsed))
|
||||||
|
{
|
||||||
|
p.Birthday = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/account/v1/email")]
|
||||||
|
public async Task<IActionResult> SetEmail([FromForm(Name = "email")] string? email,
|
||||||
|
[FromForm(Name = "value")] string? value)
|
||||||
|
{
|
||||||
|
var p = await GetCurrentPlayerAsync();
|
||||||
|
if (p is null) return Unauthorized();
|
||||||
|
var addr = (email ?? value ?? string.Empty).Trim();
|
||||||
|
if (addr.Length > 0) p.Email = addr;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/account/v1/phone")]
|
||||||
|
[HttpPost("api/account/v1/confirmphone")]
|
||||||
|
public async Task<IActionResult> SetPhone([FromForm(Name = "phone")] string? phone,
|
||||||
|
[FromForm(Name = "value")] string? value)
|
||||||
|
{
|
||||||
|
var p = await GetCurrentPlayerAsync();
|
||||||
|
if (p is null) return Unauthorized();
|
||||||
|
var num = (phone ?? value ?? string.Empty).Trim();
|
||||||
|
if (num.Length > 0) p.Phone = num;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/account/v1/profileimage")]
|
||||||
|
[HttpPost("api/account/me/profileimage")]
|
||||||
|
[HttpPost("account/me/profileimage", Order = 10)]
|
||||||
|
[HttpPost("profileimage", Order = 10)]
|
||||||
|
[HttpPut("api/account/v1/profileimage")]
|
||||||
|
[HttpPut("api/account/me/profileimage")]
|
||||||
|
[HttpPut("account/me/profileimage", Order = 10)]
|
||||||
|
[HttpPut("profileimage", Order = 10)]
|
||||||
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
|
public async Task<IActionResult> SetProfileImage(
|
||||||
|
[FromForm(Name = "imageName")] string? imageName,
|
||||||
|
[FromForm(Name = "fileName")] string? fileName,
|
||||||
|
[FromForm(Name = "image")] string? imageAlt)
|
||||||
|
{
|
||||||
|
var player = await GetCurrentPlayerAsync();
|
||||||
|
if (player is null) return Unauthorized();
|
||||||
|
|
||||||
|
var name = imageName ?? fileName ?? imageAlt
|
||||||
|
?? Request.Query["imageName"].ToString()
|
||||||
|
?? Request.Query["fileName"].ToString();
|
||||||
|
player.ProfileImageName = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation(
|
||||||
|
"[api-account] profile image set: player={PlayerId} -> {Name}",
|
||||||
|
player.Id,
|
||||||
|
player.ProfileImageName ?? "<cleared>");
|
||||||
|
await notifications.AccountChanged(player);
|
||||||
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/account/v1/namegen")]
|
||||||
|
[HttpGet("api/account/v1/namegeneration")]
|
||||||
|
public IActionResult NameGen() => Ok(new
|
||||||
|
{
|
||||||
|
Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" },
|
||||||
|
Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Controllers.API.Activities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// api.{rec.net,localhost}/api/activities/* — per-activity content
|
||||||
|
/// (charades words, party-game prompts, etc.). The 2020 watch
|
||||||
|
/// fetches these as a static list at room-join time; we ship an
|
||||||
|
/// empty list so the activity's bundled defaults kick in.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
public class ActivitiesController : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/activities/charades/v1/words")]
|
||||||
|
public IActionResult CharadesWords() => Ok(Array.Empty<object>());
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using DorkNet.Server.Auth;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Controllers.API.AgeVerification;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/ageverification")]
|
||||||
|
[Authorize]
|
||||||
|
public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("generateCode")]
|
||||||
|
[HttpGet("generateCode")]
|
||||||
|
public async Task<IActionResult> GenerateCode()
|
||||||
|
{
|
||||||
|
var pid = this.RequireCurrentPlayerId();
|
||||||
|
var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString();
|
||||||
|
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||||
|
{
|
||||||
|
PlayerId = pid,
|
||||||
|
Key = $"ageverification:{DateTime.UtcNow:yyyyMMddHHmmss}",
|
||||||
|
Value = code,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Ok(new { Code = code, VerificationCode = code, ExpiresAt = DateTime.UtcNow.AddMinutes(15) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Controllers.API.Announcements;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// api.{rec.net,localhost}/api/announcement/v{1,2}/get — fetches the
|
||||||
|
/// current server announcement (shown on the watch's home tab). The
|
||||||
|
/// payload is sourced from <see cref="CommunityBoardService"/> so the
|
||||||
|
/// admin SPA's "edit announcement" form is the single place to
|
||||||
|
/// update what players see.
|
||||||
|
///
|
||||||
|
/// Wire shape per <c>RecNet/AnnouncementDTO.cs</c> — AnnouncementId,
|
||||||
|
/// AnnouncementType, LinkType, Platform are all required via
|
||||||
|
/// <c>Util.GetKey</c> (throws on miss); Title/Body/ImageName/LinkName/
|
||||||
|
/// LinkUri/CreatedAt are GetKeyOrDefault.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
public class AnnouncementsController(CommunityBoardService communityBoard) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/announcement/v1/get")]
|
||||||
|
[HttpGet("api/announcement/v2/get")]
|
||||||
|
public async Task<IActionResult> Current()
|
||||||
|
{
|
||||||
|
var board = await communityBoard.GetAsync();
|
||||||
|
var ann = board.CurrentAnnouncement;
|
||||||
|
if (ann is null) return Ok(Array.Empty<object>());
|
||||||
|
return Ok(new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
AnnouncementId = 1L,
|
||||||
|
AnnouncementType = 0,
|
||||||
|
Title = "Server Announcement",
|
||||||
|
Body = ann.Message ?? string.Empty,
|
||||||
|
ImageName = "",
|
||||||
|
LinkType = 0,
|
||||||
|
LinkName = "",
|
||||||
|
LinkUri = ann.MoreInfoUrl ?? string.Empty,
|
||||||
|
Platform = 0,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using DorkNet.Server.Auth;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Controllers.API.AppIntegrity;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
public class AppIntegrityController(DorkNetDbContext db) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/AppIntegrity/v1/iosproducts")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> IosProducts()
|
||||||
|
{
|
||||||
|
var rows = await db.StoreItems
|
||||||
|
.Where(i => i.IsActive)
|
||||||
|
.OrderBy(i => i.Id)
|
||||||
|
.Take(100)
|
||||||
|
.Select(i => new
|
||||||
|
{
|
||||||
|
ProductId = i.Slug,
|
||||||
|
i.Price,
|
||||||
|
i.CurrencyType,
|
||||||
|
Name = i.DisplayName,
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
return Ok(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/AppIntegrity/v1/iospaymentqueuefailed")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> IosPaymentQueueFailed([FromForm] string? reason)
|
||||||
|
{
|
||||||
|
db.BugReports.Add(new BugReportEntity
|
||||||
|
{
|
||||||
|
ReporterPlayerId = this.RequireCurrentPlayerId(),
|
||||||
|
Title = "iOS payment queue failed",
|
||||||
|
Body = reason ?? Request.Query["reason"].FirstOrDefault() ?? string.Empty,
|
||||||
|
Category = "appintegrity",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Ok(new { Success = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using DorkNet.Server.Auth;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Controllers.API.Apple;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
public class AppleMusicPromotionController(DorkNetDbContext db) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/apple/musicpromotion/active")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public IActionResult Active()
|
||||||
|
{
|
||||||
|
var start = new DateTime(DateTime.UtcNow.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var end = start.AddYears(1);
|
||||||
|
return Ok(new { Active = true, StartAt = start, EndAt = end });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/apple/musicpromotion/code")]
|
||||||
|
[HttpGet("api/apple/musicpromotion/code")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> Code()
|
||||||
|
{
|
||||||
|
var me = this.RequireCurrentPlayerId();
|
||||||
|
var row = await db.PlayerSettings
|
||||||
|
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "applemusic:promotion_code");
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
row = new PlayerSettingEntity
|
||||||
|
{
|
||||||
|
PlayerId = me,
|
||||||
|
Key = "applemusic:promotion_code",
|
||||||
|
Value = $"RR-{Guid.NewGuid():N}"[..16],
|
||||||
|
};
|
||||||
|
db.PlayerSettings.Add(row);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { Code = row.Value, Redeemed = false });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Controllers.API.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Photon custom-auth webhook (api.rec.net).
|
||||||
|
///
|
||||||
|
/// Configure in Photon dashboard:
|
||||||
|
/// Authentication → Custom → URL = https://api.rec.net/api/auth/v1/photon
|
||||||
|
/// Query params sent by client: token=<our_jwt>
|
||||||
|
///
|
||||||
|
/// Photon ResultCode: 1 = success, 2 = fail, 3 = param error
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
public class PhotonAuthController(AuthService authService, PlayerService playerService) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/auth/v1/photon")]
|
||||||
|
[HttpPost("api/auth/v1/photon")]
|
||||||
|
public async Task<IActionResult> Authenticate([FromQuery] string? token, [FromQuery] string? Token)
|
||||||
|
{
|
||||||
|
var jwt = token ?? Token;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(jwt))
|
||||||
|
return Ok(new { ResultCode = 3, Message = "Missing token parameter" });
|
||||||
|
|
||||||
|
var playerId = authService.ValidateToken(jwt);
|
||||||
|
if (playerId is null)
|
||||||
|
return Ok(new { ResultCode = 2, Message = "Invalid or expired token" });
|
||||||
|
|
||||||
|
var player = await playerService.GetByIdAsync(playerId.Value);
|
||||||
|
if (player is null)
|
||||||
|
return Ok(new { ResultCode = 2, Message = "Player not found" });
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
ResultCode = 1,
|
||||||
|
Message = "ok",
|
||||||
|
UserId = player.Id.ToString(),
|
||||||
|
Nickname = player.DisplayName ?? player.Username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user