Compare commits
No commits in common. "main" and "v2.0.0" have entirely different histories.
36 changed files with 17 additions and 4202 deletions
|
|
@ -1,126 +0,0 @@
|
||||||
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,16 +3,7 @@
|
||||||
/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 = git@git.mithraic.cloud:ad3laid3/rexglue-skate3.git
|
url = https://github.com/mchughalex/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 2.0.0 LANGUAGES CXX)
|
project(skate3 VERSION 1.0.1 LANGUAGES CXX)
|
||||||
|
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
enable_language(RC)
|
enable_language(RC)
|
||||||
|
|
@ -30,15 +30,6 @@ 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)
|
||||||
|
|
@ -227,15 +218,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -248,9 +230,6 @@ 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
|
||||||
|
|
@ -283,12 +262,6 @@ 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()
|
||||||
|
|
@ -395,20 +368,6 @@ 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,12 +22,6 @@ 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,11 +1,7 @@
|
||||||
add_custom_target(generate-skate3
|
add_custom_target(generate-skate3
|
||||||
# The rexglue codegen tool writes its output and THEN crashes during
|
COMMAND $<TARGET_FILE:rex::rexglue> codegen
|
||||||
# static teardown (SIOF / heap-corruption at exit - the cvar registry and
|
${SKATE3_CODEGEN_ARGS}
|
||||||
# friends are freed out of order). The output is complete and valid, so
|
"${CMAKE_CURRENT_BINARY_DIR}/manifests/skate3.toml"
|
||||||
# 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"
|
||||||
|
|
@ -15,7 +11,9 @@ add_custom_target(generate-skate3
|
||||||
)
|
)
|
||||||
|
|
||||||
add_custom_target(generate-eawebkit
|
add_custom_target(generate-eawebkit
|
||||||
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"
|
COMMAND $<TARGET_FILE:rex::rexglue> codegen
|
||||||
|
${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
|
||||||
|
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
# 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
129
docs/releases.md
|
|
@ -1,129 +0,0 @@
|
||||||
# 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.
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,114 +0,0 @@
|
||||||
#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
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
#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
|
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
#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
|
|
||||||
|
|
@ -1,414 +0,0 @@
|
||||||
#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
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
#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,11 +13,7 @@
|
||||||
#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>
|
||||||
|
|
||||||
|
|
@ -116,108 +112,6 @@ 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,52 +35,6 @@ 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,10 +889,6 @@ 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,11 +53,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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,8 +11,6 @@
|
||||||
#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>
|
||||||
|
|
@ -683,8 +681,6 @@ 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.
|
||||||
|
|
@ -727,10 +723,6 @@ 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:
|
||||||
|
|
@ -801,10 +793,6 @@ 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]() {
|
||||||
|
|
@ -832,14 +820,10 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -960,19 +944,6 @@ 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,7 +14,6 @@
|
||||||
#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;
|
||||||
|
|
@ -41,7 +40,6 @@ 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();
|
||||||
|
|
@ -61,8 +59,6 @@ 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,15 +365,10 @@ 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](const std::filesystem::path& source,
|
auto install = [game_root = runtime_paths.game_data_root](
|
||||||
std::atomic<uint64_t>& copied_bytes,
|
const std::filesystem::path& source, 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;
|
||||||
|
|
@ -392,7 +387,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.",
|
||||||
game_root.string(), std::move(pick_source), std::move(install),
|
runtime_paths.game_data_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));
|
||||||
|
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
#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
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
#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,8 +2,6 @@
|
||||||
|
|
||||||
#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"
|
||||||
|
|
@ -106,7 +104,6 @@ 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;
|
||||||
|
|
@ -172,7 +169,6 @@ 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;
|
||||||
|
|
@ -746,7 +742,6 @@ 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,7 +38,6 @@
|
||||||
|
|
||||||
#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"
|
||||||
|
|
@ -2756,16 +2755,6 @@ 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;
|
||||||
|
|
@ -3367,7 +3356,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -3377,7 +3365,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -4142,10 +4129,6 @@ 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
|
||||||
|
|
@ -4390,7 +4373,6 @@ 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;
|
||||||
|
|
@ -4530,9 +4512,6 @@ 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
|
||||||
|
|
@ -8307,10 +8286,6 @@ 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.
|
||||||
|
|
@ -10457,9 +10432,6 @@ 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,12 +689,6 @@ 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,7 +39,6 @@
|
||||||
|
|
||||||
#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"
|
||||||
|
|
@ -127,8 +126,6 @@ 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);
|
||||||
|
|
@ -3637,12 +3634,6 @@ 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");
|
||||||
|
|
@ -4715,10 +4706,6 @@ 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 =
|
||||||
|
|
@ -7035,22 +7022,6 @@ 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)) {
|
||||||
|
|
@ -7561,11 +7532,6 @@ 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)) {
|
||||||
|
|
@ -10342,215 +10308,6 @@ 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,11 +488,7 @@ 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;
|
||||||
// The grid is read back to the CPU every frame, so it must declare
|
td.usage = nrhi::kTextureUsageRenderTarget;
|
||||||
// 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 7eb0faf7787f5e01333c228b8e3f03c32f7295ea
|
Subproject commit 9100b278b70c4f0994a47639eb11107c570ee76d
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
[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
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
#!/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."
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
@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