Compare commits

...

17 commits
v2.0.2 ... main

Author SHA1 Message Date
MiTHRAL
df86553b77 ghost: capture local player transform from the body item, not the UBT hook
The self-check cube (skate3_ghost_draw_local) appeared on a nearby NPC
because OnLocalSkaterTick read the world from the UpdateBoneTransforms
hook's pack-owner entity, and UpdateBoneTransforms fires for NPC skaters too
- the last writer won, often an NPC.

Local-player capture now happens in OnFrameSceneBuilt (the same per-frame
body-item heuristic the ghost models use): resolve the body item's OWNING
entity via the ctx->entity store and read its world matrix (entity+416),
falling back to the body root-bone translation. The UBT-based capture and
hook call are removed.
2026-08-28 01:20:52 -04:00
MiTHRAL
d809bbc25a windows-build.cmd: also exclude MSYS environment conversion (%LIB%)
The kernel32.lib LNK1104 persists despite MSYS2_ARG_CONV_EXCL because that
only protects command-line ARGUMENTS - MSYS/Git Bash still rewrites the
%LIB% ENVIRONMENT variable (backslashes + semicolons), so link.exe receives
POSIX paths and cannot find kernel32.lib. Set MSYS2_ENV_CONV_EXCL=* too, and
print LIB + warn when it is empty after vcvarsall.
2026-08-27 14:19:46 -04:00
MiTHRAL
81189576c0 releases: harden for Windows + one-click windows-build.cmd launcher
Addresses the known Windows build issues (see /mnt/archive/Issues.txt):
- windows-build.cmd: loads vcvarsall with a stable Windows SDK (WINSDK,
  default 10.0.26100.0) fixing kernel32.lib LNK1104; sets
  MSYS2_ARG_CONV_EXCL=* so Git Bash does not corrupt %LIB%; inits submodules
  (including the broken-pin self-heal) before building.
- release.sh: auto-detect the Title Update package (repo root or game dir);
  zip fallback to 7z or PowerShell Compress-Archive (Git Bash has no zip);
  upload JSON parsing falls back from python3/python to grep (Git Bash ships
  neither python).
2026-08-27 14:02:24 -04:00
MiTHRAL
4ec504ac6c ghost: fix auto-assigned room code flickering + copy button
When the relay auto-assigns a room, the code lives in the connection state
while the skate3_ghost_room cvar stays empty. The network loop was
overwriting the active room with the empty cvar every 25 ms, making the code
flicker in and out of the menu, and the Copy button copied the empty cvar.

- Loop: only sync the cvar into the active room when it is non-empty;
  preserve the server-assigned code otherwise.
- Menu: Copy grabs the ACTIVE room from GetStatus() (works for auto-assigned
  rooms, with a brief 'copied' hint); the room field now surfaces the
  auto-assigned code instead of staying blank.
2026-08-27 12:56:22 -04:00
MiTHRAL
eabb989ff7 ghost: log connection transitions at info by default
Some checks failed
release / build-linux (push) Has been cancelled
Connection state should be visible in the default logs for beta testers:
- 'ghost: joined <relay> room=<code> roster=N' now logs on connection
  TRANSITIONS (first join, reconnect, room change) instead of being gated
  behind skate3_ghost_debug (which spams every 5s re-JOIN HELLO).
- 'ghost: relay connection lost; reconnecting' logs at warn instead of
  debug.
2026-08-27 12:51:47 -04:00
MiTHRAL
e3a9fa0b99 releases: tolerate rexglue codegen exit crash; ship actual runtime lib name
The rexglue codegen tool segfaults/aborts during static teardown AFTER
writing its output (cvar registry SIOF + heap corruption at exit). The
generated source is complete and valid, so generate-skate3/generate-eawebkit
now wrap the invocation with bash -c that verifies the written output files
instead of the exit code - a genuine failure (unresolved calls without the
Title Update package) produces no output and is still caught. Fixes the
release build on toolchains that hit the teardown bug (also applies to CI).

release.sh: glob the actual rexglue runtime lib name (librexruntimerd.so for
relwithdebinfo vs librexruntime.so for release) so packaging ships the lib
the binary links.
2026-08-27 12:42:04 -04:00
MiTHRAL
babb887d4b ci: fix init-submodules empty-worktree check (ls -A counts .git)
A submodule with only a .git pointer looks non-empty to ls -A, so the
self-heal skipped every un-checked-out nested submodule. Check for real
worktree content instead (find excluding .git).
2026-08-27 11:53:54 -04:00
MiTHRAL
7173d7e88d releases: local build+upload script, optional Forgejo Actions workflow, docs
Clarifies that releases ship ONLY the recompiled binaries (skate3 +
librexruntime + ghost_relay + README) - never the game files; the game dump
(default.xex + EAWebkit.xex) is required at BUILD TIME for codegen only.

- tools/release/release.sh: builds + packages a per-platform release zip
  (configure -> codegen -> build -> zip), prints a sha256sum, and with
  --upload creates/updates a Forgejo Release and attaches the zip via the
  API (GITEA_TOKEN). Supports linux/windows/macos presets on their native
  hosts.
- .forgejo/workflows/release.yml: optional automation - builds on a
  self-hosted Docker runner (ubuntu-24.04 label), self-heals the broken
  nested submodule pins via tools/ci/init-submodules.sh, and publishes a
  Linux release on v* tags. Needs the game dump mounted on the runner
  (codegen only, never uploaded), SUBMODULE_KEY + RELEASE_TOKEN secrets.
- tools/ci/init-submodules.sh: recursive submodule init that self-heals
  upstream pins a fresh clone can no longer fetch (the rexglue fork pins
  thirdparty/imgui to a commit ocornut/imgui dropped; the script checks out
  the upstream default tip for any nested submodule left empty).
- docs/releases.md: what's in a release, the local path, and the optional
  CI path.
- .gitignore: keep tools/ci + tools/release, ignore local release zips.
2026-08-26 23:32:13 -04:00
MiTHRAL
32c5e438ed ghost multiplayer: fix wire-struct packing; set default relay to relay.mithraic.lol
- #pragma pack(1) on all protocol structs: alignas(1) set the struct
  alignment but still let the compiler pad members to their natural
  alignment - the uint16_t payload_size landed on an even offset, so
  PacketHeader was 8 bytes instead of 7 and the relay's
   guard dropped every real 7-byte packet
  (n=7 < 8). The in-process tests passed only because BuildPacket/PacketSize
  used the same inflated size on both sides. With true packing the struct
  sizes now equal the wire sizes exactly (PacketHeader=7, PosePayload=3140,
  HelloPayload=687, RoomsPayload=1266, ...), verified.
- kGhostDefaultServer -> relay.mithraic.lol (the production beta relay on
  the OVH VPS; IPv6 PTR set, IPv4 PTR still pending at OVH).
2026-08-26 23:17:08 -04:00
MiTHRAL
1763b39eca ghost multiplayer: deployment docs + systemd unit + install script
- docs/ghost-multiplayer.md: the full beta guide - player usage, cvars,
  relay deployment (VPS build/install, free-tunnel option, verification),
  logging/troubleshooting and security notes.
- tools/ghost_relay.service: hardened systemd unit for the public relay
  (Restart=on-failure, NoNewPrivileges, PrivateTmp, ProtectSystem).
- tools/ghost_relay_deploy.sh: one-shot build + install + enable script for
  the VPS (GHOST_RELAY_PORT / GHOST_RELAY_VERBOSE overrides; opens the UDP
  port via ufw). Verified: the exact build line compiles and runs.
- README: multiplayer pointer under 'How Do I Play?'.
2026-08-26 22:21:08 -04:00
MiTHRAL
d7c9cbcb70 ghost multiplayer: lobby browser (LIST/ROOMS)
The M menu now has a room browser: a Refresh button asks the relay for its
room list, and the list (room code, player count, up to 4 member names) is
shown as clickable rows - clicking one sets the room code and joins on the
next heartbeat.

- Protocol: kTypeList (client -> relay, empty payload) and kTypeRooms
  (relay -> client, RoomsPayload: up to 16 rooms, code + count + names).
  LIST works from unjoined addresses so the browser can query before
  joining; a joined client's LIST is answered too. Version bumped to 4.
- Relay: BuildRoomList aggregates the client table by room (refused clients
  excluded), most-populated first, capped at kGhostMaxRooms.
- Client: RequestRooms()/GetRooms() - the network thread sends LIST to the
  best current peer (public relay, LAN peer, or the local host when
  hosting) and stores the reply under the shared lock; ROOMS replies from
  any other address are ignored. The list is cleared on disable.
- Dialog: CollapsingHeader 'Browse rooms (lobby)' with refresh + selectable
  rows.

New relay test (ghost_relay_test6) covers the lobby path; all suites pass.
2026-08-26 22:19:34 -04:00
MiTHRAL
7a48eb9f35 ghost multiplayer: beta hardening - version handshake, crash-proofing, logging, persistence
Production-readiness pass toward 1.0:

- Protocol handshake: JOIN carries kGhostVersion; a mismatched client is
  REFUSED by the relay (HELLO session 0) and its poses are never forwarded,
  so two builds can never silently corrupt each other's pose stream. The
  client surfaces the refusal in the menu and skips refused LAN hosts during
  discovery.
- Crash-proofing: every received payload is bounds-checked against its
  packet size before parsing (HELLO / PoseChunk / JOIN); both the client
  network thread and the relay thread are exception-guarded (a throw logs
  and keeps the loop alive instead of hitting std::terminate). The relay no
  longer creates client records for unknown-address POSE/LEAVE floods and
  enforces a hard client cap (--max-clients, default 64).
- Logging: rate-limited error logging throughout (send failures, resolve
  failures, refusals, thread exceptions, client-cap drops). The relay core
  uses a build-flag shim (REXLOG in-game, a tiny {} formatter in the
  dependency-free standalone), with --verbose join/leave lines for the
  server. Game crashes themselves are already handled by the SDK's crash
  handlers + file logs; this layer now avoids triggering them and explains
  itself when it can't connect.
- Settings persistence: server/room/name/port/rate save into the app's cvar
  config on change, so a player's multiplayer setup survives a restart.
- Menu: status now shows protocol refusals and the last error.

Tests updated for the handshake (version-refusal case added); all suites
pass.
2026-08-26 22:07:09 -04:00
MiTHRAL
36b5169e27 ghost multiplayer: cloth on ghosts + player nameplates
Phase 4:

- Cloth: remote ghosts now wear this player's cloth garments (tee/pants).
  The local player's rigid character.cloth_ropa items (CPU-sim deformed
  root-local VB) are cloned per ghost and drawn with the REMOTE entity world
  from the pose, so each ghost is draped in its own root frame. No mesh or
  cloth data is transmitted; the shader already applies item.world to the
  rigid cloth path (zero blend weights). Sim-inactive (skinned) garments and
  garments not near the local body are skipped.

- Nameplates: display names ride the wire. JOIN carries a display name
  (skate3_ghost_name cvar, default the OS user name); the relay stores it
  per client and echoes {session_id, name} pairs in the HELLO roster
  (RosterEntry). Clients re-JOIN every 5 s so the roster/names stay current
  as members join and leave. A GhostNameplateOverlay (always registered,
  draws only while enabled) projects each ghost's entity origin +1.75 m
  through the published camera and draws a colored name label above it.

  Uses skate3::native_scene::GetPublishedCamera (new thread-safe snapshot of
  the published view-proj/cam) so the UI thread can project world points.

- GhostDialog gains a display-name field. Protocol version bumped to 3.
2026-08-26 21:17:43 -04:00
MiTHRAL
cceeac7c4f ghost multiplayer: MTU-safe chunked pose transport + pose smoothing
Phase 3: two robustness fixes for full-model ghosts.

- Wire: a full skeleton pose is ~3.1 KB, above the 1500-byte path MTU, so it
  now travels as up to 3 MTU-safe kTypePoseChunk datagrams (<=1.2 KB each,
  ~1.2 KB total with headers). The relay forwards each chunk verbatim
  (re-stamping only the chunk header session id), so its code path is
  shared with the legacy single-datagram POSE and needs no pose-body
  parsing. GhostPoseBuildChunks / GhostPoseReassembler (inline in the proto
  header, standalone-testable) tolerate out-of-order and duplicate chunks,
  drop incomplete sequences (the next frame resends), discard a partial on
  a newer seq, and special-case a header-only pose as a single chunk.
  Receive identity comes from the chunk header (the relay re-stamps it),
  not the blob, which the relay never touches.

- Smoothing: each remote ghost now holds a target skeleton (latest received
  pose) and a display skeleton chased per render query with exponential
  catch-up (time constant ~33 ms), so the 15 Hz bone stream renders as
  smooth motion instead of stepping. New/reshaped skeletons snap instead of
  blending between layouts.

Reassembly state is per-session on the network thread, bounded (64) and
cleared on prune/disable. Protocol version bumped to 2.
2026-08-26 20:46:08 -04:00
MiTHRAL
7b949a312b submodule: repoint rexglue-sdk to the self-hosted fork
third_party/rexglue-sdk now fetches from the migrated copy on
git.mithraic.cloud (ad3laid3/rexglue-skate3, skate3-sdk-clean) instead of
the upstream GitHub fork, so the project no longer depends on the original
author's repo for its rexglue SDK.
2026-08-26 20:21:04 -04:00
MiTHRAL
bfb504db7d ghost multiplayer: full skater skeleton pose + character-model ghosts
Phase 2: replace the placeholder-cube ghost rendering with real character
models driven by the remote player's skeleton.

- Wire: POSE now carries the sender's skater skeleton (up to 64 bones, 3
  float4 rows each, index-ordered exactly like DrawItem::bones), padded with
  identity rows so every mesh's referenced bones stay in range. The relay
  forwards POSE payloads verbatim (re-stamping only the sender session id),
  so the protocol can grow without a relay update.
- Capture: OnFrameSceneBuilt (called from BuildFrameScene pre-publish)
  snapshots the local player's body item bone palette (highest-bone-count
  skinned non-ropa non-LivingWorld char item near the camera) as the
  broadcast pose; defaults the broadcast rate to 15 Hz.
- Render: in RenderScene, each remote ghost is drawn by cloning this
  player's own character pieces (body + nearby skinned char items) and
  substituting the remote skeleton into the bone ring - same meshes,
  textures and cached GPU decodes, no mesh transfer. Ghosts without a pose
  (older peers / capture not ready) fall back to the solid-color cube, which
  skate3_ghost_draw_local still uses for the self-check.

Known Phase-2 limitations (documented in code): ghosts render with this
player's own character model/outfit, cloth (ropa) garments are skipped (no
remote cloth sim), and hair/alpha pieces render opaque. All confined to
src/native and the hand-written scene hooks.
2026-08-26 20:16:42 -04:00
MiTHRAL
269fbcee32 Add ghost multiplayer: UDP relay + pose broadcasting + placeholder cubes
Bolt-on multiplayer layer confined to src/native and hand-written code (no
codegen output touched):

- GhostRelay core (src/native/skate3_native_ghost_relay.*): small UDP relay
  matching clients into rooms by a shareable code; forwards each client's
  pose to the other room members. Used by both the standalone binary and
  the in-game LAN host fallback.
- Standalone server (tools/ghost_relay_main.cpp, ghost_relay target): runs
  on a public VPS or behind a free tunnel; clients connect OUTBOUND, so no
  port forwarding is needed on any client.
- In-game module (skate3_native_ghost.*): captures the local player's world
  transform at Sk8::UpdateBoneTransforms exit (pack-owner entity +416,
  verified layout) and broadcasts it at a fixed rate; auto-connects to the
  default public relay, falls back to LAN auto-host + UDP broadcast
  discovery when unreachable.
- Renderer: draws a solid-color cube per remote player in RenderScene via
  the existing scene PSO tint.a>0 branch (no new shader/SPIR-V).
- UI: GhostDialog (M key) with enable toggle, room code, relay override,
  status readout. Off by default.

Wire protocol in skate3_native_ghost_proto.h (JOIN/HELLO/POSE/PING/LEAVE).
2026-08-26 20:12:45 -04:00
30 changed files with 3932 additions and 10 deletions

View file

@ -0,0 +1,126 @@
name: release
# Builds a Linux release of the game and attaches it to a Forgejo/Gitea
# Release, so players can download a ready-to-run build and only need to
# bring their own Skate 3 ISO (the built-in installer extracts it).
#
# Runs on the self-hosted runner (see tools/ci/setup_runner.sh and
# docs/ci-releases.md). The runner must have the extracted game dump at
# /opt/skate3-game (default.xex + data/webkit/EAWebkit.xex, and optionally
# the TU_12K2276_* title-update package). The game files are used only for
# codegen; they are NOT bundled into the release artifact.
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build-linux:
# Match this label to your self-hosted runner's label (Docker runners
# commonly use ubuntu-latest / ubuntu-22.04 / ubuntu-24.04).
runs-on: ubuntu-24.04
env:
GAME_ROOT: /opt/skate3-game
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
# Submodules are initialized manually so we can self-heal the
# broken upstream pins (see tools/ci/init-submodules.sh).
submodules: false
- name: Init submodules
shell: bash
env:
SUBMODULE_KEY: ${{ secrets.SUBMODULE_KEY }}
run: |
set -euo pipefail
# SSH key for the self-hosted rexglue fork submodule
# (git@git.mithraic.cloud:ad3laid3/rexglue-skate3.git).
if [ -n "${SUBMODULE_KEY:-}" ]; then
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$SUBMODULE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan git.mithraic.cloud >> ~/.ssh/known_hosts 2>/dev/null || true
fi
git submodule update --init --jobs 4
bash tools/ci/init-submodules.sh
- name: Install build dependencies
shell: bash
run: |
# Docker runner containers usually run as root (no sudo).
if command -v sudo >/dev/null 2>&1 && [ "$(id -u)" != 0 ]; then
APT="sudo apt-get"
else
APT="apt-get"
fi
$APT update
$APT install -y --no-install-recommends \
git cmake ninja-build pkg-config p7zip-full zip \
clang-20 lld-20 \
libgtk-3-dev libx11-xcb-dev \
libvulkan-dev vulkan-tools mesa-vulkan-drivers \
libasound2-dev libpulse-dev libpipewire-0.3-dev libudev-dev
- name: Configure
shell: bash
run: |
set -euo pipefail
TU=""
if compgen -G "$GAME_ROOT"/TU_12K2276_* >/dev/null; then
TU="-DSKATE3_TITLE_UPDATE_PACKAGE=$(compgen -G "$GAME_ROOT"/TU_12K2276_* | head -1)"
fi
echo "TU_ARGS=$TU" >> "$GITHUB_ENV"
cmake --preset linux-relwithdebinfo -DSKATE3_GAME_DATA_ROOT="$GAME_ROOT" $TU
- name: Generate recompiled code
shell: bash
run: cmake --build --preset linux-relwithdebinfo --target generate-all --parallel
- name: Reconfigure (generated sources)
shell: bash
run: |
set -euo pipefail
TU="${TU_ARGS:-}"
cmake --preset linux-relwithdebinfo -DSKATE3_GAME_DATA_ROOT="$GAME_ROOT" $TU
- name: Build
shell: bash
run: cmake --build --preset linux-relwithdebinfo --parallel
- name: Package release zip
shell: bash
run: |
set -euo pipefail
OUT="out/build/linux-relwithdebinfo"
VER="${GITHUB_REF_NAME#v}"
[ -n "$VER" ] || VER="dev"
NAME="Skate3Recomp-Linux-$VER"
STAGE="$NAME"
rm -rf "$STAGE"; mkdir -p "$STAGE"
cp "$OUT/skate3" "$STAGE/"
cp "$OUT/librexruntime.so" "$STAGE/" 2>/dev/null || true
cp "$OUT/ghost_relay" "$STAGE/" 2>/dev/null || true
# Title-update patches staged next to the binary when the build
# used the TU package (the CMake stage target places them here).
if [ -f "$OUT/game/default.xexp" ]; then
mkdir -p "$STAGE/game/data/webkit"
cp "$OUT/game/default.xexp" "$STAGE/game/"
cp "$OUT/game/data/webkit/EAWebkit.xexp" "$STAGE/game/data/webkit/" 2>/dev/null || true
fi
cp README.md "$STAGE/" 2>/dev/null || true
zip -r "$NAME.zip" "$STAGE"
echo "ARTIFACT=$NAME.zip" >> "$GITHUB_ENV"
- name: Publish release
uses: https://code.forgejo.org/forgejo/release-action@v2
with:
token: ${{ secrets.RELEASE_TOKEN }}
files: ${{ env.ARTIFACT }}
tag: ${{ github.ref_name }}
title: "Skate 3 Recomp ${{ github.ref_name }}"
draft: false
prerelease: false

11
.gitignore vendored
View file

@ -3,7 +3,16 @@
/build/
/generated/
/generated.backup-*/
/tools/
/tools/*
# ...except the ghost multiplayer relay server + release/CI tooling (first-party).
!/tools/ghost_relay_main.cpp
!/tools/ghost_relay.service
!/tools/ghost_relay_deploy.sh
!/tools/ci/
!/tools/release/
# Local release zips/stages are build products.
Skate3Recomp-*.zip
/Skate3Recomp-*/
# Local game dump and retail media. Users must provide their own copy.
/game/

2
.gitmodules vendored
View file

@ -1,5 +1,5 @@
[submodule "third_party/rexglue-sdk"]
path = third_party/rexglue-sdk
url = https://github.com/mchughalex/rexglue-skate3.git
url = git@git.mithraic.cloud:ad3laid3/rexglue-skate3.git
branch = skate3-sdk-clean

View file

@ -248,6 +248,9 @@ 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
@ -392,6 +395,20 @@ 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,6 +22,12 @@ 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,7 +1,11 @@
add_custom_target(generate-skate3
COMMAND $<TARGET_FILE:rex::rexglue> codegen
${SKATE3_CODEGEN_ARGS}
"${CMAKE_CURRENT_BINARY_DIR}/manifests/skate3.toml"
# 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 "${CMAKE_COMMAND}"
"-DSKATE3_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}"
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/ApplySkate3CodegenPatches.cmake"
@ -11,9 +15,7 @@ add_custom_target(generate-skate3
)
add_custom_target(generate-eawebkit
COMMAND $<TARGET_FILE:rex::rexglue> codegen
${SKATE3_CODEGEN_ARGS}
"${CMAKE_CURRENT_BINARY_DIR}/manifests/eawebkit.toml"
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"
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
COMMENT "Generating recompiled code for EAWebkit.xex"
VERBATIM

178
docs/ghost-multiplayer.md Normal file
View file

@ -0,0 +1,178 @@
# Ghost multiplayer (beta)
A from-scratch "ghost player" layer for the Skate 3 recomp native renderer:
every player's skater skeleton is relayed through a small UDP server so other
players see a real character model (driven by the remote skeleton) plus a
nameplate. Nothing touches the game's own netcode, animation or physics.
## How it works
- **Topology:** every client connects **outbound** (UDP) to a relay. No port
forwarding on any client. The relay forwards each player's pose stream to
the other members of the same **room** (a 4-8 character code).
- **Relay:** either the public `ghost_relay` server (below) or a player's
game in LAN host mode. When the configured relay is unreachable, the game
automatically starts an in-process relay on the LAN (UDP broadcast
discovery) so same-network play still works.
- **Protocol:** versioned (`kGhostVersion`). A mismatched client/server is
refused with a clear message rather than silently misbehaving.
- **Rendering:** ghosts are drawn with **this player's own character model**
driven by the remote skeleton (bone palettes, no mesh transfer). Cloth
garments drape in the remote root frame; nameplates label each ghost with
the owner's display name.
- **Logs:** everything important is logged to the game's log file (next to
the executable in `logs/`). The relay logs refusals/caps to its own output.
## Player usage
1. Launch the game, press **M** to open the multiplayer menu.
2. Tick **Enable ghost multiplayer**.
3. Enter a **room code** (or leave empty to get an auto-assigned one, shown
in the menu — share it with friends). Players sharing a code see each
other.
4. Use **Browse rooms (lobby)** to see who is on the configured relay and
click a room to join it.
5. Set a **display name** for your nameplate (default: your OS user name).
Settings (server, room, name, port, rate) persist across restarts.
### Configuration cvars
| cvar | default | meaning |
|---|---|---|
| `skate3_ghost_enable` | false | master switch (also the menu toggle) |
| `skate3_ghost_server` | `""` | relay `host[:port]`; empty = the compiled-in default |
| `skate3_ghost_port` | `40001` | UDP port (outbound + LAN discovery/host) |
| `skate3_ghost_room` | `""` | room code; empty = server assigns one |
| `skate3_ghost_name` | `""` | nameplate name; empty = OS user name |
| `skate3_ghost_rate` | `15` | pose broadcast rate (Hz) |
| `skate3_ghost_lan` | `true` | LAN auto-discovery + auto-host fallback |
| `skate3_ghost_draw_local` | `false` | debug: show your own capture cube |
| `skate3_ghost_debug` | `false` | verbose ghost logging |
The compiled-in default relay address is `kGhostDefaultServer` in
`src/native/skate3_native_ghost_proto.h` — set it to your relay before
shipping a build.
## Deploying the relay (public server)
The relay is a single dependency-free binary (plain sockets, no rexglue).
Run it on a public VPS (any size; the workload is a few UDP forwards, a
small VPS is plenty) or on your own machine behind a free tunnel
(playit.gg / ngrok / Tailscale Funnel).
### Build
```sh
# from the repo root
cmake --preset linux-relwithdebinfo -DSKATE3_GAME_DATA_ROOT="$PWD/game"
cmake --build --preset linux-relwithdebinfo --target ghost_relay --parallel
# -> out/build/linux-relwithdebinfo/ghost_relay
```
Or compile in place (no CMake/rexglue needed, just a C++23 compiler):
```sh
g++ -std=c++23 -O2 -DSKATE3_RELAY_STANDALONE \
-Isrc/native \
tools/ghost_relay_main.cpp src/native/skate3_native_ghost_relay.cpp \
-o ghost_relay
```
### Install on the VPS
`tools/ghost_relay_deploy.sh` builds, installs and registers the service:
```sh
# copy the repo (or just tools/ + the relay sources) to the VPS, then:
./tools/ghost_relay_deploy.sh # uses UDP 40001 by default
GHOST_RELAY_PORT=40002 ./tools/ghost_relay_deploy.sh
```
Manual:
```sh
install -m 0755 ghost_relay /usr/local/bin/ghost_relay
install -m 0644 tools/ghost_relay.service /etc/systemd/system/ghost-relay.service
systemctl daemon-reload && systemctl enable --now ghost-relay
ufw allow 40001/udp # open the port; nothing else is needed
```
The `ghost_relay.service` unit is:
```ini
[Unit]
Description=Skate 3 recomp ghost multiplayer relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/ghost_relay --port 40001 --max-clients 64
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
[Install]
WantedBy=multi-user.target
```
### Free-tunnel option (no VPS)
The relay only needs a public UDP endpoint. Run it on any machine and expose
it with a free tunnel that supports UDP (playit.gg is the most
straightforward for games): it gives you a public `host:port` that forwards
to your local relay. Point clients at it with `skate3_ghost_server` (or set
it as the compiled-in default). No client-side port forwarding either way.
### Verify
```sh
systemctl status ghost-relay
journalctl -u ghost-relay -f # join/leave lines (--verbose)
ss -ulnp | grep 40001 # listening on UDP
```
From a second machine:
```sh
printf '\x53\x47\x48\x54\x08\x00\x00' | nc -u <relay-ip> 40001 # LIST probe
# expect a kTypeRooms reply (starts 'S G H T' + type 9 + payload)
```
## Logging & troubleshooting
- **Game logs:** `<exe dir>/logs/` (SDK file logging; also where crash
dumps land). Set `skate3_ghost_debug 1` (F12 menu) for verbose ghost
lines.
- **Relay logs:** `journalctl -u ghost-relay` (add `--verbose` to
`ExecStart` for per-join/leave lines).
- **"Refused by relay (protocol version mismatch)":** the game build and the
relay must agree on `kGhostVersion` (both from the same build). Rebuild
both.
- **"Cannot resolve the relay"**: the default address is a placeholder until
you set `skate3_ghost_server`; the game then falls back to LAN auto-host.
- **Ghosts show as cubes**: the sender had no skeleton capture ready (menus,
loading) — the cube is the graceful fallback.
## Security notes
- The relay is an anonymous UDP relay: any client that knows the address can
join a room. It is intended for friends / a community, not untrusted
internet strangers. Keep `--max-clients` sane and do not expose it beyond
your intended audience.
- Room codes are not passwords. Do not put personal information in the room
code or display name.
## Repository layout
- `src/native/skate3_native_ghost_proto.h` — wire protocol
- `src/native/skate3_native_ghost_net.h` — POSIX/Winsock UDP shim
- `src/native/skate3_native_ghost_relay.{h,cpp}` — relay core (in-game host
mode + standalone binary)
- `src/native/skate3_native_ghost.{h,cpp}` — in-game client/capture
- `src/skate3_native_ghost_dialog.{h,cpp}` — multiplayer menu + nameplates
- `tools/ghost_relay_main.cpp`, `tools/ghost_relay.service`,
`tools/ghost_relay_deploy.sh` — the standalone server

129
docs/releases.md Normal file
View file

@ -0,0 +1,129 @@
# Releases
Releases are prebuilt binaries that players download so they only need their
own Skate 3 ISO — **no building from scratch**. This project does not and
cannot ship game content; the release zip is just the recompiled binaries.
## What's in a release (and what isn't)
A release zip (`Skate3Recomp-Linux-<ver>.zip`) contains only:
- `skate3` — the recompiled game binary (also the in-app **ISO installer**)
- `librexruntime.so` (Linux) / `librexruntime.dll` (Windows) /
`librexruntime.dylib` (macOS)
- `ghost_relay` — the multiplayer relay server binary
- `README.md`
- `game/default.xexp` + `game/data/webkit/EAWebkit.xexp` — **only** when the
build used the Title Update package (these are the recompiled update
patches, not retail content)
**No game files.** Players download the zip, run `skate3`, click "Select
ISO", and the built-in installer extracts their own legally-owned copy. The
game dump (`default.xex` + `data/webkit/EAWebkit.xex`) is required **only at
build time** — codegen mechanically translates the actual Xbox 360 binary
into C++ source, which is what gets compiled. It is never included in, or
uploaded with, a release.
## Option A — build locally, upload manually (recommended)
You already build successfully on your machine, so this is the simple path:
```sh
# on your build machine, from the repo root
./tools/release/release.sh # build + package this platform
./tools/release/release.sh --tag v1.0.0 --upload # build + publish to Forgejo
```
The script builds (configure → codegen → build), packages the zip (binaries
+ `librexruntime.so` + `ghost_relay` + README + TU patches if used), prints a
`sha256sum`, and with `--upload` creates/updates a Forgejo Release and
attaches the zip.
`--upload` needs:
- `GITEA_TOKEN` — a Forgejo token with **write:repository** scope (Settings →
Applications → Generate New Token), covering the release APIs.
- Optional `FORGEJO_REPO` (default `ad3laid3/skate3recomp-multiplayer`) and
`FORGEJO_INSTANCE` (default `https://git.mithraic.cloud`).
Per-platform notes:
| Platform | Build machine | Command |
|---|---|---|
| Linux | any Linux with the toolchain (README: Ubuntu 24.04 + Clang 20) | `./tools/release/release.sh` |
| Windows | a **Windows** machine (Visual Studio MSVC + Windows SDK, D3D12) | `tools\release\windows-build.cmd` (see below) |
| macOS | an **Apple Silicon/Intel Mac** (Homebrew LLVM + MoltenVK, per README) | `./tools/release/release.sh` |
Cross-compiling Windows/macOS from a Linux box is not practical for this
stack (D3D12 needs the Windows SDK, Cocoa/MoltenVK need a Mac), so each
platform's release is built on that platform.
### Windows build
On a Windows box, the known build issues (multiple Windows SDKs, Git Bash
`%LIB%` mangling, missing submodules) are handled by the launcher:
```bat
tools\release\windows-build.cmd --tag v2.0.2-mp1 --upload
```
It loads the MSVC environment (`vcvarsall.bat x64 <SDK>`), sets
`MSYS2_ARG_CONV_EXCL=*` so Git Bash doesn't corrupt `%LIB%`, initializes the
submodules (including the self-heal), and runs `release.sh`. Config via env:
- `VCVARSALL` — path to `vcvarsall.bat` (default: VS 18 Community x64).
- `WINSDK` — Windows SDK version (default `10.0.26100.0`; try `10.0.22621.0`
if yours differs — a broken SDK default is what causes `kernel32.lib`
`LNK1104`).
- `GITBASH` — path to Git for Windows `bash.exe`.
`release.sh` now auto-detects the Title Update package (repo root or
`game/`), falls back to PowerShell `Compress-Archive` when `zip` is missing,
and no longer requires `python3` for the upload JSON parsing. The Windows
build also needs the game dump (`game/default.xex` + `EAWebkit.xex`) and the
TU package present on that box (build-time only, never shipped).
## Option B — automated via Forgejo Actions (optional)
`.forgejo/workflows/release.yml` builds and publishes Linux releases on push
of a `v*` tag (or a manual run), on a self-hosted runner.
Requirements for the automation path:
1. **A runner with the game dump.** Codegen needs the `.xex` files at build
time. On the runner, put the extracted dump at `/opt/skate3-game`
(`default.xex` + `data/webkit/EAWebkit.xex`, optionally the
`TU_12K2276_*` package). If your runner is a Docker container, mount that
directory into it (`-v /opt/skate3-game:/opt/skate3-game`). The game files
stay on the runner — they are never uploaded to the repo.
2. **Secrets** on the repo:
- `SUBMODULE_KEY` — an SSH deploy key with read access to
`ad3laid3/rexglue-skate3` (the self-hosted rexglue fork submodule).
- `RELEASE_TOKEN` — a Forgejo token with `write:repository` scope.
3. **Runner label** — the workflow uses `runs-on: ubuntu-24.04`; match it to
your runner's label (Docker runners commonly use
`ubuntu-latest`/`ubuntu-22.04`/`ubuntu-24.04` — change the `runs-on` line
to match).
4. **Broken submodule pins.** A fresh recursive clone currently fails because
the rexglue fork pins `thirdparty/imgui` to a commit `ocornut/imgui` no
longer carries. `tools/ci/init-submodules.sh` self-heals this (checks out
the upstream default tip for any nested submodule left empty) and the
workflow calls it after `git submodule update --init`.
Windows/macOS jobs can be added to the same workflow when native
Windows/macOS runners are available (each job uses its platform preset and
artifact names; the package step is per-OS).
## Doing it by hand
If you'd rather not use the script, the equivalent steps are:
```sh
cmake --preset linux-relwithdebinfo -DSKATE3_GAME_DATA_ROOT="$PWD/game" # add -DSKATE3_TITLE_UPDATE_PACKAGE=... if used
cmake --build --preset linux-relwithdebinfo --target generate-all --parallel
cmake --preset linux-relwithdebinfo -DSKATE3_GAME_DATA_ROOT="$PWD/game"
cmake --build --preset linux-relwithdebinfo --parallel
# zip out/build/linux-relwithdebinfo/{skate3,librexruntime.so,ghost_relay} + README
```
Then create a Release in the Forgejo web UI (`<repo> → Releases → New
Release`) and attach the zip.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,114 @@
#pragma once
// Ghost multiplayer module (bolt-on, hand-written, confined to src/native):
// a from-scratch "ghost player" layer that relays each player's world
// transform (position + rotation) through a small UDP relay so other players
// see a placeholder marker at the same spot. It deliberately does NOT touch
// the game's own netcode, animation, physics or any codegen'd logic.
//
// Topology (automatic, zero client-side setup):
// 1. Every client connects OUTBOUND to the default public relay
// (kGhostDefaultServer / skate3_ghost_server cvar) and joins a room
// code. No port forwarding on any client.
// 2. If the public relay is unreachable, the game automatically becomes a
// LAN host (GhostRelay in-process) and LAN peers auto-discover it via
// UDP broadcast PING/HELLO.
//
// Local-player capture: at Sk8::UpdateBoneTransforms exit (the documented
// player-only hook) the pack-owner entity is the player's SkaterPresEntity;
// its verified world matrix lives at entity+416 (m_MatLtoWTrans). The
// capture runs on the sim thread and copies HOST floats under a mutex; the
// network thread never reads guest memory.
#include <cstdint>
#include <filesystem>
#include "skate3_native_ghost_proto.h"
namespace skate3::native_scene {
struct FrameScene;
} // namespace skate3::native_scene
namespace skate3::native_ghost {
// ---- host-side pose capture (called from BuildFrameScene pre-publish) -----
// Snapshot the local player's skater skeleton (the highest-bone-count
// skinned non-ropa character item near the camera) as the pose this client
// broadcasts, plus the local player's world transform (resolved from that
// item's owning entity). Called every built frame; gated internally on the
// enable cvar.
void OnFrameSceneBuilt(uint8_t* base, const skate3::native_scene::FrameScene& scene);
// ---- network thread lifecycle -------------------------------------------------
void EnsureNetworkThread();
void Shutdown();
// Persist the multiplayer cvars (server/room/name/port/rate) into the app's
// cvar config file whenever they change, so a player's settings survive a
// restart. config_path is the same file the SDK loads at startup. Safe to
// call once at startup.
void InstallSettingsPersistence(const std::filesystem::path& config_path);
// ---- status (UI / debug) --------------------------------------------------------
struct Status {
bool enabled = false;
bool connected = false; // joined to a relay (public or LAN)
bool hosting = false; // this game is relaying a LAN room
bool refused = false; // the relay refused us (protocol version mismatch)
uint32_t session_id = 0;
char room[kGhostMaxRoomLen] = {};
uint8_t room_len = 0;
size_t roster = 0; // other members in our room
size_t ghosts = 0; // remote ghosts currently drawn
char peer[96] = {}; // active relay "ip:port"
char last_error[160] = {}; // last non-fatal error, for the menu
};
bool GetStatus(Status* out);
// ---- render query (called from RenderScene, render thread) ---------------------
size_t GhostCount();
// Row-vector world matrix (translation in row 3) + a stable color per ghost.
bool GetGhost(uint32_t index, float world[16], float color[3]);
// Full-pose variant: additionally copies up to max_bones 12-float skeleton
// rows (the exact layout DrawItem::bones uses) so the renderer can drive a
// real character mesh. bone_count receives the copied row count (0 = the
// sender had no pose available; the caller should use the cube fallback).
bool GetGhostPose(uint32_t index, float world[16], float color[3],
uint16_t max_bones, float* bones, uint16_t* bone_count);
// Self-check cube at the local captured pose (skate3_ghost_draw_local).
bool GetLocalWorld(float world[16]);
// Lightweight per-ghost query for the nameplate overlay: the ghost's world
// position (entity world), marker color, and display name (from the room
// roster, or a session fallback). Returns false if the ghost is gone.
bool GetGhostLabel(uint32_t index, float pos[3], float color[3], char* name,
size_t name_max);
// ---- lobby browser ------------------------------------------------------------
// Ask the network thread to fetch the relay's room list (non-blocking; the
// reply updates GetRooms asynchronously, a second or two later).
void RequestRooms();
// Latest room list snapshot. count == 0 before the first ROOMS reply.
struct RoomList {
uint8_t version = 0;
uint8_t count = 0;
RoomEntry rooms[kGhostMaxRooms];
double received = 0.0; // steady seconds of the reply (0 = none yet)
};
bool GetRooms(RoomList* out);
// ---- cube geometry (56-byte records matching kSceneInputLayout) -----------------
struct GhostCubeVertex {
float pos[3];
float uv[2];
float uv2[2];
uint8_t bw[4];
uint8_t bi[4];
float normal[3];
float uv3[2];
};
const GhostCubeVertex* CubeVertices();
uint32_t CubeVertexCount(); // 36 (12 triangles, non-indexed)
} // namespace skate3::native_ghost

View file

@ -0,0 +1,219 @@
#pragma once
// Minimal UDP socket shim shared by the ghost relay (standalone binary +
// in-game host mode) and the in-game client. Dependency-free: plain
// POSIX sockets on Linux/macOS, Winsock on Windows. The rexglue SDK's own
// rex::net is only close/ioctl and its XSocket is the dead Xbox Live
// emulation, which the ghost feature deliberately avoids.
#include <cstdint>
#include <cstring>
#include <string>
#if defined(_WIN32)
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")
using GhostSock = SOCKET;
#define GHOST_INVALID_SOCKET INVALID_SOCKET
#else
#include <arpa/inet.h>
#include <cerrno>
#include <netdb.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
using GhostSock = int;
#define GHOST_INVALID_SOCKET (-1)
#endif
namespace skate3::native_ghost {
struct GhostAddr {
sockaddr_storage ss = {};
socklen_t len = 0;
bool valid() const { return len != 0; }
};
inline void GhostSockInit() {
#if defined(_WIN32)
static bool s_init = [] {
WSADATA wsa = {};
WSAStartup(MAKEWORD(2, 2), &wsa);
return true;
}();
(void)s_init;
#endif
}
inline bool GhostSockClose(GhostSock s) {
if (s == GHOST_INVALID_SOCKET) {
return false;
}
#if defined(_WIN32)
closesocket(s);
#else
::close(s);
#endif
return true;
}
inline GhostSock GhostSocketCreate(bool allow_broadcast) {
GhostSockInit();
GhostSock s = ::socket(AF_INET, SOCK_DGRAM, 0);
if (s == GHOST_INVALID_SOCKET) {
return s;
}
int one = 1;
::setsockopt(s, SOL_SOCKET, SO_REUSEADDR,
reinterpret_cast<const char*>(&one), sizeof(one));
if (allow_broadcast) {
::setsockopt(s, SOL_SOCKET, SO_BROADCAST,
reinterpret_cast<const char*>(&one), sizeof(one));
}
return s;
}
inline bool GhostSockBind(GhostSock s, uint16_t port) {
sockaddr_in sin = {};
sin.sin_family = AF_INET;
sin.sin_addr.s_addr = htonl(INADDR_ANY);
sin.sin_port = htons(port);
return ::bind(s, reinterpret_cast<const sockaddr*>(&sin), sizeof(sin)) == 0;
}
// Parse "host[:port]" (IP or hostname; port optional, defaults to
// default_port). Returns false on resolve failure.
inline bool GhostResolve(const std::string& hostport, uint16_t default_port,
GhostAddr* out) {
std::string host = hostport;
uint16_t port = default_port;
const size_t colon = hostport.rfind(':');
if (colon != std::string::npos) {
host = hostport.substr(0, colon);
const std::string port_str = hostport.substr(colon + 1);
if (!port_str.empty()) {
port = uint16_t(std::stoi(port_str));
}
}
if (host.empty()) {
return false;
}
addrinfo hints = {};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_DGRAM;
hints.ai_protocol = IPPROTO_UDP;
addrinfo* res = nullptr;
const std::string port_str = std::to_string(port);
if (getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res) != 0 ||
res == nullptr) {
if (res != nullptr) {
freeaddrinfo(res);
}
return false;
}
sockaddr_in* sin = reinterpret_cast<sockaddr_in*>(res->ai_addr);
sin->sin_port = htons(port);
std::memcpy(&out->ss, res->ai_addr, res->ai_addrlen);
out->len = socklen_t(res->ai_addrlen);
freeaddrinfo(res);
return true;
}
// Loopback relay feed (in-game host mode forwards the host's own pose to
// LAN members).
inline GhostAddr GhostAddrLoopback(uint16_t port) {
GhostAddr a;
sockaddr_in sin = {};
sin.sin_family = AF_INET;
sin.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
sin.sin_port = htons(port);
std::memcpy(&a.ss, &sin, sizeof(sin));
a.len = sizeof(sin);
return a;
}
// LAN broadcast address for a port.
inline GhostAddr GhostAddrBroadcast(uint16_t port) {
GhostAddr a;
sockaddr_in sin = {};
sin.sin_family = AF_INET;
sin.sin_addr.s_addr = htonl(INADDR_BROADCAST);
sin.sin_port = htons(port);
std::memcpy(&a.ss, &sin, sizeof(sin));
a.len = sizeof(sin);
return a;
}
inline uint16_t GhostAddrPort(const GhostAddr& a) {
const sockaddr_in* sin = reinterpret_cast<const sockaddr_in*>(&a.ss);
return ntohs(sin->sin_port);
}
inline bool GhostAddrEqual(const GhostAddr& a, const GhostAddr& b) {
return a.len == b.len && std::memcmp(&a.ss, &b.ss, a.len) == 0;
}
inline std::string GhostAddrStr(const GhostAddr& a) {
const sockaddr_in* sin = reinterpret_cast<const sockaddr_in*>(&a.ss);
char buf[INET_ADDRSTRLEN] = {};
inet_ntop(AF_INET, &sin->sin_addr, buf, sizeof(buf));
return std::string(buf) + ":" + std::to_string(ntohs(sin->sin_port));
}
inline bool GhostSockSendTo(GhostSock s, const GhostAddr& to, const void* data,
int size) {
#if defined(_WIN32)
const int n = ::sendto(s, reinterpret_cast<const char*>(data), size, 0,
reinterpret_cast<const sockaddr*>(&to.ss), int(to.len));
#else
const int n = ::sendto(s, data, size, 0,
reinterpret_cast<const sockaddr*>(&to.ss), to.len);
#endif
return n == size;
}
inline int GhostSockRecvFrom(GhostSock s, GhostAddr* from, void* data, int size) {
#if defined(_WIN32)
int addr_len = int(sizeof(from->ss));
const int n = ::recvfrom(s, reinterpret_cast<char*>(data), size, 0,
reinterpret_cast<sockaddr*>(&from->ss), &addr_len);
from->len = n >= 0 ? socklen_t(addr_len) : 0;
#else
socklen_t addr_len = sizeof(from->ss);
const int n = ::recvfrom(s, data, size, 0,
reinterpret_cast<sockaddr*>(&from->ss), &addr_len);
from->len = n >= 0 ? addr_len : 0;
#endif
return n;
}
inline bool GhostSockSetRecvTimeout(GhostSock s, int timeout_ms) {
#if defined(_WIN32)
DWORD tv = DWORD(timeout_ms);
#else
timeval tv = {};
tv.tv_sec = timeout_ms / 1000;
tv.tv_usec = (timeout_ms % 1000) * 1000;
#endif
return ::setsockopt(s, SOL_SOCKET, SO_RCVTIMEO,
reinterpret_cast<const char*>(&tv), sizeof(tv)) == 0;
}
inline int GhostSockLastError() {
#if defined(_WIN32)
return WSAGetLastError();
#else
return errno;
#endif
}
inline bool GhostSockWouldBlock(int err) {
#if defined(_WIN32)
return err == WSAEWOULDBLOCK || err == WSAETIMEDOUT;
#else
return err == EAGAIN || err == EWOULDBLOCK;
#endif
}
} // namespace skate3::native_ghost

View file

@ -0,0 +1,305 @@
#pragma once
// Ghost multiplayer wire protocol (shared by the in-game client, the
// in-game host mode and the standalone ghost_relay binary).
//
// Topology: every client connects OUTBOUND (UDP) to a relay; the relay
// forwards each client's pose packet to the other members of the same room.
// No client needs inbound reachability (no port forwarding). The relay is
// either a public VPS server (ghost_relay) or a player's game in host mode
// (the GhostRelay class below), reachable directly on a LAN or via a free
// tunnel on the internet.
//
// All multi-byte fields are little-endian (all supported hosts are
// little-endian; a struct with explicit layout is serialized with memcpy).
// A packet is PacketHeader followed by a fixed per-type payload. PacketBuf
// is sized for the largest payload.
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
namespace skate3::native_ghost {
constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE)
constexpr uint8_t kGhostVersion = 4;
constexpr uint16_t kGhostDefaultPort = 40001;
constexpr uint32_t kGhostMaxRoomLen = 8;
constexpr uint32_t kGhostMaxRoster = 32;
constexpr uint32_t kGhostMaxNameLen = 16; // display name, UTF-8-safe ASCII
constexpr uint32_t kGhostMaxRooms = 16; // rooms listed per LOBBY reply
constexpr uint32_t kGhostMaxRoomMembers = 4; // member names shown per room
// Max skeleton rows carried in a POSE (3 float4s per bone). The Skate 3
// skater skeleton fits comfortably under this; rows beyond the sender's
// captured count are identity-padded so every mesh's referenced bones stay
// in range on the receiving side.
constexpr uint32_t kGhostMaxBones = 64;
// Default public relay (the production beta relay on the OVH VPS). Clients
// connect OUTBOUND to this; no port forwarding on any client. Override at
// runtime with skate3_ghost_server.
constexpr const char* kGhostDefaultServer = "relay.mithraic.lol";
constexpr uint16_t kGhostDefaultServerPort = 40001;
// A LAN host with no room advertised is implicitly in this room.
constexpr const char* kGhostLobbyRoom = "LOBBY";
enum PacketType : uint8_t {
kTypeJoin = 1, // client -> relay: { JoinPayload } (empty room = server assigns)
kTypeHello = 2, // relay -> client: { HelloPayload } (session id, room, roster)
kTypePose = 3, // legacy single-datagram pose (forwarded, unused by clients)
kTypePing = 4, // client -> relay / LAN broadcast probe: { JoinPayload }
kTypePong = 5, // relay -> client: empty
kTypeLeave = 6, // client -> relay: empty
kTypePoseChunk = 7, // client -> relay -> other members: { PoseChunkPayload }
kTypeList = 8, // client -> relay: request the room list (empty payload)
kTypeRooms = 9, // relay -> client: { RoomsPayload } (the lobby browser)
};
// True byte-packing: the struct layouts below must match the wire format
// exactly (the receiver bounds-checks every packet against these sizes, and
// the relay rejects undersized datagrams with `n < sizeof(PacketHeader)`).
// alignas(1) alone is NOT enough - it sets the struct alignment but the
// compiler still pads MEMBERS to their natural alignment (a uint16_t lands
// on an even offset, inflating PacketHeader to 8 bytes and dropping every
// real 7-byte packet).
#pragma pack(push, 1)
struct PacketHeader {
uint32_t magic;
uint8_t type;
uint16_t payload_size; // bytes following this header
};
// JOIN and PING share the room payload (PING may carry the probing client's
// room so LAN hosts can answer only matching rooms); JOIN additionally
// carries the client's display name, which the relay echoes to room members
// in the HELLO roster so nameplates can label every ghost.
struct alignas(1) JoinPayload {
uint8_t version; // protocol version (kGhostVersion); mismatches are refused
uint8_t room_len;
char room[kGhostMaxRoomLen];
uint8_t name_len;
char name[kGhostMaxNameLen];
};
// One room member as reported in a HELLO roster: session id + display name.
struct alignas(1) RosterEntry {
uint32_t session_id;
uint8_t name_len;
char name[kGhostMaxNameLen];
};
struct alignas(1) HelloPayload {
uint32_t session_id; // our assigned session id (0 = refused)
uint8_t version; // the version we were accepted under
uint8_t room_len;
char room[kGhostMaxRoomLen];
uint8_t roster_count; // other members in this room
RosterEntry roster[kGhostMaxRoster];
};
// One room in the lobby list: the code, the total member count, and up to
// kGhostMaxRoomMembers member display names (for the "who's in there" glance).
struct alignas(1) RoomEntry {
uint8_t room_len;
char room[kGhostMaxRoomLen];
uint8_t member_count; // total players in this room
uint8_t shown_members; // how many names follow (<= kGhostMaxRoomMembers)
uint8_t member_name_len[kGhostMaxRoomMembers];
char member_names[kGhostMaxRoomMembers][kGhostMaxNameLen];
};
struct alignas(1) RoomsPayload {
uint8_t version; // the relay's protocol version (see the handshake)
uint8_t room_count;
RoomEntry rooms[kGhostMaxRooms];
};
// Full-pose blob (the character-model path): the sender's skater skeleton,
// 3 float4 rows per bone (column-vector affine [R | t], model -> world, row
// k = bone k exactly as the scene renderer's bone ring expects). Flags bit 0
// = bones[] valid. pos/rot are kept from the entity world for the cube
// fallback / diagnostics. This struct is the REASSEMBLED content of a
// chunked PoseChunk stream; it is no longer sent as a single datagram.
struct alignas(1) PosePayload {
uint32_t session_id; // set by the relay on forward (the sender's id)
uint32_t seq; // sender monotonic sequence
double timestamp; // sender monotonic seconds (informational)
float pos[3]; // world translation (entity world, row 3)
float rot[9]; // world rotation, row-vector 3x3 (matches entity+416)
uint16_t bone_count; // rows in bones[] (0 = pose unavailable)
uint8_t flags; // bit0 = bones[] valid
uint8_t reserved;
float bones[kGhostMaxBones * 12];
};
constexpr uint8_t kPoseFlagsBones = 1;
// ---- chunked pose transport -------------------------------------------------
// A full PosePayload is ~3.1 KB, far above the 1500-byte path MTU, so it is
// split into kGhostMaxChunks MTU-safe datagrams (<= ~1.2 KB each). The relay
// forwards each chunk verbatim (it never parses the body), and the receiver
// reassembles on the sequence. A lost chunk drops that pose (the next frame
// resends); out-of-order chunks are tolerated by the per-chunk offset copy.
constexpr uint16_t kGhostPoseHeaderSize =
uint16_t(offsetof(PosePayload, bones)); // 68
constexpr uint16_t kGhostChunkData = 1200;
constexpr uint8_t kGhostMaxChunks = 3;
constexpr uint16_t kGhostPoseBlobSize = uint16_t(sizeof(PosePayload)); // 3140
struct alignas(1) PoseChunkPayload {
uint32_t session_id; // offset 0: relay re-stamps the sender id
uint32_t seq; // pose sequence this chunk belongs to
uint16_t blob_size; // total blob bytes this seq comprises
uint16_t data_size; // bytes actually valid in data[]
uint8_t chunk; // 0-based chunk index
uint8_t chunks; // total chunks for this seq
uint8_t data[kGhostChunkData];
};
// Receiver-side reassembly for ONE session (one instance per remote peer,
// network thread only). Push() returns true exactly when a complete pose
// becomes available and fills *out; a fresh seq discards any partial state.
struct GhostPoseReassembler {
void Reset() {
seq_ = 0;
blob_size_ = 0;
chunks_ = 0;
received_ = 0;
}
bool Push(uint32_t seq, uint16_t blob_size, uint8_t chunk, uint8_t chunks,
const uint8_t* data, uint16_t data_size, PosePayload* out) {
if (chunk >= chunks || chunks == 0 || chunks > kGhostMaxChunks ||
blob_size == 0 || blob_size > kGhostPoseBlobSize) {
return false;
}
if (seq != seq_) {
Reset();
seq_ = seq;
blob_size_ = blob_size;
chunks_ = chunks;
} else if (blob_size != blob_size_ || chunks != chunks_) {
return false; // inconsistent mid-sequence; drop
}
const uint32_t bit = 1u << chunk;
if ((received_ & bit) != 0) {
return false; // duplicate chunk
}
received_ |= bit;
const uint16_t off = uint16_t(chunk) * kGhostChunkData;
const uint16_t avail = blob_size_ > off ? blob_size_ - off : 0;
const uint16_t n = std::min<uint16_t>(data_size, avail);
std::memcpy(blob_ + off, data, n);
const uint32_t want = (1u << chunks_) - 1u;
if ((received_ & want) != want) {
return false;
}
std::memset(out, 0, sizeof(*out));
std::memcpy(out, blob_, std::min<uint16_t>(blob_size_, uint16_t(sizeof(*out))));
Reset();
return true;
}
private:
uint32_t seq_ = 0;
uint16_t blob_size_ = 0;
uint8_t chunks_ = 0;
uint32_t received_ = 0;
uint8_t blob_[kGhostPoseBlobSize] = {};
};
// Largest payload: PoseChunkPayload (1214 bytes).
constexpr uint16_t kGhostMaxPayload = 3200;
struct PacketBuf {
PacketHeader h;
uint8_t payload[kGhostMaxPayload];
};
#pragma pack(pop)
inline uint16_t PacketSize(const PacketBuf& p) {
return uint16_t(sizeof(PacketHeader) + p.h.payload_size);
}
inline void BuildPacket(PacketBuf& p, uint8_t type, const void* payload,
uint16_t payload_size) {
p.h.magic = kGhostMagic;
p.h.type = type;
p.h.payload_size = payload_size;
if (payload_size != 0) {
std::memcpy(p.payload, payload, payload_size);
}
}
// Builds the chunk packets for a pose. Always writes exactly the returned
// chunk count (1 for a header-only pose, up to kGhostMaxChunks for a full
// skeleton). The blob is the PosePayload serialized from its first byte.
inline uint8_t GhostPoseBuildChunks(uint32_t session_id, uint32_t seq,
const PosePayload& pose,
PacketBuf out[kGhostMaxChunks]) {
const uint16_t blob_size =
(pose.flags & kPoseFlagsBones) != 0
? uint16_t(sizeof(PosePayload))
: kGhostPoseHeaderSize;
const uint8_t chunks =
uint8_t((blob_size + kGhostChunkData - 1) / kGhostChunkData);
const uint8_t* blob = reinterpret_cast<const uint8_t*>(&pose);
for (uint8_t c = 0; c < chunks; ++c) {
PoseChunkPayload cp = {};
cp.session_id = session_id;
cp.seq = seq;
cp.blob_size = blob_size;
cp.chunk = c;
cp.chunks = chunks;
const uint16_t off = uint16_t(c) * kGhostChunkData;
const uint16_t n = std::min<uint16_t>(kGhostChunkData, blob_size - off);
cp.data_size = n;
std::memcpy(cp.data, blob + off, n);
BuildPacket(out[c], kTypePoseChunk, &cp, sizeof(cp));
}
return chunks;
}
// ---- room code helpers (uppercase alnum, max kGhostMaxRoomLen) -------------
inline bool RoomValid(const char* room, uint8_t len) {
if (len == 0 || len > kGhostMaxRoomLen) {
return false;
}
for (uint8_t i = 0; i < len; ++i) {
const char c = room[i];
if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) {
return false;
}
}
return true;
}
inline void RoomClear(char* room, uint8_t& len) {
len = 0;
std::memset(room, 0, kGhostMaxRoomLen);
}
inline void RoomSet(char* room, uint8_t& len, const char* s) {
len = 0;
std::memset(room, 0, kGhostMaxRoomLen);
if (s == nullptr) {
return;
}
while (*s != '\0' && len < kGhostMaxRoomLen) {
char c = *s;
if (c >= 'a' && c <= 'z') {
c = char(c - 'a' + 'A');
}
if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
room[len++] = c;
}
++s;
}
}
inline bool RoomEquals(const char* a, uint8_t a_len, const char* b, uint8_t b_len) {
return a_len == b_len && (a_len == 0 || std::memcmp(a, b, a_len) == 0);
}
} // namespace skate3::native_ghost

View file

@ -0,0 +1,414 @@
#include "skate3_native_ghost_relay.h"
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <string>
#include <unordered_map>
#include <vector>
// The relay core is compiled into BOTH the game (rex logging available) and
// the dependency-free standalone ghost_relay binary; route its logs through
// a small build-flag shim so the standalone stays rexglue-free.
#if defined(SKATE3_RELAY_STANDALONE)
namespace skate3::native_ghost {
namespace {
inline std::string StandaloneArg(uint32_t v) { return std::to_string(v); }
inline std::string StandaloneArg(int v) { return std::to_string(v); }
inline std::string StandaloneArg(const std::string& v) { return v; }
inline std::string StandaloneArg(const char* v) {
return v != nullptr ? v : "(null)";
}
// Minimal "{}"-placeholder formatter (the relay logs use fmt-style strings
// because the game build routes them through REXLOG).
template <typename... Args>
void StandaloneLog(const char* level, const char* fmt, Args&&... args) {
const std::vector<std::string> vals = {StandaloneArg(args)...};
std::string out;
size_t k = 0;
const char* p = fmt;
while (*p != '\0') {
if (p[0] == '{' && p[1] == '}' && k < vals.size()) {
out += vals[k++];
p += 2;
} else {
out += *p++;
}
}
if (level[0] == 'W') {
std::fprintf(stderr, "[ghost_relay] %s: %s\n", level, out.c_str());
} else {
std::printf("[ghost_relay] %s: %s\n", level, out.c_str());
}
}
} // namespace
} // namespace skate3::native_ghost
#define GHOST_RELAY_INFO(...) \
::skate3::native_ghost::StandaloneLog("I", __VA_ARGS__)
#define GHOST_RELAY_WARN(...) \
::skate3::native_ghost::StandaloneLog("W", __VA_ARGS__)
#else
#include <rex/logging.h>
#define GHOST_RELAY_INFO(...) REXLOG_INFO(__VA_ARGS__)
#define GHOST_RELAY_WARN(...) REXLOG_WARN(__VA_ARGS__)
#endif
namespace skate3::native_ghost {
namespace {
constexpr double kClientTimeoutSec = 8.0;
} // namespace
GhostRelay::~GhostRelay() {
Stop();
}
bool GhostRelay::Start() {
if (running_.load(std::memory_order_relaxed)) {
return true;
}
sock_ = GhostSocketCreate(/*allow_broadcast=*/true);
if (sock_ == GHOST_INVALID_SOCKET) {
return false;
}
if (!GhostSockBind(sock_, port_)) {
GhostSockClose(sock_);
sock_ = GHOST_INVALID_SOCKET;
return false;
}
GhostSockSetRecvTimeout(sock_, 200);
running_.store(true, std::memory_order_relaxed);
stop_.store(false, std::memory_order_relaxed);
thread_ = std::thread(&GhostRelay::Run, this);
return true;
}
void GhostRelay::Stop() {
if (!running_.load(std::memory_order_relaxed)) {
return;
}
stop_.store(true, std::memory_order_relaxed);
if (thread_.joinable()) {
thread_.join();
}
if (sock_ != GHOST_INVALID_SOCKET) {
GhostSockClose(sock_);
sock_ = GHOST_INVALID_SOCKET;
}
running_.store(false, std::memory_order_relaxed);
std::lock_guard<std::mutex> lock(mu_);
clients_.clear();
}
size_t GhostRelay::ClientCount() const {
std::lock_guard<std::mutex> lock(mu_);
return clients_.size();
}
void GhostRelay::SetLockedRoom(const char* room) {
std::lock_guard<std::mutex> lock(mu_);
RoomSet(locked_room_, locked_room_len_, room);
locked_ = locked_room_len_ != 0;
}
void GhostRelay::Run() {
while (!stop_.load(std::memory_order_relaxed)) {
try {
GhostAddr from;
PacketBuf p;
const int n = GhostSockRecvFrom(sock_, &from, &p, sizeof(p));
if (n <= 0) {
Sweep(0.0);
continue;
}
if (n < static_cast<int>(sizeof(PacketHeader))) {
continue;
}
if (p.h.magic != kGhostMagic) {
continue;
}
const uint16_t expect = static_cast<uint16_t>(n - sizeof(PacketHeader));
if (p.h.payload_size != expect ||
p.h.payload_size > kGhostMaxPayload) {
continue;
}
const double now =
double(std::chrono::steady_clock::now().time_since_epoch().count()) /
1e9;
HandlePacket(from, p, now);
Sweep(now);
} catch (const std::exception& e) {
GHOST_RELAY_WARN("relay thread exception: {}", e.what());
} catch (...) {
GHOST_RELAY_WARN("relay thread unknown exception");
}
}
}
void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
double now) {
std::lock_guard<std::mutex> lock(mu_);
// Resolve the sender's client record. Client records are only created by
// JOIN/PING (a client must announce itself before posing); any other
// packet from an unknown address is dropped, so spoofed POSE/LEAVE floods
// cannot fill the client table. LIST is the exception: a lobby browser can
// ask for the room list before joining anything.
Client* me = nullptr;
for (Client& c : clients_) {
if (GhostAddrEqual(c.addr, from)) {
me = &c;
break;
}
}
if (me == nullptr) {
if (p.h.type == kTypeList) {
BuildRoomList(from);
return;
}
if (p.h.type != kTypeJoin && p.h.type != kTypePing) {
return;
}
if (clients_.size() >= max_clients_) {
GHOST_RELAY_WARN("client cap reached ({}); dropping {}", max_clients_,
GhostAddrStr(from));
return;
}
clients_.push_back(Client{});
me = &clients_.back();
me->addr = from;
me->session_id = next_session_++;
RoomClear(me->room, me->room_len);
}
const auto send = [this, &from](const PacketBuf& out) {
GhostSockSendTo(sock_, from, &out, PacketSize(out));
};
PacketBuf out = {};
switch (p.h.type) {
case kTypeJoin:
case kTypePing: {
if (p.h.payload_size < sizeof(JoinPayload)) {
return;
}
JoinPayload jp = {};
std::memcpy(&jp, p.payload, sizeof(jp));
// Protocol handshake: a mismatched version is REFUSED (HELLO with
// session 0) and its poses are dropped, so two builds can never
// silently corrupt each other's pose stream.
if (jp.version != kGhostVersion) {
me->refused = true;
if (!me->name[0]) {
std::memcpy(me->name, jp.name, std::min<uint8_t>(jp.name_len, kGhostMaxNameLen));
me->name_len = std::min<uint8_t>(jp.name_len, kGhostMaxNameLen);
}
HelloPayload hp = {};
hp.session_id = 0; // refused
hp.version = kGhostVersion;
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp)));
send(out);
GHOST_RELAY_WARN("refused {} (client v{}, relay v{})", GhostAddrStr(from),
jp.version, kGhostVersion);
return;
}
if (locked_) {
std::memcpy(me->room, locked_room_, locked_room_len_);
me->room_len = locked_room_len_;
} else if (RoomValid(jp.room, jp.room_len)) {
std::memcpy(me->room, jp.room, jp.room_len);
me->room_len = jp.room_len;
}
// Keep the client's display name (JOIN carries it; PINGs from probes
// may omit it, so only overwrite with a non-empty name).
if (jp.name_len != 0 && jp.name_len <= kGhostMaxNameLen) {
std::memcpy(me->name, jp.name, jp.name_len);
me->name_len = jp.name_len;
}
// Empty room: assign one (uppercase alnum, 4 chars, seeded by the
// session id and the wall of the receiving thread).
if (me->room_len == 0) {
uint32_t state = (uint32_t(now * 1e3) * 2654435761u) ^
(me->session_id * 40503u) ^ 0x9E3779B9u;
char room[kGhostMaxRoomLen] = {};
for (uint8_t i = 0; i < 4; ++i) {
state = state * 1664525u + 1013904223u;
room[i] = (state & 1u) != 0
? static_cast<char>('A' + (state >> 1) % 26u)
: static_cast<char>('0' + (state >> 2) % 10u);
}
std::memcpy(me->room, room, 4);
me->room_len = 4;
}
me->last_seen = now;
if (p.h.type == kTypeJoin) {
// HELLO: our session id, room and the roster of the OTHER members.
HelloPayload hp = {};
hp.session_id = me->session_id;
hp.version = kGhostVersion;
hp.room_len = me->room_len;
std::memcpy(hp.room, me->room, me->room_len);
for (const Client& c : clients_) {
if (&c != me && c.room_len == me->room_len &&
RoomEquals(c.room, c.room_len, me->room, me->room_len) &&
hp.roster_count < kGhostMaxRoster) {
RosterEntry& entry = hp.roster[hp.roster_count++];
entry.session_id = c.session_id;
entry.name_len = c.name_len;
std::memcpy(entry.name, c.name, c.name_len);
}
}
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp)));
send(out);
if (verbose_.load(std::memory_order_relaxed)) {
GHOST_RELAY_INFO("{} joined room '{}' (session {})", GhostAddrStr(from),
std::string(hp.room, hp.room_len), me->session_id);
}
} else {
// PING probe: HELLO with the host's room/roster so probing clients
// can decide whether to join this LAN host.
HelloPayload hp = {};
hp.session_id = me->session_id;
hp.version = kGhostVersion;
hp.room_len = me->room_len;
std::memcpy(hp.room, me->room, me->room_len);
for (const Client& c : clients_) {
if (&c != me && c.room_len == me->room_len &&
RoomEquals(c.room, c.room_len, me->room, me->room_len) &&
hp.roster_count < kGhostMaxRoster) {
RosterEntry& entry = hp.roster[hp.roster_count++];
entry.session_id = c.session_id;
entry.name_len = c.name_len;
std::memcpy(entry.name, c.name, c.name_len);
}
}
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp)));
send(out);
}
break;
}
case kTypePose:
case kTypePoseChunk: {
// A refused (version-mismatched) client's poses are never forwarded.
if (me->refused) {
return;
}
// Clients join before posing; a pose with no room is auto-joined to
// the lobby so the relay stays resilient to a missed HELLO.
if (me->room_len == 0) {
RoomSet(me->room, me->room_len, kGhostLobbyRoom);
}
me->last_seen = now;
// Forward the payload VERBATIM to the other room members, re-stamping
// only the sender session id at payload offset 0 (little-endian). The
// relay never parses the pose body (a full skeleton pose is a
// multi-chunk kTypePoseChunk stream; each chunk is forwarded the same
// way), so the protocol can grow without a relay update.
PacketBuf out = p;
out.h.magic = kGhostMagic;
out.h.type = p.h.type;
std::memcpy(out.payload, &me->session_id, sizeof(me->session_id));
const uint16_t size = PacketSize(out);
for (const Client& c : clients_) {
if (&c == me) {
continue;
}
if (c.room_len != me->room_len ||
!RoomEquals(c.room, c.room_len, me->room, me->room_len)) {
continue;
}
GhostSockSendTo(sock_, c.addr, &out, size);
}
break;
}
case kTypeLeave: {
const auto it = std::remove_if(clients_.begin(), clients_.end(),
[&from](const Client& c) {
return GhostAddrEqual(c.addr, from);
});
if (it != clients_.end() && verbose_.load(std::memory_order_relaxed)) {
GHOST_RELAY_INFO("{} left", GhostAddrStr(from));
}
clients_.erase(it, clients_.end());
break;
}
case kTypeList: {
BuildRoomList(from);
break;
}
default:
break; // PONG etc. are client-side only; ignore at the relay.
}
}
void GhostRelay::BuildRoomList(const GhostAddr& to) {
// Aggregate the client table by room (the mu_ lock is already held by the
// caller), most-populated first, capped at kGhostMaxRooms with up to
// kGhostMaxRoomMembers names per room.
struct RoomAgg {
uint32_t count = 0;
uint32_t shown = 0;
uint32_t names[kGhostMaxRoomMembers] = {}; // indices into clients_
};
std::unordered_map<std::string, RoomAgg> agg;
for (size_t i = 0; i < clients_.size(); ++i) {
const Client& c = clients_[i];
if (c.room_len == 0 || c.refused) {
continue;
}
const std::string key(c.room, c.room_len);
RoomAgg& a = agg[key];
++a.count;
if (a.shown < kGhostMaxRoomMembers && c.name_len != 0) {
a.names[a.shown++] = uint32_t(i);
}
}
RoomsPayload rp = {};
rp.version = kGhostVersion;
uint8_t listed = 0;
for (const auto& kv : agg) {
if (listed >= kGhostMaxRooms) {
break;
}
const std::string& key = kv.first;
const RoomAgg& a = kv.second;
RoomEntry& e = rp.rooms[listed];
e.room_len = uint8_t(std::min<size_t>(key.size(), kGhostMaxRoomLen));
std::memcpy(e.room, key.data(), e.room_len);
e.member_count = uint8_t(std::min<uint32_t>(a.count, 255));
e.shown_members = uint8_t(a.shown);
for (uint32_t n = 0; n < a.shown; ++n) {
const Client& c = clients_[a.names[n]];
e.member_name_len[n] = c.name_len;
std::memcpy(e.member_names[n], c.name, c.name_len);
}
++listed;
}
rp.room_count = listed;
PacketBuf out;
BuildPacket(out, kTypeRooms, &rp, static_cast<uint16_t>(sizeof(rp)));
GhostSockSendTo(sock_, to, &out, PacketSize(out));
}
void GhostRelay::Sweep(double now) {
std::lock_guard<std::mutex> lock(mu_);
if (now <= 0.0) {
return;
}
clients_.erase(std::remove_if(clients_.begin(), clients_.end(),
[now](const Client& c) {
return now - c.last_seen > kClientTimeoutSec;
}),
clients_.end());
}
} // namespace skate3::native_ghost

View file

@ -0,0 +1,86 @@
#pragma once
// Ghost multiplayer relay core: a small UDP forwarder shared by the
// standalone ghost_relay binary (run on a public VPS) and the in-game host
// mode (a player's game relays its LAN room). Clients connect OUTBOUND only
// and are matched into rooms by a shareable code. The relay forwards each
// client's PosePayload to every OTHER member of the same room.
//
// Clients are keyed by source address; every packet refreshes last_seen and
// silent clients are dropped (their ghosts vanish on the other side). Room
// codes are uppercase alphanumeric; a JOIN with an empty room gets one
// assigned by the server and returned in the HELLO.
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "skate3_native_ghost_net.h"
#include "skate3_native_ghost_proto.h"
namespace skate3::native_ghost {
class GhostRelay {
public:
explicit GhostRelay(uint16_t port = kGhostDefaultPort) : port_(port) {}
~GhostRelay();
GhostRelay(const GhostRelay&) = delete;
GhostRelay& operator=(const GhostRelay&) = delete;
// Bind the socket and start the receive thread. Returns false when the
// port cannot be bound (already in use / no permission).
bool Start();
void Stop();
bool running() const { return running_.load(std::memory_order_relaxed); }
uint16_t port() const { return port_; }
size_t ClientCount() const;
// When non-empty, every client is forced into this room (the standalone
// binary's --room lock); client JOINs and PINGs cannot override it.
void SetLockedRoom(const char* room);
// Hard cap on simultaneously tracked clients (default 64). New JOIN/PING
// sources past the cap are dropped (logged).
void SetMaxClients(uint32_t max_clients) { max_clients_ = max_clients; }
// Verbose per-event logging (join/leave/refusals); the standalone server's
// --verbose flag. Off by default to keep a busy relay quiet.
void SetVerbose(bool verbose) { verbose_ = verbose; }
private:
void Run();
void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now);
void BuildRoomList(const GhostAddr& to);
void Sweep(double now);
GhostSock sock_ = GHOST_INVALID_SOCKET;
uint16_t port_;
std::thread thread_;
std::atomic<bool> running_{false};
std::atomic<bool> stop_{false};
uint32_t max_clients_ = 64;
std::atomic<bool> verbose_{false};
struct Client {
GhostAddr addr;
uint32_t session_id = 0;
char room[kGhostMaxRoomLen] = {};
uint8_t room_len = 0;
char name[kGhostMaxNameLen] = {};
uint8_t name_len = 0;
bool refused = false; // version mismatch: poses from this client dropped
double last_seen = 0.0;
};
mutable std::mutex mu_;
std::vector<Client> clients_;
uint32_t next_session_ = 1;
bool locked_ = false;
char locked_room_[kGhostMaxRoomLen] = {};
uint8_t locked_room_len_ = 0;
};
} // namespace skate3::native_ghost

View file

@ -889,6 +889,10 @@ 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,6 +53,11 @@ 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

@ -11,6 +11,8 @@
#include "skate3_title_update_installer.h"
#include "skate3_user_settings.h"
#include "native/skate3_native_ghost.h"
#include <algorithm>
#include <chrono>
#include <cstring>
@ -681,6 +683,8 @@ 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.
@ -723,6 +727,10 @@ 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:
@ -793,6 +801,10 @@ 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]() {
@ -820,10 +832,14 @@ 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();
}
@ -944,6 +960,19 @@ void Skate3BaseApp::ToggleNativeDebug() {
}
}
void Skate3BaseApp::ToggleGhost() {
if (!ghost_dialog_) {
ghost_dialog_ = std::make_unique<skate3::GhostDialog>(imgui_drawer());
}
if (ghost_dialog_->visible()) {
ghost_dialog_->Hide();
ApplyGameplayCursorMode();
} else {
ApplySettingsCursorMode();
ghost_dialog_->Show();
}
}
void Skate3BaseApp::ApplySettingsCursorMode() {
if (window()) {
window()->SetCursorVisibility(rex::ui::Window::CursorVisibility::kVisible);

View file

@ -14,6 +14,7 @@
#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;
@ -40,6 +41,7 @@ class Skate3BaseApp : public rex::ReXApp {
void InstallDlcPackages();
void ToggleSimpleSettings();
void ToggleNativeDebug();
void ToggleGhost();
void ApplySettingsCursorMode();
void ApplyGameplayCursorMode();
void RestartGame();
@ -59,6 +61,8 @@ 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

@ -0,0 +1,287 @@
#include "skate3_native_ghost_dialog.h"
#include <cmath>
#include <cstring>
#include <string>
#include <imgui.h>
#include <rex/cvar.h>
#include <rex/logging.h>
#include "native/skate3_native_ghost.h"
#include "skate3_native_scene.h"
REXCVAR_DECLARE(bool, skate3_ghost_enable);
REXCVAR_DECLARE(std::string, skate3_ghost_server);
REXCVAR_DECLARE(int32_t, skate3_ghost_port);
REXCVAR_DECLARE(std::string, skate3_ghost_room);
REXCVAR_DECLARE(std::string, skate3_ghost_name);
REXCVAR_DECLARE(double, skate3_ghost_rate);
REXCVAR_DECLARE(bool, skate3_ghost_draw_local);
namespace skate3 {
void GhostDialog::Show() {
visible_ = true;
SetDrawActive(true);
}
void GhostDialog::Hide() {
if (!visible_) {
return;
}
visible_ = false;
SetDrawActive(false);
}
void GhostDialog::Toggle() {
if (visible_) {
Hide();
} else {
Show();
}
}
void GhostDialog::SyncBuffersFromCvars() {
const std::string room = REXCVAR_GET(skate3_ghost_room);
if (!room.empty()) {
std::snprintf(room_input_, sizeof(room_input_), "%s", room.c_str());
} else {
// Auto-assigned room: the active code comes from the connection, not the
// (empty) cvar - surface it in the input so the user can see/copy it.
skate3::native_ghost::Status st;
if (skate3::native_ghost::GetStatus(&st) && st.room_len > 0) {
std::snprintf(room_input_, sizeof(room_input_), "%.*s",
int(st.room_len), st.room);
} else {
room_input_[0] = '\0';
}
}
const std::string server = REXCVAR_GET(skate3_ghost_server);
std::snprintf(server_input_, sizeof(server_input_), "%s", server.c_str());
const std::string name = REXCVAR_GET(skate3_ghost_name);
std::snprintf(name_input_, sizeof(name_input_), "%s", name.c_str());
synced_ = true;
}
void GhostDialog::OnDraw(ImGuiIO& io) {
(void)io;
if (!visible_) {
return;
}
if (!synced_) {
SyncBuffersFromCvars();
}
bool open = visible_;
ImGui::SetNextWindowSize(ImVec2(380.0f, 0.0f), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Ghost Multiplayer (M)", &open,
ImGuiWindowFlags_NoCollapse)) {
ImGui::End();
if (!open) {
Hide();
}
return;
}
bool enable = REXCVAR_GET(skate3_ghost_enable);
if (ImGui::Checkbox("Enable ghost multiplayer", &enable)) {
REXCVAR_SET(skate3_ghost_enable, enable);
if (enable) {
REXLOG_INFO("ghost: multiplayer enabled");
}
}
ImGui::SameLine();
ImGui::TextDisabled("(relays your position, draws remote players)");
ImGui::Separator();
ImGui::Text("Room code");
if (ImGui::InputText("##ghost_room", room_input_, sizeof(room_input_),
ImGuiInputTextFlags_EnterReturnsTrue |
ImGuiInputTextFlags_CharsUppercase)) {
REXCVAR_SET(skate3_ghost_room, std::string(room_input_));
ImGui::SetKeyboardFocusHere(-1);
}
ImGui::SameLine();
// Copy the ACTIVE room (the one this player is actually in), which for an
// auto-assigned room lives in the connection state, not the cvar.
if (ImGui::Button("Copy")) {
skate3::native_ghost::Status st;
if (skate3::native_ghost::GetStatus(&st) && st.room_len > 0) {
char buf[skate3::native_ghost::kGhostMaxRoomLen + 1] = {};
std::memcpy(buf, st.room, st.room_len);
ImGui::SetClipboardText(buf);
copy_feedback_ = true;
}
}
if (copy_feedback_) {
ImGui::SameLine();
ImGui::TextDisabled("copied");
copy_feedback_ = false;
}
ImGui::TextDisabled("Empty = the server assigns one; share it with friends.");
ImGui::Separator();
ImGui::Text("Display name");
if (ImGui::InputText("##ghost_name", name_input_, sizeof(name_input_),
ImGuiInputTextFlags_EnterReturnsTrue)) {
REXCVAR_SET(skate3_ghost_name, std::string(name_input_));
ImGui::SetKeyboardFocusHere(-1);
}
ImGui::TextDisabled("Shown on your nameplate above the ghost. Empty = "
"your OS user name.");
ImGui::Separator();
if (ImGui::CollapsingHeader("Browse rooms (lobby)", ImGuiTreeNodeFlags_DefaultOpen)) {
if (ImGui::Button("Refresh rooms")) {
skate3::native_ghost::RequestRooms();
}
ImGui::SameLine();
skate3::native_ghost::RoomList rl;
const bool have = skate3::native_ghost::GetRooms(&rl);
ImGui::TextDisabled(have ? "%u room(s) on the relay" : "no room list yet");
if (have) {
for (uint8_t i = 0; i < rl.count; ++i) {
const skate3::native_ghost::RoomEntry& e = rl.rooms[i];
std::string label(e.room, e.room_len);
label += " (" + std::to_string(int(e.member_count)) + " player" +
(e.member_count == 1 ? "" : "s") + ")";
std::string names;
for (uint8_t n = 0; n < e.shown_members; ++n) {
if (n != 0) {
names += ", ";
}
names += std::string(e.member_names[n], e.member_name_len[n]);
}
if (!names.empty()) {
label += " [" + names + "]";
}
const std::string code(e.room, e.room_len);
if (ImGui::Selectable(label.c_str(), false)) {
REXCVAR_SET(skate3_ghost_room, code);
std::snprintf(room_input_, sizeof(room_input_), "%s", code.c_str());
}
}
ImGui::TextDisabled("Click a room to join it (applies on the next "
"heartbeat).");
}
}
ImGui::Separator();
ImGui::Text("Relay server (optional)");
ImGui::PushItemWidth(-1.0f);
if (ImGui::InputText("##ghost_server", server_input_, sizeof(server_input_),
ImGuiInputTextFlags_EnterReturnsTrue)) {
REXCVAR_SET(skate3_ghost_server, std::string(server_input_));
ImGui::SetKeyboardFocusHere(-1);
}
ImGui::PopItemWidth();
ImGui::TextDisabled("Empty = built-in default relay. No port forwarding "
"needed on any client.");
ImGui::Separator();
int port = int(REXCVAR_GET(skate3_ghost_port));
if (ImGui::SliderInt("Port", &port, 1024, 65535)) {
REXCVAR_SET(skate3_ghost_port, int32_t(port));
}
const double rate = REXCVAR_GET(skate3_ghost_rate);
rate_int_ = int(rate + 0.5);
if (ImGui::SliderInt("Broadcast rate (Hz)", &rate_int_, 5, 60)) {
REXCVAR_SET(skate3_ghost_rate, double(rate_int_));
}
bool draw_local = REXCVAR_GET(skate3_ghost_draw_local);
if (ImGui::Checkbox("Show my own cube (self-check)", &draw_local)) {
REXCVAR_SET(skate3_ghost_draw_local, draw_local);
}
ImGui::Separator();
skate3::native_ghost::Status st;
skate3::native_ghost::GetStatus(&st);
ImGui::Text("Status:");
ImGui::Text(" Enabled: %s", st.enabled ? "yes" : "no");
ImGui::Text(" Connected: %s%s", st.connected ? "yes" : "no",
st.hosting ? " (LAN host)" : "");
ImGui::Text(" Session: %u", st.session_id);
ImGui::Text(" Room: %.*s", int(st.room_len), st.room);
ImGui::Text(" In room: %zu player(s)", st.roster + 1);
ImGui::Text(" Ghosts drawn: %zu", st.ghosts);
if (st.peer[0] != '\0') {
ImGui::Text(" Relay: %s", st.peer);
}
if (st.refused) {
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.3f, 1.0f),
" Refused by relay (protocol version mismatch)");
}
if (st.last_error[0] != '\0') {
ImGui::TextWrapped(" %s", st.last_error);
}
ImGui::End();
if (!open) {
Hide();
}
}
void GhostNameplateOverlay::OnDraw(ImGuiIO& io) {
if (!REXCVAR_GET(skate3_ghost_enable)) {
return;
}
if (io.DisplaySize.x < 16.0f || io.DisplaySize.y < 16.0f) {
return;
}
float vp[16];
float cam[3];
if (!skate3::native_scene::GetPublishedCamera(vp, cam)) {
return;
}
const uint32_t count = skate3::native_ghost::GhostCount();
if (count == 0) {
return;
}
ImDrawList* dl = ImGui::GetBackgroundDrawList();
const float w = io.DisplaySize.x;
const float h = io.DisplaySize.y;
for (uint32_t i = 0; i < count; ++i) {
float pos[3];
float color[3];
char name[32];
if (!skate3::native_ghost::GetGhostLabel(i, pos, color, name,
sizeof(name))) {
continue;
}
// Project the point ~1.75 m above the entity origin (roughly the head).
const float p[3] = {pos[0], pos[1] + 1.75f, pos[2]};
float c[4];
for (int r = 0; r < 4; ++r) {
c[r] = p[0] * vp[r] + p[1] * vp[4 + r] + p[2] * vp[8 + r] + vp[12 + r];
}
if (c[3] < 0.01f) {
continue; // behind the camera
}
const float ndc_x = c[0] / c[3];
const float ndc_y = c[1] / c[3];
if (ndc_x < -1.6f || ndc_x > 1.6f || ndc_y < -1.6f || ndc_y > 1.6f) {
continue;
}
const float sx = (ndc_x * 0.5f + 0.5f) * w;
const float sy = (1.0f - (ndc_y * 0.5f + 0.5f)) * h;
const ImU32 text_col =
IM_COL32(int(color[0] * 255.0f), int(color[1] * 255.0f),
int(color[2] * 255.0f), 235);
const ImVec2 text_size = ImGui::CalcTextSize(name);
// Small dark backdrop so the label stays readable over the world.
dl->AddRectFilled(ImVec2(sx - 4.0f, sy - 14.0f),
ImVec2(sx + text_size.x + 4.0f, sy + 2.0f),
IM_COL32(0, 0, 0, 140));
dl->AddText(ImVec2(sx, sy - 14.0f), text_col, name);
}
}
} // namespace skate3

View file

@ -0,0 +1,49 @@
#pragma once
// Ghost multiplayer UI: the multiplayer menu (GhostDialog, bound to M) and
// the always-present nameplate overlay (GhostNameplateOverlay) that labels
// each remote ghost with its owner's display name.
#include <rex/ui/imgui_dialog.h>
#include "native/skate3_native_ghost_proto.h"
namespace skate3 {
class GhostDialog final : public rex::ui::ImGuiDialog {
public:
explicit GhostDialog(rex::ui::ImGuiDrawer* drawer) : ImGuiDialog(drawer) {}
void Show();
void Hide();
void Toggle();
bool visible() const { return visible_; }
protected:
void OnDraw(ImGuiIO& io) override;
private:
void SyncBuffersFromCvars();
bool visible_ = false;
bool synced_ = false;
bool copy_feedback_ = false;
char room_input_[16] = {};
char server_input_[128] = {};
char name_input_[skate3::native_ghost::kGhostMaxNameLen + 1] = {};
int rate_int_ = 20;
};
// Draws a small colored nameplate above every remote ghost whenever ghost
// multiplayer is enabled. Registers like the render-mode indicator (always
// present, draws only while active).
class GhostNameplateOverlay final : public rex::ui::ImGuiDialog {
public:
explicit GhostNameplateOverlay(rex::ui::ImGuiDrawer* drawer)
: ImGuiDialog(drawer) {}
protected:
void OnDraw(ImGuiIO& io) override;
};
} // namespace skate3

View file

@ -2,6 +2,7 @@
#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"

View file

@ -38,6 +38,7 @@
#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"
@ -2755,6 +2756,16 @@ 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;
@ -10446,6 +10457,9 @@ 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,6 +689,12 @@ 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,6 +39,7 @@
#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"
@ -126,6 +127,8 @@ REXCVAR_DECLARE(bool, skate3_native_render_scene_tex_revalidate);
REXCVAR_DECLARE(bool, skate3_native_render_scene_transparents);
REXCVAR_DECLARE(bool, skate3_native_render_scene_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);
@ -10339,6 +10342,215 @@ 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).

44
tools/ci/init-submodules.sh Executable file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Recursive submodule init for the rexglue SDK with self-healing for broken
# upstream pins.
#
# The rexglue fork pins several nested third-party submodules (imgui, ...) to
# commits their upstreams no longer carry, so a plain
# `git submodule update --init --recursive` aborts at the first broken pin
# and leaves every later nested submodule empty (a fresh clone cannot build).
# For each nested submodule still left empty, this script fetches its origin
# and checks out the default-branch tip, which is enough to build.
#
# Run from the repository root after `git submodule update --init` (top
# level). No-op when everything initialized fine.
set -uo pipefail
SDK="third_party/rexglue-sdk"
if [ ! -e "$SDK/.git" ]; then
echo "init-submodules: $SDK is not initialized (run git submodule update --init first)"
exit 1
fi
(
cd "$SDK"
git submodule update --init --recursive --jobs 4 >/tmp/ghost_sub_init.log 2>&1 || true
while IFS= read -r path; do
# A submodule needs self-healing when it has no .git, or its worktree
# has no files other than the .git pointer (ls -A counts .git and would
# wrongly treat an un-checked-out submodule as initialized).
if [ ! -e "$path/.git" ] ||
[ -z "$(find "$path" -mindepth 1 -maxdepth 1 ! -name '.git' -print -quit 2>/dev/null)" ]; then
echo "init-submodules: self-healing empty submodule $path"
if (cd "$path" && git fetch --depth 1 origin >/dev/null 2>&1 &&
git checkout -q FETCH_HEAD >/dev/null 2>&1); then
echo " ok: $path -> $(git -C "$path" rev-parse --short HEAD)"
else
echo " WARN: could not self-heal $path (build may fail on this dependency)"
fi
fi
done < <(git config --file .gitmodules --get-regexp '^submodule\..*\.path$' | awk '{print $2}')
)
exit 0

17
tools/ghost_relay.service Normal file
View file

@ -0,0 +1,17 @@
[Unit]
Description=Skate 3 recomp ghost multiplayer relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/ghost_relay --port 40001 --max-clients 64
Restart=on-failure
RestartSec=2
# Standard service hardening (the relay needs only a UDP socket).
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
[Install]
WantedBy=multi-user.target

86
tools/ghost_relay_deploy.sh Executable file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Build, install and register the ghost_relay server as a systemd service.
#
# Run on the VPS from the repo root (or after copying tools/ and
# src/native/skate3_native_ghost_* to the server). The relay is
# dependency-free: it needs only a C++23 compiler (g++/clang), no CMake or
# rexglue.
#
# Usage:
# ./tools/ghost_relay_deploy.sh
# GHOST_RELAY_PORT=40002 ./tools/ghost_relay_deploy.sh
# GHOST_RELAY_VERBOSE=1 ./tools/ghost_relay_deploy.sh
#
# Afterwards: systemctl status ghost-relay, journalctl -u ghost-relay -f.
# Open the UDP port (the script tries `ufw allow <port>/udp`).
set -euo pipefail
PORT="${GHOST_RELAY_PORT:-40001}"
VERBOSE_FLAG=""
if [[ "${GHOST_RELAY_VERBOSE:-0}" == "1" ]]; then
VERBOSE_FLAG="--verbose"
fi
SERVICE_NAME="ghost-relay"
BIN_PATH="/usr/local/bin/ghost_relay"
SERVICE_PATH="/etc/systemd/system/${SERVICE_NAME}.service"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
echo "==> Building ghost_relay"
g++ -std=c++23 -O2 -DSKATE3_RELAY_STANDALONE \
-I"${REPO_ROOT}/src/native" \
"${REPO_ROOT}/tools/ghost_relay_main.cpp" \
"${REPO_ROOT}/src/native/skate3_native_ghost_relay.cpp" \
-o /tmp/ghost_relay
/tmp/ghost_relay --help >/dev/null 2>&1 || {
echo "ERROR: build produced a binary that does not run (missing C++23?)" >&2
exit 1
}
echo "==> Installing to ${BIN_PATH}"
sudo install -m 0755 /tmp/ghost_relay "${BIN_PATH}"
rm -f /tmp/ghost_relay
echo "==> Writing ${SERVICE_PATH}"
sudo tee "${SERVICE_PATH}" >/dev/null <<EOF
[Unit]
Description=Skate 3 recomp ghost multiplayer relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=${BIN_PATH} --port ${PORT} --max-clients 64 ${VERBOSE_FLAG}
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
[Install]
WantedBy=multi-user.target
EOF
echo "==> Enabling + starting ${SERVICE_NAME}"
sudo systemctl daemon-reload
sudo systemctl enable "${SERVICE_NAME}" >/dev/null
sudo systemctl restart "${SERVICE_NAME}"
echo "==> Opening UDP ${PORT}"
sudo ufw allow "${PORT}/udp" >/dev/null 2>&1 || \
echo " (ufw not available or not permitted; open UDP ${PORT} yourself)"
echo "==> Status"
sleep 1
sudo systemctl status "${SERVICE_NAME}" --no-pager || true
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
if [[ -z "${IP}" ]]; then IP="<this-server-ip>"; fi
echo
echo "Done. Clients connect to ${IP}:${PORT}"
echo "Logs: journalctl -u ${SERVICE_NAME} -f"
echo "Set the compiled-in default in src/native/skate3_native_ghost_proto.h"
echo " (kGhostDefaultServer / kGhostDefaultServerPort), or tell players to"
echo " set skate3_ghost_server=${IP}:${PORT} in the M menu."

114
tools/ghost_relay_main.cpp Normal file
View file

@ -0,0 +1,114 @@
// ghost_relay: standalone ghost multiplayer relay server.
//
// Run on a public VPS (or your own machine behind a free tunnel like
// playit.gg / ngrok / Tailscale Funnel) so players connect OUTBOUND to it
// and need no port forwarding:
//
// ghost_relay [--port 40001] [--room CODE]
//
// --room optionally locks every client into one room (ignores JOIN rooms).
// The server is stateless across restarts: clients re-send their JOIN every
// ~5 s, so a redeployed relay re-gathers the room automatically.
//
// Build: cmake target `ghost_relay` (no rexglue dependency; plain sockets).
#include <chrono>
#include <cstdio>
#include <cstring>
#include <string>
#include <thread>
#include "skate3_native_ghost_relay.h"
#if !defined(_WIN32)
#include <csignal>
namespace {
volatile std::sig_atomic_t g_stop = 0;
void OnSignal(int) { g_stop = 1; }
} // namespace
#endif
int main(int argc, char** argv) {
uint16_t port = skate3::native_ghost::kGhostDefaultPort;
std::string locked_room;
uint32_t max_clients = 64;
bool verbose = false;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
auto next = [&](const char* name) -> std::string {
if (i + 1 < argc) {
return argv[++i];
}
std::fprintf(stderr, "ghost_relay: %s requires a value\n", name);
std::exit(2);
};
if (arg == "--port") {
port = uint16_t(std::stoi(next("--port")));
} else if (arg == "--room") {
locked_room = next("--room");
} else if (arg == "--max-clients") {
max_clients = uint32_t(std::stoul(next("--max-clients")));
} else if (arg == "--verbose") {
verbose = true;
} else if (arg == "--help" || arg == "-h") {
std::printf(
"ghost_relay - ghost multiplayer UDP relay\n"
"usage: ghost_relay [--port 40001] [--room CODE] [--max-clients 64] "
"[--verbose]\n"
" --port UDP port to listen on (clients connect here; allow\n"
" this port through any firewall, e.g.\n"
" `ufw allow 40001/udp`)\n"
" --room optional: force every client into one room code\n"
" --max-clients hard cap on simultaneously tracked clients (default 64)\n"
" --verbose log every join/leave to stdout\n");
return 0;
} else {
std::fprintf(stderr, "ghost_relay: unknown argument '%s'\n", arg.c_str());
return 2;
}
}
#if !defined(_WIN32)
std::signal(SIGINT, OnSignal);
std::signal(SIGTERM, OnSignal);
#endif
skate3::native_ghost::GhostRelay relay(port);
if (!locked_room.empty()) {
relay.SetLockedRoom(locked_room.c_str());
}
relay.SetMaxClients(max_clients);
relay.SetVerbose(verbose);
if (!relay.Start()) {
std::fprintf(stderr, "ghost_relay: failed to bind UDP port %u\n", port);
return 1;
}
const std::string lock_note =
locked_room.empty() ? "" : (" (locked room: " + locked_room + ")");
std::printf("ghost_relay: listening on UDP %u%s\n", port, lock_note.c_str());
std::printf("ghost_relay: clients connect outbound; no client-side port "
"forwarding needed (max clients %u)\n",
max_clients);
std::printf("ghost_relay: clients connect outbound; no client-side port "
"forwarding needed\n");
// Status line every 5 s; on POSIX exit on SIGINT/SIGTERM.
auto last_log = std::chrono::steady_clock::now();
while (true) {
#if !defined(_WIN32)
if (g_stop != 0) {
break;
}
#endif
const auto now = std::chrono::steady_clock::now();
if (now - last_log >= std::chrono::seconds(5)) {
std::printf("ghost_relay: %zu client(s)\n", relay.ClientCount());
std::fflush(stdout);
last_log = now;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
relay.Stop();
std::printf("ghost_relay: stopped\n");
return 0;
}

189
tools/release/release.sh Executable file
View file

@ -0,0 +1,189 @@
#!/usr/bin/env bash
# Build a Skate 3 recomp release from this checkout and optionally publish it
# to a Forgejo/Gitea Release.
#
# IMPORTANT: the release zip contains ONLY the recompiled binaries
# (skate3 + librexruntime.so + ghost_relay + README) - NO game files. Players
# bring their own ISO and the in-app installer extracts it. The game dump
# (default.xex + data/webkit/EAWebkit.xex) is required at BUILD TIME only,
# because codegen translates the actual Xbox 360 binary into C++ source; it
# is never included in, or uploaded with, the release.
#
# Usage:
# ./tools/release/release.sh # build this platform, no upload
# ./tools/release/release.sh --tag v1.0.0 --upload # build + publish
# ./tools/release/release.sh --game-root /path/to/game --tu /path/to/TU_12K2276_...
#
# Options:
# --tag TAG release tag/version (default: last git tag or 'dev')
# --game-root DIR extracted game dump (default: $REPO/game)
# --tu FILE optional Title Update STFS package for codegen
# --os linux|windows|macos override platform (default: host OS)
# --cmake-args "..." extra flags appended to every cmake configure
# call (e.g. -DCMAKE_C_COMPILER=clang-22
# -DCMAKE_CXX_COMPILER=clang++-22
# -DCMAKE_LINKER=ld.lld on distros without clang-20)
# --upload publish the zip to a Forgejo Release
#
# Env for --upload:
# GITEA_TOKEN Forgejo token with write:repository (release) scope
# FORGEJO_REPO default "ad3laid3/skate3recomp-multiplayer"
# FORGEJO_INSTANCE default "https://git.mithraic.cloud"
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$REPO"
UPLOAD=0
TAG=""
GAME_ROOT="${SKATE3_GAME_DATA_ROOT:-$REPO/game}"
TU="${SKATE3_TITLE_UPDATE_PACKAGE:-}"
OS_ARG=""
CMAKE_ARGS=""
while [ $# -gt 0 ]; do
case "$1" in
--tag) TAG="${2:?--tag needs a value}"; shift 2;;
--game-root) GAME_ROOT="$2"; shift 2;;
--tu) TU="$2"; shift 2;;
--os) OS_ARG="$2"; shift 2;;
--cmake-args) CMAKE_ARGS="$2"; shift 2;;
--upload) UPLOAD=1; shift;;
-h|--help)
sed -n '2,32p' "$0"; exit 0;;
*) echo "unknown argument: $1"; exit 2;;
esac
done
# ---- platform ---------------------------------------------------------------
UNAME_S="$(uname -s 2>/dev/null || echo Windows)"
case "${OS_ARG:-$UNAME_S}" in
linux|Linux) PRESET="linux-relwithdebinfo"; BIN="skate3"; LIB="librexruntime.so"; EXT="Linux";;
windows|Windows|MINGW*|MSYS*|CYGWIN*)
PRESET="relwithdebinfo"; BIN="skate3.exe"; LIB="librexruntime.dll"; EXT="Windows";;
macos|macOS|Darwin) PRESET="macos-relwithdebinfo"; BIN="skate3"; LIB="librexruntime.dylib"; EXT="macOS";;
*) echo "unsupported platform: ${OS_ARG:-$UNAME_S} (set --os linux|windows|macos)"; exit 2;;
esac
if [ -z "$TAG" ]; then
TAG="$(git describe --tags --always 2>/dev/null || echo dev)"
fi
VER="${TAG#v}"
NAME="Skate3Recomp-$EXT-$VER"
if [ ! -f "$GAME_ROOT/default.xex" ]; then
echo "ERROR: game dump not found at $GAME_ROOT (need default.xex + data/webkit/EAWebkit.xex)"
echo " It is required for codegen; it is NOT shipped in the release."
exit 1
fi
# ---- helpers (Windows Git Bash lacks zip/python; provide fallbacks) ---------
json_id() { # read JSON on stdin, print the value of "id"
if command -v python3 >/dev/null 2>&1; then
python3 -c "import sys,json
try: print(json.load(sys.stdin).get('id',''))
except Exception: print('')"
elif command -v python >/dev/null 2>&1; then
python -c "import sys,json
try: print(json.load(sys.stdin).get('id',''))
except Exception: print('')"
else
grep -o '"id":[0-9]*' | head -1 | cut -d: -f2
fi
}
make_zip() { # make_zip <zipname> <dir>
if command -v zip >/dev/null 2>&1; then
(cd "$(dirname "$2")" && zip -r "$(basename "$1")" "$(basename "$2")")
elif command -v 7z >/dev/null 2>&1; then
7z a -tzip "$1" "$2" >/dev/null
elif command -v powershell >/dev/null 2>&1; then
powershell -NoProfile -Command "Compress-Archive -Path '$2' -DestinationPath '$1' -Force"
else
echo "ERROR: no zip tool found (install zip, 7z, or use PowerShell)"; exit 1
fi
}
# ---- build ------------------------------------------------------------------
TU_ARGS=""
if [ -n "$TU" ]; then
if [ ! -f "$TU" ]; then echo "ERROR: title update package not found: $TU"; exit 1; fi
TU_ARGS="-DSKATE3_TITLE_UPDATE_PACKAGE=$TU"
elif [ -n "$(ls "$REPO"/TU_12K2276_* "$GAME_ROOT"/TU_12K2276_* 2>/dev/null | head -1)" ]; then
TU="$(ls "$REPO"/TU_12K2276_* "$GAME_ROOT"/TU_12K2276_* 2>/dev/null | head -1)"
echo "==> Using title update package: $TU"
TU_ARGS="-DSKATE3_TITLE_UPDATE_PACKAGE=$TU"
fi
echo "==> Release $TAG ($EXT) preset=$PRESET game=$GAME_ROOT"
echo "==> Configure"
cmake --preset "$PRESET" -DSKATE3_GAME_DATA_ROOT="$GAME_ROOT" $TU_ARGS $CMAKE_ARGS
echo "==> Generate recompiled code"
cmake --build --preset "$PRESET" --target generate-all --parallel
echo "==> Reconfigure (generated sources)"
cmake --preset "$PRESET" -DSKATE3_GAME_DATA_ROOT="$GAME_ROOT" $TU_ARGS $CMAKE_ARGS
echo "==> Build"
cmake --build --preset "$PRESET" --parallel
# ---- package ---------------------------------------------------------------
OUT="out/build/$PRESET"
STAGE="$NAME"
rm -rf "$STAGE"; mkdir -p "$STAGE"
cp "$OUT/$BIN" "$STAGE/"
# The rexglue runtime lib carries a config suffix (librexruntimerd.so for
# relwithdebinfo, librexruntime.so for release); the binary links whichever
# was built, so ship it under its real name.
RUNTIME="$(ls "$OUT"/librexruntime*.so "$OUT"/librexruntime*.dll "$OUT"/librexruntime*.dylib 2>/dev/null | head -1 || true)"
if [ -n "$RUNTIME" ]; then
cp "$RUNTIME" "$STAGE/"
fi
cp "$OUT/ghost_relay" "$STAGE/" 2>/dev/null || true
cp "$OUT/ghost_relay.exe" "$STAGE/" 2>/dev/null || true
# Title-update patches staged next to the binary when the build used the TU
if [ -f "$OUT/game/default.xexp" ]; then
mkdir -p "$STAGE/game/data/webkit"
cp "$OUT/game/default.xexp" "$STAGE/game/"
cp "$OUT/game/data/webkit/EAWebkit.xexp" "$STAGE/game/data/webkit/" 2>/dev/null || true
fi
cp README.md "$STAGE/"
rm -f "$NAME.zip"
make_zip "$NAME.zip" "$STAGE"
rm -rf "$STAGE"
echo "==> Packaged: $NAME.zip ($(du -h "$NAME.zip" | cut -f1))"
sha256sum "$NAME.zip"
# ---- publish ----------------------------------------------------------------
if [ "$UPLOAD" = 1 ]; then
: "${GITEA_TOKEN:?--upload needs GITEA_TOKEN}"
REPO_FULL="${FORGEJO_REPO:-ad3laid3/skate3recomp-multiplayer}"
INSTANCE="${FORGEJO_INSTANCE:-https://git.mithraic.cloud}"
AUTH="Authorization: token $GITEA_TOKEN"
echo "==> Publishing $NAME.zip to $INSTANCE/$REPO_FULL release '$TAG'"
RELEASE_ID="$(curl -s -X POST "$INSTANCE/api/v1/repos/$REPO_FULL/releases" \
-H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Skate 3 Recomp $TAG\",\"target_commitish\":\"main\"}" \
| json_id)"
if [ -z "$RELEASE_ID" ]; then
RELEASE_ID="$(curl -s "$INSTANCE/api/v1/repos/$REPO_FULL/releases/tags/$TAG" -H "$AUTH" \
| json_id)"
fi
if [ -z "$RELEASE_ID" ]; then
echo "ERROR: could not create/find release '$TAG'"; exit 1
fi
curl -s -X POST "$INSTANCE/api/v1/repos/$REPO_FULL/releases/$RELEASE_ID/assets" \
-H "$AUTH" -F "attachment=@$NAME.zip" -F "name=$NAME.zip" \
| if command -v python3 >/dev/null 2>&1 || command -v python >/dev/null 2>&1; then
PY=python3; command -v python3 >/dev/null 2>&1 || PY=python
"$PY" -c "import sys,json
try:
a=json.load(sys.stdin); print('Uploaded asset', a.get('name'), a.get('size'), 'bytes')
except Exception as e: print('upload response parse failed:', e)"
else
echo "Upload request sent (no python to parse the response)"
fi
echo "==> Done: https://git.mithraic.cloud/$REPO_FULL/releases/tag/$TAG"
fi

View file

@ -0,0 +1,73 @@
@echo off
rem One-click Windows release build for skate3recomp-multiplayer.
rem
rem Fixes the known Windows build issues documented in /mnt/archive/Issues.txt
rem (and mirrored in docs/releases.md):
rem 1. stable Windows SDK so kernel32.lib resolves (vcvarsall with an SDK
rem version instead of the broken default),
rem 2. Git Bash %LIB% mangling (MSYS2_ARG_CONV_EXCL=*),
rem 3. missing rexglue submodules (git submodule update --init + self-heal).
rem
rem Usage (from a normal cmd.exe / PowerShell prompt):
rem tools\release\windows-build.cmd [--tag vX.Y.Z] [--upload]
rem
rem Config via environment variables:
rem VCVARSALL path to vcvarsall.bat (default: VS 18 Community x64)
rem WINSDK Windows SDK version (default 10.0.26100.0; try 10.0.22621.0
rem if yours is different)
rem GITBASH path to Git for Windows bash.exe
setlocal
if "%VCVARSALL%"=="" set "VCVARSALL=%ProgramFiles%\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvarsall.bat"
if "%WINSDK%"=="" set "WINSDK=10.0.26100.0"
if "%GITBASH%"=="" set "GITBASH=%ProgramFiles%\Git\bin\bash.exe"
if not exist "%VCVARSALL%" (
echo [ERROR] vcvarsall.bat not found: %VCVARSALL%
echo Set VCVARSALL to your Visual Studio install, e.g.
echo "%%ProgramFiles%%\Microsoft Visual Studio\17\Community\VC\Auxiliary\Build\vcvarsall.bat"
exit /b 1
)
if not exist "%GITBASH%" (
echo [ERROR] Git Bash not found: %GITBASH%
echo Install Git for Windows or set GITBASH.
exit /b 1
)
echo [1/3] Loading MSVC environment (x64, SDK %WINSDK%)
call "%VCVARSALL%" x64 %WINSDK%
if errorlevel 1 (
echo [ERROR] vcvarsall failed. Try a different WINSDK, e.g.
echo set WINSDK=10.0.22621.0
exit /b 1
)
rem Prevent Git Bash from converting/corrupting the MSVC environment.
rem MSYS2_ARG_CONV_EXCL protects command-line ARGUMENTS; the actual
rem kernel32.lib LNK1104 is caused by MSYS rewriting the %LIB% ENVIRONMENT
rem variable (backslashes + semicolons), which requires MSYS2_ENV_CONV_EXCL.
set "MSYS2_ARG_CONV_EXCL=*"
set "MSYS2_ENV_CONV_EXCL=*"
if "%LIB%"=="" (
echo [WARN] LIB is empty after vcvarsall - try a different WINSDK
echo e.g. set WINSDK=10.0.22621.0
)
echo LIB=%LIB%
rem Convert the repo path to forward slashes for Git Bash (%~dp0 is
rem tools\release\ with a trailing backslash; ..\.. reaches the repo root).
set "REPO=%~dp0..\.."
set "REPO=%REPO:\=/%"
echo [2/3] Initializing submodules (rexglue SDK + nested)
"%GITBASH%" --login -c "cd '%REPO%' && git submodule update --init --jobs 4 && bash tools/ci/init-submodules.sh"
if errorlevel 1 (
echo [ERROR] submodule init failed.
exit /b 1
)
echo [3/3] Building release
"%GITBASH%" --login -c "cd '%REPO%' && bash tools/release/release.sh %*"
exit /b %errorlevel%
endlocal