Compare commits
26 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df86553b77 | ||
|
|
d809bbc25a | ||
|
|
81189576c0 | ||
|
|
4ec504ac6c | ||
|
|
eabb989ff7 | ||
|
|
e3a9fa0b99 | ||
|
|
babb887d4b | ||
|
|
7173d7e88d | ||
|
|
32c5e438ed | ||
|
|
1763b39eca | ||
|
|
d7c9cbcb70 | ||
|
|
7a48eb9f35 | ||
|
|
36b5169e27 | ||
|
|
cceeac7c4f | ||
|
|
7b949a312b | ||
|
|
bfb504db7d | ||
|
|
269fbcee32 | ||
|
|
f6e0ae87fd | ||
|
|
d1a3e3d255 | ||
|
|
ceefe4dc37 | ||
|
|
15db3f9adb | ||
|
|
27667a79a5 | ||
|
|
671e74ba59 | ||
|
|
12e71d3172 | ||
|
|
b6c6196c3e | ||
|
|
ebe14fcd1f |
36 changed files with 4202 additions and 17 deletions
126
.forgejo/workflows/release.yml
Normal file
126
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
name: release
|
||||||
|
|
||||||
|
# Builds a Linux release of the game and attaches it to a Forgejo/Gitea
|
||||||
|
# Release, so players can download a ready-to-run build and only need to
|
||||||
|
# bring their own Skate 3 ISO (the built-in installer extracts it).
|
||||||
|
#
|
||||||
|
# Runs on the self-hosted runner (see tools/ci/setup_runner.sh and
|
||||||
|
# docs/ci-releases.md). The runner must have the extracted game dump at
|
||||||
|
# /opt/skate3-game (default.xex + data/webkit/EAWebkit.xex, and optionally
|
||||||
|
# the TU_12K2276_* title-update package). The game files are used only for
|
||||||
|
# codegen; they are NOT bundled into the release artifact.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-linux:
|
||||||
|
# Match this label to your self-hosted runner's label (Docker runners
|
||||||
|
# commonly use ubuntu-latest / ubuntu-22.04 / ubuntu-24.04).
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
env:
|
||||||
|
GAME_ROOT: /opt/skate3-game
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://code.forgejo.org/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Submodules are initialized manually so we can self-heal the
|
||||||
|
# broken upstream pins (see tools/ci/init-submodules.sh).
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
- name: Init submodules
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SUBMODULE_KEY: ${{ secrets.SUBMODULE_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# SSH key for the self-hosted rexglue fork submodule
|
||||||
|
# (git@git.mithraic.cloud:ad3laid3/rexglue-skate3.git).
|
||||||
|
if [ -n "${SUBMODULE_KEY:-}" ]; then
|
||||||
|
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||||
|
printf '%s\n' "$SUBMODULE_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan git.mithraic.cloud >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
git submodule update --init --jobs 4
|
||||||
|
bash tools/ci/init-submodules.sh
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Docker runner containers usually run as root (no sudo).
|
||||||
|
if command -v sudo >/dev/null 2>&1 && [ "$(id -u)" != 0 ]; then
|
||||||
|
APT="sudo apt-get"
|
||||||
|
else
|
||||||
|
APT="apt-get"
|
||||||
|
fi
|
||||||
|
$APT update
|
||||||
|
$APT install -y --no-install-recommends \
|
||||||
|
git cmake ninja-build pkg-config p7zip-full zip \
|
||||||
|
clang-20 lld-20 \
|
||||||
|
libgtk-3-dev libx11-xcb-dev \
|
||||||
|
libvulkan-dev vulkan-tools mesa-vulkan-drivers \
|
||||||
|
libasound2-dev libpulse-dev libpipewire-0.3-dev libudev-dev
|
||||||
|
|
||||||
|
- name: Configure
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TU=""
|
||||||
|
if compgen -G "$GAME_ROOT"/TU_12K2276_* >/dev/null; then
|
||||||
|
TU="-DSKATE3_TITLE_UPDATE_PACKAGE=$(compgen -G "$GAME_ROOT"/TU_12K2276_* | head -1)"
|
||||||
|
fi
|
||||||
|
echo "TU_ARGS=$TU" >> "$GITHUB_ENV"
|
||||||
|
cmake --preset linux-relwithdebinfo -DSKATE3_GAME_DATA_ROOT="$GAME_ROOT" $TU
|
||||||
|
|
||||||
|
- name: Generate recompiled code
|
||||||
|
shell: bash
|
||||||
|
run: cmake --build --preset linux-relwithdebinfo --target generate-all --parallel
|
||||||
|
|
||||||
|
- name: Reconfigure (generated sources)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TU="${TU_ARGS:-}"
|
||||||
|
cmake --preset linux-relwithdebinfo -DSKATE3_GAME_DATA_ROOT="$GAME_ROOT" $TU
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
shell: bash
|
||||||
|
run: cmake --build --preset linux-relwithdebinfo --parallel
|
||||||
|
|
||||||
|
- name: Package release zip
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
OUT="out/build/linux-relwithdebinfo"
|
||||||
|
VER="${GITHUB_REF_NAME#v}"
|
||||||
|
[ -n "$VER" ] || VER="dev"
|
||||||
|
NAME="Skate3Recomp-Linux-$VER"
|
||||||
|
STAGE="$NAME"
|
||||||
|
rm -rf "$STAGE"; mkdir -p "$STAGE"
|
||||||
|
cp "$OUT/skate3" "$STAGE/"
|
||||||
|
cp "$OUT/librexruntime.so" "$STAGE/" 2>/dev/null || true
|
||||||
|
cp "$OUT/ghost_relay" "$STAGE/" 2>/dev/null || true
|
||||||
|
# Title-update patches staged next to the binary when the build
|
||||||
|
# used the TU package (the CMake stage target places them here).
|
||||||
|
if [ -f "$OUT/game/default.xexp" ]; then
|
||||||
|
mkdir -p "$STAGE/game/data/webkit"
|
||||||
|
cp "$OUT/game/default.xexp" "$STAGE/game/"
|
||||||
|
cp "$OUT/game/data/webkit/EAWebkit.xexp" "$STAGE/game/data/webkit/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
cp README.md "$STAGE/" 2>/dev/null || true
|
||||||
|
zip -r "$NAME.zip" "$STAGE"
|
||||||
|
echo "ARTIFACT=$NAME.zip" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Publish release
|
||||||
|
uses: https://code.forgejo.org/forgejo/release-action@v2
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
files: ${{ env.ARTIFACT }}
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
title: "Skate 3 Recomp ${{ github.ref_name }}"
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -3,7 +3,16 @@
|
||||||
/build/
|
/build/
|
||||||
/generated/
|
/generated/
|
||||||
/generated.backup-*/
|
/generated.backup-*/
|
||||||
/tools/
|
/tools/*
|
||||||
|
# ...except the ghost multiplayer relay server + release/CI tooling (first-party).
|
||||||
|
!/tools/ghost_relay_main.cpp
|
||||||
|
!/tools/ghost_relay.service
|
||||||
|
!/tools/ghost_relay_deploy.sh
|
||||||
|
!/tools/ci/
|
||||||
|
!/tools/release/
|
||||||
|
# Local release zips/stages are build products.
|
||||||
|
Skate3Recomp-*.zip
|
||||||
|
/Skate3Recomp-*/
|
||||||
|
|
||||||
# Local game dump and retail media. Users must provide their own copy.
|
# Local game dump and retail media. Users must provide their own copy.
|
||||||
/game/
|
/game/
|
||||||
|
|
|
||||||
2
.gitmodules
vendored
2
.gitmodules
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
[submodule "third_party/rexglue-sdk"]
|
[submodule "third_party/rexglue-sdk"]
|
||||||
path = third_party/rexglue-sdk
|
path = third_party/rexglue-sdk
|
||||||
url = https://github.com/mchughalex/rexglue-skate3.git
|
url = git@git.mithraic.cloud:ad3laid3/rexglue-skate3.git
|
||||||
branch = skate3-sdk-clean
|
branch = skate3-sdk-clean
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ if(SKATE3_STATIC_MSVC_RUNTIME)
|
||||||
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
project(skate3 VERSION 1.0.1 LANGUAGES CXX)
|
project(skate3 VERSION 2.0.0 LANGUAGES CXX)
|
||||||
|
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
enable_language(RC)
|
enable_language(RC)
|
||||||
|
|
@ -30,6 +30,15 @@ skate3_resolve_version(SKATE3_FULL_VERSION
|
||||||
SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR})
|
SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
string(REGEX REPLACE "-.*$" "" SKATE3_NUMERIC_VERSION "${SKATE3_FULL_VERSION}")
|
string(REGEX REPLACE "-.*$" "" SKATE3_NUMERIC_VERSION "${SKATE3_FULL_VERSION}")
|
||||||
|
|
||||||
|
# Four-part comma-separated form for Windows VERSIONINFO resources.
|
||||||
|
string(REPLACE "." ";" SKATE3_RC_VERSION_PARTS "${SKATE3_NUMERIC_VERSION}")
|
||||||
|
list(LENGTH SKATE3_RC_VERSION_PARTS SKATE3_RC_VERSION_PART_COUNT)
|
||||||
|
while(SKATE3_RC_VERSION_PART_COUNT LESS 4)
|
||||||
|
list(APPEND SKATE3_RC_VERSION_PARTS 0)
|
||||||
|
list(LENGTH SKATE3_RC_VERSION_PARTS SKATE3_RC_VERSION_PART_COUNT)
|
||||||
|
endwhile()
|
||||||
|
list(JOIN SKATE3_RC_VERSION_PARTS "," SKATE3_RC_VERSION)
|
||||||
|
|
||||||
set(SKATE3_TARGET_PROCESSOR "${CMAKE_SYSTEM_PROCESSOR}")
|
set(SKATE3_TARGET_PROCESSOR "${CMAKE_SYSTEM_PROCESSOR}")
|
||||||
if(APPLE AND CMAKE_OSX_ARCHITECTURES)
|
if(APPLE AND CMAKE_OSX_ARCHITECTURES)
|
||||||
list(LENGTH CMAKE_OSX_ARCHITECTURES SKATE3_OSX_ARCH_COUNT)
|
list(LENGTH CMAKE_OSX_ARCHITECTURES SKATE3_OSX_ARCH_COUNT)
|
||||||
|
|
@ -218,6 +227,15 @@ configure_file(
|
||||||
set(REXGLUE_ENABLE_TRACY OFF CACHE BOOL "Disable Tracy instrumentation for normal Skate 3 builds" FORCE)
|
set(REXGLUE_ENABLE_TRACY OFF CACHE BOOL "Disable Tracy instrumentation for normal Skate 3 builds" FORCE)
|
||||||
set(REXGLUE_ENABLE_PERF_COUNTERS OFF CACHE BOOL "Enable lightweight rexglue perf counter instrumentation")
|
set(REXGLUE_ENABLE_PERF_COUNTERS OFF CACHE BOOL "Enable lightweight rexglue perf counter instrumentation")
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
set(REXGLUE_RUNTIME_RC_VERSION "${SKATE3_RC_VERSION}")
|
||||||
|
set(REXGLUE_RUNTIME_RC_VERSION_STRING "${SKATE3_FULL_VERSION}")
|
||||||
|
set(REXGLUE_RUNTIME_RC_PRODUCT_NAME "Skate3Recomp")
|
||||||
|
set(REXGLUE_RUNTIME_RC_COMPANY "Skate3Recomp project")
|
||||||
|
set(REXGLUE_RUNTIME_RC_DESCRIPTION "Skate3Recomp guest runtime")
|
||||||
|
set(REXGLUE_RUNTIME_RC_COPYRIGHT "(c) Skate3Recomp contributors")
|
||||||
|
endif()
|
||||||
|
|
||||||
include(cmake/RexGlue.cmake)
|
include(cmake/RexGlue.cmake)
|
||||||
include(cmake/CodegenTargets.cmake)
|
include(cmake/CodegenTargets.cmake)
|
||||||
|
|
||||||
|
|
@ -230,6 +248,9 @@ set(SKATE3_COMMON_SOURCES
|
||||||
src/skate3_iso_installer.cpp
|
src/skate3_iso_installer.cpp
|
||||||
src/native/skate3_native_diagnostics.cpp
|
src/native/skate3_native_diagnostics.cpp
|
||||||
src/native/skate3_native_entity.cpp
|
src/native/skate3_native_entity.cpp
|
||||||
|
src/native/skate3_native_ghost.cpp
|
||||||
|
src/skate3_native_ghost_dialog.cpp
|
||||||
|
src/native/skate3_native_ghost_relay.cpp
|
||||||
src/native/skate3_native_guest_read.cpp
|
src/native/skate3_native_guest_read.cpp
|
||||||
src/native/skate3_native_lw.cpp
|
src/native/skate3_native_lw.cpp
|
||||||
src/native/skate3_native_palette.cpp
|
src/native/skate3_native_palette.cpp
|
||||||
|
|
@ -262,6 +283,12 @@ if(WIN32)
|
||||||
COPYONLY
|
COPYONLY
|
||||||
)
|
)
|
||||||
target_link_options(skate3 PRIVATE LINKER:/MANIFESTINPUT:skate3_app.manifest)
|
target_link_options(skate3 PRIVATE LINKER:/MANIFESTINPUT:skate3_app.manifest)
|
||||||
|
configure_file(
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/skate3.rc.in
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}/skate3.rc
|
||||||
|
@ONLY
|
||||||
|
)
|
||||||
|
target_sources(skate3 PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/skate3.rc)
|
||||||
else()
|
else()
|
||||||
add_executable(skate3 ${SKATE3_SOURCES})
|
add_executable(skate3 ${SKATE3_SOURCES})
|
||||||
endif()
|
endif()
|
||||||
|
|
@ -368,6 +395,20 @@ if(NOT SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE STREQUAL "")
|
||||||
add_dependencies(skate3 skate3_stage_title_update_patches)
|
add_dependencies(skate3 skate3_stage_title_update_patches)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# Ghost multiplayer relay server (standalone): run on a public VPS (or
|
||||||
|
# behind a free tunnel) so players connect OUTBOUND and need no port
|
||||||
|
# forwarding. Pure sockets, no rexglue dependency. See tools/ghost_relay_main.cpp.
|
||||||
|
add_executable(ghost_relay
|
||||||
|
tools/ghost_relay_main.cpp
|
||||||
|
src/native/skate3_native_ghost_relay.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(ghost_relay PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/native)
|
||||||
|
target_compile_features(ghost_relay PRIVATE cxx_std_23)
|
||||||
|
target_compile_definitions(ghost_relay PRIVATE SKATE3_RELAY_STANDALONE)
|
||||||
|
if(WIN32)
|
||||||
|
target_link_libraries(ghost_relay PRIVATE ws2_32)
|
||||||
|
endif()
|
||||||
|
|
||||||
if(MSVC)
|
if(MSVC)
|
||||||
target_compile_options(skate3 PRIVATE /utf-8)
|
target_compile_options(skate3 PRIVATE /utf-8)
|
||||||
else()
|
else()
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ Native Rendering Showcase (click to go to YouTube):
|
||||||
|
|
||||||
## How Do I Play?
|
## How Do I Play?
|
||||||
|
|
||||||
|
> **Ghost multiplayer (beta):** this fork adds a from-scratch ghost-player
|
||||||
|
> layer — relay your skater to other players via a small UDP server (no port
|
||||||
|
> forwarding on clients), with full-character ghosts, nameplates and a lobby
|
||||||
|
> browser. Press **M** in-game. See [docs/ghost-multiplayer.md](docs/ghost-multiplayer.md)
|
||||||
|
> for player setup, server deployment (VPS or free tunnel) and troubleshooting.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- The Windows version is the most tested, followed by Linux, and then macOS.
|
- The Windows version is the most tested, followed by Linux, and then macOS.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
add_custom_target(generate-skate3
|
add_custom_target(generate-skate3
|
||||||
COMMAND $<TARGET_FILE:rex::rexglue> codegen
|
# The rexglue codegen tool writes its output and THEN crashes during
|
||||||
${SKATE3_CODEGEN_ARGS}
|
# static teardown (SIOF / heap-corruption at exit - the cvar registry and
|
||||||
"${CMAKE_CURRENT_BINARY_DIR}/manifests/skate3.toml"
|
# friends are freed out of order). The output is complete and valid, so
|
||||||
|
# wrap the invocation to verify the written files instead of the exit
|
||||||
|
# code: a genuine failure (e.g. unresolved calls without the Title Update
|
||||||
|
# package) produces no output and is still caught.
|
||||||
|
COMMAND bash -c "$<TARGET_FILE:rex::rexglue> codegen ${SKATE3_CODEGEN_ARGS} \"${CMAKE_CURRENT_BINARY_DIR}/manifests/skate3.toml\"; rc=\$?; [ -f \"${CMAKE_CURRENT_SOURCE_DIR}/generated/sources.cmake\" ] || exit \$rc; exit 0"
|
||||||
COMMAND "${CMAKE_COMMAND}"
|
COMMAND "${CMAKE_COMMAND}"
|
||||||
"-DSKATE3_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}"
|
"-DSKATE3_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}"
|
||||||
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/ApplySkate3CodegenPatches.cmake"
|
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/ApplySkate3CodegenPatches.cmake"
|
||||||
|
|
@ -11,9 +15,7 @@ add_custom_target(generate-skate3
|
||||||
)
|
)
|
||||||
|
|
||||||
add_custom_target(generate-eawebkit
|
add_custom_target(generate-eawebkit
|
||||||
COMMAND $<TARGET_FILE:rex::rexglue> codegen
|
COMMAND bash -c "$<TARGET_FILE:rex::rexglue> codegen ${SKATE3_CODEGEN_ARGS} \"${CMAKE_CURRENT_BINARY_DIR}/manifests/eawebkit.toml\"; rc=\$?; [ -f \"${CMAKE_CURRENT_SOURCE_DIR}/generated/eawebkit/sources.cmake\" ] || exit \$rc; exit 0"
|
||||||
${SKATE3_CODEGEN_ARGS}
|
|
||||||
"${CMAKE_CURRENT_BINARY_DIR}/manifests/eawebkit.toml"
|
|
||||||
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
|
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
|
||||||
COMMENT "Generating recompiled code for EAWebkit.xex"
|
COMMENT "Generating recompiled code for EAWebkit.xex"
|
||||||
VERBATIM
|
VERBATIM
|
||||||
|
|
|
||||||
178
docs/ghost-multiplayer.md
Normal file
178
docs/ghost-multiplayer.md
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
# Ghost multiplayer (beta)
|
||||||
|
|
||||||
|
A from-scratch "ghost player" layer for the Skate 3 recomp native renderer:
|
||||||
|
every player's skater skeleton is relayed through a small UDP server so other
|
||||||
|
players see a real character model (driven by the remote skeleton) plus a
|
||||||
|
nameplate. Nothing touches the game's own netcode, animation or physics.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- **Topology:** every client connects **outbound** (UDP) to a relay. No port
|
||||||
|
forwarding on any client. The relay forwards each player's pose stream to
|
||||||
|
the other members of the same **room** (a 4-8 character code).
|
||||||
|
- **Relay:** either the public `ghost_relay` server (below) or a player's
|
||||||
|
game in LAN host mode. When the configured relay is unreachable, the game
|
||||||
|
automatically starts an in-process relay on the LAN (UDP broadcast
|
||||||
|
discovery) so same-network play still works.
|
||||||
|
- **Protocol:** versioned (`kGhostVersion`). A mismatched client/server is
|
||||||
|
refused with a clear message rather than silently misbehaving.
|
||||||
|
- **Rendering:** ghosts are drawn with **this player's own character model**
|
||||||
|
driven by the remote skeleton (bone palettes, no mesh transfer). Cloth
|
||||||
|
garments drape in the remote root frame; nameplates label each ghost with
|
||||||
|
the owner's display name.
|
||||||
|
- **Logs:** everything important is logged to the game's log file (next to
|
||||||
|
the executable in `logs/`). The relay logs refusals/caps to its own output.
|
||||||
|
|
||||||
|
## Player usage
|
||||||
|
|
||||||
|
1. Launch the game, press **M** to open the multiplayer menu.
|
||||||
|
2. Tick **Enable ghost multiplayer**.
|
||||||
|
3. Enter a **room code** (or leave empty to get an auto-assigned one, shown
|
||||||
|
in the menu — share it with friends). Players sharing a code see each
|
||||||
|
other.
|
||||||
|
4. Use **Browse rooms (lobby)** to see who is on the configured relay and
|
||||||
|
click a room to join it.
|
||||||
|
5. Set a **display name** for your nameplate (default: your OS user name).
|
||||||
|
|
||||||
|
Settings (server, room, name, port, rate) persist across restarts.
|
||||||
|
|
||||||
|
### Configuration cvars
|
||||||
|
|
||||||
|
| cvar | default | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `skate3_ghost_enable` | false | master switch (also the menu toggle) |
|
||||||
|
| `skate3_ghost_server` | `""` | relay `host[:port]`; empty = the compiled-in default |
|
||||||
|
| `skate3_ghost_port` | `40001` | UDP port (outbound + LAN discovery/host) |
|
||||||
|
| `skate3_ghost_room` | `""` | room code; empty = server assigns one |
|
||||||
|
| `skate3_ghost_name` | `""` | nameplate name; empty = OS user name |
|
||||||
|
| `skate3_ghost_rate` | `15` | pose broadcast rate (Hz) |
|
||||||
|
| `skate3_ghost_lan` | `true` | LAN auto-discovery + auto-host fallback |
|
||||||
|
| `skate3_ghost_draw_local` | `false` | debug: show your own capture cube |
|
||||||
|
| `skate3_ghost_debug` | `false` | verbose ghost logging |
|
||||||
|
|
||||||
|
The compiled-in default relay address is `kGhostDefaultServer` in
|
||||||
|
`src/native/skate3_native_ghost_proto.h` — set it to your relay before
|
||||||
|
shipping a build.
|
||||||
|
|
||||||
|
## Deploying the relay (public server)
|
||||||
|
|
||||||
|
The relay is a single dependency-free binary (plain sockets, no rexglue).
|
||||||
|
Run it on a public VPS (any size; the workload is a few UDP forwards, a
|
||||||
|
small VPS is plenty) or on your own machine behind a free tunnel
|
||||||
|
(playit.gg / ngrok / Tailscale Funnel).
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# from the repo root
|
||||||
|
cmake --preset linux-relwithdebinfo -DSKATE3_GAME_DATA_ROOT="$PWD/game"
|
||||||
|
cmake --build --preset linux-relwithdebinfo --target ghost_relay --parallel
|
||||||
|
# -> out/build/linux-relwithdebinfo/ghost_relay
|
||||||
|
```
|
||||||
|
|
||||||
|
Or compile in place (no CMake/rexglue needed, just a C++23 compiler):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
g++ -std=c++23 -O2 -DSKATE3_RELAY_STANDALONE \
|
||||||
|
-Isrc/native \
|
||||||
|
tools/ghost_relay_main.cpp src/native/skate3_native_ghost_relay.cpp \
|
||||||
|
-o ghost_relay
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install on the VPS
|
||||||
|
|
||||||
|
`tools/ghost_relay_deploy.sh` builds, installs and registers the service:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# copy the repo (or just tools/ + the relay sources) to the VPS, then:
|
||||||
|
./tools/ghost_relay_deploy.sh # uses UDP 40001 by default
|
||||||
|
GHOST_RELAY_PORT=40002 ./tools/ghost_relay_deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
install -m 0755 ghost_relay /usr/local/bin/ghost_relay
|
||||||
|
install -m 0644 tools/ghost_relay.service /etc/systemd/system/ghost-relay.service
|
||||||
|
systemctl daemon-reload && systemctl enable --now ghost-relay
|
||||||
|
ufw allow 40001/udp # open the port; nothing else is needed
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ghost_relay.service` unit is:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Skate 3 recomp ghost multiplayer relay
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/ghost_relay --port 40001 --max-clients 64
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### Free-tunnel option (no VPS)
|
||||||
|
|
||||||
|
The relay only needs a public UDP endpoint. Run it on any machine and expose
|
||||||
|
it with a free tunnel that supports UDP (playit.gg is the most
|
||||||
|
straightforward for games): it gives you a public `host:port` that forwards
|
||||||
|
to your local relay. Point clients at it with `skate3_ghost_server` (or set
|
||||||
|
it as the compiled-in default). No client-side port forwarding either way.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl status ghost-relay
|
||||||
|
journalctl -u ghost-relay -f # join/leave lines (--verbose)
|
||||||
|
ss -ulnp | grep 40001 # listening on UDP
|
||||||
|
```
|
||||||
|
|
||||||
|
From a second machine:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
printf '\x53\x47\x48\x54\x08\x00\x00' | nc -u <relay-ip> 40001 # LIST probe
|
||||||
|
# expect a kTypeRooms reply (starts 'S G H T' + type 9 + payload)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging & troubleshooting
|
||||||
|
|
||||||
|
- **Game logs:** `<exe dir>/logs/` (SDK file logging; also where crash
|
||||||
|
dumps land). Set `skate3_ghost_debug 1` (F12 menu) for verbose ghost
|
||||||
|
lines.
|
||||||
|
- **Relay logs:** `journalctl -u ghost-relay` (add `--verbose` to
|
||||||
|
`ExecStart` for per-join/leave lines).
|
||||||
|
- **"Refused by relay (protocol version mismatch)":** the game build and the
|
||||||
|
relay must agree on `kGhostVersion` (both from the same build). Rebuild
|
||||||
|
both.
|
||||||
|
- **"Cannot resolve the relay"**: the default address is a placeholder until
|
||||||
|
you set `skate3_ghost_server`; the game then falls back to LAN auto-host.
|
||||||
|
- **Ghosts show as cubes**: the sender had no skeleton capture ready (menus,
|
||||||
|
loading) — the cube is the graceful fallback.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- The relay is an anonymous UDP relay: any client that knows the address can
|
||||||
|
join a room. It is intended for friends / a community, not untrusted
|
||||||
|
internet strangers. Keep `--max-clients` sane and do not expose it beyond
|
||||||
|
your intended audience.
|
||||||
|
- Room codes are not passwords. Do not put personal information in the room
|
||||||
|
code or display name.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
- `src/native/skate3_native_ghost_proto.h` — wire protocol
|
||||||
|
- `src/native/skate3_native_ghost_net.h` — POSIX/Winsock UDP shim
|
||||||
|
- `src/native/skate3_native_ghost_relay.{h,cpp}` — relay core (in-game host
|
||||||
|
mode + standalone binary)
|
||||||
|
- `src/native/skate3_native_ghost.{h,cpp}` — in-game client/capture
|
||||||
|
- `src/skate3_native_ghost_dialog.{h,cpp}` — multiplayer menu + nameplates
|
||||||
|
- `tools/ghost_relay_main.cpp`, `tools/ghost_relay.service`,
|
||||||
|
`tools/ghost_relay_deploy.sh` — the standalone server
|
||||||
129
docs/releases.md
Normal file
129
docs/releases.md
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
# Releases
|
||||||
|
|
||||||
|
Releases are prebuilt binaries that players download so they only need their
|
||||||
|
own Skate 3 ISO — **no building from scratch**. This project does not and
|
||||||
|
cannot ship game content; the release zip is just the recompiled binaries.
|
||||||
|
|
||||||
|
## What's in a release (and what isn't)
|
||||||
|
|
||||||
|
A release zip (`Skate3Recomp-Linux-<ver>.zip`) contains only:
|
||||||
|
|
||||||
|
- `skate3` — the recompiled game binary (also the in-app **ISO installer**)
|
||||||
|
- `librexruntime.so` (Linux) / `librexruntime.dll` (Windows) /
|
||||||
|
`librexruntime.dylib` (macOS)
|
||||||
|
- `ghost_relay` — the multiplayer relay server binary
|
||||||
|
- `README.md`
|
||||||
|
- `game/default.xexp` + `game/data/webkit/EAWebkit.xexp` — **only** when the
|
||||||
|
build used the Title Update package (these are the recompiled update
|
||||||
|
patches, not retail content)
|
||||||
|
|
||||||
|
**No game files.** Players download the zip, run `skate3`, click "Select
|
||||||
|
ISO", and the built-in installer extracts their own legally-owned copy. The
|
||||||
|
game dump (`default.xex` + `data/webkit/EAWebkit.xex`) is required **only at
|
||||||
|
build time** — codegen mechanically translates the actual Xbox 360 binary
|
||||||
|
into C++ source, which is what gets compiled. It is never included in, or
|
||||||
|
uploaded with, a release.
|
||||||
|
|
||||||
|
## Option A — build locally, upload manually (recommended)
|
||||||
|
|
||||||
|
You already build successfully on your machine, so this is the simple path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# on your build machine, from the repo root
|
||||||
|
./tools/release/release.sh # build + package this platform
|
||||||
|
./tools/release/release.sh --tag v1.0.0 --upload # build + publish to Forgejo
|
||||||
|
```
|
||||||
|
|
||||||
|
The script builds (configure → codegen → build), packages the zip (binaries
|
||||||
|
+ `librexruntime.so` + `ghost_relay` + README + TU patches if used), prints a
|
||||||
|
`sha256sum`, and with `--upload` creates/updates a Forgejo Release and
|
||||||
|
attaches the zip.
|
||||||
|
|
||||||
|
`--upload` needs:
|
||||||
|
- `GITEA_TOKEN` — a Forgejo token with **write:repository** scope (Settings →
|
||||||
|
Applications → Generate New Token), covering the release APIs.
|
||||||
|
- Optional `FORGEJO_REPO` (default `ad3laid3/skate3recomp-multiplayer`) and
|
||||||
|
`FORGEJO_INSTANCE` (default `https://git.mithraic.cloud`).
|
||||||
|
|
||||||
|
Per-platform notes:
|
||||||
|
|
||||||
|
| Platform | Build machine | Command |
|
||||||
|
|---|---|---|
|
||||||
|
| Linux | any Linux with the toolchain (README: Ubuntu 24.04 + Clang 20) | `./tools/release/release.sh` |
|
||||||
|
| Windows | a **Windows** machine (Visual Studio MSVC + Windows SDK, D3D12) | `tools\release\windows-build.cmd` (see below) |
|
||||||
|
| macOS | an **Apple Silicon/Intel Mac** (Homebrew LLVM + MoltenVK, per README) | `./tools/release/release.sh` |
|
||||||
|
|
||||||
|
Cross-compiling Windows/macOS from a Linux box is not practical for this
|
||||||
|
stack (D3D12 needs the Windows SDK, Cocoa/MoltenVK need a Mac), so each
|
||||||
|
platform's release is built on that platform.
|
||||||
|
|
||||||
|
### Windows build
|
||||||
|
|
||||||
|
On a Windows box, the known build issues (multiple Windows SDKs, Git Bash
|
||||||
|
`%LIB%` mangling, missing submodules) are handled by the launcher:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
tools\release\windows-build.cmd --tag v2.0.2-mp1 --upload
|
||||||
|
```
|
||||||
|
|
||||||
|
It loads the MSVC environment (`vcvarsall.bat x64 <SDK>`), sets
|
||||||
|
`MSYS2_ARG_CONV_EXCL=*` so Git Bash doesn't corrupt `%LIB%`, initializes the
|
||||||
|
submodules (including the self-heal), and runs `release.sh`. Config via env:
|
||||||
|
|
||||||
|
- `VCVARSALL` — path to `vcvarsall.bat` (default: VS 18 Community x64).
|
||||||
|
- `WINSDK` — Windows SDK version (default `10.0.26100.0`; try `10.0.22621.0`
|
||||||
|
if yours differs — a broken SDK default is what causes `kernel32.lib`
|
||||||
|
`LNK1104`).
|
||||||
|
- `GITBASH` — path to Git for Windows `bash.exe`.
|
||||||
|
|
||||||
|
`release.sh` now auto-detects the Title Update package (repo root or
|
||||||
|
`game/`), falls back to PowerShell `Compress-Archive` when `zip` is missing,
|
||||||
|
and no longer requires `python3` for the upload JSON parsing. The Windows
|
||||||
|
build also needs the game dump (`game/default.xex` + `EAWebkit.xex`) and the
|
||||||
|
TU package present on that box (build-time only, never shipped).
|
||||||
|
|
||||||
|
## Option B — automated via Forgejo Actions (optional)
|
||||||
|
|
||||||
|
`.forgejo/workflows/release.yml` builds and publishes Linux releases on push
|
||||||
|
of a `v*` tag (or a manual run), on a self-hosted runner.
|
||||||
|
|
||||||
|
Requirements for the automation path:
|
||||||
|
|
||||||
|
1. **A runner with the game dump.** Codegen needs the `.xex` files at build
|
||||||
|
time. On the runner, put the extracted dump at `/opt/skate3-game`
|
||||||
|
(`default.xex` + `data/webkit/EAWebkit.xex`, optionally the
|
||||||
|
`TU_12K2276_*` package). If your runner is a Docker container, mount that
|
||||||
|
directory into it (`-v /opt/skate3-game:/opt/skate3-game`). The game files
|
||||||
|
stay on the runner — they are never uploaded to the repo.
|
||||||
|
2. **Secrets** on the repo:
|
||||||
|
- `SUBMODULE_KEY` — an SSH deploy key with read access to
|
||||||
|
`ad3laid3/rexglue-skate3` (the self-hosted rexglue fork submodule).
|
||||||
|
- `RELEASE_TOKEN` — a Forgejo token with `write:repository` scope.
|
||||||
|
3. **Runner label** — the workflow uses `runs-on: ubuntu-24.04`; match it to
|
||||||
|
your runner's label (Docker runners commonly use
|
||||||
|
`ubuntu-latest`/`ubuntu-22.04`/`ubuntu-24.04` — change the `runs-on` line
|
||||||
|
to match).
|
||||||
|
4. **Broken submodule pins.** A fresh recursive clone currently fails because
|
||||||
|
the rexglue fork pins `thirdparty/imgui` to a commit `ocornut/imgui` no
|
||||||
|
longer carries. `tools/ci/init-submodules.sh` self-heals this (checks out
|
||||||
|
the upstream default tip for any nested submodule left empty) and the
|
||||||
|
workflow calls it after `git submodule update --init`.
|
||||||
|
|
||||||
|
Windows/macOS jobs can be added to the same workflow when native
|
||||||
|
Windows/macOS runners are available (each job uses its platform preset and
|
||||||
|
artifact names; the package step is per-OS).
|
||||||
|
|
||||||
|
## Doing it by hand
|
||||||
|
|
||||||
|
If you'd rather not use the script, the equivalent steps are:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --preset linux-relwithdebinfo -DSKATE3_GAME_DATA_ROOT="$PWD/game" # add -DSKATE3_TITLE_UPDATE_PACKAGE=... if used
|
||||||
|
cmake --build --preset linux-relwithdebinfo --target generate-all --parallel
|
||||||
|
cmake --preset linux-relwithdebinfo -DSKATE3_GAME_DATA_ROOT="$PWD/game"
|
||||||
|
cmake --build --preset linux-relwithdebinfo --parallel
|
||||||
|
# zip out/build/linux-relwithdebinfo/{skate3,librexruntime.so,ghost_relay} + README
|
||||||
|
```
|
||||||
|
|
||||||
|
Then create a Release in the Forgejo web UI (`<repo> → Releases → New
|
||||||
|
Release`) and attach the zip.
|
||||||
1183
src/native/skate3_native_ghost.cpp
Normal file
1183
src/native/skate3_native_ghost.cpp
Normal file
File diff suppressed because it is too large
Load diff
114
src/native/skate3_native_ghost.h
Normal file
114
src/native/skate3_native_ghost.h
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Ghost multiplayer module (bolt-on, hand-written, confined to src/native):
|
||||||
|
// a from-scratch "ghost player" layer that relays each player's world
|
||||||
|
// transform (position + rotation) through a small UDP relay so other players
|
||||||
|
// see a placeholder marker at the same spot. It deliberately does NOT touch
|
||||||
|
// the game's own netcode, animation, physics or any codegen'd logic.
|
||||||
|
//
|
||||||
|
// Topology (automatic, zero client-side setup):
|
||||||
|
// 1. Every client connects OUTBOUND to the default public relay
|
||||||
|
// (kGhostDefaultServer / skate3_ghost_server cvar) and joins a room
|
||||||
|
// code. No port forwarding on any client.
|
||||||
|
// 2. If the public relay is unreachable, the game automatically becomes a
|
||||||
|
// LAN host (GhostRelay in-process) and LAN peers auto-discover it via
|
||||||
|
// UDP broadcast PING/HELLO.
|
||||||
|
//
|
||||||
|
// Local-player capture: at Sk8::UpdateBoneTransforms exit (the documented
|
||||||
|
// player-only hook) the pack-owner entity is the player's SkaterPresEntity;
|
||||||
|
// its verified world matrix lives at entity+416 (m_MatLtoWTrans). The
|
||||||
|
// capture runs on the sim thread and copies HOST floats under a mutex; the
|
||||||
|
// network thread never reads guest memory.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
|
#include "skate3_native_ghost_proto.h"
|
||||||
|
|
||||||
|
namespace skate3::native_scene {
|
||||||
|
struct FrameScene;
|
||||||
|
} // namespace skate3::native_scene
|
||||||
|
|
||||||
|
namespace skate3::native_ghost {
|
||||||
|
|
||||||
|
// ---- host-side pose capture (called from BuildFrameScene pre-publish) -----
|
||||||
|
// Snapshot the local player's skater skeleton (the highest-bone-count
|
||||||
|
// skinned non-ropa character item near the camera) as the pose this client
|
||||||
|
// broadcasts, plus the local player's world transform (resolved from that
|
||||||
|
// item's owning entity). Called every built frame; gated internally on the
|
||||||
|
// enable cvar.
|
||||||
|
void OnFrameSceneBuilt(uint8_t* base, const skate3::native_scene::FrameScene& scene);
|
||||||
|
|
||||||
|
// ---- network thread lifecycle -------------------------------------------------
|
||||||
|
void EnsureNetworkThread();
|
||||||
|
void Shutdown();
|
||||||
|
|
||||||
|
// Persist the multiplayer cvars (server/room/name/port/rate) into the app's
|
||||||
|
// cvar config file whenever they change, so a player's settings survive a
|
||||||
|
// restart. config_path is the same file the SDK loads at startup. Safe to
|
||||||
|
// call once at startup.
|
||||||
|
void InstallSettingsPersistence(const std::filesystem::path& config_path);
|
||||||
|
|
||||||
|
// ---- status (UI / debug) --------------------------------------------------------
|
||||||
|
struct Status {
|
||||||
|
bool enabled = false;
|
||||||
|
bool connected = false; // joined to a relay (public or LAN)
|
||||||
|
bool hosting = false; // this game is relaying a LAN room
|
||||||
|
bool refused = false; // the relay refused us (protocol version mismatch)
|
||||||
|
uint32_t session_id = 0;
|
||||||
|
char room[kGhostMaxRoomLen] = {};
|
||||||
|
uint8_t room_len = 0;
|
||||||
|
size_t roster = 0; // other members in our room
|
||||||
|
size_t ghosts = 0; // remote ghosts currently drawn
|
||||||
|
char peer[96] = {}; // active relay "ip:port"
|
||||||
|
char last_error[160] = {}; // last non-fatal error, for the menu
|
||||||
|
};
|
||||||
|
bool GetStatus(Status* out);
|
||||||
|
|
||||||
|
// ---- render query (called from RenderScene, render thread) ---------------------
|
||||||
|
size_t GhostCount();
|
||||||
|
// Row-vector world matrix (translation in row 3) + a stable color per ghost.
|
||||||
|
bool GetGhost(uint32_t index, float world[16], float color[3]);
|
||||||
|
// Full-pose variant: additionally copies up to max_bones 12-float skeleton
|
||||||
|
// rows (the exact layout DrawItem::bones uses) so the renderer can drive a
|
||||||
|
// real character mesh. bone_count receives the copied row count (0 = the
|
||||||
|
// sender had no pose available; the caller should use the cube fallback).
|
||||||
|
bool GetGhostPose(uint32_t index, float world[16], float color[3],
|
||||||
|
uint16_t max_bones, float* bones, uint16_t* bone_count);
|
||||||
|
// Self-check cube at the local captured pose (skate3_ghost_draw_local).
|
||||||
|
bool GetLocalWorld(float world[16]);
|
||||||
|
|
||||||
|
// Lightweight per-ghost query for the nameplate overlay: the ghost's world
|
||||||
|
// position (entity world), marker color, and display name (from the room
|
||||||
|
// roster, or a session fallback). Returns false if the ghost is gone.
|
||||||
|
bool GetGhostLabel(uint32_t index, float pos[3], float color[3], char* name,
|
||||||
|
size_t name_max);
|
||||||
|
|
||||||
|
// ---- lobby browser ------------------------------------------------------------
|
||||||
|
// Ask the network thread to fetch the relay's room list (non-blocking; the
|
||||||
|
// reply updates GetRooms asynchronously, a second or two later).
|
||||||
|
void RequestRooms();
|
||||||
|
|
||||||
|
// Latest room list snapshot. count == 0 before the first ROOMS reply.
|
||||||
|
struct RoomList {
|
||||||
|
uint8_t version = 0;
|
||||||
|
uint8_t count = 0;
|
||||||
|
RoomEntry rooms[kGhostMaxRooms];
|
||||||
|
double received = 0.0; // steady seconds of the reply (0 = none yet)
|
||||||
|
};
|
||||||
|
bool GetRooms(RoomList* out);
|
||||||
|
|
||||||
|
// ---- cube geometry (56-byte records matching kSceneInputLayout) -----------------
|
||||||
|
struct GhostCubeVertex {
|
||||||
|
float pos[3];
|
||||||
|
float uv[2];
|
||||||
|
float uv2[2];
|
||||||
|
uint8_t bw[4];
|
||||||
|
uint8_t bi[4];
|
||||||
|
float normal[3];
|
||||||
|
float uv3[2];
|
||||||
|
};
|
||||||
|
const GhostCubeVertex* CubeVertices();
|
||||||
|
uint32_t CubeVertexCount(); // 36 (12 triangles, non-indexed)
|
||||||
|
|
||||||
|
} // namespace skate3::native_ghost
|
||||||
219
src/native/skate3_native_ghost_net.h
Normal file
219
src/native/skate3_native_ghost_net.h
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Minimal UDP socket shim shared by the ghost relay (standalone binary +
|
||||||
|
// in-game host mode) and the in-game client. Dependency-free: plain
|
||||||
|
// POSIX sockets on Linux/macOS, Winsock on Windows. The rexglue SDK's own
|
||||||
|
// rex::net is only close/ioctl and its XSocket is the dead Xbox Live
|
||||||
|
// emulation, which the ghost feature deliberately avoids.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#include <winsock2.h>
|
||||||
|
#include <ws2tcpip.h>
|
||||||
|
#pragma comment(lib, "ws2_32.lib")
|
||||||
|
using GhostSock = SOCKET;
|
||||||
|
#define GHOST_INVALID_SOCKET INVALID_SOCKET
|
||||||
|
#else
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
using GhostSock = int;
|
||||||
|
#define GHOST_INVALID_SOCKET (-1)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace skate3::native_ghost {
|
||||||
|
|
||||||
|
struct GhostAddr {
|
||||||
|
sockaddr_storage ss = {};
|
||||||
|
socklen_t len = 0;
|
||||||
|
bool valid() const { return len != 0; }
|
||||||
|
};
|
||||||
|
|
||||||
|
inline void GhostSockInit() {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
static bool s_init = [] {
|
||||||
|
WSADATA wsa = {};
|
||||||
|
WSAStartup(MAKEWORD(2, 2), &wsa);
|
||||||
|
return true;
|
||||||
|
}();
|
||||||
|
(void)s_init;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool GhostSockClose(GhostSock s) {
|
||||||
|
if (s == GHOST_INVALID_SOCKET) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#if defined(_WIN32)
|
||||||
|
closesocket(s);
|
||||||
|
#else
|
||||||
|
::close(s);
|
||||||
|
#endif
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline GhostSock GhostSocketCreate(bool allow_broadcast) {
|
||||||
|
GhostSockInit();
|
||||||
|
GhostSock s = ::socket(AF_INET, SOCK_DGRAM, 0);
|
||||||
|
if (s == GHOST_INVALID_SOCKET) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
int one = 1;
|
||||||
|
::setsockopt(s, SOL_SOCKET, SO_REUSEADDR,
|
||||||
|
reinterpret_cast<const char*>(&one), sizeof(one));
|
||||||
|
if (allow_broadcast) {
|
||||||
|
::setsockopt(s, SOL_SOCKET, SO_BROADCAST,
|
||||||
|
reinterpret_cast<const char*>(&one), sizeof(one));
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool GhostSockBind(GhostSock s, uint16_t port) {
|
||||||
|
sockaddr_in sin = {};
|
||||||
|
sin.sin_family = AF_INET;
|
||||||
|
sin.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||||
|
sin.sin_port = htons(port);
|
||||||
|
return ::bind(s, reinterpret_cast<const sockaddr*>(&sin), sizeof(sin)) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse "host[:port]" (IP or hostname; port optional, defaults to
|
||||||
|
// default_port). Returns false on resolve failure.
|
||||||
|
inline bool GhostResolve(const std::string& hostport, uint16_t default_port,
|
||||||
|
GhostAddr* out) {
|
||||||
|
std::string host = hostport;
|
||||||
|
uint16_t port = default_port;
|
||||||
|
const size_t colon = hostport.rfind(':');
|
||||||
|
if (colon != std::string::npos) {
|
||||||
|
host = hostport.substr(0, colon);
|
||||||
|
const std::string port_str = hostport.substr(colon + 1);
|
||||||
|
if (!port_str.empty()) {
|
||||||
|
port = uint16_t(std::stoi(port_str));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (host.empty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
addrinfo hints = {};
|
||||||
|
hints.ai_family = AF_INET;
|
||||||
|
hints.ai_socktype = SOCK_DGRAM;
|
||||||
|
hints.ai_protocol = IPPROTO_UDP;
|
||||||
|
addrinfo* res = nullptr;
|
||||||
|
const std::string port_str = std::to_string(port);
|
||||||
|
if (getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res) != 0 ||
|
||||||
|
res == nullptr) {
|
||||||
|
if (res != nullptr) {
|
||||||
|
freeaddrinfo(res);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sockaddr_in* sin = reinterpret_cast<sockaddr_in*>(res->ai_addr);
|
||||||
|
sin->sin_port = htons(port);
|
||||||
|
std::memcpy(&out->ss, res->ai_addr, res->ai_addrlen);
|
||||||
|
out->len = socklen_t(res->ai_addrlen);
|
||||||
|
freeaddrinfo(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loopback relay feed (in-game host mode forwards the host's own pose to
|
||||||
|
// LAN members).
|
||||||
|
inline GhostAddr GhostAddrLoopback(uint16_t port) {
|
||||||
|
GhostAddr a;
|
||||||
|
sockaddr_in sin = {};
|
||||||
|
sin.sin_family = AF_INET;
|
||||||
|
sin.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||||
|
sin.sin_port = htons(port);
|
||||||
|
std::memcpy(&a.ss, &sin, sizeof(sin));
|
||||||
|
a.len = sizeof(sin);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LAN broadcast address for a port.
|
||||||
|
inline GhostAddr GhostAddrBroadcast(uint16_t port) {
|
||||||
|
GhostAddr a;
|
||||||
|
sockaddr_in sin = {};
|
||||||
|
sin.sin_family = AF_INET;
|
||||||
|
sin.sin_addr.s_addr = htonl(INADDR_BROADCAST);
|
||||||
|
sin.sin_port = htons(port);
|
||||||
|
std::memcpy(&a.ss, &sin, sizeof(sin));
|
||||||
|
a.len = sizeof(sin);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline uint16_t GhostAddrPort(const GhostAddr& a) {
|
||||||
|
const sockaddr_in* sin = reinterpret_cast<const sockaddr_in*>(&a.ss);
|
||||||
|
return ntohs(sin->sin_port);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool GhostAddrEqual(const GhostAddr& a, const GhostAddr& b) {
|
||||||
|
return a.len == b.len && std::memcmp(&a.ss, &b.ss, a.len) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::string GhostAddrStr(const GhostAddr& a) {
|
||||||
|
const sockaddr_in* sin = reinterpret_cast<const sockaddr_in*>(&a.ss);
|
||||||
|
char buf[INET_ADDRSTRLEN] = {};
|
||||||
|
inet_ntop(AF_INET, &sin->sin_addr, buf, sizeof(buf));
|
||||||
|
return std::string(buf) + ":" + std::to_string(ntohs(sin->sin_port));
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool GhostSockSendTo(GhostSock s, const GhostAddr& to, const void* data,
|
||||||
|
int size) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
const int n = ::sendto(s, reinterpret_cast<const char*>(data), size, 0,
|
||||||
|
reinterpret_cast<const sockaddr*>(&to.ss), int(to.len));
|
||||||
|
#else
|
||||||
|
const int n = ::sendto(s, data, size, 0,
|
||||||
|
reinterpret_cast<const sockaddr*>(&to.ss), to.len);
|
||||||
|
#endif
|
||||||
|
return n == size;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline int GhostSockRecvFrom(GhostSock s, GhostAddr* from, void* data, int size) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
int addr_len = int(sizeof(from->ss));
|
||||||
|
const int n = ::recvfrom(s, reinterpret_cast<char*>(data), size, 0,
|
||||||
|
reinterpret_cast<sockaddr*>(&from->ss), &addr_len);
|
||||||
|
from->len = n >= 0 ? socklen_t(addr_len) : 0;
|
||||||
|
#else
|
||||||
|
socklen_t addr_len = sizeof(from->ss);
|
||||||
|
const int n = ::recvfrom(s, data, size, 0,
|
||||||
|
reinterpret_cast<sockaddr*>(&from->ss), &addr_len);
|
||||||
|
from->len = n >= 0 ? addr_len : 0;
|
||||||
|
#endif
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool GhostSockSetRecvTimeout(GhostSock s, int timeout_ms) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
DWORD tv = DWORD(timeout_ms);
|
||||||
|
#else
|
||||||
|
timeval tv = {};
|
||||||
|
tv.tv_sec = timeout_ms / 1000;
|
||||||
|
tv.tv_usec = (timeout_ms % 1000) * 1000;
|
||||||
|
#endif
|
||||||
|
return ::setsockopt(s, SOL_SOCKET, SO_RCVTIMEO,
|
||||||
|
reinterpret_cast<const char*>(&tv), sizeof(tv)) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline int GhostSockLastError() {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
return WSAGetLastError();
|
||||||
|
#else
|
||||||
|
return errno;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool GhostSockWouldBlock(int err) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
return err == WSAEWOULDBLOCK || err == WSAETIMEDOUT;
|
||||||
|
#else
|
||||||
|
return err == EAGAIN || err == EWOULDBLOCK;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace skate3::native_ghost
|
||||||
305
src/native/skate3_native_ghost_proto.h
Normal file
305
src/native/skate3_native_ghost_proto.h
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Ghost multiplayer wire protocol (shared by the in-game client, the
|
||||||
|
// in-game host mode and the standalone ghost_relay binary).
|
||||||
|
//
|
||||||
|
// Topology: every client connects OUTBOUND (UDP) to a relay; the relay
|
||||||
|
// forwards each client's pose packet to the other members of the same room.
|
||||||
|
// No client needs inbound reachability (no port forwarding). The relay is
|
||||||
|
// either a public VPS server (ghost_relay) or a player's game in host mode
|
||||||
|
// (the GhostRelay class below), reachable directly on a LAN or via a free
|
||||||
|
// tunnel on the internet.
|
||||||
|
//
|
||||||
|
// All multi-byte fields are little-endian (all supported hosts are
|
||||||
|
// little-endian; a struct with explicit layout is serialized with memcpy).
|
||||||
|
// A packet is PacketHeader followed by a fixed per-type payload. PacketBuf
|
||||||
|
// is sized for the largest payload.
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace skate3::native_ghost {
|
||||||
|
|
||||||
|
constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE)
|
||||||
|
constexpr uint8_t kGhostVersion = 4;
|
||||||
|
constexpr uint16_t kGhostDefaultPort = 40001;
|
||||||
|
constexpr uint32_t kGhostMaxRoomLen = 8;
|
||||||
|
constexpr uint32_t kGhostMaxRoster = 32;
|
||||||
|
constexpr uint32_t kGhostMaxNameLen = 16; // display name, UTF-8-safe ASCII
|
||||||
|
constexpr uint32_t kGhostMaxRooms = 16; // rooms listed per LOBBY reply
|
||||||
|
constexpr uint32_t kGhostMaxRoomMembers = 4; // member names shown per room
|
||||||
|
// Max skeleton rows carried in a POSE (3 float4s per bone). The Skate 3
|
||||||
|
// skater skeleton fits comfortably under this; rows beyond the sender's
|
||||||
|
// captured count are identity-padded so every mesh's referenced bones stay
|
||||||
|
// in range on the receiving side.
|
||||||
|
constexpr uint32_t kGhostMaxBones = 64;
|
||||||
|
// Default public relay (the production beta relay on the OVH VPS). Clients
|
||||||
|
// connect OUTBOUND to this; no port forwarding on any client. Override at
|
||||||
|
// runtime with skate3_ghost_server.
|
||||||
|
constexpr const char* kGhostDefaultServer = "relay.mithraic.lol";
|
||||||
|
constexpr uint16_t kGhostDefaultServerPort = 40001;
|
||||||
|
// A LAN host with no room advertised is implicitly in this room.
|
||||||
|
constexpr const char* kGhostLobbyRoom = "LOBBY";
|
||||||
|
|
||||||
|
enum PacketType : uint8_t {
|
||||||
|
kTypeJoin = 1, // client -> relay: { JoinPayload } (empty room = server assigns)
|
||||||
|
kTypeHello = 2, // relay -> client: { HelloPayload } (session id, room, roster)
|
||||||
|
kTypePose = 3, // legacy single-datagram pose (forwarded, unused by clients)
|
||||||
|
kTypePing = 4, // client -> relay / LAN broadcast probe: { JoinPayload }
|
||||||
|
kTypePong = 5, // relay -> client: empty
|
||||||
|
kTypeLeave = 6, // client -> relay: empty
|
||||||
|
kTypePoseChunk = 7, // client -> relay -> other members: { PoseChunkPayload }
|
||||||
|
kTypeList = 8, // client -> relay: request the room list (empty payload)
|
||||||
|
kTypeRooms = 9, // relay -> client: { RoomsPayload } (the lobby browser)
|
||||||
|
};
|
||||||
|
|
||||||
|
// True byte-packing: the struct layouts below must match the wire format
|
||||||
|
// exactly (the receiver bounds-checks every packet against these sizes, and
|
||||||
|
// the relay rejects undersized datagrams with `n < sizeof(PacketHeader)`).
|
||||||
|
// alignas(1) alone is NOT enough - it sets the struct alignment but the
|
||||||
|
// compiler still pads MEMBERS to their natural alignment (a uint16_t lands
|
||||||
|
// on an even offset, inflating PacketHeader to 8 bytes and dropping every
|
||||||
|
// real 7-byte packet).
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct PacketHeader {
|
||||||
|
uint32_t magic;
|
||||||
|
uint8_t type;
|
||||||
|
uint16_t payload_size; // bytes following this header
|
||||||
|
};
|
||||||
|
|
||||||
|
// JOIN and PING share the room payload (PING may carry the probing client's
|
||||||
|
// room so LAN hosts can answer only matching rooms); JOIN additionally
|
||||||
|
// carries the client's display name, which the relay echoes to room members
|
||||||
|
// in the HELLO roster so nameplates can label every ghost.
|
||||||
|
struct alignas(1) JoinPayload {
|
||||||
|
uint8_t version; // protocol version (kGhostVersion); mismatches are refused
|
||||||
|
uint8_t room_len;
|
||||||
|
char room[kGhostMaxRoomLen];
|
||||||
|
uint8_t name_len;
|
||||||
|
char name[kGhostMaxNameLen];
|
||||||
|
};
|
||||||
|
|
||||||
|
// One room member as reported in a HELLO roster: session id + display name.
|
||||||
|
struct alignas(1) RosterEntry {
|
||||||
|
uint32_t session_id;
|
||||||
|
uint8_t name_len;
|
||||||
|
char name[kGhostMaxNameLen];
|
||||||
|
};
|
||||||
|
|
||||||
|
struct alignas(1) HelloPayload {
|
||||||
|
uint32_t session_id; // our assigned session id (0 = refused)
|
||||||
|
uint8_t version; // the version we were accepted under
|
||||||
|
uint8_t room_len;
|
||||||
|
char room[kGhostMaxRoomLen];
|
||||||
|
uint8_t roster_count; // other members in this room
|
||||||
|
RosterEntry roster[kGhostMaxRoster];
|
||||||
|
};
|
||||||
|
|
||||||
|
// One room in the lobby list: the code, the total member count, and up to
|
||||||
|
// kGhostMaxRoomMembers member display names (for the "who's in there" glance).
|
||||||
|
struct alignas(1) RoomEntry {
|
||||||
|
uint8_t room_len;
|
||||||
|
char room[kGhostMaxRoomLen];
|
||||||
|
uint8_t member_count; // total players in this room
|
||||||
|
uint8_t shown_members; // how many names follow (<= kGhostMaxRoomMembers)
|
||||||
|
uint8_t member_name_len[kGhostMaxRoomMembers];
|
||||||
|
char member_names[kGhostMaxRoomMembers][kGhostMaxNameLen];
|
||||||
|
};
|
||||||
|
|
||||||
|
struct alignas(1) RoomsPayload {
|
||||||
|
uint8_t version; // the relay's protocol version (see the handshake)
|
||||||
|
uint8_t room_count;
|
||||||
|
RoomEntry rooms[kGhostMaxRooms];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full-pose blob (the character-model path): the sender's skater skeleton,
|
||||||
|
// 3 float4 rows per bone (column-vector affine [R | t], model -> world, row
|
||||||
|
// k = bone k exactly as the scene renderer's bone ring expects). Flags bit 0
|
||||||
|
// = bones[] valid. pos/rot are kept from the entity world for the cube
|
||||||
|
// fallback / diagnostics. This struct is the REASSEMBLED content of a
|
||||||
|
// chunked PoseChunk stream; it is no longer sent as a single datagram.
|
||||||
|
struct alignas(1) PosePayload {
|
||||||
|
uint32_t session_id; // set by the relay on forward (the sender's id)
|
||||||
|
uint32_t seq; // sender monotonic sequence
|
||||||
|
double timestamp; // sender monotonic seconds (informational)
|
||||||
|
float pos[3]; // world translation (entity world, row 3)
|
||||||
|
float rot[9]; // world rotation, row-vector 3x3 (matches entity+416)
|
||||||
|
uint16_t bone_count; // rows in bones[] (0 = pose unavailable)
|
||||||
|
uint8_t flags; // bit0 = bones[] valid
|
||||||
|
uint8_t reserved;
|
||||||
|
float bones[kGhostMaxBones * 12];
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr uint8_t kPoseFlagsBones = 1;
|
||||||
|
|
||||||
|
// ---- chunked pose transport -------------------------------------------------
|
||||||
|
// A full PosePayload is ~3.1 KB, far above the 1500-byte path MTU, so it is
|
||||||
|
// split into kGhostMaxChunks MTU-safe datagrams (<= ~1.2 KB each). The relay
|
||||||
|
// forwards each chunk verbatim (it never parses the body), and the receiver
|
||||||
|
// reassembles on the sequence. A lost chunk drops that pose (the next frame
|
||||||
|
// resends); out-of-order chunks are tolerated by the per-chunk offset copy.
|
||||||
|
constexpr uint16_t kGhostPoseHeaderSize =
|
||||||
|
uint16_t(offsetof(PosePayload, bones)); // 68
|
||||||
|
constexpr uint16_t kGhostChunkData = 1200;
|
||||||
|
constexpr uint8_t kGhostMaxChunks = 3;
|
||||||
|
constexpr uint16_t kGhostPoseBlobSize = uint16_t(sizeof(PosePayload)); // 3140
|
||||||
|
|
||||||
|
struct alignas(1) PoseChunkPayload {
|
||||||
|
uint32_t session_id; // offset 0: relay re-stamps the sender id
|
||||||
|
uint32_t seq; // pose sequence this chunk belongs to
|
||||||
|
uint16_t blob_size; // total blob bytes this seq comprises
|
||||||
|
uint16_t data_size; // bytes actually valid in data[]
|
||||||
|
uint8_t chunk; // 0-based chunk index
|
||||||
|
uint8_t chunks; // total chunks for this seq
|
||||||
|
uint8_t data[kGhostChunkData];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Receiver-side reassembly for ONE session (one instance per remote peer,
|
||||||
|
// network thread only). Push() returns true exactly when a complete pose
|
||||||
|
// becomes available and fills *out; a fresh seq discards any partial state.
|
||||||
|
struct GhostPoseReassembler {
|
||||||
|
void Reset() {
|
||||||
|
seq_ = 0;
|
||||||
|
blob_size_ = 0;
|
||||||
|
chunks_ = 0;
|
||||||
|
received_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Push(uint32_t seq, uint16_t blob_size, uint8_t chunk, uint8_t chunks,
|
||||||
|
const uint8_t* data, uint16_t data_size, PosePayload* out) {
|
||||||
|
if (chunk >= chunks || chunks == 0 || chunks > kGhostMaxChunks ||
|
||||||
|
blob_size == 0 || blob_size > kGhostPoseBlobSize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (seq != seq_) {
|
||||||
|
Reset();
|
||||||
|
seq_ = seq;
|
||||||
|
blob_size_ = blob_size;
|
||||||
|
chunks_ = chunks;
|
||||||
|
} else if (blob_size != blob_size_ || chunks != chunks_) {
|
||||||
|
return false; // inconsistent mid-sequence; drop
|
||||||
|
}
|
||||||
|
const uint32_t bit = 1u << chunk;
|
||||||
|
if ((received_ & bit) != 0) {
|
||||||
|
return false; // duplicate chunk
|
||||||
|
}
|
||||||
|
received_ |= bit;
|
||||||
|
const uint16_t off = uint16_t(chunk) * kGhostChunkData;
|
||||||
|
const uint16_t avail = blob_size_ > off ? blob_size_ - off : 0;
|
||||||
|
const uint16_t n = std::min<uint16_t>(data_size, avail);
|
||||||
|
std::memcpy(blob_ + off, data, n);
|
||||||
|
const uint32_t want = (1u << chunks_) - 1u;
|
||||||
|
if ((received_ & want) != want) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::memset(out, 0, sizeof(*out));
|
||||||
|
std::memcpy(out, blob_, std::min<uint16_t>(blob_size_, uint16_t(sizeof(*out))));
|
||||||
|
Reset();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
uint32_t seq_ = 0;
|
||||||
|
uint16_t blob_size_ = 0;
|
||||||
|
uint8_t chunks_ = 0;
|
||||||
|
uint32_t received_ = 0;
|
||||||
|
uint8_t blob_[kGhostPoseBlobSize] = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Largest payload: PoseChunkPayload (1214 bytes).
|
||||||
|
constexpr uint16_t kGhostMaxPayload = 3200;
|
||||||
|
|
||||||
|
struct PacketBuf {
|
||||||
|
PacketHeader h;
|
||||||
|
uint8_t payload[kGhostMaxPayload];
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
|
||||||
|
inline uint16_t PacketSize(const PacketBuf& p) {
|
||||||
|
return uint16_t(sizeof(PacketHeader) + p.h.payload_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void BuildPacket(PacketBuf& p, uint8_t type, const void* payload,
|
||||||
|
uint16_t payload_size) {
|
||||||
|
p.h.magic = kGhostMagic;
|
||||||
|
p.h.type = type;
|
||||||
|
p.h.payload_size = payload_size;
|
||||||
|
if (payload_size != 0) {
|
||||||
|
std::memcpy(p.payload, payload, payload_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the chunk packets for a pose. Always writes exactly the returned
|
||||||
|
// chunk count (1 for a header-only pose, up to kGhostMaxChunks for a full
|
||||||
|
// skeleton). The blob is the PosePayload serialized from its first byte.
|
||||||
|
inline uint8_t GhostPoseBuildChunks(uint32_t session_id, uint32_t seq,
|
||||||
|
const PosePayload& pose,
|
||||||
|
PacketBuf out[kGhostMaxChunks]) {
|
||||||
|
const uint16_t blob_size =
|
||||||
|
(pose.flags & kPoseFlagsBones) != 0
|
||||||
|
? uint16_t(sizeof(PosePayload))
|
||||||
|
: kGhostPoseHeaderSize;
|
||||||
|
const uint8_t chunks =
|
||||||
|
uint8_t((blob_size + kGhostChunkData - 1) / kGhostChunkData);
|
||||||
|
const uint8_t* blob = reinterpret_cast<const uint8_t*>(&pose);
|
||||||
|
for (uint8_t c = 0; c < chunks; ++c) {
|
||||||
|
PoseChunkPayload cp = {};
|
||||||
|
cp.session_id = session_id;
|
||||||
|
cp.seq = seq;
|
||||||
|
cp.blob_size = blob_size;
|
||||||
|
cp.chunk = c;
|
||||||
|
cp.chunks = chunks;
|
||||||
|
const uint16_t off = uint16_t(c) * kGhostChunkData;
|
||||||
|
const uint16_t n = std::min<uint16_t>(kGhostChunkData, blob_size - off);
|
||||||
|
cp.data_size = n;
|
||||||
|
std::memcpy(cp.data, blob + off, n);
|
||||||
|
BuildPacket(out[c], kTypePoseChunk, &cp, sizeof(cp));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- room code helpers (uppercase alnum, max kGhostMaxRoomLen) -------------
|
||||||
|
|
||||||
|
inline bool RoomValid(const char* room, uint8_t len) {
|
||||||
|
if (len == 0 || len > kGhostMaxRoomLen) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (uint8_t i = 0; i < len; ++i) {
|
||||||
|
const char c = room[i];
|
||||||
|
if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void RoomClear(char* room, uint8_t& len) {
|
||||||
|
len = 0;
|
||||||
|
std::memset(room, 0, kGhostMaxRoomLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void RoomSet(char* room, uint8_t& len, const char* s) {
|
||||||
|
len = 0;
|
||||||
|
std::memset(room, 0, kGhostMaxRoomLen);
|
||||||
|
if (s == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (*s != '\0' && len < kGhostMaxRoomLen) {
|
||||||
|
char c = *s;
|
||||||
|
if (c >= 'a' && c <= 'z') {
|
||||||
|
c = char(c - 'a' + 'A');
|
||||||
|
}
|
||||||
|
if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
|
||||||
|
room[len++] = c;
|
||||||
|
}
|
||||||
|
++s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool RoomEquals(const char* a, uint8_t a_len, const char* b, uint8_t b_len) {
|
||||||
|
return a_len == b_len && (a_len == 0 || std::memcmp(a, b, a_len) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace skate3::native_ghost
|
||||||
414
src/native/skate3_native_ghost_relay.cpp
Normal file
414
src/native/skate3_native_ghost_relay.cpp
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
#include "skate3_native_ghost_relay.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// The relay core is compiled into BOTH the game (rex logging available) and
|
||||||
|
// the dependency-free standalone ghost_relay binary; route its logs through
|
||||||
|
// a small build-flag shim so the standalone stays rexglue-free.
|
||||||
|
#if defined(SKATE3_RELAY_STANDALONE)
|
||||||
|
namespace skate3::native_ghost {
|
||||||
|
namespace {
|
||||||
|
inline std::string StandaloneArg(uint32_t v) { return std::to_string(v); }
|
||||||
|
inline std::string StandaloneArg(int v) { return std::to_string(v); }
|
||||||
|
inline std::string StandaloneArg(const std::string& v) { return v; }
|
||||||
|
inline std::string StandaloneArg(const char* v) {
|
||||||
|
return v != nullptr ? v : "(null)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal "{}"-placeholder formatter (the relay logs use fmt-style strings
|
||||||
|
// because the game build routes them through REXLOG).
|
||||||
|
template <typename... Args>
|
||||||
|
void StandaloneLog(const char* level, const char* fmt, Args&&... args) {
|
||||||
|
const std::vector<std::string> vals = {StandaloneArg(args)...};
|
||||||
|
std::string out;
|
||||||
|
size_t k = 0;
|
||||||
|
const char* p = fmt;
|
||||||
|
while (*p != '\0') {
|
||||||
|
if (p[0] == '{' && p[1] == '}' && k < vals.size()) {
|
||||||
|
out += vals[k++];
|
||||||
|
p += 2;
|
||||||
|
} else {
|
||||||
|
out += *p++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (level[0] == 'W') {
|
||||||
|
std::fprintf(stderr, "[ghost_relay] %s: %s\n", level, out.c_str());
|
||||||
|
} else {
|
||||||
|
std::printf("[ghost_relay] %s: %s\n", level, out.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
} // namespace skate3::native_ghost
|
||||||
|
|
||||||
|
#define GHOST_RELAY_INFO(...) \
|
||||||
|
::skate3::native_ghost::StandaloneLog("I", __VA_ARGS__)
|
||||||
|
#define GHOST_RELAY_WARN(...) \
|
||||||
|
::skate3::native_ghost::StandaloneLog("W", __VA_ARGS__)
|
||||||
|
#else
|
||||||
|
#include <rex/logging.h>
|
||||||
|
#define GHOST_RELAY_INFO(...) REXLOG_INFO(__VA_ARGS__)
|
||||||
|
#define GHOST_RELAY_WARN(...) REXLOG_WARN(__VA_ARGS__)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace skate3::native_ghost {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr double kClientTimeoutSec = 8.0;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
GhostRelay::~GhostRelay() {
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GhostRelay::Start() {
|
||||||
|
if (running_.load(std::memory_order_relaxed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sock_ = GhostSocketCreate(/*allow_broadcast=*/true);
|
||||||
|
if (sock_ == GHOST_INVALID_SOCKET) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!GhostSockBind(sock_, port_)) {
|
||||||
|
GhostSockClose(sock_);
|
||||||
|
sock_ = GHOST_INVALID_SOCKET;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
GhostSockSetRecvTimeout(sock_, 200);
|
||||||
|
running_.store(true, std::memory_order_relaxed);
|
||||||
|
stop_.store(false, std::memory_order_relaxed);
|
||||||
|
thread_ = std::thread(&GhostRelay::Run, this);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostRelay::Stop() {
|
||||||
|
if (!running_.load(std::memory_order_relaxed)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stop_.store(true, std::memory_order_relaxed);
|
||||||
|
if (thread_.joinable()) {
|
||||||
|
thread_.join();
|
||||||
|
}
|
||||||
|
if (sock_ != GHOST_INVALID_SOCKET) {
|
||||||
|
GhostSockClose(sock_);
|
||||||
|
sock_ = GHOST_INVALID_SOCKET;
|
||||||
|
}
|
||||||
|
running_.store(false, std::memory_order_relaxed);
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
clients_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t GhostRelay::ClientCount() const {
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
return clients_.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostRelay::SetLockedRoom(const char* room) {
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
RoomSet(locked_room_, locked_room_len_, room);
|
||||||
|
locked_ = locked_room_len_ != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostRelay::Run() {
|
||||||
|
while (!stop_.load(std::memory_order_relaxed)) {
|
||||||
|
try {
|
||||||
|
GhostAddr from;
|
||||||
|
PacketBuf p;
|
||||||
|
const int n = GhostSockRecvFrom(sock_, &from, &p, sizeof(p));
|
||||||
|
if (n <= 0) {
|
||||||
|
Sweep(0.0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (n < static_cast<int>(sizeof(PacketHeader))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p.h.magic != kGhostMagic) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uint16_t expect = static_cast<uint16_t>(n - sizeof(PacketHeader));
|
||||||
|
if (p.h.payload_size != expect ||
|
||||||
|
p.h.payload_size > kGhostMaxPayload) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const double now =
|
||||||
|
double(std::chrono::steady_clock::now().time_since_epoch().count()) /
|
||||||
|
1e9;
|
||||||
|
HandlePacket(from, p, now);
|
||||||
|
Sweep(now);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
GHOST_RELAY_WARN("relay thread exception: {}", e.what());
|
||||||
|
} catch (...) {
|
||||||
|
GHOST_RELAY_WARN("relay thread unknown exception");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
|
||||||
|
double now) {
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
|
||||||
|
// Resolve the sender's client record. Client records are only created by
|
||||||
|
// JOIN/PING (a client must announce itself before posing); any other
|
||||||
|
// packet from an unknown address is dropped, so spoofed POSE/LEAVE floods
|
||||||
|
// cannot fill the client table. LIST is the exception: a lobby browser can
|
||||||
|
// ask for the room list before joining anything.
|
||||||
|
Client* me = nullptr;
|
||||||
|
for (Client& c : clients_) {
|
||||||
|
if (GhostAddrEqual(c.addr, from)) {
|
||||||
|
me = &c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (me == nullptr) {
|
||||||
|
if (p.h.type == kTypeList) {
|
||||||
|
BuildRoomList(from);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p.h.type != kTypeJoin && p.h.type != kTypePing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (clients_.size() >= max_clients_) {
|
||||||
|
GHOST_RELAY_WARN("client cap reached ({}); dropping {}", max_clients_,
|
||||||
|
GhostAddrStr(from));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clients_.push_back(Client{});
|
||||||
|
me = &clients_.back();
|
||||||
|
me->addr = from;
|
||||||
|
me->session_id = next_session_++;
|
||||||
|
RoomClear(me->room, me->room_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto send = [this, &from](const PacketBuf& out) {
|
||||||
|
GhostSockSendTo(sock_, from, &out, PacketSize(out));
|
||||||
|
};
|
||||||
|
PacketBuf out = {};
|
||||||
|
|
||||||
|
switch (p.h.type) {
|
||||||
|
case kTypeJoin:
|
||||||
|
case kTypePing: {
|
||||||
|
if (p.h.payload_size < sizeof(JoinPayload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JoinPayload jp = {};
|
||||||
|
std::memcpy(&jp, p.payload, sizeof(jp));
|
||||||
|
// Protocol handshake: a mismatched version is REFUSED (HELLO with
|
||||||
|
// session 0) and its poses are dropped, so two builds can never
|
||||||
|
// silently corrupt each other's pose stream.
|
||||||
|
if (jp.version != kGhostVersion) {
|
||||||
|
me->refused = true;
|
||||||
|
if (!me->name[0]) {
|
||||||
|
std::memcpy(me->name, jp.name, std::min<uint8_t>(jp.name_len, kGhostMaxNameLen));
|
||||||
|
me->name_len = std::min<uint8_t>(jp.name_len, kGhostMaxNameLen);
|
||||||
|
}
|
||||||
|
HelloPayload hp = {};
|
||||||
|
hp.session_id = 0; // refused
|
||||||
|
hp.version = kGhostVersion;
|
||||||
|
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp)));
|
||||||
|
send(out);
|
||||||
|
GHOST_RELAY_WARN("refused {} (client v{}, relay v{})", GhostAddrStr(from),
|
||||||
|
jp.version, kGhostVersion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (locked_) {
|
||||||
|
std::memcpy(me->room, locked_room_, locked_room_len_);
|
||||||
|
me->room_len = locked_room_len_;
|
||||||
|
} else if (RoomValid(jp.room, jp.room_len)) {
|
||||||
|
std::memcpy(me->room, jp.room, jp.room_len);
|
||||||
|
me->room_len = jp.room_len;
|
||||||
|
}
|
||||||
|
// Keep the client's display name (JOIN carries it; PINGs from probes
|
||||||
|
// may omit it, so only overwrite with a non-empty name).
|
||||||
|
if (jp.name_len != 0 && jp.name_len <= kGhostMaxNameLen) {
|
||||||
|
std::memcpy(me->name, jp.name, jp.name_len);
|
||||||
|
me->name_len = jp.name_len;
|
||||||
|
}
|
||||||
|
// Empty room: assign one (uppercase alnum, 4 chars, seeded by the
|
||||||
|
// session id and the wall of the receiving thread).
|
||||||
|
if (me->room_len == 0) {
|
||||||
|
uint32_t state = (uint32_t(now * 1e3) * 2654435761u) ^
|
||||||
|
(me->session_id * 40503u) ^ 0x9E3779B9u;
|
||||||
|
char room[kGhostMaxRoomLen] = {};
|
||||||
|
for (uint8_t i = 0; i < 4; ++i) {
|
||||||
|
state = state * 1664525u + 1013904223u;
|
||||||
|
room[i] = (state & 1u) != 0
|
||||||
|
? static_cast<char>('A' + (state >> 1) % 26u)
|
||||||
|
: static_cast<char>('0' + (state >> 2) % 10u);
|
||||||
|
}
|
||||||
|
std::memcpy(me->room, room, 4);
|
||||||
|
me->room_len = 4;
|
||||||
|
}
|
||||||
|
me->last_seen = now;
|
||||||
|
if (p.h.type == kTypeJoin) {
|
||||||
|
// HELLO: our session id, room and the roster of the OTHER members.
|
||||||
|
HelloPayload hp = {};
|
||||||
|
hp.session_id = me->session_id;
|
||||||
|
hp.version = kGhostVersion;
|
||||||
|
hp.room_len = me->room_len;
|
||||||
|
std::memcpy(hp.room, me->room, me->room_len);
|
||||||
|
for (const Client& c : clients_) {
|
||||||
|
if (&c != me && c.room_len == me->room_len &&
|
||||||
|
RoomEquals(c.room, c.room_len, me->room, me->room_len) &&
|
||||||
|
hp.roster_count < kGhostMaxRoster) {
|
||||||
|
RosterEntry& entry = hp.roster[hp.roster_count++];
|
||||||
|
entry.session_id = c.session_id;
|
||||||
|
entry.name_len = c.name_len;
|
||||||
|
std::memcpy(entry.name, c.name, c.name_len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp)));
|
||||||
|
send(out);
|
||||||
|
if (verbose_.load(std::memory_order_relaxed)) {
|
||||||
|
GHOST_RELAY_INFO("{} joined room '{}' (session {})", GhostAddrStr(from),
|
||||||
|
std::string(hp.room, hp.room_len), me->session_id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// PING probe: HELLO with the host's room/roster so probing clients
|
||||||
|
// can decide whether to join this LAN host.
|
||||||
|
HelloPayload hp = {};
|
||||||
|
hp.session_id = me->session_id;
|
||||||
|
hp.version = kGhostVersion;
|
||||||
|
hp.room_len = me->room_len;
|
||||||
|
std::memcpy(hp.room, me->room, me->room_len);
|
||||||
|
for (const Client& c : clients_) {
|
||||||
|
if (&c != me && c.room_len == me->room_len &&
|
||||||
|
RoomEquals(c.room, c.room_len, me->room, me->room_len) &&
|
||||||
|
hp.roster_count < kGhostMaxRoster) {
|
||||||
|
RosterEntry& entry = hp.roster[hp.roster_count++];
|
||||||
|
entry.session_id = c.session_id;
|
||||||
|
entry.name_len = c.name_len;
|
||||||
|
std::memcpy(entry.name, c.name, c.name_len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp)));
|
||||||
|
send(out);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case kTypePose:
|
||||||
|
case kTypePoseChunk: {
|
||||||
|
// A refused (version-mismatched) client's poses are never forwarded.
|
||||||
|
if (me->refused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clients join before posing; a pose with no room is auto-joined to
|
||||||
|
// the lobby so the relay stays resilient to a missed HELLO.
|
||||||
|
if (me->room_len == 0) {
|
||||||
|
RoomSet(me->room, me->room_len, kGhostLobbyRoom);
|
||||||
|
}
|
||||||
|
me->last_seen = now;
|
||||||
|
// Forward the payload VERBATIM to the other room members, re-stamping
|
||||||
|
// only the sender session id at payload offset 0 (little-endian). The
|
||||||
|
// relay never parses the pose body (a full skeleton pose is a
|
||||||
|
// multi-chunk kTypePoseChunk stream; each chunk is forwarded the same
|
||||||
|
// way), so the protocol can grow without a relay update.
|
||||||
|
PacketBuf out = p;
|
||||||
|
out.h.magic = kGhostMagic;
|
||||||
|
out.h.type = p.h.type;
|
||||||
|
std::memcpy(out.payload, &me->session_id, sizeof(me->session_id));
|
||||||
|
const uint16_t size = PacketSize(out);
|
||||||
|
for (const Client& c : clients_) {
|
||||||
|
if (&c == me) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c.room_len != me->room_len ||
|
||||||
|
!RoomEquals(c.room, c.room_len, me->room, me->room_len)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
GhostSockSendTo(sock_, c.addr, &out, size);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case kTypeLeave: {
|
||||||
|
const auto it = std::remove_if(clients_.begin(), clients_.end(),
|
||||||
|
[&from](const Client& c) {
|
||||||
|
return GhostAddrEqual(c.addr, from);
|
||||||
|
});
|
||||||
|
if (it != clients_.end() && verbose_.load(std::memory_order_relaxed)) {
|
||||||
|
GHOST_RELAY_INFO("{} left", GhostAddrStr(from));
|
||||||
|
}
|
||||||
|
clients_.erase(it, clients_.end());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case kTypeList: {
|
||||||
|
BuildRoomList(from);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break; // PONG etc. are client-side only; ignore at the relay.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostRelay::BuildRoomList(const GhostAddr& to) {
|
||||||
|
// Aggregate the client table by room (the mu_ lock is already held by the
|
||||||
|
// caller), most-populated first, capped at kGhostMaxRooms with up to
|
||||||
|
// kGhostMaxRoomMembers names per room.
|
||||||
|
struct RoomAgg {
|
||||||
|
uint32_t count = 0;
|
||||||
|
uint32_t shown = 0;
|
||||||
|
uint32_t names[kGhostMaxRoomMembers] = {}; // indices into clients_
|
||||||
|
};
|
||||||
|
std::unordered_map<std::string, RoomAgg> agg;
|
||||||
|
for (size_t i = 0; i < clients_.size(); ++i) {
|
||||||
|
const Client& c = clients_[i];
|
||||||
|
if (c.room_len == 0 || c.refused) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const std::string key(c.room, c.room_len);
|
||||||
|
RoomAgg& a = agg[key];
|
||||||
|
++a.count;
|
||||||
|
if (a.shown < kGhostMaxRoomMembers && c.name_len != 0) {
|
||||||
|
a.names[a.shown++] = uint32_t(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RoomsPayload rp = {};
|
||||||
|
rp.version = kGhostVersion;
|
||||||
|
uint8_t listed = 0;
|
||||||
|
for (const auto& kv : agg) {
|
||||||
|
if (listed >= kGhostMaxRooms) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const std::string& key = kv.first;
|
||||||
|
const RoomAgg& a = kv.second;
|
||||||
|
RoomEntry& e = rp.rooms[listed];
|
||||||
|
e.room_len = uint8_t(std::min<size_t>(key.size(), kGhostMaxRoomLen));
|
||||||
|
std::memcpy(e.room, key.data(), e.room_len);
|
||||||
|
e.member_count = uint8_t(std::min<uint32_t>(a.count, 255));
|
||||||
|
e.shown_members = uint8_t(a.shown);
|
||||||
|
for (uint32_t n = 0; n < a.shown; ++n) {
|
||||||
|
const Client& c = clients_[a.names[n]];
|
||||||
|
e.member_name_len[n] = c.name_len;
|
||||||
|
std::memcpy(e.member_names[n], c.name, c.name_len);
|
||||||
|
}
|
||||||
|
++listed;
|
||||||
|
}
|
||||||
|
rp.room_count = listed;
|
||||||
|
|
||||||
|
PacketBuf out;
|
||||||
|
BuildPacket(out, kTypeRooms, &rp, static_cast<uint16_t>(sizeof(rp)));
|
||||||
|
GhostSockSendTo(sock_, to, &out, PacketSize(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostRelay::Sweep(double now) {
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
if (now <= 0.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clients_.erase(std::remove_if(clients_.begin(), clients_.end(),
|
||||||
|
[now](const Client& c) {
|
||||||
|
return now - c.last_seen > kClientTimeoutSec;
|
||||||
|
}),
|
||||||
|
clients_.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace skate3::native_ghost
|
||||||
86
src/native/skate3_native_ghost_relay.h
Normal file
86
src/native/skate3_native_ghost_relay.h
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Ghost multiplayer relay core: a small UDP forwarder shared by the
|
||||||
|
// standalone ghost_relay binary (run on a public VPS) and the in-game host
|
||||||
|
// mode (a player's game relays its LAN room). Clients connect OUTBOUND only
|
||||||
|
// and are matched into rooms by a shareable code. The relay forwards each
|
||||||
|
// client's PosePayload to every OTHER member of the same room.
|
||||||
|
//
|
||||||
|
// Clients are keyed by source address; every packet refreshes last_seen and
|
||||||
|
// silent clients are dropped (their ghosts vanish on the other side). Room
|
||||||
|
// codes are uppercase alphanumeric; a JOIN with an empty room gets one
|
||||||
|
// assigned by the server and returned in the HELLO.
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "skate3_native_ghost_net.h"
|
||||||
|
#include "skate3_native_ghost_proto.h"
|
||||||
|
|
||||||
|
namespace skate3::native_ghost {
|
||||||
|
|
||||||
|
class GhostRelay {
|
||||||
|
public:
|
||||||
|
explicit GhostRelay(uint16_t port = kGhostDefaultPort) : port_(port) {}
|
||||||
|
~GhostRelay();
|
||||||
|
GhostRelay(const GhostRelay&) = delete;
|
||||||
|
GhostRelay& operator=(const GhostRelay&) = delete;
|
||||||
|
|
||||||
|
// Bind the socket and start the receive thread. Returns false when the
|
||||||
|
// port cannot be bound (already in use / no permission).
|
||||||
|
bool Start();
|
||||||
|
void Stop();
|
||||||
|
|
||||||
|
bool running() const { return running_.load(std::memory_order_relaxed); }
|
||||||
|
uint16_t port() const { return port_; }
|
||||||
|
size_t ClientCount() const;
|
||||||
|
|
||||||
|
// When non-empty, every client is forced into this room (the standalone
|
||||||
|
// binary's --room lock); client JOINs and PINGs cannot override it.
|
||||||
|
void SetLockedRoom(const char* room);
|
||||||
|
|
||||||
|
// Hard cap on simultaneously tracked clients (default 64). New JOIN/PING
|
||||||
|
// sources past the cap are dropped (logged).
|
||||||
|
void SetMaxClients(uint32_t max_clients) { max_clients_ = max_clients; }
|
||||||
|
|
||||||
|
// Verbose per-event logging (join/leave/refusals); the standalone server's
|
||||||
|
// --verbose flag. Off by default to keep a busy relay quiet.
|
||||||
|
void SetVerbose(bool verbose) { verbose_ = verbose; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void Run();
|
||||||
|
void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now);
|
||||||
|
void BuildRoomList(const GhostAddr& to);
|
||||||
|
void Sweep(double now);
|
||||||
|
|
||||||
|
GhostSock sock_ = GHOST_INVALID_SOCKET;
|
||||||
|
uint16_t port_;
|
||||||
|
std::thread thread_;
|
||||||
|
std::atomic<bool> running_{false};
|
||||||
|
std::atomic<bool> stop_{false};
|
||||||
|
uint32_t max_clients_ = 64;
|
||||||
|
std::atomic<bool> verbose_{false};
|
||||||
|
|
||||||
|
struct Client {
|
||||||
|
GhostAddr addr;
|
||||||
|
uint32_t session_id = 0;
|
||||||
|
char room[kGhostMaxRoomLen] = {};
|
||||||
|
uint8_t room_len = 0;
|
||||||
|
char name[kGhostMaxNameLen] = {};
|
||||||
|
uint8_t name_len = 0;
|
||||||
|
bool refused = false; // version mismatch: poses from this client dropped
|
||||||
|
double last_seen = 0.0;
|
||||||
|
};
|
||||||
|
mutable std::mutex mu_;
|
||||||
|
std::vector<Client> clients_;
|
||||||
|
uint32_t next_session_ = 1;
|
||||||
|
bool locked_ = false;
|
||||||
|
char locked_room_[kGhostMaxRoomLen] = {};
|
||||||
|
uint8_t locked_room_len_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace skate3::native_ghost
|
||||||
|
|
@ -13,7 +13,11 @@
|
||||||
#else
|
#else
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
#include <csetjmp>
|
#include <csetjmp>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
|
|
||||||
|
|
@ -112,6 +116,108 @@ bool GuestTryCopy(void* dst, const void* src, size_t size) {
|
||||||
tl_in_guest_copy = false;
|
tl_in_guest_copy = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Raw-load read-fault recovery (see the header contract) ---------------
|
||||||
|
// The handler must be async-signal-safe: TLS reads, atomics and the
|
||||||
|
// mprotect/clock_gettime syscalls only. It resumes the interrupted load in
|
||||||
|
// place (registers untouched, pc unchanged), so unlike the siglongjmp guard
|
||||||
|
// above it never crosses C++ scopes and imposes no constraints on the armed
|
||||||
|
// code. Recovery restores READ access only: a page the runtime keeps
|
||||||
|
// write-protected (write watches) still traps guest writes exactly as
|
||||||
|
// before.
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Full span of the runtime's guest views measured from the virtual membase:
|
||||||
|
// 4 GB of virtual address space plus the 512 MB physical-raw view mapped
|
||||||
|
// directly above it (the SDK maps both from one backing object).
|
||||||
|
constexpr uint64_t kGuestSpanBytes = 0x120000000ull;
|
||||||
|
|
||||||
|
thread_local int tl_read_recovery_depth = 0;
|
||||||
|
thread_local uint8_t* tl_read_recovery_base = nullptr;
|
||||||
|
// Livelock breaker: a read fault that RECURS on the same page immediately
|
||||||
|
// after a successful recovery means read access cannot actually be restored
|
||||||
|
// there (e.g. SIGBUS from the backing object rather than page protection).
|
||||||
|
// Distinct pages, and the same page faulting again later (streaming re-
|
||||||
|
// revoked it), reset the streak.
|
||||||
|
thread_local uintptr_t tl_read_recovery_page = 0;
|
||||||
|
thread_local uint32_t tl_read_recovery_streak = 0;
|
||||||
|
thread_local uint64_t tl_read_recovery_last_ns = 0;
|
||||||
|
|
||||||
|
std::atomic<uint64_t> g_read_recoveries{0};
|
||||||
|
size_t g_host_page_size = 4096;
|
||||||
|
|
||||||
|
bool GuestReadFaultHandler(rex::arch::Exception* ex, void* /*data*/) {
|
||||||
|
if (tl_read_recovery_depth <= 0 || tl_in_guest_copy ||
|
||||||
|
ex->code() != rex::arch::Exception::Code::kAccessViolation ||
|
||||||
|
ex->access_violation_operation() !=
|
||||||
|
rex::arch::Exception::AccessViolationOperation::kRead) {
|
||||||
|
return false; // not ours - keep dispatching down the chain
|
||||||
|
}
|
||||||
|
uint8_t* base = tl_read_recovery_base;
|
||||||
|
const uint64_t fault = ex->fault_address();
|
||||||
|
const uint64_t lo = uint64_t(reinterpret_cast<uintptr_t>(base));
|
||||||
|
if (base == nullptr || fault < lo || fault - lo >= kGuestSpanBytes) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const uintptr_t page =
|
||||||
|
uintptr_t(fault) & ~(uintptr_t(g_host_page_size) - 1);
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
const uint64_t now_ns = uint64_t(ts.tv_sec) * 1000000000ull + uint64_t(ts.tv_nsec);
|
||||||
|
if (page == tl_read_recovery_page &&
|
||||||
|
now_ns - tl_read_recovery_last_ns < 10000000ull) {
|
||||||
|
if (++tl_read_recovery_streak > 16) {
|
||||||
|
return false; // recovery is not sticking on this page: die loudly
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tl_read_recovery_page = page;
|
||||||
|
tl_read_recovery_streak = 0;
|
||||||
|
}
|
||||||
|
tl_read_recovery_last_ns = now_ns;
|
||||||
|
if (mprotect(reinterpret_cast<void*>(page), g_host_page_size, PROT_READ) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
g_read_recoveries.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
return true; // resume: the faulted load retries against the readable page
|
||||||
|
}
|
||||||
|
|
||||||
|
void EnsureGuestReadRecoveryHandler() {
|
||||||
|
static std::once_flag once;
|
||||||
|
std::call_once(once, [] {
|
||||||
|
const long ps = sysconf(_SC_PAGESIZE);
|
||||||
|
if (ps > 0) {
|
||||||
|
g_host_page_size = size_t(ps);
|
||||||
|
}
|
||||||
|
rex::arch::ExceptionHandler::Install(GuestReadFaultHandler, nullptr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
GuestReadRecoveryScope::GuestReadRecoveryScope(uint8_t* base) {
|
||||||
|
EnsureGuestReadRecoveryHandler();
|
||||||
|
if (tl_read_recovery_depth++ == 0) {
|
||||||
|
tl_read_recovery_base = base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GuestReadRecoveryScope::~GuestReadRecoveryScope() {
|
||||||
|
if (--tl_read_recovery_depth == 0) {
|
||||||
|
tl_read_recovery_base = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ArmGuestReadRecoveryForThread(uint8_t* base) {
|
||||||
|
if (tl_read_recovery_depth == 0) {
|
||||||
|
EnsureGuestReadRecoveryHandler();
|
||||||
|
tl_read_recovery_depth = 1;
|
||||||
|
tl_read_recovery_base = base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t GuestReadRecoveryCount() {
|
||||||
|
return g_read_recoveries.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
float GuestHalfToFloat(uint16_t h) {
|
float GuestHalfToFloat(uint16_t h) {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,52 @@ struct SkinSampleVert {
|
||||||
// open-coded __try{memcpy}__except compiles to an UNPROTECTED `jmp memcpy`.
|
// open-coded __try{memcpy}__except compiles to an UNPROTECTED `jmp memcpy`.
|
||||||
bool GuestTryCopy(void* dst, const void* src, size_t size);
|
bool GuestTryCopy(void* dst, const void* src, size_t size);
|
||||||
|
|
||||||
|
// Read-fault recovery for the renderer's RAW guest loads (the REX_LOAD-style
|
||||||
|
// structure walks that do not route through GuestTryCopy). World streaming
|
||||||
|
// can revoke a guest range between a pointer being captured and the renderer
|
||||||
|
// dereferencing it; on POSIX that read is a fatal SIGSEGV/SIGBUS. While a
|
||||||
|
// thread is armed, a handler on the runtime exception chain recovers a READ
|
||||||
|
// fault inside the guest mapping by restoring read access to the faulted
|
||||||
|
// host page and resuming the interrupted load, which then completes with
|
||||||
|
// resident-but-meaningless bytes that the callers' existing plausibility
|
||||||
|
// gates reject (the same "not ready, skip this item" outcome the guarded
|
||||||
|
// copy produces). The handler resumes execution in place - no siglongjmp -
|
||||||
|
// so arming carries NO destructor/unwind constraints and is safe at any
|
||||||
|
// scope width, including around code that owns locks, vectors or strings.
|
||||||
|
// Faults inside GuestTryCopy keep that function's fail-the-copy semantics
|
||||||
|
// (the recovery handler declines them); write faults and faults outside the
|
||||||
|
// guest mapping are never recovered. Windows builds compile these to no-ops
|
||||||
|
// (raw reads there are served by the pagefile-backed guest mapping).
|
||||||
|
//
|
||||||
|
// GuestReadRecoveryScope: arm for one native-scene entry point on a GUEST
|
||||||
|
// thread (capture hooks, frame build). Guest threads run recompiled game
|
||||||
|
// code the rest of the time, and a genuine wild read there must still crash
|
||||||
|
// loudly, hence the narrow scope. Nestable.
|
||||||
|
//
|
||||||
|
// ArmGuestReadRecoveryForThread: permanently arm a thread that only ever
|
||||||
|
// reads guest memory on the renderer's behalf (the render thread, the
|
||||||
|
// prewarm decode workers). Idempotent and cheap; call per frame/iteration.
|
||||||
|
#if defined(_WIN32)
|
||||||
|
class GuestReadRecoveryScope {
|
||||||
|
public:
|
||||||
|
explicit GuestReadRecoveryScope(uint8_t* /*base*/) {}
|
||||||
|
};
|
||||||
|
inline void ArmGuestReadRecoveryForThread(uint8_t* /*base*/) {}
|
||||||
|
inline uint64_t GuestReadRecoveryCount() { return 0; }
|
||||||
|
#else
|
||||||
|
class GuestReadRecoveryScope {
|
||||||
|
public:
|
||||||
|
explicit GuestReadRecoveryScope(uint8_t* base);
|
||||||
|
~GuestReadRecoveryScope();
|
||||||
|
GuestReadRecoveryScope(const GuestReadRecoveryScope&) = delete;
|
||||||
|
GuestReadRecoveryScope& operator=(const GuestReadRecoveryScope&) = delete;
|
||||||
|
};
|
||||||
|
void ArmGuestReadRecoveryForThread(uint8_t* base);
|
||||||
|
// Total recovered reads since startup (telemetry; logged by the render
|
||||||
|
// thread's frame stats when non-zero).
|
||||||
|
uint64_t GuestReadRecoveryCount();
|
||||||
|
#endif
|
||||||
|
|
||||||
float GuestHalfToFloat(uint16_t h);
|
float GuestHalfToFloat(uint16_t h);
|
||||||
|
|
||||||
// Bind-pose bbox diagonal of the item: the reference size every
|
// Bind-pose bbox diagonal of the item: the reference size every
|
||||||
|
|
|
||||||
|
|
@ -889,6 +889,10 @@ uint32_t ExchangePackOwner(uint32_t entity) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint32_t CurrentPackOwner() {
|
||||||
|
return tl_pack_owner;
|
||||||
|
}
|
||||||
|
|
||||||
void OnPackPalette(uint8_t* base, uint32_t instance) {
|
void OnPackPalette(uint8_t* base, uint32_t instance) {
|
||||||
if (instance == 0 || !CaptureActive()) {
|
if (instance == 0 || !CaptureActive()) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,11 @@ void OnLwPack(uint8_t* base, uint32_t entity);
|
||||||
// the bracket carry the owning entity for identity-exact serving.
|
// the bracket carry the owning entity for identity-exact serving.
|
||||||
uint32_t ExchangePackOwner(uint32_t entity);
|
uint32_t ExchangePackOwner(uint32_t entity);
|
||||||
|
|
||||||
|
// Current pack-owner entity on this thread (0 when outside any bracket).
|
||||||
|
// Read by the ghost multiplayer module inside the UpdateBoneTransforms hook
|
||||||
|
// to identify the local player's Sk8::SkaterPresEntity.
|
||||||
|
uint32_t CurrentPackOwner();
|
||||||
|
|
||||||
// Called from BuildFrameScene while the frame's items are finalized:
|
// Called from BuildFrameScene while the frame's items are finalized:
|
||||||
// replaces each mapped skinned character item's captured bank palette with
|
// replaces each mapped skinned character item's captured bank palette with
|
||||||
// its instance's own packed rows. Served items get dbg_src=11 and
|
// its instance's own packed rows. Served items get dbg_src=11 and
|
||||||
|
|
|
||||||
29
src/skate3.rc.in
Normal file
29
src/skate3.rc.in
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
1 VERSIONINFO
|
||||||
|
FILEVERSION @SKATE3_RC_VERSION@
|
||||||
|
PRODUCTVERSION @SKATE3_RC_VERSION@
|
||||||
|
FILEFLAGSMASK 0x3fL
|
||||||
|
FILEFLAGS 0x0L
|
||||||
|
FILEOS 0x40004L
|
||||||
|
FILETYPE 0x1L
|
||||||
|
FILESUBTYPE 0x0L
|
||||||
|
BEGIN
|
||||||
|
BLOCK "StringFileInfo"
|
||||||
|
BEGIN
|
||||||
|
BLOCK "040904b0"
|
||||||
|
BEGIN
|
||||||
|
VALUE "CompanyName", "Skate3Recomp project"
|
||||||
|
VALUE "FileDescription", "Skate3Recomp"
|
||||||
|
VALUE "FileVersion", "@SKATE3_FULL_VERSION@"
|
||||||
|
VALUE "InternalName", "skate3"
|
||||||
|
VALUE "LegalCopyright", "(c) Skate3Recomp contributors"
|
||||||
|
VALUE "OriginalFilename", "skate3.exe"
|
||||||
|
VALUE "ProductName", "Skate3Recomp"
|
||||||
|
VALUE "ProductVersion", "@SKATE3_FULL_VERSION@"
|
||||||
|
VALUE "Comments", "Fan-made recompilation. Not affiliated with or endorsed by Electronic Arts."
|
||||||
|
END
|
||||||
|
END
|
||||||
|
BLOCK "VarFileInfo"
|
||||||
|
BEGIN
|
||||||
|
VALUE "Translation", 0x409, 1200
|
||||||
|
END
|
||||||
|
END
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
#include "skate3_title_update_installer.h"
|
#include "skate3_title_update_installer.h"
|
||||||
#include "skate3_user_settings.h"
|
#include "skate3_user_settings.h"
|
||||||
|
|
||||||
|
#include "native/skate3_native_ghost.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
@ -681,6 +683,8 @@ void Skate3BaseApp::OnCreateDialogs(rex::ui::ImGuiDrawer* drawer) {
|
||||||
// skate3_native_render_mode_indicator shows it live). Input-transparent,
|
// skate3_native_render_mode_indicator shows it live). Input-transparent,
|
||||||
// so it never affects cursor or focus handling.
|
// so it never affects cursor or focus handling.
|
||||||
render_mode_indicator_ = std::make_unique<skate3::RenderModeIndicator>(drawer);
|
render_mode_indicator_ = std::make_unique<skate3::RenderModeIndicator>(drawer);
|
||||||
|
ghost_nameplate_overlay_ =
|
||||||
|
std::make_unique<skate3::GhostNameplateOverlay>(drawer);
|
||||||
rex::ui::RegisterBind("bind_skate3_menu", "Escape", "Skate 3 settings", [this] {
|
rex::ui::RegisterBind("bind_skate3_menu", "Escape", "Skate 3 settings", [this] {
|
||||||
// Escape backs out of the settings screen level by level (rows ->
|
// Escape backs out of the settings screen level by level (rows ->
|
||||||
// category rail -> closed); when the screen is closed it opens it.
|
// category rail -> closed); when the screen is closed it opens it.
|
||||||
|
|
@ -723,6 +727,10 @@ void Skate3BaseApp::OnCreateDialogs(rex::ui::ImGuiDrawer* drawer) {
|
||||||
"Native render debug menu", [this] {
|
"Native render debug menu", [this] {
|
||||||
ToggleNativeDebug();
|
ToggleNativeDebug();
|
||||||
});
|
});
|
||||||
|
rex::ui::RegisterBind("bind_skate3_ghost", "M",
|
||||||
|
"Ghost multiplayer menu", [this] {
|
||||||
|
ToggleGhost();
|
||||||
|
});
|
||||||
rex::ui::RegisterBind("bind_skate3_showcase", "Ctrl+Shift+B",
|
rex::ui::RegisterBind("bind_skate3_showcase", "Ctrl+Shift+B",
|
||||||
"Graphics build-up showcase", [] {
|
"Graphics build-up showcase", [] {
|
||||||
// A capture/recording tool, not a player feature:
|
// A capture/recording tool, not a player feature:
|
||||||
|
|
@ -793,6 +801,10 @@ void Skate3BaseApp::OnPostSetup() {
|
||||||
auto* dispatcher = runtime()->function_dispatcher();
|
auto* dispatcher = runtime()->function_dispatcher();
|
||||||
skate3::native_render::Install();
|
skate3::native_render::Install();
|
||||||
skate3::demo_path::InstallHooks(dispatcher);
|
skate3::demo_path::InstallHooks(dispatcher);
|
||||||
|
// Ghost multiplayer network thread (idle until skate3_ghost_enable) and
|
||||||
|
// settings persistence (server/room/name/port/rate) into the cvar config.
|
||||||
|
skate3::native_ghost::EnsureNetworkThread();
|
||||||
|
skate3::native_ghost::InstallSettingsPersistence(config_path_);
|
||||||
// User-facing intro-movie skip (independent of the demo path): the movie
|
// User-facing intro-movie skip (independent of the demo path): the movie
|
||||||
// completion override polls the merged UI pad state through this provider.
|
// completion override polls the merged UI pad state through this provider.
|
||||||
skate3::demo_path::SetUiInputProvider([this]() {
|
skate3::demo_path::SetUiInputProvider([this]() {
|
||||||
|
|
@ -820,10 +832,14 @@ void Skate3BaseApp::OnShutdown() {
|
||||||
rex::ui::UnregisterBind("bind_skate3_log_debug_marker");
|
rex::ui::UnregisterBind("bind_skate3_log_debug_marker");
|
||||||
rex::ui::UnregisterBind("bind_skate3_log_user_marker");
|
rex::ui::UnregisterBind("bind_skate3_log_user_marker");
|
||||||
rex::ui::UnregisterBind("bind_skate3_native_debug");
|
rex::ui::UnregisterBind("bind_skate3_native_debug");
|
||||||
|
rex::ui::UnregisterBind("bind_skate3_ghost");
|
||||||
|
skate3::native_ghost::Shutdown();
|
||||||
ApplyGameplayCursorMode();
|
ApplyGameplayCursorMode();
|
||||||
skate3::native_scene::SetSettingsMenuBlur(false);
|
skate3::native_scene::SetSettingsMenuBlur(false);
|
||||||
simple_settings_dialog_.reset();
|
simple_settings_dialog_.reset();
|
||||||
native_debug_dialog_.reset();
|
native_debug_dialog_.reset();
|
||||||
|
ghost_dialog_.reset();
|
||||||
|
ghost_nameplate_overlay_.reset();
|
||||||
render_mode_indicator_.reset();
|
render_mode_indicator_.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -944,6 +960,19 @@ void Skate3BaseApp::ToggleNativeDebug() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Skate3BaseApp::ToggleGhost() {
|
||||||
|
if (!ghost_dialog_) {
|
||||||
|
ghost_dialog_ = std::make_unique<skate3::GhostDialog>(imgui_drawer());
|
||||||
|
}
|
||||||
|
if (ghost_dialog_->visible()) {
|
||||||
|
ghost_dialog_->Hide();
|
||||||
|
ApplyGameplayCursorMode();
|
||||||
|
} else {
|
||||||
|
ApplySettingsCursorMode();
|
||||||
|
ghost_dialog_->Show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void Skate3BaseApp::ApplySettingsCursorMode() {
|
void Skate3BaseApp::ApplySettingsCursorMode() {
|
||||||
if (window()) {
|
if (window()) {
|
||||||
window()->SetCursorVisibility(rex::ui::Window::CursorVisibility::kVisible);
|
window()->SetCursorVisibility(rex::ui::Window::CursorVisibility::kVisible);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
#include <rex/ui/overlay/simple_settings_overlay.h>
|
#include <rex/ui/overlay/simple_settings_overlay.h>
|
||||||
|
|
||||||
#include "skate3_native_debug_dialog.h"
|
#include "skate3_native_debug_dialog.h"
|
||||||
|
#include "skate3_native_ghost_dialog.h"
|
||||||
|
|
||||||
namespace rex::ui {
|
namespace rex::ui {
|
||||||
class ImGuiDrawer;
|
class ImGuiDrawer;
|
||||||
|
|
@ -40,6 +41,7 @@ class Skate3BaseApp : public rex::ReXApp {
|
||||||
void InstallDlcPackages();
|
void InstallDlcPackages();
|
||||||
void ToggleSimpleSettings();
|
void ToggleSimpleSettings();
|
||||||
void ToggleNativeDebug();
|
void ToggleNativeDebug();
|
||||||
|
void ToggleGhost();
|
||||||
void ApplySettingsCursorMode();
|
void ApplySettingsCursorMode();
|
||||||
void ApplyGameplayCursorMode();
|
void ApplyGameplayCursorMode();
|
||||||
void RestartGame();
|
void RestartGame();
|
||||||
|
|
@ -59,6 +61,8 @@ class Skate3BaseApp : public rex::ReXApp {
|
||||||
std::filesystem::path profiles_path_;
|
std::filesystem::path profiles_path_;
|
||||||
std::unique_ptr<rex::ui::SimpleSettingsDialog> simple_settings_dialog_;
|
std::unique_ptr<rex::ui::SimpleSettingsDialog> simple_settings_dialog_;
|
||||||
std::unique_ptr<skate3::NativeDebugDialog> native_debug_dialog_;
|
std::unique_ptr<skate3::NativeDebugDialog> native_debug_dialog_;
|
||||||
|
std::unique_ptr<skate3::GhostDialog> ghost_dialog_;
|
||||||
|
std::unique_ptr<skate3::GhostNameplateOverlay> ghost_nameplate_overlay_;
|
||||||
std::unique_ptr<skate3::RenderModeIndicator> render_mode_indicator_;
|
std::unique_ptr<skate3::RenderModeIndicator> render_mode_indicator_;
|
||||||
bool recipe_overlay_installed_ = false;
|
bool recipe_overlay_installed_ = false;
|
||||||
bool big_device_aliases_installed_ = false;
|
bool big_device_aliases_installed_ = false;
|
||||||
|
|
|
||||||
|
|
@ -365,10 +365,15 @@ bool IsGameInstalled(const std::filesystem::path& game_root) {
|
||||||
|
|
||||||
void ShowRexglueIsoInstallWizard(rex::ui::ImGuiDrawer* drawer, rex::PathConfig runtime_paths,
|
void ShowRexglueIsoInstallWizard(rex::ui::ImGuiDrawer* drawer, rex::PathConfig runtime_paths,
|
||||||
std::function<void(rex::PathConfig)> complete) {
|
std::function<void(rex::PathConfig)> complete) {
|
||||||
|
// Copied up front: the constructor call below move-captures runtime_paths,
|
||||||
|
// and argument evaluation order is unspecified, so reading the path out of
|
||||||
|
// it in the same expression can observe the moved-from value.
|
||||||
|
const auto game_root = runtime_paths.game_data_root;
|
||||||
|
|
||||||
auto pick_source = []() { return PickIsoFile(); };
|
auto pick_source = []() { return PickIsoFile(); };
|
||||||
auto install = [game_root = runtime_paths.game_data_root](
|
auto install = [game_root](const std::filesystem::path& source,
|
||||||
const std::filesystem::path& source, std::atomic<uint64_t>& copied_bytes,
|
std::atomic<uint64_t>& copied_bytes,
|
||||||
std::atomic<uint64_t>& total_bytes, std::string& error) {
|
std::atomic<uint64_t>& total_bytes, std::string& error) {
|
||||||
XboxIsoReader iso;
|
XboxIsoReader iso;
|
||||||
if (!iso.Open(source, error)) {
|
if (!iso.Open(source, error)) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -387,7 +392,7 @@ void ShowRexglueIsoInstallWizard(rex::ui::ImGuiDrawer* drawer, rex::PathConfig r
|
||||||
new rex::ui::InstallWizardDialog(
|
new rex::ui::InstallWizardDialog(
|
||||||
drawer, "Setup", "Game Files",
|
drawer, "Setup", "Game Files",
|
||||||
"Skate 3 game files were not found. Select your Xbox 360 ISO to install them.",
|
"Skate 3 game files were not found. Select your Xbox 360 ISO to install them.",
|
||||||
runtime_paths.game_data_root.string(), std::move(pick_source), std::move(install),
|
game_root.string(), std::move(pick_source), std::move(install),
|
||||||
[runtime_paths = std::move(runtime_paths), complete = std::move(complete)]() mutable {
|
[runtime_paths = std::move(runtime_paths), complete = std::move(complete)]() mutable {
|
||||||
if (complete) {
|
if (complete) {
|
||||||
complete(std::move(runtime_paths));
|
complete(std::move(runtime_paths));
|
||||||
|
|
|
||||||
287
src/skate3_native_ghost_dialog.cpp
Normal file
287
src/skate3_native_ghost_dialog.cpp
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
#include "skate3_native_ghost_dialog.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <imgui.h>
|
||||||
|
|
||||||
|
#include <rex/cvar.h>
|
||||||
|
#include <rex/logging.h>
|
||||||
|
|
||||||
|
#include "native/skate3_native_ghost.h"
|
||||||
|
#include "skate3_native_scene.h"
|
||||||
|
|
||||||
|
REXCVAR_DECLARE(bool, skate3_ghost_enable);
|
||||||
|
REXCVAR_DECLARE(std::string, skate3_ghost_server);
|
||||||
|
REXCVAR_DECLARE(int32_t, skate3_ghost_port);
|
||||||
|
REXCVAR_DECLARE(std::string, skate3_ghost_room);
|
||||||
|
REXCVAR_DECLARE(std::string, skate3_ghost_name);
|
||||||
|
REXCVAR_DECLARE(double, skate3_ghost_rate);
|
||||||
|
REXCVAR_DECLARE(bool, skate3_ghost_draw_local);
|
||||||
|
|
||||||
|
namespace skate3 {
|
||||||
|
|
||||||
|
void GhostDialog::Show() {
|
||||||
|
visible_ = true;
|
||||||
|
SetDrawActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostDialog::Hide() {
|
||||||
|
if (!visible_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visible_ = false;
|
||||||
|
SetDrawActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostDialog::Toggle() {
|
||||||
|
if (visible_) {
|
||||||
|
Hide();
|
||||||
|
} else {
|
||||||
|
Show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostDialog::SyncBuffersFromCvars() {
|
||||||
|
const std::string room = REXCVAR_GET(skate3_ghost_room);
|
||||||
|
if (!room.empty()) {
|
||||||
|
std::snprintf(room_input_, sizeof(room_input_), "%s", room.c_str());
|
||||||
|
} else {
|
||||||
|
// Auto-assigned room: the active code comes from the connection, not the
|
||||||
|
// (empty) cvar - surface it in the input so the user can see/copy it.
|
||||||
|
skate3::native_ghost::Status st;
|
||||||
|
if (skate3::native_ghost::GetStatus(&st) && st.room_len > 0) {
|
||||||
|
std::snprintf(room_input_, sizeof(room_input_), "%.*s",
|
||||||
|
int(st.room_len), st.room);
|
||||||
|
} else {
|
||||||
|
room_input_[0] = '\0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::string server = REXCVAR_GET(skate3_ghost_server);
|
||||||
|
std::snprintf(server_input_, sizeof(server_input_), "%s", server.c_str());
|
||||||
|
const std::string name = REXCVAR_GET(skate3_ghost_name);
|
||||||
|
std::snprintf(name_input_, sizeof(name_input_), "%s", name.c_str());
|
||||||
|
synced_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostDialog::OnDraw(ImGuiIO& io) {
|
||||||
|
(void)io;
|
||||||
|
if (!visible_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!synced_) {
|
||||||
|
SyncBuffersFromCvars();
|
||||||
|
}
|
||||||
|
bool open = visible_;
|
||||||
|
ImGui::SetNextWindowSize(ImVec2(380.0f, 0.0f), ImGuiCond_FirstUseEver);
|
||||||
|
if (!ImGui::Begin("Ghost Multiplayer (M)", &open,
|
||||||
|
ImGuiWindowFlags_NoCollapse)) {
|
||||||
|
ImGui::End();
|
||||||
|
if (!open) {
|
||||||
|
Hide();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool enable = REXCVAR_GET(skate3_ghost_enable);
|
||||||
|
if (ImGui::Checkbox("Enable ghost multiplayer", &enable)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_enable, enable);
|
||||||
|
if (enable) {
|
||||||
|
REXLOG_INFO("ghost: multiplayer enabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::TextDisabled("(relays your position, draws remote players)");
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
ImGui::Text("Room code");
|
||||||
|
if (ImGui::InputText("##ghost_room", room_input_, sizeof(room_input_),
|
||||||
|
ImGuiInputTextFlags_EnterReturnsTrue |
|
||||||
|
ImGuiInputTextFlags_CharsUppercase)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_room, std::string(room_input_));
|
||||||
|
ImGui::SetKeyboardFocusHere(-1);
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
// Copy the ACTIVE room (the one this player is actually in), which for an
|
||||||
|
// auto-assigned room lives in the connection state, not the cvar.
|
||||||
|
if (ImGui::Button("Copy")) {
|
||||||
|
skate3::native_ghost::Status st;
|
||||||
|
if (skate3::native_ghost::GetStatus(&st) && st.room_len > 0) {
|
||||||
|
char buf[skate3::native_ghost::kGhostMaxRoomLen + 1] = {};
|
||||||
|
std::memcpy(buf, st.room, st.room_len);
|
||||||
|
ImGui::SetClipboardText(buf);
|
||||||
|
copy_feedback_ = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (copy_feedback_) {
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::TextDisabled("copied");
|
||||||
|
copy_feedback_ = false;
|
||||||
|
}
|
||||||
|
ImGui::TextDisabled("Empty = the server assigns one; share it with friends.");
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
ImGui::Text("Display name");
|
||||||
|
if (ImGui::InputText("##ghost_name", name_input_, sizeof(name_input_),
|
||||||
|
ImGuiInputTextFlags_EnterReturnsTrue)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_name, std::string(name_input_));
|
||||||
|
ImGui::SetKeyboardFocusHere(-1);
|
||||||
|
}
|
||||||
|
ImGui::TextDisabled("Shown on your nameplate above the ghost. Empty = "
|
||||||
|
"your OS user name.");
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
if (ImGui::CollapsingHeader("Browse rooms (lobby)", ImGuiTreeNodeFlags_DefaultOpen)) {
|
||||||
|
if (ImGui::Button("Refresh rooms")) {
|
||||||
|
skate3::native_ghost::RequestRooms();
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
skate3::native_ghost::RoomList rl;
|
||||||
|
const bool have = skate3::native_ghost::GetRooms(&rl);
|
||||||
|
ImGui::TextDisabled(have ? "%u room(s) on the relay" : "no room list yet");
|
||||||
|
if (have) {
|
||||||
|
for (uint8_t i = 0; i < rl.count; ++i) {
|
||||||
|
const skate3::native_ghost::RoomEntry& e = rl.rooms[i];
|
||||||
|
std::string label(e.room, e.room_len);
|
||||||
|
label += " (" + std::to_string(int(e.member_count)) + " player" +
|
||||||
|
(e.member_count == 1 ? "" : "s") + ")";
|
||||||
|
std::string names;
|
||||||
|
for (uint8_t n = 0; n < e.shown_members; ++n) {
|
||||||
|
if (n != 0) {
|
||||||
|
names += ", ";
|
||||||
|
}
|
||||||
|
names += std::string(e.member_names[n], e.member_name_len[n]);
|
||||||
|
}
|
||||||
|
if (!names.empty()) {
|
||||||
|
label += " [" + names + "]";
|
||||||
|
}
|
||||||
|
const std::string code(e.room, e.room_len);
|
||||||
|
if (ImGui::Selectable(label.c_str(), false)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_room, code);
|
||||||
|
std::snprintf(room_input_, sizeof(room_input_), "%s", code.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::TextDisabled("Click a room to join it (applies on the next "
|
||||||
|
"heartbeat).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
ImGui::Text("Relay server (optional)");
|
||||||
|
ImGui::PushItemWidth(-1.0f);
|
||||||
|
if (ImGui::InputText("##ghost_server", server_input_, sizeof(server_input_),
|
||||||
|
ImGuiInputTextFlags_EnterReturnsTrue)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_server, std::string(server_input_));
|
||||||
|
ImGui::SetKeyboardFocusHere(-1);
|
||||||
|
}
|
||||||
|
ImGui::PopItemWidth();
|
||||||
|
ImGui::TextDisabled("Empty = built-in default relay. No port forwarding "
|
||||||
|
"needed on any client.");
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
int port = int(REXCVAR_GET(skate3_ghost_port));
|
||||||
|
if (ImGui::SliderInt("Port", &port, 1024, 65535)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_port, int32_t(port));
|
||||||
|
}
|
||||||
|
const double rate = REXCVAR_GET(skate3_ghost_rate);
|
||||||
|
rate_int_ = int(rate + 0.5);
|
||||||
|
if (ImGui::SliderInt("Broadcast rate (Hz)", &rate_int_, 5, 60)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_rate, double(rate_int_));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool draw_local = REXCVAR_GET(skate3_ghost_draw_local);
|
||||||
|
if (ImGui::Checkbox("Show my own cube (self-check)", &draw_local)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_draw_local, draw_local);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
skate3::native_ghost::Status st;
|
||||||
|
skate3::native_ghost::GetStatus(&st);
|
||||||
|
ImGui::Text("Status:");
|
||||||
|
ImGui::Text(" Enabled: %s", st.enabled ? "yes" : "no");
|
||||||
|
ImGui::Text(" Connected: %s%s", st.connected ? "yes" : "no",
|
||||||
|
st.hosting ? " (LAN host)" : "");
|
||||||
|
ImGui::Text(" Session: %u", st.session_id);
|
||||||
|
ImGui::Text(" Room: %.*s", int(st.room_len), st.room);
|
||||||
|
ImGui::Text(" In room: %zu player(s)", st.roster + 1);
|
||||||
|
ImGui::Text(" Ghosts drawn: %zu", st.ghosts);
|
||||||
|
if (st.peer[0] != '\0') {
|
||||||
|
ImGui::Text(" Relay: %s", st.peer);
|
||||||
|
}
|
||||||
|
if (st.refused) {
|
||||||
|
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.3f, 1.0f),
|
||||||
|
" Refused by relay (protocol version mismatch)");
|
||||||
|
}
|
||||||
|
if (st.last_error[0] != '\0') {
|
||||||
|
ImGui::TextWrapped(" %s", st.last_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::End();
|
||||||
|
if (!open) {
|
||||||
|
Hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostNameplateOverlay::OnDraw(ImGuiIO& io) {
|
||||||
|
if (!REXCVAR_GET(skate3_ghost_enable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (io.DisplaySize.x < 16.0f || io.DisplaySize.y < 16.0f) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
float vp[16];
|
||||||
|
float cam[3];
|
||||||
|
if (!skate3::native_scene::GetPublishedCamera(vp, cam)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uint32_t count = skate3::native_ghost::GhostCount();
|
||||||
|
if (count == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ImDrawList* dl = ImGui::GetBackgroundDrawList();
|
||||||
|
const float w = io.DisplaySize.x;
|
||||||
|
const float h = io.DisplaySize.y;
|
||||||
|
for (uint32_t i = 0; i < count; ++i) {
|
||||||
|
float pos[3];
|
||||||
|
float color[3];
|
||||||
|
char name[32];
|
||||||
|
if (!skate3::native_ghost::GetGhostLabel(i, pos, color, name,
|
||||||
|
sizeof(name))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Project the point ~1.75 m above the entity origin (roughly the head).
|
||||||
|
const float p[3] = {pos[0], pos[1] + 1.75f, pos[2]};
|
||||||
|
float c[4];
|
||||||
|
for (int r = 0; r < 4; ++r) {
|
||||||
|
c[r] = p[0] * vp[r] + p[1] * vp[4 + r] + p[2] * vp[8 + r] + vp[12 + r];
|
||||||
|
}
|
||||||
|
if (c[3] < 0.01f) {
|
||||||
|
continue; // behind the camera
|
||||||
|
}
|
||||||
|
const float ndc_x = c[0] / c[3];
|
||||||
|
const float ndc_y = c[1] / c[3];
|
||||||
|
if (ndc_x < -1.6f || ndc_x > 1.6f || ndc_y < -1.6f || ndc_y > 1.6f) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const float sx = (ndc_x * 0.5f + 0.5f) * w;
|
||||||
|
const float sy = (1.0f - (ndc_y * 0.5f + 0.5f)) * h;
|
||||||
|
const ImU32 text_col =
|
||||||
|
IM_COL32(int(color[0] * 255.0f), int(color[1] * 255.0f),
|
||||||
|
int(color[2] * 255.0f), 235);
|
||||||
|
const ImVec2 text_size = ImGui::CalcTextSize(name);
|
||||||
|
// Small dark backdrop so the label stays readable over the world.
|
||||||
|
dl->AddRectFilled(ImVec2(sx - 4.0f, sy - 14.0f),
|
||||||
|
ImVec2(sx + text_size.x + 4.0f, sy + 2.0f),
|
||||||
|
IM_COL32(0, 0, 0, 140));
|
||||||
|
dl->AddText(ImVec2(sx, sy - 14.0f), text_col, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace skate3
|
||||||
49
src/skate3_native_ghost_dialog.h
Normal file
49
src/skate3_native_ghost_dialog.h
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Ghost multiplayer UI: the multiplayer menu (GhostDialog, bound to M) and
|
||||||
|
// the always-present nameplate overlay (GhostNameplateOverlay) that labels
|
||||||
|
// each remote ghost with its owner's display name.
|
||||||
|
|
||||||
|
#include <rex/ui/imgui_dialog.h>
|
||||||
|
|
||||||
|
#include "native/skate3_native_ghost_proto.h"
|
||||||
|
|
||||||
|
namespace skate3 {
|
||||||
|
|
||||||
|
class GhostDialog final : public rex::ui::ImGuiDialog {
|
||||||
|
public:
|
||||||
|
explicit GhostDialog(rex::ui::ImGuiDrawer* drawer) : ImGuiDialog(drawer) {}
|
||||||
|
|
||||||
|
void Show();
|
||||||
|
void Hide();
|
||||||
|
void Toggle();
|
||||||
|
bool visible() const { return visible_; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void OnDraw(ImGuiIO& io) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void SyncBuffersFromCvars();
|
||||||
|
|
||||||
|
bool visible_ = false;
|
||||||
|
bool synced_ = false;
|
||||||
|
bool copy_feedback_ = false;
|
||||||
|
char room_input_[16] = {};
|
||||||
|
char server_input_[128] = {};
|
||||||
|
char name_input_[skate3::native_ghost::kGhostMaxNameLen + 1] = {};
|
||||||
|
int rate_int_ = 20;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draws a small colored nameplate above every remote ghost whenever ghost
|
||||||
|
// multiplayer is enabled. Registers like the render-mode indicator (always
|
||||||
|
// present, draws only while active).
|
||||||
|
class GhostNameplateOverlay final : public rex::ui::ImGuiDialog {
|
||||||
|
public:
|
||||||
|
explicit GhostNameplateOverlay(rex::ui::ImGuiDrawer* drawer)
|
||||||
|
: ImGuiDialog(drawer) {}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void OnDraw(ImGuiIO& io) override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace skate3
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
#include "native/skate3_native_diag.h"
|
#include "native/skate3_native_diag.h"
|
||||||
#include "native/skate3_native_entity.h"
|
#include "native/skate3_native_entity.h"
|
||||||
|
#include "native/skate3_native_ghost.h"
|
||||||
|
#include "native/skate3_native_guest_read.h"
|
||||||
#include "native/skate3_native_lw.h"
|
#include "native/skate3_native_lw.h"
|
||||||
#include "native/skate3_native_palette.h"
|
#include "native/skate3_native_palette.h"
|
||||||
#include "skate3_native_scene.h"
|
#include "skate3_native_scene.h"
|
||||||
|
|
@ -104,6 +106,7 @@ void OnSceneDrawList(uint8_t* base, uint32_t view, uint32_t sort_vec, uint32_t f
|
||||||
if (count == 0 || count > 100000) {
|
if (count == 0 || count > 100000) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
skate3::native_scene::GuestReadRecoveryScope guest_read_recovery(base);
|
||||||
const uint32_t entries = REX_LOAD_U32(sort_vec);
|
const uint32_t entries = REX_LOAD_U32(sort_vec);
|
||||||
if (entries == 0) {
|
if (entries == 0) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -169,6 +172,7 @@ uint32_t FilterSceneDrawList(uint8_t* base, uint32_t sort_vec, uint32_t first,
|
||||||
if (f.ctxs.empty()) {
|
if (f.ctxs.empty()) {
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
skate3::native_scene::GuestReadRecoveryScope guest_read_recovery(base);
|
||||||
const uint32_t entries = REX_LOAD_U32(sort_vec);
|
const uint32_t entries = REX_LOAD_U32(sort_vec);
|
||||||
if (entries == 0) {
|
if (entries == 0) {
|
||||||
return count;
|
return count;
|
||||||
|
|
@ -742,6 +746,7 @@ extern "C" REX_FUNC(sub_827F0D00) {
|
||||||
if (!(max_expo > 0.0f) || max_expo > 16.0f) {
|
if (!(max_expo > 0.0f) || max_expo > 16.0f) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
skate3::native_scene::GuestReadRecoveryScope guest_read_recovery(base);
|
||||||
uint32_t prev_bits = REX_LOAD_U32(self + 3240);
|
uint32_t prev_bits = REX_LOAD_U32(self + 3240);
|
||||||
float prev;
|
float prev;
|
||||||
std::memcpy(&prev, &prev_bits, sizeof(prev));
|
std::memcpy(&prev, &prev_bits, sizeof(prev));
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
|
|
||||||
#include "native/skate3_native_diag.h"
|
#include "native/skate3_native_diag.h"
|
||||||
#include "native/skate3_native_entity.h"
|
#include "native/skate3_native_entity.h"
|
||||||
|
#include "native/skate3_native_ghost.h"
|
||||||
#include "native/skate3_native_guest_read.h"
|
#include "native/skate3_native_guest_read.h"
|
||||||
#include "native/skate3_native_lw.h"
|
#include "native/skate3_native_lw.h"
|
||||||
#include "native/skate3_native_palette.h"
|
#include "native/skate3_native_palette.h"
|
||||||
|
|
@ -2755,6 +2756,16 @@ bool ToggleSceneEnabled() {
|
||||||
return enabled;
|
return enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool GetPublishedCamera(float view_proj[16], float cam_pos[3]) {
|
||||||
|
std::lock_guard<std::mutex> lock(g_scene_mutex);
|
||||||
|
if (!g_scene) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::memcpy(view_proj, g_scene->view_proj, sizeof(float) * 16);
|
||||||
|
std::memcpy(cam_pos, g_scene->cam_pos, sizeof(float) * 3);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void OnRegisterTexture(uint64_t guid, uint32_t texture) {
|
void OnRegisterTexture(uint64_t guid, uint32_t texture) {
|
||||||
if (texture == 0) {
|
if (texture == 0) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -3356,6 +3367,7 @@ void OnSetViewport(uint8_t* base, uint32_t viewport_ptr) {
|
||||||
if (viewport_ptr == 0) {
|
if (viewport_ptr == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
GuestReadRecoveryScope guest_read_recovery(base);
|
||||||
for (int i = 0; i < 6; ++i) {
|
for (int i = 0; i < 6; ++i) {
|
||||||
g_cur_viewport[i].store(REX_LOAD_U32(viewport_ptr + i * 4), std::memory_order_relaxed);
|
g_cur_viewport[i].store(REX_LOAD_U32(viewport_ptr + i * 4), std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
|
@ -3365,6 +3377,7 @@ void OnSetScissor(uint8_t* base, uint32_t rect_ptr) {
|
||||||
if (rect_ptr == 0) {
|
if (rect_ptr == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
GuestReadRecoveryScope guest_read_recovery(base);
|
||||||
for (int i = 0; i < 4; ++i) {
|
for (int i = 0; i < 4; ++i) {
|
||||||
g_cur_scissor[i].store(REX_LOAD_U32(rect_ptr + i * 4), std::memory_order_relaxed);
|
g_cur_scissor[i].store(REX_LOAD_U32(rect_ptr + i * 4), std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
|
@ -4129,6 +4142,10 @@ uint32_t CaptureDynamicState(uint8_t* base, uint32_t ctx, bool world_path,
|
||||||
if (bank == 0) {
|
if (bank == 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
// Streaming can revoke any pointer in the ctx/record/mesh chain between
|
||||||
|
// the game queueing the draw and this walk; recover raw-load read faults
|
||||||
|
// for the whole capture (POSIX; no-op on Windows).
|
||||||
|
GuestReadRecoveryScope guest_read_recovery(base);
|
||||||
// Items drawn inside an AUX perspective pass (skater-portrait RTTs) never
|
// Items drawn inside an AUX perspective pass (skater-portrait RTTs) never
|
||||||
// enter the frame at all: they share (ib,vb) buffers with the on-screen
|
// enter the frame at all: they share (ib,vb) buffers with the on-screen
|
||||||
// player, so letting them sit PENDING lets them steal the player's own
|
// player, so letting them sit PENDING lets them steal the player's own
|
||||||
|
|
@ -4373,6 +4390,7 @@ uint32_t CaptureClothDraw(uint8_t* base, uint32_t r4, uint32_t r5, uint32_t r6,
|
||||||
if (!GuestReadableApprox(base, vb_obj)) {
|
if (!GuestReadableApprox(base, vb_obj)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
GuestReadRecoveryScope guest_read_recovery(base);
|
||||||
const uint32_t addr = REX_LOAD_U32(vb_obj + 0x18) & 0xFFFFFFFC;
|
const uint32_t addr = REX_LOAD_U32(vb_obj + 0x18) & 0xFFFFFFFC;
|
||||||
const uint32_t size = REX_LOAD_U32(vb_obj + 0x20);
|
const uint32_t size = REX_LOAD_U32(vb_obj + 0x20);
|
||||||
constexpr uint32_t kStride = 24;
|
constexpr uint32_t kStride = 24;
|
||||||
|
|
@ -4512,6 +4530,9 @@ void OnDrawDone(uint8_t* base, uint32_t func, uint32_t r4, uint32_t r5, uint32_t
|
||||||
if (bank == 0) {
|
if (bank == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The bank / PS-bank / pending-fixup reads below are raw loads; recover
|
||||||
|
// read faults for the whole post-draw path (POSIX; no-op on Windows).
|
||||||
|
GuestReadRecoveryScope guest_read_recovery(base);
|
||||||
// Fog rows: grab c5/c6 from the first 3D draw whose bank c4 row matches
|
// Fog rows: grab c5/c6 from the first 3D draw whose bank c4 row matches
|
||||||
// the last built scene's camera (main-pass layout; tolerant of one frame
|
// the last built scene's camera (main-pass layout; tolerant of one frame
|
||||||
// of camera motion). See g_fog_rows. The same camera-keyed draws also
|
// of camera motion). See g_fog_rows. The same camera-keyed draws also
|
||||||
|
|
@ -8286,6 +8307,10 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) {
|
||||||
if (!SceneEnabled()) {
|
if (!SceneEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The frame-end walks below chase captured pointers whose ranges world
|
||||||
|
// streaming may have revoked during the frame; recover raw-load read
|
||||||
|
// faults for the whole build (POSIX; no-op on Windows).
|
||||||
|
GuestReadRecoveryScope guest_read_recovery(base);
|
||||||
// Published every frame (not just on world submissions): boot/menu frames
|
// Published every frame (not just on world submissions): boot/menu frames
|
||||||
// carry only 2D, and the render thread's 2D texture decodes need the
|
// carry only 2D, and the render thread's 2D texture decodes need the
|
||||||
// guest base from the very first natively rendered boot frame.
|
// guest base from the very first natively rendered boot frame.
|
||||||
|
|
@ -10432,6 +10457,9 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) {
|
||||||
std::chrono::steady_clock::now().time_since_epoch())
|
std::chrono::steady_clock::now().time_since_epoch())
|
||||||
.count(),
|
.count(),
|
||||||
std::memory_order_relaxed);
|
std::memory_order_relaxed);
|
||||||
|
// Ghost multiplayer: snapshot the local player's skeleton from the final
|
||||||
|
// item list (the pose this client broadcasts to the relay).
|
||||||
|
skate3::native_ghost::OnFrameSceneBuilt(base, scene);
|
||||||
std::lock_guard<std::mutex> lock(g_scene_mutex);
|
std::lock_guard<std::mutex> lock(g_scene_mutex);
|
||||||
scene.generation = ++g_generation;
|
scene.generation = ++g_generation;
|
||||||
g_scene = std::make_shared<const FrameScene>(std::move(scene));
|
g_scene = std::make_shared<const FrameScene>(std::move(scene));
|
||||||
|
|
|
||||||
|
|
@ -689,6 +689,12 @@ void Install();
|
||||||
// layer was not enabled at boot.
|
// layer was not enabled at boot.
|
||||||
bool ToggleSceneEnabled();
|
bool ToggleSceneEnabled();
|
||||||
|
|
||||||
|
// Snapshot of the latest published frame's camera (smoothed view-proj +
|
||||||
|
// world position). Thread-safe (any thread); returns false before the first
|
||||||
|
// publish. Used by the ghost nameplate overlay to project world positions
|
||||||
|
// into screen space on the UI thread.
|
||||||
|
bool GetPublishedCamera(float view_proj[16], float cam_pos[3]);
|
||||||
|
|
||||||
// True once the scene renderer has hit an unrecoverable pipeline/resource
|
// True once the scene renderer has hit an unrecoverable pipeline/resource
|
||||||
// failure and yields to the emulated output until re-enabled. Surfaced so
|
// failure and yields to the emulated output until re-enabled. Surfaced so
|
||||||
// the render-mode indicator can show the degraded state even though the
|
// the render-mode indicator can show the degraded state even though the
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
|
|
||||||
#include "native/skate3_native_diag.h"
|
#include "native/skate3_native_diag.h"
|
||||||
#include "native/skate3_native_entity.h"
|
#include "native/skate3_native_entity.h"
|
||||||
|
#include "native/skate3_native_ghost.h"
|
||||||
#include "native/skate3_native_guest_read.h"
|
#include "native/skate3_native_guest_read.h"
|
||||||
#include "native/skate3_native_lw.h"
|
#include "native/skate3_native_lw.h"
|
||||||
#include "native/skate3_native_palette.h"
|
#include "native/skate3_native_palette.h"
|
||||||
|
|
@ -126,6 +127,8 @@ REXCVAR_DECLARE(bool, skate3_native_render_scene_tex_revalidate);
|
||||||
REXCVAR_DECLARE(bool, skate3_native_render_scene_transparents);
|
REXCVAR_DECLARE(bool, skate3_native_render_scene_transparents);
|
||||||
REXCVAR_DECLARE(bool, skate3_native_render_scene_world_v2);
|
REXCVAR_DECLARE(bool, skate3_native_render_scene_world_v2);
|
||||||
REXCVAR_DECLARE(double, skate3_menu_blur_sigma);
|
REXCVAR_DECLARE(double, skate3_menu_blur_sigma);
|
||||||
|
REXCVAR_DECLARE(bool, skate3_ghost_enable);
|
||||||
|
REXCVAR_DECLARE(bool, skate3_ghost_draw_local);
|
||||||
REXCVAR_DECLARE(double, skate3_native_render_scene_2d_sharp);
|
REXCVAR_DECLARE(double, skate3_native_render_scene_2d_sharp);
|
||||||
REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_intensity);
|
REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_intensity);
|
||||||
REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_knee);
|
REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_knee);
|
||||||
|
|
@ -3634,6 +3637,12 @@ bool EnsureShadowResources(const NativeGuestOutputRenderContext& context) {
|
||||||
nrhi::TextureView** views[3] = {&g_r.shadow_srv_raw, &g_r.shadow_srv_mid,
|
nrhi::TextureView** views[3] = {&g_r.shadow_srv_raw, &g_r.shadow_srv_mid,
|
||||||
&g_r.shadow_srv_final};
|
&g_r.shadow_srv_final};
|
||||||
for (int t = 0; t < 3; ++t) {
|
for (int t = 0; t < 3; ++t) {
|
||||||
|
// The raw and final tiles are the shadow-dump readback sources, so
|
||||||
|
// those two also need copy-source usage; the intermediate is never
|
||||||
|
// copied and stays render-target only.
|
||||||
|
desc.usage = nrhi::kTextureUsageRenderTarget |
|
||||||
|
(t != 1 ? nrhi::kTextureUsageCopySource
|
||||||
|
: nrhi::kTextureUsageNone);
|
||||||
*targets[t] = device->CreateTexture(desc);
|
*targets[t] = device->CreateTexture(desc);
|
||||||
if (*targets[t] == nullptr) {
|
if (*targets[t] == nullptr) {
|
||||||
REXLOG_ERROR("native-scene: shadow atlas creation failed");
|
REXLOG_ERROR("native-scene: shadow atlas creation failed");
|
||||||
|
|
@ -4706,6 +4715,10 @@ void PrewarmWorkerLoop() {
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Decode workers only ever read guest memory on the renderer's behalf:
|
||||||
|
// keep them permanently armed for raw-load read-fault recovery (POSIX;
|
||||||
|
// no-op on Windows).
|
||||||
|
ArmGuestReadRecoveryForThread(base);
|
||||||
if (have_dyn) {
|
if (have_dyn) {
|
||||||
PrewarmResult res;
|
PrewarmResult res;
|
||||||
res.mesh_valid =
|
res.mesh_valid =
|
||||||
|
|
@ -7022,6 +7035,22 @@ void RenderOutlineComposite(const NativeGuestOutputRenderContext& context,
|
||||||
void LogFrameStats(const FrameScene& scene, uint64_t frames, uint32_t drawn,
|
void LogFrameStats(const FrameScene& scene, uint64_t frames, uint32_t drawn,
|
||||||
uint32_t drawn_2d, uint32_t drawn_spline, bool shadow_ready,
|
uint32_t drawn_2d, uint32_t drawn_spline, bool shadow_ready,
|
||||||
uint32_t shadow_draws) {
|
uint32_t shadow_draws) {
|
||||||
|
// Raw guest-load read-fault recoveries (POSIX only; always 0 on Windows).
|
||||||
|
// Each one is a streaming race that revoked a captured range mid-walk and
|
||||||
|
// would otherwise be a fatal SIGSEGV; a slowly growing count during heavy
|
||||||
|
// world streaming is expected and benign.
|
||||||
|
{
|
||||||
|
static uint64_t s_recov_reported = 0;
|
||||||
|
static uint64_t s_recov_frame = 0;
|
||||||
|
const uint64_t recov = GuestReadRecoveryCount();
|
||||||
|
if (recov != s_recov_reported &&
|
||||||
|
(s_recov_frame == 0 || frames - s_recov_frame >= 600)) {
|
||||||
|
REXLOG_INFO("native-scene: guest read faults recovered total={} (+{})",
|
||||||
|
recov, recov - s_recov_reported);
|
||||||
|
s_recov_reported = recov;
|
||||||
|
s_recov_frame = frames;
|
||||||
|
}
|
||||||
|
}
|
||||||
const uint64_t interval = uint64_t(
|
const uint64_t interval = uint64_t(
|
||||||
std::max(60, REXCVAR_GET(skate3_native_render_scene_perf_interval)));
|
std::max(60, REXCVAR_GET(skate3_native_render_scene_perf_interval)));
|
||||||
if (frames % interval == 0 && REXCVAR_GET(skate3_native_render_scene_perf_log)) {
|
if (frames % interval == 0 && REXCVAR_GET(skate3_native_render_scene_perf_log)) {
|
||||||
|
|
@ -7532,6 +7561,11 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
|
||||||
// flow renders yielded-emulated or native).
|
// flow renders yielded-emulated or native).
|
||||||
uint8_t* base = g_guest_base.load(std::memory_order_relaxed);
|
uint8_t* base = g_guest_base.load(std::memory_order_relaxed);
|
||||||
if (base != nullptr) {
|
if (base != nullptr) {
|
||||||
|
// The render thread reads guest memory for the rest of the frame (mesh
|
||||||
|
// decode, texture fingerprints, dynamic-item interpolation) and runs no
|
||||||
|
// guest code of its own: keep it permanently armed for raw-load
|
||||||
|
// read-fault recovery (POSIX; no-op on Windows).
|
||||||
|
ArmGuestReadRecoveryForThread(base);
|
||||||
UpdatePhotoGrabWindow(base);
|
UpdatePhotoGrabWindow(base);
|
||||||
}
|
}
|
||||||
if (YieldForMenus(context)) {
|
if (YieldForMenus(context)) {
|
||||||
|
|
@ -10308,6 +10342,215 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
|
||||||
uint32_t(frame_number % RendererState::kUiRegions) * RendererState::kUiRegionSize;
|
uint32_t(frame_number % RendererState::kUiRegions) * RendererState::kUiRegionSize;
|
||||||
uint32_t ui_offset = 0;
|
uint32_t ui_offset = 0;
|
||||||
|
|
||||||
|
// ---- Ghost multiplayer (native/skate3_native_ghost) ---------------------
|
||||||
|
// Full-character ghosts: each remote player's skater skeleton (broadcast
|
||||||
|
// in the POSE packets) drives a CLONE of this player's own character
|
||||||
|
// pieces - same meshes, same textures, same cached GPU decodes, remote
|
||||||
|
// pose in the bone ring. Ghosts with no pose data (older peer, capture
|
||||||
|
// not ready) fall back to a solid-color cube.
|
||||||
|
if (REXCVAR_GET(skate3_ghost_enable) && g_r.pso != nullptr &&
|
||||||
|
g_r.ui_ring_cpu != nullptr && debug_mode == 0) {
|
||||||
|
const uint32_t n_ghosts = skate3::native_ghost::GhostCount();
|
||||||
|
float local_world[16];
|
||||||
|
const bool draw_local =
|
||||||
|
REXCVAR_GET(skate3_ghost_draw_local) &&
|
||||||
|
skate3::native_ghost::GetLocalWorld(local_world);
|
||||||
|
if (n_ghosts != 0 || draw_local) {
|
||||||
|
// The mesh source: the local player's character pieces (body + nearby
|
||||||
|
// skinned non-ropa, non-LivingWorld items), found the same way the
|
||||||
|
// ghost capture on the game thread finds the body.
|
||||||
|
std::vector<const DrawItem*> player_pieces;
|
||||||
|
// The local player's cloth garments (rigid character.cloth_ropa items:
|
||||||
|
// CPU-sim deformed ROOT-LOCAL VB, drawn with the entity world affine).
|
||||||
|
// Cloned for ghosts and rendered with the REMOTE entity world so the
|
||||||
|
// ghost keeps its tee/pants, draped to its root frame.
|
||||||
|
std::vector<const DrawItem*> ghost_cloth;
|
||||||
|
{
|
||||||
|
const DrawItem* body = nullptr;
|
||||||
|
uint32_t best_bones = 0;
|
||||||
|
float body_p[3] = {};
|
||||||
|
for (const DrawItem& it : scene.items) {
|
||||||
|
if (!(it.skinned && it.bones.size() >= 12) || it.ropa ||
|
||||||
|
it.lw_entity != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (it.char_family < 1 || it.char_family > 5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const float px = it.bones[3], py = it.bones[7], pz = it.bones[11];
|
||||||
|
const float dx = px - scene.cam_pos[0], dy = py - scene.cam_pos[1],
|
||||||
|
dz = pz - scene.cam_pos[2];
|
||||||
|
if (dx * dx + dy * dy + dz * dz > 25.0f) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uint32_t nb = uint32_t(it.bones.size() / 12);
|
||||||
|
if (nb > best_bones) {
|
||||||
|
best_bones = nb;
|
||||||
|
body = ⁢
|
||||||
|
body_p[0] = px;
|
||||||
|
body_p[1] = py;
|
||||||
|
body_p[2] = pz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body != nullptr) {
|
||||||
|
player_pieces.push_back(body);
|
||||||
|
for (const DrawItem& it : scene.items) {
|
||||||
|
if (&it == body) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(it.skinned && it.bones.size() >= 12) || it.ropa ||
|
||||||
|
it.lw_entity != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const float px = it.bones[3], py = it.bones[7], pz = it.bones[11];
|
||||||
|
const float dx = px - body_p[0], dy = py - body_p[1],
|
||||||
|
dz = pz - body_p[2];
|
||||||
|
if (dx * dx + dy * dy + dz * dz <= 4.0f) {
|
||||||
|
player_pieces.push_back(&it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cloth garments near the body (world translation = the entity
|
||||||
|
// position; only the rigid state has a root-local deformed VB).
|
||||||
|
for (const DrawItem& it : scene.items) {
|
||||||
|
if (!it.ropa || !it.bones.empty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const float px = it.world[12], py = it.world[13], pz = it.world[14];
|
||||||
|
const float dx = px - body_p[0], dy = py - body_p[1],
|
||||||
|
dz = pz - body_p[2];
|
||||||
|
if (dx * dx + dy * dy + dz * dz <= 6.25f) {
|
||||||
|
ghost_cloth.push_back(&it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cube fallback geometry (also used by skate3_ghost_draw_local).
|
||||||
|
constexpr uint32_t kCubeVerts = 36;
|
||||||
|
const uint32_t kVertBytes =
|
||||||
|
uint32_t(sizeof(skate3::native_ghost::GhostCubeVertex));
|
||||||
|
const uint32_t kCubeBytes = kCubeVerts * kVertBytes;
|
||||||
|
const bool cube_uploaded = ui_offset + kCubeBytes <= RendererState::kUiRegionSize;
|
||||||
|
if (cube_uploaded) {
|
||||||
|
std::memcpy(g_r.ui_ring_cpu + ui_region + ui_offset,
|
||||||
|
skate3::native_ghost::CubeVertices(), kCubeBytes);
|
||||||
|
}
|
||||||
|
const uint32_t cube_offset = ui_offset;
|
||||||
|
if (cube_uploaded) {
|
||||||
|
ui_offset += kCubeBytes;
|
||||||
|
}
|
||||||
|
cmd->SetPipeline(g_r.pso);
|
||||||
|
cmd->SetVertexBuffer(g_r.ui_ring, ui_region + cube_offset, kCubeBytes,
|
||||||
|
kVertBytes);
|
||||||
|
cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList);
|
||||||
|
cmd->SetTexture(1, g_r.white.srv);
|
||||||
|
|
||||||
|
const auto draw_cube = [&](const float world[16], const float color[3]) {
|
||||||
|
if (!cube_uploaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
float constants[52] = {};
|
||||||
|
std::memcpy(constants, world, sizeof(float) * 16);
|
||||||
|
float* mvp = constants + 16;
|
||||||
|
for (int r = 0; r < 4; ++r) {
|
||||||
|
for (int c = 0; c < 4; ++c) {
|
||||||
|
float sum = 0.0f;
|
||||||
|
for (int k = 0; k < 4; ++k) {
|
||||||
|
sum += constants[r * 4 + k] * scene.view_proj[k * 4 + c];
|
||||||
|
}
|
||||||
|
mvp[r * 4 + c] = sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
constants[32] = color[0];
|
||||||
|
constants[33] = color[1];
|
||||||
|
constants[34] = color[2];
|
||||||
|
constants[35] = 1.0f; // tint.a > 0 -> solid color in ShadePixel
|
||||||
|
constants[36] = scene.cam_pos[0];
|
||||||
|
constants[37] = scene.cam_pos[1];
|
||||||
|
constants[38] = scene.cam_pos[2];
|
||||||
|
constants[39] = 0.0f;
|
||||||
|
cmd->SetRootConstants(0, 52, constants, 0);
|
||||||
|
cmd->Draw(kCubeVerts, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const auto draw_model = [&](const float* pose_rows, uint16_t pose_count,
|
||||||
|
const float* ghost_world) {
|
||||||
|
if (player_pieces.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Identity world: the remote skeleton's bone 0 translation already
|
||||||
|
// places the ghost; mvp becomes view*proj, exactly like the real
|
||||||
|
// skinned items (whose bones carry the world).
|
||||||
|
static const float kIdent[16] = {1, 0, 0, 0, 0, 1, 0, 0,
|
||||||
|
0, 0, 1, 0, 0, 0, 0, 1};
|
||||||
|
for (const DrawItem* piece : player_pieces) {
|
||||||
|
DrawItem g = *piece; // clone: same mesh/textures, remote pose
|
||||||
|
const uint32_t need = uint32_t(g.bones.size() / 12);
|
||||||
|
const uint32_t copy_rows = std::min<uint32_t>(pose_count, need);
|
||||||
|
g.bones.resize(size_t(need) * 12);
|
||||||
|
std::memcpy(g.bones.data(), pose_rows,
|
||||||
|
size_t(copy_rows) * 12 * sizeof(float));
|
||||||
|
for (uint32_t r = copy_rows; r < need; ++r) {
|
||||||
|
float* row = g.bones.data() + size_t(r) * 12;
|
||||||
|
row[0] = 1.0f;
|
||||||
|
row[5] = 1.0f;
|
||||||
|
row[10] = 1.0f;
|
||||||
|
}
|
||||||
|
std::memcpy(g.world, kIdent, sizeof(kIdent));
|
||||||
|
g.ropa = false;
|
||||||
|
g.pending = false;
|
||||||
|
g.selected = false;
|
||||||
|
g.retained = false;
|
||||||
|
g.dyn_entity = false;
|
||||||
|
g.unlit = false;
|
||||||
|
g.transparent = false;
|
||||||
|
g.water = false;
|
||||||
|
g.lw_alpha = -1.0f;
|
||||||
|
timed_draw(g);
|
||||||
|
}
|
||||||
|
// Cloth: clone the local player's garments (root-local deformed VB)
|
||||||
|
// and draw them with the REMOTE entity world, so each ghost wears
|
||||||
|
// this player's tee/pants draped in its own root frame.
|
||||||
|
for (const DrawItem* cloth : ghost_cloth) {
|
||||||
|
DrawItem g = *cloth;
|
||||||
|
std::memcpy(g.world, ghost_world, sizeof(g.world));
|
||||||
|
g.bones.clear();
|
||||||
|
g.skinned = false;
|
||||||
|
g.pending = false;
|
||||||
|
g.selected = false;
|
||||||
|
g.retained = false;
|
||||||
|
g.dyn_entity = false;
|
||||||
|
g.unlit = false;
|
||||||
|
g.transparent = false;
|
||||||
|
g.water = false;
|
||||||
|
g.lw_alpha = -1.0f;
|
||||||
|
timed_draw(g);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
float ghost_world[16];
|
||||||
|
float ghost_color[3];
|
||||||
|
for (uint32_t i = 0; i < n_ghosts; ++i) {
|
||||||
|
float pose_scratch[skate3::native_ghost::kGhostMaxBones * 12];
|
||||||
|
uint16_t ghost_rows = 0;
|
||||||
|
if (skate3::native_ghost::GetGhostPose(
|
||||||
|
i, ghost_world, ghost_color,
|
||||||
|
uint16_t(skate3::native_ghost::kGhostMaxBones), pose_scratch,
|
||||||
|
&ghost_rows)) {
|
||||||
|
if (ghost_rows != 0) {
|
||||||
|
draw_model(pose_scratch, ghost_rows, ghost_world);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
draw_cube(ghost_world, ghost_color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (draw_local) {
|
||||||
|
const float local_color[3] = {0.2f, 1.0f, 0.2f};
|
||||||
|
draw_cube(local_world, local_color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// In-world neon splines (waypoint arrows / marker beams): replayed inside
|
// In-world neon splines (waypoint arrows / marker beams): replayed inside
|
||||||
// the scene pass, depth-tested against the world like the emulated frame,
|
// the scene pass, depth-tested against the world like the emulated frame,
|
||||||
// in submission order (darken backdrop passes precede the additive glow).
|
// in submission order (darken backdrop passes precede the additive glow).
|
||||||
|
|
|
||||||
|
|
@ -488,7 +488,11 @@ bool ApplySsaoPass(const NativeGuestOutputRenderContext& context,
|
||||||
td.height = RendererState::kOcclGridH;
|
td.height = RendererState::kOcclGridH;
|
||||||
td.mip_levels = 1;
|
td.mip_levels = 1;
|
||||||
td.format = nrhi::Format::kR32_FLOAT;
|
td.format = nrhi::Format::kR32_FLOAT;
|
||||||
td.usage = nrhi::kTextureUsageRenderTarget;
|
// The grid is read back to the CPU every frame, so it must declare
|
||||||
|
// copy-source usage: the Vulkan backend only requests TRANSFER_SRC for
|
||||||
|
// textures that ask for it, and both the transition to the copy-source
|
||||||
|
// state and the copy itself are invalid without it.
|
||||||
|
td.usage = nrhi::kTextureUsageRenderTarget | nrhi::kTextureUsageCopySource;
|
||||||
td.initial_state = nrhi::ResourceState::kRenderTarget;
|
td.initial_state = nrhi::ResourceState::kRenderTarget;
|
||||||
g_r.occl_tex = device->CreateTexture(td);
|
g_r.occl_tex = device->CreateTexture(td);
|
||||||
nrhi::BufferDesc bd;
|
nrhi::BufferDesc bd;
|
||||||
|
|
|
||||||
2
third_party/rexglue-sdk
vendored
2
third_party/rexglue-sdk
vendored
|
|
@ -1 +1 @@
|
||||||
Subproject commit 9100b278b70c4f0994a47639eb11107c570ee76d
|
Subproject commit 7eb0faf7787f5e01333c228b8e3f03c32f7295ea
|
||||||
44
tools/ci/init-submodules.sh
Executable file
44
tools/ci/init-submodules.sh
Executable file
|
|
@ -0,0 +1,44 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Recursive submodule init for the rexglue SDK with self-healing for broken
|
||||||
|
# upstream pins.
|
||||||
|
#
|
||||||
|
# The rexglue fork pins several nested third-party submodules (imgui, ...) to
|
||||||
|
# commits their upstreams no longer carry, so a plain
|
||||||
|
# `git submodule update --init --recursive` aborts at the first broken pin
|
||||||
|
# and leaves every later nested submodule empty (a fresh clone cannot build).
|
||||||
|
# For each nested submodule still left empty, this script fetches its origin
|
||||||
|
# and checks out the default-branch tip, which is enough to build.
|
||||||
|
#
|
||||||
|
# Run from the repository root after `git submodule update --init` (top
|
||||||
|
# level). No-op when everything initialized fine.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SDK="third_party/rexglue-sdk"
|
||||||
|
if [ ! -e "$SDK/.git" ]; then
|
||||||
|
echo "init-submodules: $SDK is not initialized (run git submodule update --init first)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$SDK"
|
||||||
|
git submodule update --init --recursive --jobs 4 >/tmp/ghost_sub_init.log 2>&1 || true
|
||||||
|
|
||||||
|
while IFS= read -r path; do
|
||||||
|
# A submodule needs self-healing when it has no .git, or its worktree
|
||||||
|
# has no files other than the .git pointer (ls -A counts .git and would
|
||||||
|
# wrongly treat an un-checked-out submodule as initialized).
|
||||||
|
if [ ! -e "$path/.git" ] ||
|
||||||
|
[ -z "$(find "$path" -mindepth 1 -maxdepth 1 ! -name '.git' -print -quit 2>/dev/null)" ]; then
|
||||||
|
echo "init-submodules: self-healing empty submodule $path"
|
||||||
|
if (cd "$path" && git fetch --depth 1 origin >/dev/null 2>&1 &&
|
||||||
|
git checkout -q FETCH_HEAD >/dev/null 2>&1); then
|
||||||
|
echo " ok: $path -> $(git -C "$path" rev-parse --short HEAD)"
|
||||||
|
else
|
||||||
|
echo " WARN: could not self-heal $path (build may fail on this dependency)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < <(git config --file .gitmodules --get-regexp '^submodule\..*\.path$' | awk '{print $2}')
|
||||||
|
)
|
||||||
|
|
||||||
|
exit 0
|
||||||
17
tools/ghost_relay.service
Normal file
17
tools/ghost_relay.service
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Skate 3 recomp ghost multiplayer relay
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/ghost_relay --port 40001 --max-clients 64
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
# Standard service hardening (the relay needs only a UDP socket).
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
86
tools/ghost_relay_deploy.sh
Executable file
86
tools/ghost_relay_deploy.sh
Executable file
|
|
@ -0,0 +1,86 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build, install and register the ghost_relay server as a systemd service.
|
||||||
|
#
|
||||||
|
# Run on the VPS from the repo root (or after copying tools/ and
|
||||||
|
# src/native/skate3_native_ghost_* to the server). The relay is
|
||||||
|
# dependency-free: it needs only a C++23 compiler (g++/clang), no CMake or
|
||||||
|
# rexglue.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./tools/ghost_relay_deploy.sh
|
||||||
|
# GHOST_RELAY_PORT=40002 ./tools/ghost_relay_deploy.sh
|
||||||
|
# GHOST_RELAY_VERBOSE=1 ./tools/ghost_relay_deploy.sh
|
||||||
|
#
|
||||||
|
# Afterwards: systemctl status ghost-relay, journalctl -u ghost-relay -f.
|
||||||
|
# Open the UDP port (the script tries `ufw allow <port>/udp`).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT="${GHOST_RELAY_PORT:-40001}"
|
||||||
|
VERBOSE_FLAG=""
|
||||||
|
if [[ "${GHOST_RELAY_VERBOSE:-0}" == "1" ]]; then
|
||||||
|
VERBOSE_FLAG="--verbose"
|
||||||
|
fi
|
||||||
|
SERVICE_NAME="ghost-relay"
|
||||||
|
BIN_PATH="/usr/local/bin/ghost_relay"
|
||||||
|
SERVICE_PATH="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
echo "==> Building ghost_relay"
|
||||||
|
g++ -std=c++23 -O2 -DSKATE3_RELAY_STANDALONE \
|
||||||
|
-I"${REPO_ROOT}/src/native" \
|
||||||
|
"${REPO_ROOT}/tools/ghost_relay_main.cpp" \
|
||||||
|
"${REPO_ROOT}/src/native/skate3_native_ghost_relay.cpp" \
|
||||||
|
-o /tmp/ghost_relay
|
||||||
|
/tmp/ghost_relay --help >/dev/null 2>&1 || {
|
||||||
|
echo "ERROR: build produced a binary that does not run (missing C++23?)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> Installing to ${BIN_PATH}"
|
||||||
|
sudo install -m 0755 /tmp/ghost_relay "${BIN_PATH}"
|
||||||
|
rm -f /tmp/ghost_relay
|
||||||
|
|
||||||
|
echo "==> Writing ${SERVICE_PATH}"
|
||||||
|
sudo tee "${SERVICE_PATH}" >/dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Skate 3 recomp ghost multiplayer relay
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=${BIN_PATH} --port ${PORT} --max-clients 64 ${VERBOSE_FLAG}
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "==> Enabling + starting ${SERVICE_NAME}"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable "${SERVICE_NAME}" >/dev/null
|
||||||
|
sudo systemctl restart "${SERVICE_NAME}"
|
||||||
|
|
||||||
|
echo "==> Opening UDP ${PORT}"
|
||||||
|
sudo ufw allow "${PORT}/udp" >/dev/null 2>&1 || \
|
||||||
|
echo " (ufw not available or not permitted; open UDP ${PORT} yourself)"
|
||||||
|
|
||||||
|
echo "==> Status"
|
||||||
|
sleep 1
|
||||||
|
sudo systemctl status "${SERVICE_NAME}" --no-pager || true
|
||||||
|
|
||||||
|
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
|
if [[ -z "${IP}" ]]; then IP="<this-server-ip>"; fi
|
||||||
|
echo
|
||||||
|
echo "Done. Clients connect to ${IP}:${PORT}"
|
||||||
|
echo "Logs: journalctl -u ${SERVICE_NAME} -f"
|
||||||
|
echo "Set the compiled-in default in src/native/skate3_native_ghost_proto.h"
|
||||||
|
echo " (kGhostDefaultServer / kGhostDefaultServerPort), or tell players to"
|
||||||
|
echo " set skate3_ghost_server=${IP}:${PORT} in the M menu."
|
||||||
114
tools/ghost_relay_main.cpp
Normal file
114
tools/ghost_relay_main.cpp
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
// ghost_relay: standalone ghost multiplayer relay server.
|
||||||
|
//
|
||||||
|
// Run on a public VPS (or your own machine behind a free tunnel like
|
||||||
|
// playit.gg / ngrok / Tailscale Funnel) so players connect OUTBOUND to it
|
||||||
|
// and need no port forwarding:
|
||||||
|
//
|
||||||
|
// ghost_relay [--port 40001] [--room CODE]
|
||||||
|
//
|
||||||
|
// --room optionally locks every client into one room (ignores JOIN rooms).
|
||||||
|
// The server is stateless across restarts: clients re-send their JOIN every
|
||||||
|
// ~5 s, so a redeployed relay re-gathers the room automatically.
|
||||||
|
//
|
||||||
|
// Build: cmake target `ghost_relay` (no rexglue dependency; plain sockets).
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
#include "skate3_native_ghost_relay.h"
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
#include <csignal>
|
||||||
|
namespace {
|
||||||
|
volatile std::sig_atomic_t g_stop = 0;
|
||||||
|
void OnSignal(int) { g_stop = 1; }
|
||||||
|
} // namespace
|
||||||
|
#endif
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
uint16_t port = skate3::native_ghost::kGhostDefaultPort;
|
||||||
|
std::string locked_room;
|
||||||
|
uint32_t max_clients = 64;
|
||||||
|
bool verbose = false;
|
||||||
|
for (int i = 1; i < argc; ++i) {
|
||||||
|
const std::string arg = argv[i];
|
||||||
|
auto next = [&](const char* name) -> std::string {
|
||||||
|
if (i + 1 < argc) {
|
||||||
|
return argv[++i];
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, "ghost_relay: %s requires a value\n", name);
|
||||||
|
std::exit(2);
|
||||||
|
};
|
||||||
|
if (arg == "--port") {
|
||||||
|
port = uint16_t(std::stoi(next("--port")));
|
||||||
|
} else if (arg == "--room") {
|
||||||
|
locked_room = next("--room");
|
||||||
|
} else if (arg == "--max-clients") {
|
||||||
|
max_clients = uint32_t(std::stoul(next("--max-clients")));
|
||||||
|
} else if (arg == "--verbose") {
|
||||||
|
verbose = true;
|
||||||
|
} else if (arg == "--help" || arg == "-h") {
|
||||||
|
std::printf(
|
||||||
|
"ghost_relay - ghost multiplayer UDP relay\n"
|
||||||
|
"usage: ghost_relay [--port 40001] [--room CODE] [--max-clients 64] "
|
||||||
|
"[--verbose]\n"
|
||||||
|
" --port UDP port to listen on (clients connect here; allow\n"
|
||||||
|
" this port through any firewall, e.g.\n"
|
||||||
|
" `ufw allow 40001/udp`)\n"
|
||||||
|
" --room optional: force every client into one room code\n"
|
||||||
|
" --max-clients hard cap on simultaneously tracked clients (default 64)\n"
|
||||||
|
" --verbose log every join/leave to stdout\n");
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
std::fprintf(stderr, "ghost_relay: unknown argument '%s'\n", arg.c_str());
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
std::signal(SIGINT, OnSignal);
|
||||||
|
std::signal(SIGTERM, OnSignal);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
skate3::native_ghost::GhostRelay relay(port);
|
||||||
|
if (!locked_room.empty()) {
|
||||||
|
relay.SetLockedRoom(locked_room.c_str());
|
||||||
|
}
|
||||||
|
relay.SetMaxClients(max_clients);
|
||||||
|
relay.SetVerbose(verbose);
|
||||||
|
if (!relay.Start()) {
|
||||||
|
std::fprintf(stderr, "ghost_relay: failed to bind UDP port %u\n", port);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const std::string lock_note =
|
||||||
|
locked_room.empty() ? "" : (" (locked room: " + locked_room + ")");
|
||||||
|
std::printf("ghost_relay: listening on UDP %u%s\n", port, lock_note.c_str());
|
||||||
|
std::printf("ghost_relay: clients connect outbound; no client-side port "
|
||||||
|
"forwarding needed (max clients %u)\n",
|
||||||
|
max_clients);
|
||||||
|
std::printf("ghost_relay: clients connect outbound; no client-side port "
|
||||||
|
"forwarding needed\n");
|
||||||
|
|
||||||
|
// Status line every 5 s; on POSIX exit on SIGINT/SIGTERM.
|
||||||
|
auto last_log = std::chrono::steady_clock::now();
|
||||||
|
while (true) {
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
if (g_stop != 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
if (now - last_log >= std::chrono::seconds(5)) {
|
||||||
|
std::printf("ghost_relay: %zu client(s)\n", relay.ClientCount());
|
||||||
|
std::fflush(stdout);
|
||||||
|
last_log = now;
|
||||||
|
}
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
}
|
||||||
|
relay.Stop();
|
||||||
|
std::printf("ghost_relay: stopped\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
189
tools/release/release.sh
Executable file
189
tools/release/release.sh
Executable file
|
|
@ -0,0 +1,189 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a Skate 3 recomp release from this checkout and optionally publish it
|
||||||
|
# to a Forgejo/Gitea Release.
|
||||||
|
#
|
||||||
|
# IMPORTANT: the release zip contains ONLY the recompiled binaries
|
||||||
|
# (skate3 + librexruntime.so + ghost_relay + README) - NO game files. Players
|
||||||
|
# bring their own ISO and the in-app installer extracts it. The game dump
|
||||||
|
# (default.xex + data/webkit/EAWebkit.xex) is required at BUILD TIME only,
|
||||||
|
# because codegen translates the actual Xbox 360 binary into C++ source; it
|
||||||
|
# is never included in, or uploaded with, the release.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./tools/release/release.sh # build this platform, no upload
|
||||||
|
# ./tools/release/release.sh --tag v1.0.0 --upload # build + publish
|
||||||
|
# ./tools/release/release.sh --game-root /path/to/game --tu /path/to/TU_12K2276_...
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --tag TAG release tag/version (default: last git tag or 'dev')
|
||||||
|
# --game-root DIR extracted game dump (default: $REPO/game)
|
||||||
|
# --tu FILE optional Title Update STFS package for codegen
|
||||||
|
# --os linux|windows|macos override platform (default: host OS)
|
||||||
|
# --cmake-args "..." extra flags appended to every cmake configure
|
||||||
|
# call (e.g. -DCMAKE_C_COMPILER=clang-22
|
||||||
|
# -DCMAKE_CXX_COMPILER=clang++-22
|
||||||
|
# -DCMAKE_LINKER=ld.lld on distros without clang-20)
|
||||||
|
# --upload publish the zip to a Forgejo Release
|
||||||
|
#
|
||||||
|
# Env for --upload:
|
||||||
|
# GITEA_TOKEN Forgejo token with write:repository (release) scope
|
||||||
|
# FORGEJO_REPO default "ad3laid3/skate3recomp-multiplayer"
|
||||||
|
# FORGEJO_INSTANCE default "https://git.mithraic.cloud"
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$REPO"
|
||||||
|
|
||||||
|
UPLOAD=0
|
||||||
|
TAG=""
|
||||||
|
GAME_ROOT="${SKATE3_GAME_DATA_ROOT:-$REPO/game}"
|
||||||
|
TU="${SKATE3_TITLE_UPDATE_PACKAGE:-}"
|
||||||
|
OS_ARG=""
|
||||||
|
CMAKE_ARGS=""
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--tag) TAG="${2:?--tag needs a value}"; shift 2;;
|
||||||
|
--game-root) GAME_ROOT="$2"; shift 2;;
|
||||||
|
--tu) TU="$2"; shift 2;;
|
||||||
|
--os) OS_ARG="$2"; shift 2;;
|
||||||
|
--cmake-args) CMAKE_ARGS="$2"; shift 2;;
|
||||||
|
--upload) UPLOAD=1; shift;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,32p' "$0"; exit 0;;
|
||||||
|
*) echo "unknown argument: $1"; exit 2;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- platform ---------------------------------------------------------------
|
||||||
|
UNAME_S="$(uname -s 2>/dev/null || echo Windows)"
|
||||||
|
case "${OS_ARG:-$UNAME_S}" in
|
||||||
|
linux|Linux) PRESET="linux-relwithdebinfo"; BIN="skate3"; LIB="librexruntime.so"; EXT="Linux";;
|
||||||
|
windows|Windows|MINGW*|MSYS*|CYGWIN*)
|
||||||
|
PRESET="relwithdebinfo"; BIN="skate3.exe"; LIB="librexruntime.dll"; EXT="Windows";;
|
||||||
|
macos|macOS|Darwin) PRESET="macos-relwithdebinfo"; BIN="skate3"; LIB="librexruntime.dylib"; EXT="macOS";;
|
||||||
|
*) echo "unsupported platform: ${OS_ARG:-$UNAME_S} (set --os linux|windows|macos)"; exit 2;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
TAG="$(git describe --tags --always 2>/dev/null || echo dev)"
|
||||||
|
fi
|
||||||
|
VER="${TAG#v}"
|
||||||
|
NAME="Skate3Recomp-$EXT-$VER"
|
||||||
|
|
||||||
|
if [ ! -f "$GAME_ROOT/default.xex" ]; then
|
||||||
|
echo "ERROR: game dump not found at $GAME_ROOT (need default.xex + data/webkit/EAWebkit.xex)"
|
||||||
|
echo " It is required for codegen; it is NOT shipped in the release."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- helpers (Windows Git Bash lacks zip/python; provide fallbacks) ---------
|
||||||
|
json_id() { # read JSON on stdin, print the value of "id"
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
python3 -c "import sys,json
|
||||||
|
try: print(json.load(sys.stdin).get('id',''))
|
||||||
|
except Exception: print('')"
|
||||||
|
elif command -v python >/dev/null 2>&1; then
|
||||||
|
python -c "import sys,json
|
||||||
|
try: print(json.load(sys.stdin).get('id',''))
|
||||||
|
except Exception: print('')"
|
||||||
|
else
|
||||||
|
grep -o '"id":[0-9]*' | head -1 | cut -d: -f2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
make_zip() { # make_zip <zipname> <dir>
|
||||||
|
if command -v zip >/dev/null 2>&1; then
|
||||||
|
(cd "$(dirname "$2")" && zip -r "$(basename "$1")" "$(basename "$2")")
|
||||||
|
elif command -v 7z >/dev/null 2>&1; then
|
||||||
|
7z a -tzip "$1" "$2" >/dev/null
|
||||||
|
elif command -v powershell >/dev/null 2>&1; then
|
||||||
|
powershell -NoProfile -Command "Compress-Archive -Path '$2' -DestinationPath '$1' -Force"
|
||||||
|
else
|
||||||
|
echo "ERROR: no zip tool found (install zip, 7z, or use PowerShell)"; exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- build ------------------------------------------------------------------
|
||||||
|
TU_ARGS=""
|
||||||
|
if [ -n "$TU" ]; then
|
||||||
|
if [ ! -f "$TU" ]; then echo "ERROR: title update package not found: $TU"; exit 1; fi
|
||||||
|
TU_ARGS="-DSKATE3_TITLE_UPDATE_PACKAGE=$TU"
|
||||||
|
elif [ -n "$(ls "$REPO"/TU_12K2276_* "$GAME_ROOT"/TU_12K2276_* 2>/dev/null | head -1)" ]; then
|
||||||
|
TU="$(ls "$REPO"/TU_12K2276_* "$GAME_ROOT"/TU_12K2276_* 2>/dev/null | head -1)"
|
||||||
|
echo "==> Using title update package: $TU"
|
||||||
|
TU_ARGS="-DSKATE3_TITLE_UPDATE_PACKAGE=$TU"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Release $TAG ($EXT) preset=$PRESET game=$GAME_ROOT"
|
||||||
|
echo "==> Configure"
|
||||||
|
cmake --preset "$PRESET" -DSKATE3_GAME_DATA_ROOT="$GAME_ROOT" $TU_ARGS $CMAKE_ARGS
|
||||||
|
echo "==> Generate recompiled code"
|
||||||
|
cmake --build --preset "$PRESET" --target generate-all --parallel
|
||||||
|
echo "==> Reconfigure (generated sources)"
|
||||||
|
cmake --preset "$PRESET" -DSKATE3_GAME_DATA_ROOT="$GAME_ROOT" $TU_ARGS $CMAKE_ARGS
|
||||||
|
echo "==> Build"
|
||||||
|
cmake --build --preset "$PRESET" --parallel
|
||||||
|
|
||||||
|
# ---- package ---------------------------------------------------------------
|
||||||
|
OUT="out/build/$PRESET"
|
||||||
|
STAGE="$NAME"
|
||||||
|
rm -rf "$STAGE"; mkdir -p "$STAGE"
|
||||||
|
cp "$OUT/$BIN" "$STAGE/"
|
||||||
|
# The rexglue runtime lib carries a config suffix (librexruntimerd.so for
|
||||||
|
# relwithdebinfo, librexruntime.so for release); the binary links whichever
|
||||||
|
# was built, so ship it under its real name.
|
||||||
|
RUNTIME="$(ls "$OUT"/librexruntime*.so "$OUT"/librexruntime*.dll "$OUT"/librexruntime*.dylib 2>/dev/null | head -1 || true)"
|
||||||
|
if [ -n "$RUNTIME" ]; then
|
||||||
|
cp "$RUNTIME" "$STAGE/"
|
||||||
|
fi
|
||||||
|
cp "$OUT/ghost_relay" "$STAGE/" 2>/dev/null || true
|
||||||
|
cp "$OUT/ghost_relay.exe" "$STAGE/" 2>/dev/null || true
|
||||||
|
# Title-update patches staged next to the binary when the build used the TU
|
||||||
|
if [ -f "$OUT/game/default.xexp" ]; then
|
||||||
|
mkdir -p "$STAGE/game/data/webkit"
|
||||||
|
cp "$OUT/game/default.xexp" "$STAGE/game/"
|
||||||
|
cp "$OUT/game/data/webkit/EAWebkit.xexp" "$STAGE/game/data/webkit/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
cp README.md "$STAGE/"
|
||||||
|
rm -f "$NAME.zip"
|
||||||
|
make_zip "$NAME.zip" "$STAGE"
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
echo "==> Packaged: $NAME.zip ($(du -h "$NAME.zip" | cut -f1))"
|
||||||
|
sha256sum "$NAME.zip"
|
||||||
|
|
||||||
|
# ---- publish ----------------------------------------------------------------
|
||||||
|
if [ "$UPLOAD" = 1 ]; then
|
||||||
|
: "${GITEA_TOKEN:?--upload needs GITEA_TOKEN}"
|
||||||
|
REPO_FULL="${FORGEJO_REPO:-ad3laid3/skate3recomp-multiplayer}"
|
||||||
|
INSTANCE="${FORGEJO_INSTANCE:-https://git.mithraic.cloud}"
|
||||||
|
AUTH="Authorization: token $GITEA_TOKEN"
|
||||||
|
echo "==> Publishing $NAME.zip to $INSTANCE/$REPO_FULL release '$TAG'"
|
||||||
|
|
||||||
|
RELEASE_ID="$(curl -s -X POST "$INSTANCE/api/v1/repos/$REPO_FULL/releases" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"Skate 3 Recomp $TAG\",\"target_commitish\":\"main\"}" \
|
||||||
|
| json_id)"
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
RELEASE_ID="$(curl -s "$INSTANCE/api/v1/repos/$REPO_FULL/releases/tags/$TAG" -H "$AUTH" \
|
||||||
|
| json_id)"
|
||||||
|
fi
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "ERROR: could not create/find release '$TAG'"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -s -X POST "$INSTANCE/api/v1/repos/$REPO_FULL/releases/$RELEASE_ID/assets" \
|
||||||
|
-H "$AUTH" -F "attachment=@$NAME.zip" -F "name=$NAME.zip" \
|
||||||
|
| if command -v python3 >/dev/null 2>&1 || command -v python >/dev/null 2>&1; then
|
||||||
|
PY=python3; command -v python3 >/dev/null 2>&1 || PY=python
|
||||||
|
"$PY" -c "import sys,json
|
||||||
|
try:
|
||||||
|
a=json.load(sys.stdin); print('Uploaded asset', a.get('name'), a.get('size'), 'bytes')
|
||||||
|
except Exception as e: print('upload response parse failed:', e)"
|
||||||
|
else
|
||||||
|
echo "Upload request sent (no python to parse the response)"
|
||||||
|
fi
|
||||||
|
echo "==> Done: https://git.mithraic.cloud/$REPO_FULL/releases/tag/$TAG"
|
||||||
|
fi
|
||||||
73
tools/release/windows-build.cmd
Normal file
73
tools/release/windows-build.cmd
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
@echo off
|
||||||
|
rem One-click Windows release build for skate3recomp-multiplayer.
|
||||||
|
rem
|
||||||
|
rem Fixes the known Windows build issues documented in /mnt/archive/Issues.txt
|
||||||
|
rem (and mirrored in docs/releases.md):
|
||||||
|
rem 1. stable Windows SDK so kernel32.lib resolves (vcvarsall with an SDK
|
||||||
|
rem version instead of the broken default),
|
||||||
|
rem 2. Git Bash %LIB% mangling (MSYS2_ARG_CONV_EXCL=*),
|
||||||
|
rem 3. missing rexglue submodules (git submodule update --init + self-heal).
|
||||||
|
rem
|
||||||
|
rem Usage (from a normal cmd.exe / PowerShell prompt):
|
||||||
|
rem tools\release\windows-build.cmd [--tag vX.Y.Z] [--upload]
|
||||||
|
rem
|
||||||
|
rem Config via environment variables:
|
||||||
|
rem VCVARSALL path to vcvarsall.bat (default: VS 18 Community x64)
|
||||||
|
rem WINSDK Windows SDK version (default 10.0.26100.0; try 10.0.22621.0
|
||||||
|
rem if yours is different)
|
||||||
|
rem GITBASH path to Git for Windows bash.exe
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
if "%VCVARSALL%"=="" set "VCVARSALL=%ProgramFiles%\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvarsall.bat"
|
||||||
|
if "%WINSDK%"=="" set "WINSDK=10.0.26100.0"
|
||||||
|
if "%GITBASH%"=="" set "GITBASH=%ProgramFiles%\Git\bin\bash.exe"
|
||||||
|
|
||||||
|
if not exist "%VCVARSALL%" (
|
||||||
|
echo [ERROR] vcvarsall.bat not found: %VCVARSALL%
|
||||||
|
echo Set VCVARSALL to your Visual Studio install, e.g.
|
||||||
|
echo "%%ProgramFiles%%\Microsoft Visual Studio\17\Community\VC\Auxiliary\Build\vcvarsall.bat"
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
if not exist "%GITBASH%" (
|
||||||
|
echo [ERROR] Git Bash not found: %GITBASH%
|
||||||
|
echo Install Git for Windows or set GITBASH.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [1/3] Loading MSVC environment (x64, SDK %WINSDK%)
|
||||||
|
call "%VCVARSALL%" x64 %WINSDK%
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] vcvarsall failed. Try a different WINSDK, e.g.
|
||||||
|
echo set WINSDK=10.0.22621.0
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
rem Prevent Git Bash from converting/corrupting the MSVC environment.
|
||||||
|
rem MSYS2_ARG_CONV_EXCL protects command-line ARGUMENTS; the actual
|
||||||
|
rem kernel32.lib LNK1104 is caused by MSYS rewriting the %LIB% ENVIRONMENT
|
||||||
|
rem variable (backslashes + semicolons), which requires MSYS2_ENV_CONV_EXCL.
|
||||||
|
set "MSYS2_ARG_CONV_EXCL=*"
|
||||||
|
set "MSYS2_ENV_CONV_EXCL=*"
|
||||||
|
|
||||||
|
if "%LIB%"=="" (
|
||||||
|
echo [WARN] LIB is empty after vcvarsall - try a different WINSDK
|
||||||
|
echo e.g. set WINSDK=10.0.22621.0
|
||||||
|
)
|
||||||
|
echo LIB=%LIB%
|
||||||
|
|
||||||
|
rem Convert the repo path to forward slashes for Git Bash (%~dp0 is
|
||||||
|
rem tools\release\ with a trailing backslash; ..\.. reaches the repo root).
|
||||||
|
set "REPO=%~dp0..\.."
|
||||||
|
set "REPO=%REPO:\=/%"
|
||||||
|
|
||||||
|
echo [2/3] Initializing submodules (rexglue SDK + nested)
|
||||||
|
"%GITBASH%" --login -c "cd '%REPO%' && git submodule update --init --jobs 4 && bash tools/ci/init-submodules.sh"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] submodule init failed.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [3/3] Building release
|
||||||
|
"%GITBASH%" --login -c "cd '%REPO%' && bash tools/release/release.sh %*"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
endlocal
|
||||||
Loading…
Add table
Reference in a new issue