Compare commits

..

No commits in common. "main" and "v2.0.0" have entirely different histories.
main ... v2.0.0

36 changed files with 17 additions and 4202 deletions

View file

@ -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
View file

@ -3,16 +3,7 @@
/build/
/generated/
/generated.backup-*/
/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-*/
/tools/
# Local game dump and retail media. Users must provide their own copy.
/game/

2
.gitmodules vendored
View file

@ -1,5 +1,5 @@
[submodule "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

View file

@ -11,7 +11,7 @@ if(SKATE3_STATIC_MSVC_RUNTIME)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
endif()
project(skate3 VERSION 2.0.0 LANGUAGES CXX)
project(skate3 VERSION 1.0.1 LANGUAGES CXX)
if(WIN32)
enable_language(RC)
@ -30,15 +30,6 @@ skate3_resolve_version(SKATE3_FULL_VERSION
SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR})
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}")
if(APPLE AND CMAKE_OSX_ARCHITECTURES)
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_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/CodegenTargets.cmake)
@ -248,9 +230,6 @@ set(SKATE3_COMMON_SOURCES
src/skate3_iso_installer.cpp
src/native/skate3_native_diagnostics.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_lw.cpp
src/native/skate3_native_palette.cpp
@ -283,12 +262,6 @@ if(WIN32)
COPYONLY
)
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()
add_executable(skate3 ${SKATE3_SOURCES})
endif()
@ -395,20 +368,6 @@ if(NOT SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE STREQUAL "")
add_dependencies(skate3 skate3_stage_title_update_patches)
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)
target_compile_options(skate3 PRIVATE /utf-8)
else()

View file

@ -22,12 +22,6 @@ Native Rendering Showcase (click to go to YouTube):
## 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:
- The Windows version is the most tested, followed by Linux, and then macOS.

View file

@ -1,11 +1,7 @@
add_custom_target(generate-skate3
# The rexglue codegen tool writes its output and THEN crashes during
# static teardown (SIOF / heap-corruption at exit - the cvar registry and
# friends are freed out of order). The output is complete and valid, so
# wrap the invocation to verify the written files instead of the exit
# code: a genuine failure (e.g. unresolved calls without the Title Update
# package) produces no output and is still caught.
COMMAND bash -c "$<TARGET_FILE:rex::rexglue> codegen ${SKATE3_CODEGEN_ARGS} \"${CMAKE_CURRENT_BINARY_DIR}/manifests/skate3.toml\"; rc=\$?; [ -f \"${CMAKE_CURRENT_SOURCE_DIR}/generated/sources.cmake\" ] || exit \$rc; exit 0"
COMMAND $<TARGET_FILE:rex::rexglue> codegen
${SKATE3_CODEGEN_ARGS}
"${CMAKE_CURRENT_BINARY_DIR}/manifests/skate3.toml"
COMMAND "${CMAKE_COMMAND}"
"-DSKATE3_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}"
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/ApplySkate3CodegenPatches.cmake"
@ -15,7 +11,9 @@ add_custom_target(generate-skate3
)
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}"
COMMENT "Generating recompiled code for EAWebkit.xex"
VERBATIM

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -13,11 +13,7 @@
#else
#include <pthread.h>
#include <signal.h>
#include <sys/mman.h>
#include <time.h>
#include <unistd.h>
#include <atomic>
#include <csetjmp>
#include <mutex>
@ -116,108 +112,6 @@ bool GuestTryCopy(void* dst, const void* src, size_t size) {
tl_in_guest_copy = false;
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
float GuestHalfToFloat(uint16_t h) {

View file

@ -35,52 +35,6 @@ struct SkinSampleVert {
// open-coded __try{memcpy}__except compiles to an UNPROTECTED `jmp memcpy`.
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);
// Bind-pose bbox diagonal of the item: the reference size every

View file

@ -889,10 +889,6 @@ uint32_t ExchangePackOwner(uint32_t entity) {
return prev;
}
uint32_t CurrentPackOwner() {
return tl_pack_owner;
}
void OnPackPalette(uint8_t* base, uint32_t instance) {
if (instance == 0 || !CaptureActive()) {
return;

View file

@ -53,11 +53,6 @@ void OnLwPack(uint8_t* base, uint32_t entity);
// the bracket carry the owning entity for identity-exact serving.
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:
// replaces each mapped skinned character item's captured bank palette with
// its instance's own packed rows. Served items get dbg_src=11 and

View file

@ -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

View file

@ -11,8 +11,6 @@
#include "skate3_title_update_installer.h"
#include "skate3_user_settings.h"
#include "native/skate3_native_ghost.h"
#include <algorithm>
#include <chrono>
#include <cstring>
@ -683,8 +681,6 @@ void Skate3BaseApp::OnCreateDialogs(rex::ui::ImGuiDrawer* drawer) {
// skate3_native_render_mode_indicator shows it live). Input-transparent,
// so it never affects cursor or focus handling.
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] {
// Escape backs out of the settings screen level by level (rows ->
// 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] {
ToggleNativeDebug();
});
rex::ui::RegisterBind("bind_skate3_ghost", "M",
"Ghost multiplayer menu", [this] {
ToggleGhost();
});
rex::ui::RegisterBind("bind_skate3_showcase", "Ctrl+Shift+B",
"Graphics build-up showcase", [] {
// A capture/recording tool, not a player feature:
@ -801,10 +793,6 @@ void Skate3BaseApp::OnPostSetup() {
auto* dispatcher = runtime()->function_dispatcher();
skate3::native_render::Install();
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
// completion override polls the merged UI pad state through this provider.
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_user_marker");
rex::ui::UnregisterBind("bind_skate3_native_debug");
rex::ui::UnregisterBind("bind_skate3_ghost");
skate3::native_ghost::Shutdown();
ApplyGameplayCursorMode();
skate3::native_scene::SetSettingsMenuBlur(false);
simple_settings_dialog_.reset();
native_debug_dialog_.reset();
ghost_dialog_.reset();
ghost_nameplate_overlay_.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() {
if (window()) {
window()->SetCursorVisibility(rex::ui::Window::CursorVisibility::kVisible);

View file

@ -14,7 +14,6 @@
#include <rex/ui/overlay/simple_settings_overlay.h>
#include "skate3_native_debug_dialog.h"
#include "skate3_native_ghost_dialog.h"
namespace rex::ui {
class ImGuiDrawer;
@ -41,7 +40,6 @@ class Skate3BaseApp : public rex::ReXApp {
void InstallDlcPackages();
void ToggleSimpleSettings();
void ToggleNativeDebug();
void ToggleGhost();
void ApplySettingsCursorMode();
void ApplyGameplayCursorMode();
void RestartGame();
@ -61,8 +59,6 @@ class Skate3BaseApp : public rex::ReXApp {
std::filesystem::path profiles_path_;
std::unique_ptr<rex::ui::SimpleSettingsDialog> simple_settings_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_;
bool recipe_overlay_installed_ = false;
bool big_device_aliases_installed_ = false;

View file

@ -365,14 +365,9 @@ bool IsGameInstalled(const std::filesystem::path& game_root) {
void ShowRexglueIsoInstallWizard(rex::ui::ImGuiDrawer* drawer, rex::PathConfig runtime_paths,
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 install = [game_root](const std::filesystem::path& source,
std::atomic<uint64_t>& copied_bytes,
auto install = [game_root = runtime_paths.game_data_root](
const std::filesystem::path& source, std::atomic<uint64_t>& copied_bytes,
std::atomic<uint64_t>& total_bytes, std::string& error) {
XboxIsoReader iso;
if (!iso.Open(source, error)) {
@ -392,7 +387,7 @@ void ShowRexglueIsoInstallWizard(rex::ui::ImGuiDrawer* drawer, rex::PathConfig r
new rex::ui::InstallWizardDialog(
drawer, "Setup", "Game Files",
"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 {
if (complete) {
complete(std::move(runtime_paths));

View file

@ -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

View file

@ -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

View file

@ -2,8 +2,6 @@
#include "native/skate3_native_diag.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_palette.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) {
return;
}
skate3::native_scene::GuestReadRecoveryScope guest_read_recovery(base);
const uint32_t entries = REX_LOAD_U32(sort_vec);
if (entries == 0) {
return;
@ -172,7 +169,6 @@ uint32_t FilterSceneDrawList(uint8_t* base, uint32_t sort_vec, uint32_t first,
if (f.ctxs.empty()) {
return count;
}
skate3::native_scene::GuestReadRecoveryScope guest_read_recovery(base);
const uint32_t entries = REX_LOAD_U32(sort_vec);
if (entries == 0) {
return count;
@ -746,7 +742,6 @@ extern "C" REX_FUNC(sub_827F0D00) {
if (!(max_expo > 0.0f) || max_expo > 16.0f) {
return;
}
skate3::native_scene::GuestReadRecoveryScope guest_read_recovery(base);
uint32_t prev_bits = REX_LOAD_U32(self + 3240);
float prev;
std::memcpy(&prev, &prev_bits, sizeof(prev));

View file

@ -38,7 +38,6 @@
#include "native/skate3_native_diag.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_palette.h"
@ -2756,16 +2755,6 @@ bool ToggleSceneEnabled() {
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) {
if (texture == 0) {
return;
@ -3367,7 +3356,6 @@ void OnSetViewport(uint8_t* base, uint32_t viewport_ptr) {
if (viewport_ptr == 0) {
return;
}
GuestReadRecoveryScope guest_read_recovery(base);
for (int i = 0; i < 6; ++i) {
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) {
return;
}
GuestReadRecoveryScope guest_read_recovery(base);
for (int i = 0; i < 4; ++i) {
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) {
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
// 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
@ -4390,7 +4373,6 @@ uint32_t CaptureClothDraw(uint8_t* base, uint32_t r4, uint32_t r5, uint32_t r6,
if (!GuestReadableApprox(base, vb_obj)) {
return 0;
}
GuestReadRecoveryScope guest_read_recovery(base);
const uint32_t addr = REX_LOAD_U32(vb_obj + 0x18) & 0xFFFFFFFC;
const uint32_t size = REX_LOAD_U32(vb_obj + 0x20);
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) {
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
// 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
@ -8307,10 +8286,6 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) {
if (!SceneEnabled()) {
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
// carry only 2D, and the render thread's 2D texture decodes need the
// 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())
.count(),
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);
scene.generation = ++g_generation;
g_scene = std::make_shared<const FrameScene>(std::move(scene));

View file

@ -689,12 +689,6 @@ void Install();
// layer was not enabled at boot.
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
// failure and yields to the emulated output until re-enabled. Surfaced so
// the render-mode indicator can show the degraded state even though the

View file

@ -39,7 +39,6 @@
#include "native/skate3_native_diag.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_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_world_v2);
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_bloom_intensity);
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,
&g_r.shadow_srv_final};
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);
if (*targets[t] == nullptr) {
REXLOG_ERROR("native-scene: shadow atlas creation failed");
@ -4715,10 +4706,6 @@ void PrewarmWorkerLoop() {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
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) {
PrewarmResult res;
res.mesh_valid =
@ -7035,22 +7022,6 @@ void RenderOutlineComposite(const NativeGuestOutputRenderContext& context,
void LogFrameStats(const FrameScene& scene, uint64_t frames, uint32_t drawn,
uint32_t drawn_2d, uint32_t drawn_spline, bool shadow_ready,
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(
std::max(60, REXCVAR_GET(skate3_native_render_scene_perf_interval)));
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).
uint8_t* base = g_guest_base.load(std::memory_order_relaxed);
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);
}
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 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 = &it;
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
// the scene pass, depth-tested against the world like the emulated frame,
// in submission order (darken backdrop passes precede the additive glow).

View file

@ -488,11 +488,7 @@ bool ApplySsaoPass(const NativeGuestOutputRenderContext& context,
td.height = RendererState::kOcclGridH;
td.mip_levels = 1;
td.format = nrhi::Format::kR32_FLOAT;
// The grid is read back to the CPU every frame, so it must declare
// copy-source usage: the Vulkan backend only requests TRANSFER_SRC for
// textures that ask for it, and both the transition to the copy-source
// state and the copy itself are invalid without it.
td.usage = nrhi::kTextureUsageRenderTarget | nrhi::kTextureUsageCopySource;
td.usage = nrhi::kTextureUsageRenderTarget;
td.initial_state = nrhi::ResourceState::kRenderTarget;
g_r.occl_tex = device->CreateTexture(td);
nrhi::BufferDesc bd;

@ -1 +1 @@
Subproject commit 7eb0faf7787f5e01333c228b8e3f03c32f7295ea
Subproject commit 9100b278b70c4f0994a47639eb11107c570ee76d

View file

@ -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

View file

@ -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

View file

@ -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."

View file

@ -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;
}

View file

@ -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

View file

@ -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