From 7b90f4bccd97e5b174073d8a681f5e5111cc487e Mon Sep 17 00:00:00 2001 From: Zendevve Date: Mon, 24 Aug 2026 08:40:47 +0800 Subject: [PATCH 1/3] chore: rename OpenFight -> OpenCade - package names @openfight -> @opencade, crates openfight-* -> opencade-* - Tauri productName/identifier com.openfight -> com.opencade - docker images, compose services, DB names, env vars - docs, README, AGENTS, LICENSE, CI, Cargo/ pnpm manifests - verified cargo check --workspace passes - pull latest main (05918c6 proof-of-match vertical slice) merged --- .clippy.toml | 2 +- .env.example | 12 +- .github/workflows/ci.yml | 12 +- .gitignore | 10 +- AGENTS.md | 16 +-- CONTRIBUTING.md | 16 +-- Cargo.lock | 20 +-- Cargo.toml | 2 +- LICENSE | 2 +- README.md | 37 +++--- adapters/fbneo/Cargo.toml | 8 +- adapters/fbneo/src/lib.rs | 6 +- apps/client/index.html | 2 +- apps/client/package.json | 4 +- apps/client/src-tauri/Cargo.lock | 12 +- apps/client/src-tauri/Cargo.toml | 12 +- apps/client/src-tauri/src/commands/fs.rs | 4 +- apps/client/src-tauri/src/commands/process.rs | 4 +- apps/client/src-tauri/tauri.conf.json | 6 +- apps/client/src/App.tsx | 10 +- apps/client/src/lib/api.ts | 4 +- apps/client/src/lib/report.ts | 4 +- apps/client/src/lib/store.ts | 2 +- apps/client/src/lib/ws.ts | 6 +- apps/client/src/main.tsx | 2 +- apps/client/src/routes/Auth.tsx | 2 +- apps/server/Cargo.lock | 6 +- apps/server/Cargo.toml | 10 +- apps/server/Dockerfile | 16 +-- apps/server/src/config.rs | 10 +- apps/server/src/error.rs | 2 +- apps/server/src/health.rs | 2 +- apps/server/src/lib.rs | 4 +- apps/server/src/main.rs | 4 +- apps/server/src/room_state.rs | 2 +- apps/server/src/routes/auth.rs | 2 +- apps/server/src/routes/challenges.rs | 4 +- apps/server/src/routes/games.rs | 2 +- apps/server/src/routes/lobbies.rs | 2 +- apps/server/src/routes/rooms.rs | 2 +- apps/server/src/routes/servers.rs | 2 +- apps/server/src/state.rs | 2 +- apps/server/src/ws.rs | 8 +- apps/server/tests/api_flow.rs | 8 +- docker-compose.yml | 22 ++-- docs/ARCHITECTURE.md | 117 +++++++++--------- docs/NEXT_IMPLEMENTATIONS.md | 10 +- docs/adr/0001-proof-of-match-boundaries.md | 12 +- docs/alpha/LAN_TEST.md | 2 +- docs/alpha/MATCH_REPORT_TEMPLATE.md | 2 +- docs/reference-fightcade-install.md | 6 +- package.json | 6 +- packages/emulator-sdk/Cargo.lock | 2 +- packages/emulator-sdk/Cargo.toml | 6 +- packages/emulator-sdk/src/lib.rs | 4 +- packages/game-definitions/Cargo.toml | 6 +- packages/game-definitions/package.json | 4 +- .../game-definitions/schema/game.schema.json | 2 +- packages/networking/Cargo.toml | 8 +- packages/networking/src/lib.rs | 4 +- packages/protocol/Cargo.lock | 2 +- packages/protocol/Cargo.toml | 6 +- packages/protocol/package.json | 4 +- packages/protocol/src/lib.rs | 2 +- packages/shared/Cargo.toml | 6 +- packages/shared/src/lib.rs | 2 +- pnpm-lock.yaml | 2 +- research/GUARDRAILS.md | 20 +-- 68 files changed, 278 insertions(+), 284 deletions(-) diff --git a/.clippy.toml b/.clippy.toml index 1ed2c00..587456d 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -1,4 +1,4 @@ -# Clippy configuration for OpenFight +# Clippy configuration for OpenCade # See https://doc.rust-lang.org/clippy/configuration.html # This file is read by `cargo clippy` at workspace root. # Rust 1.78 reads this dotfile directly; keep a single config to avoid ambiguity. diff --git a/.env.example b/.env.example index 045c1fe..353a372 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,15 @@ -# OpenFight environment — copy to .env and adjust for your machine. +# OpenCade environment — copy to .env and adjust for your machine. # Docker Compose reads this automatically when present. -DATABASE_URL=postgres://openfight:openfight@db:5432/openfight +DATABASE_URL=postgres://opencade:opencade@db:5432/opencade # Generate with: openssl rand -hex 32 SESSION_SECRET=replace-with-at-least-32-random-bytes ALLOWED_ORIGINS=http://localhost:1420,tauri://localhost -OPENFIGHT_ENV=production +OPENCADE_ENV=production # Optional overrides: # RUST_LOG=info # PORT=8080 -# POSTGRES_USER=openfight -# POSTGRES_PASSWORD=openfight -# POSTGRES_DB=openfight +# POSTGRES_USER=opencade +# POSTGRES_PASSWORD=opencade +# POSTGRES_DB=opencade diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c736513..6e215ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,13 +19,13 @@ jobs: postgres: image: postgres:16-alpine env: - POSTGRES_USER: openfight - POSTGRES_PASSWORD: openfight - POSTGRES_DB: openfight_test + POSTGRES_USER: opencade + POSTGRES_PASSWORD: opencade + POSTGRES_DB: opencade_test ports: - 5432:5432 options: >- - --health-cmd "pg_isready -U openfight -d openfight_test" + --health-cmd "pg_isready -U opencade -d opencade_test" --health-interval 5s --health-timeout 5s --health-retries 10 @@ -51,7 +51,7 @@ jobs: - name: cargo test run: cargo test --workspace --all-features --locked env: - DATABASE_URL: postgres://openfight:openfight@localhost:5432/openfight_test + DATABASE_URL: postgres://opencade:opencade@localhost:5432/opencade_test SESSION_SECRET: ci-test-secret-at-least-32-bytes-long - name: Setup pnpm for protocol drift check @@ -67,7 +67,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Generate TypeScript protocol bindings - run: cargo test -p openfight-protocol --lib export_bindings + run: cargo test -p opencade-protocol --lib export_bindings - name: Normalize generated protocol bindings run: pnpm exec prettier --ignore-path /dev/null --write packages/protocol/src/generated diff --git a/.gitignore b/.gitignore index 858693b..4ff7ddc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # ────────────────────────────────────────────────────────────── -# OpenFight — .gitignore +# OpenCade — .gitignore # Keep the repository free of build artefacts, secrets, # proprietary binaries, ROMs, and user-local state. # ────────────────────────────────────────────────────────────── @@ -95,12 +95,12 @@ research/protocol/dumps/ !research/behavior/.gitkeep !research/notes/.gitkeep -# ── OpenFight user data (mirrors %APPDATA%/OpenFight/) ────── +# ── OpenCade user data (mirrors %APPDATA%/OpenCade/) ────── # Developers may run the app locally; never commit its state. -OpenFight/ -%APPDATA%/OpenFight/ +OpenCade/ +%APPDATA%/OpenCade/ AppData/ -*.openfight/ +*.opencade/ config.toml config.local.toml diff --git a/AGENTS.md b/AGENTS.md index 6adb52f..9b9308e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # Repository Guidelines -> **Community:** https://discord.gg/Y4rDyTScPe — where we discuss OpenFight and everything around it (not only OpenFight). +> **Community:** https://discord.gg/Y4rDyTScPe — where we discuss OpenCade and everything around it (not only OpenCade). ## Project Overview -OpenFight — open-source arcade netplay platform, clean-room alternative to proprietary Fightcade. Monorepo at `D:/OpenFight` (Apache-2.0) with Tauri + React + TypeScript client and Rust + Axum + PostgreSQL server. `D:/Fightcade` (v2.1.45) is **read-only reference** — never copied into this repo (see `docs/ARCHITECTURE.md §2` and `docs/reference-fightcade-install.md`). Goal: lobby → challenge → versioned signaling → P2P (or WS relay) → safe emulator launch. +OpenCade — open-source arcade netplay platform, clean-room alternative to proprietary Fightcade. Monorepo at `D:/OpenCade` (Apache-2.0) with Tauri + React + TypeScript client and Rust + Axum + PostgreSQL server. `D:/Fightcade` (v2.1.45) is **read-only reference** — never copied into this repo (see `docs/ARCHITECTURE.md §2` and `docs/reference-fightcade-install.md`). Goal: lobby → challenge → versioned signaling → P2P (or WS relay) → safe emulator launch. ## Architecture & Data Flow @@ -22,7 +22,7 @@ OpenFight — open-source arcade netplay platform, clean-room alternative to pro - `packages/emulator-sdk/` — `pub trait EmulatorAdapter { detect/validate/get_version/launch/stop/configure }` + `LaunchCtx`, `ChildHandle`. No shell, path canonicalization + prefix check. - `packages/game-definitions/` — declarative `games/*.toml` (`schema_version=1`, `id`, `name`, `emulator="fbneo"`, `[launch] args=["{rom}"]`, `[validation] required_files=["neogeo.zip"]`) + `src/loader.rs` + legacy `emulator/*.json` → TOML importer (build-time only). - `adapters/fbneo/` — only required adapter in MVP (`fcadefbneo.exe` detection, `fcadefbneo.default.ini` version check, safe arg building). Future `flycast`/`snes9x` behind feature flags. -- `services/relay/` — placeholder crate `openfight-relay` (future STUN/TURN); not required for MVP (in-process WS relay suffices). +- `services/relay/` — placeholder crate `opencade-relay` (future STUN/TURN); not required for MVP (in-process WS relay suffices). - `research/` — **not shipped** — `observations/`, `protocol/`, `binaries/` (gitignored), `network/`, `behavior/`, `notes/` + `GUARDRAILS.md`. Keep `research/binaries/.gitkeep`. - `docs/` — `ARCHITECTURE.md` (authoritative), `reference-fightcade-install.md` (read-only install notes). - `tests/`, `docker/`, `.github/workflows/` — integration tests, compose overlay, CI. @@ -39,7 +39,7 @@ pnpm format && pnpm lint # prettier + eslint cargo fmt --all -- --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace -cargo run -p openfight-server -- --migrate # sqlx migrate run +cargo run -p opencade-server -- --migrate # sqlx migrate run cargo run -p game-defs-import -- D:/Fightcade/emulator/fbneo_roms.json --out packages/game-definitions/games # infra @@ -48,13 +48,13 @@ curl http://localhost:8080/health && curl http://localhost:8080/ready psql $DATABASE_URL -f apps/server/migrations/001_users.sql # diagnostics -pnpm tauri dev -- --log openfight # client logs to %APPDATA%/OpenFight/logs/openfight.log +pnpm tauri dev -- --log opencade # client logs to %APPDATA%/OpenCade/logs/opencade.log ``` ## Code Conventions & Common Patterns - **Formatting:** `cargo fmt` (rustfmt edition 2021, `max_width=100`) and `pnpm format` (Prettier) — CI blocks on `fmt --check`. `clippy -D warnings` required. -- **Naming:** crates `openfight-*`, TS packages `@openfight/*`, adapters `fbneo` (kebab), TOML ids `sfiii3`, `kof98` (snake, lowercase). DB tables `snake_case`; API/WS JSON also `snake_case` via `#[serde(rename_all = "snake_case")]` for payload keys and `#[serde(rename = "type")]` for the `type` field (see `Envelope` in `apps/server/src/main.rs:17-24` and `packages/protocol`). +- **Naming:** crates `opencade-*`, TS packages `@opencade/*`, adapters `fbneo` (kebab), TOML ids `sfiii3`, `kof98` (snake, lowercase). DB tables `snake_case`; API/WS JSON also `snake_case` via `#[serde(rename_all = "snake_case")]` for payload keys and `#[serde(rename = "type")]` for the `type` field (see `Envelope` in `apps/server/src/main.rs:17-24` and `packages/protocol`). - **Error handling:** `thiserror` + `anyhow` in Rust, never `unwrap()` in server paths; TS `Result`-style returns from `src/lib/api.ts`. Structured `tracing` logs (JSON in prod, pretty in dev) — never log tokens, passwords, or ROM paths with PII. - **Async:** Tokio everywhere server-side; Tauri commands `async` with `#[tauri::command(async)]`; WS client uses `tokio-tungstenite` + backoff. No blocking in async context. - **Protocol:** every WS message `Envelope {type:"signaling.offer", version:"1.0", request_id, timestamp, payload:{room_id,candidate}}` with `version` as **string** `"1.0"` canonical (compat `"1"` accepted) matching `pub const PROTOCOL_VERSION: &str = "1.0"` and `is_supported_version` in `packages/protocol/src/lib.rs:14-21` / `pub version: String` in `apps/server/src/main.rs:20`. Server validates `version` then `type`, returns `{code:"unknown_type"}` for unknown `type`, `{code:"version_unsupported"}` for unsupported version; forward-compatible bump via `"2.0"` handler. @@ -83,14 +83,14 @@ pnpm tauri dev -- --log openfight # client logs to %APPDATA%/OpenFight/logs/ope - **Runtime:** Rust 1.78+ (MSRV), Tauri 1.x (WebView2 on Windows), Node 20+ with **pnpm 9** (not npm/yarn), Postgres 16-alpine (sqlx compile-time checked). No Bun. No Electron. - **Package manager:** `pnpm` at root (`pnpm-workspace.yaml` covers `apps/*`, `packages/*`, `adapters/*`, `services/*`) — `pnpm install` only, commit `pnpm-lock.yaml`. - **Build:** `cargo build --workspace`, `pnpm -C apps/client build`, `tauri build` (MSI/NSIS on Windows). Docker multi-stage `rust:1.78 → debian:bookworm-slim`. -- **Env:** `DATABASE_URL=postgres://openfight:openfight@db:5432/openfight`, `SESSION_SECRET` (32B CSPRNG), `RUST_LOG=info,openfight_server=debug`. Never commit `.env` (see `.env.example`). +- **Env:** `DATABASE_URL=postgres://opencade:opencade@db:5432/opencade`, `SESSION_SECRET` (32B CSPRNG), `RUST_LOG=info,opencade_server=debug`. Never commit `.env` (see `.env.example`). - **OS:** Windows 10/11 primary (Tauri), Linux/macOS viable via same stack — no `.lnk` shortcuts, use `tauri::path`. - **Tooling constraints:** keep `disableDevTools` off in dev, on in prod via Tauri `tauri.conf.json > build > devPath`. No `shell` permission in `tauri.conf.json`; use `process` allowlist. ## Testing & QA - **No ROMs/binaries in tests:** use fixtures under `tests/fixtures/` (tiny TOML, mock adapter). `research/binaries/` is gitignored. -- **Unit:** `cargo test -p openfight-protocol -- envelope serde`, `-p emulator-sdk -- arg escaping`, `-p game-definitions -- loader/scan`, `pnpm test` for `packages/shared` (Vitest). +- **Unit:** `cargo test -p opencade-protocol -- envelope serde`, `-p emulator-sdk -- arg escaping`, `-p game-definitions -- loader/scan`, `pnpm test` for `packages/shared` (Vitest). - **Integration:** `cargo test --workspace -- --ignored` (spins `Axum` + `postgres` via `docker compose up -d db`, registers two users, WS presence → `challenge.send/accept` → `signaling.offer/answer/candidate` → `room PLAYING` → disconnect). - ** networking:** LAN, same NAT, different NAT, symmetric NAT (expect relay fallback), packet loss/latency injection via `tc`; `diagnostics:network_test` command asserts `nat:cone|symmetric`, `rtt_ms`, `relay_reachable`. - **Manual QA loop (MVP):** `docker compose up -d` → `curl /health` → `pnpm tauri dev` → login → `Games` shows owned `sfiii3` (needs `sfiii3.zip` + `neogeo.zip` under `emulator/fbneo/ROMs/` scanned locally) → challenge peer in `Lobbies/:gameId` → accept → `CONNECTING` (P2P or relay) → emulator spawns `fcadefbneo.exe` with escaped `C:\path with spaces\sfiii3.zip` → play → `FINISHED` → export logs `Settings → Export Logs`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 571f8f9..238a171 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ -# Contributing to OpenFight +# Contributing to OpenCade Thank you for helping build an open, self-hostable alternative for arcade netplay. This document describes how to contribute safely and consistently. By contributing you agree that your contributions will be licensed under the **Apache License 2.0** (see `LICENSE`). -> **Golden rule:** OpenFight is a **clean-room reimplementation**. We study the existing proprietary platform only as a black box, write down what we observe, and then independently design and implement. We never copy, paste, decompile, or redistribute proprietary code, binaries, ROMs, or assets. +> **Golden rule:** OpenCade is a **clean-room reimplementation**. We study the existing proprietary platform only as a black box, write down what we observe, and then independently design and implement. We never copy, paste, decompile, or redistribute proprietary code, binaries, ROMs, or assets. --- @@ -65,7 +65,7 @@ Copy-Item .env.example .env ## Repository Layout ``` -openfight/ +opencade/ ├── apps/ │ ├── client/ # Tauri + React + TypeScript desktop client │ │ └── src-tauri/ # Rust native layer (process/fs/logging) @@ -80,7 +80,7 @@ openfight/ │ ├── fbneo/ # FBNeo adapter (first adapter) │ └── mame/ # (future) ├── services/ -│ └── relay/ # openfight-relay (TURN-like fallback) +│ └── relay/ # opencade-relay (TURN-like fallback) ├── research/ # NOT SHIPPED — observations only (see GUARDRAILS.md) ├── docs/ ├── docker/ @@ -167,7 +167,7 @@ Observation → Documentation → Design → Implementation 2. **Documentation** — Write a short note in `research/notes/` or `research/behavior/` describing the observed behaviour, the evidence, your confidence (Low / Medium / High), and the _implementation implication_ in your own words. Example template is in `research/GUARDRAILS.md`. -3. **Design** — In the PR or a `docs/` ADR, describe the OpenFight design you propose _without referencing proprietary source_. Cite only your observation note and public specs. +3. **Design** — In the PR or a `docs/` ADR, describe the OpenCade design you propose _without referencing proprietary source_. Cite only your observation note and public specs. 4. **Implementation** — Write original Rust / TypeScript from the design. Do not copy decompiled output, do not paraphrase `lib/main.js`, do not look at `fcade.exe` strings while coding. @@ -183,7 +183,7 @@ The following will be rejected in CI and in review: - Decompiled or transcribed proprietary source, including `fc2-electron/resources/app/lib/main.js` and `lib/static/login.js`. - Copyrighted ROMs or archives: `*.zip` / `*.7z` / `*.chd` game images, `emulator/*.json` outputs copied as committed TOML without review. - Proprietary assets: `assets/*.wav` challenge sounds, icons, or other Fightcade media. -- Credentials, API keys, tokens, `.env` with secrets, `%APPDATA%/OpenFight/` dumps. +- Credentials, API keys, tokens, `.env` with secrets, `%APPDATA%/OpenCade/` dumps. - `research/binaries/` content. See `research/GUARDRAILS.md` for the full allow / deny matrix and the local importer workflow for `emulator/*.json` → TOML. @@ -251,7 +251,7 @@ pnpm test # If you touched Docker / migrations docker compose config --quiet -cargo test -p openfight-server --test api_integration # if present +cargo test -p opencade-server --test api_integration # if present ``` Include in the PR description: which levels you tested and on what OS. For UI changes, attach a screenshot or short clip. @@ -275,4 +275,4 @@ Draft PRs are welcome for early feedback — mark as `Draft` and note what is st --- -Thank you for building OpenFight the right way — original code, open protocol, self-hostable infrastructure. +Thank you for building OpenCade the right way — original code, open protocol, self-hostable infrastructure. diff --git a/Cargo.lock b/Cargo.lock index d2fac60..4a33f08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -919,16 +919,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "openfight-adapter-fbneo" +name = "opencade-adapter-fbneo" version = "0.1.0" dependencies = [ - "openfight-emulator-sdk", + "opencade-emulator-sdk", "serde", "thiserror", ] [[package]] -name = "openfight-emulator-sdk" +name = "opencade-emulator-sdk" version = "0.1.0" dependencies = [ "serde", @@ -936,7 +936,7 @@ dependencies = [ ] [[package]] -name = "openfight-game-definitions" +name = "opencade-game-definitions" version = "0.1.0" dependencies = [ "regex", @@ -948,10 +948,10 @@ dependencies = [ ] [[package]] -name = "openfight-networking" +name = "opencade-networking" version = "0.1.0" dependencies = [ - "openfight-emulator-sdk", + "opencade-emulator-sdk", "serde", "serde_json", "thiserror", @@ -959,7 +959,7 @@ dependencies = [ ] [[package]] -name = "openfight-protocol" +name = "opencade-protocol" version = "0.1.0" dependencies = [ "chrono", @@ -970,7 +970,7 @@ dependencies = [ ] [[package]] -name = "openfight-server" +name = "opencade-server" version = "0.1.0" dependencies = [ "argon2", @@ -978,7 +978,7 @@ dependencies = [ "chrono", "dashmap", "futures-util", - "openfight-protocol", + "opencade-protocol", "regex", "serde", "serde_json", @@ -996,7 +996,7 @@ dependencies = [ ] [[package]] -name = "openfight-shared" +name = "opencade-shared" version = "0.1.0" dependencies = [ "serde", diff --git a/Cargo.toml b/Cargo.toml index e823473..29b8b87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,5 +14,5 @@ resolver = "2" edition = "2021" version = "0.1.0" license = "Apache-2.0" -repository = "https://github.com/openfight/openfight" +repository = "https://github.com/opencade/opencade" rust-version = "1.78" diff --git a/LICENSE b/LICENSE index 6a75287..08df7ca 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 OpenFight Contributors + Copyright 2026 OpenCade Contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 29ffedd..de99833 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# OpenFight +# OpenCade [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/Y4rDyTScPe) @@ -7,7 +7,7 @@ > **Open-source arcade netplay — a clean-room, community-owned alternative for low-latency rollback matchmaking and emulation.** -OpenFight is a monorepo for a modern arcade netplay platform: Rust server (Axum + PostgreSQL), Tauri + React + TypeScript desktop client, and a pluggable emulator adapter SDK. The repository now contains an executable **Proof of Match** control plane, deterministic mock-adapter data plane, safe local FBNeo launch boundary, and LAN UDP transport. FBNeo netplay, NAT traversal, and relay fallback remain explicitly unproven and are not advertised as implemented. +OpenCade is a monorepo for a modern arcade netplay platform: Rust server (Axum + PostgreSQL), Tauri + React + TypeScript desktop client, and a pluggable emulator adapter SDK. The repository now contains an executable **Proof of Match** control plane, deterministic mock-adapter data plane, safe local FBNeo launch boundary, and LAN UDP transport. FBNeo netplay, NAT traversal, and relay fallback remain explicitly unproven and are not advertised as implemented. --- @@ -30,10 +30,10 @@ docker compose up -d # verify curl http://localhost:8080/health -docker compose logs -f openfight-server +docker compose logs -f opencade-server ``` -This starts `openfight-server` and PostgreSQL. The server applies committed SQLx migrations before it begins serving. A relay service is intentionally deferred. +This starts `opencade-server` and PostgreSQL. The server applies committed SQLx migrations before it begins serving. A relay service is intentionally deferred. ```bash # stop @@ -53,7 +53,7 @@ pnpm install pnpm -C apps/client tauri dev # or run web-only frontend -pnpm --filter @openfight/client dev +pnpm --filter @opencade/client dev # production bundle pnpm -C apps/client tauri build @@ -66,7 +66,7 @@ pnpm -C apps/client tauri build ## Repository Structure ``` -OpenFight/ +OpenCade/ ├── apps/ │ ├── client/ # Tauri + React + TypeScript desktop client │ │ ├── src/ # Auth, games, lobby, challenge, and match views @@ -93,12 +93,13 @@ OpenFight/ │ ├── behavior/ # UX flows, state transitions │ └── notes/ # Working scratch (not source of truth) ├── docs/ -│ ├── ARCHITECTURE.md # System architecture & subsystem map -│ ├── adr/ # Architecture decision records -│ ├── alpha/ # LAN test and match-report procedures -│ └── IMPLEMENTATION_STATUS.md # Verified scope and explicit non-claims +│ ├── ARCHITECTURE.md # System architecture & subsystem map (authoritative) +│ ├── adr/ # Architecture decision records +│ ├── alpha/ # LAN test and match-report procedures +│ ├── IMPLEMENTATION_STATUS.md # Verified scope and explicit non-claims +│ └── reference-fightcade-install.md # D:/Fightcade read-only notes (never copied) ├── docker/ -│ └── compose.yml # (or ./docker-compose.yml at root) +│ └── (compose at root: `docker-compose.yml` — see also `docker/` if present) ├── .github/ │ └── workflows/ # CI (fmt, clippy, test, build) └── tests/ # Cross-package integration tests @@ -112,7 +113,7 @@ See **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** for the full system design, > **D:/Fightcade is a read-only reference. No proprietary binaries, ROMs, or credentials are shipped in this repository.** -OpenFight is built under a strict clean-room process: +OpenCade is built under a strict clean-room process: 1. **Observation** — black-box study of behavior and protocols against `D:/Fightcade` as an installed reference. Notes go to `research/` only. 2. **Documentation** — observations are distilled into specs (`docs/`, `packages/protocol`). @@ -123,15 +124,13 @@ OpenFight is built under a strict clean-room process: **Allowed:** original source under Apache-2.0, documentation, licensed dependencies, public specifications. -The `research/` directory is workspace-only and is **not shipped** in release artifacts or the -server container image. The process and prohibited material are documented in -[`research/GUARDRAILS.md`](research/GUARDRAILS.md). +The `research/` directory is workspace-only and is **not shipped** in any release artifact or the server container image. The process and prohibited material are documented in `research/GUARDRAILS.md` and `docs/ARCHITECTURE.md §18`. CI enforces the guardrail (`research/` is excluded from builds and binary scans block proprietary artifacts). --- ## Architecture -High-level: `Client (Tauri)` ↔ `Server (Axum REST + authenticated WebSocket)` for the control plane; direct UDP or the deterministic in-memory transport carries OpenFight input frames; the adapter boundary owns safe local emulator execution. +High-level: `Client (Tauri)` ↔ `Server (Axum REST + authenticated WebSocket)` for the control plane; direct UDP or the deterministic in-memory transport carries OpenCade input frames; the adapter boundary owns safe local emulator execution. - **Server:** auth (Argon2id), hashed sessions, games, server hints, lobbies, durable challenges, rooms/matches, and authenticated WebSocket signaling (`offer`/`answer`/`candidate`). @@ -153,14 +152,14 @@ Full reference: **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)**. | **M3** | Client Shell | Tauri + React shell, routing (Games/Lobbies/Friends/Servers/Settings), Rust fs/process/logging | Client launches, talks to server, diagnostics panel | | **M4** | Emulator SDK | Adapter trait (`detect`/`validate`/`getVersion`/`launch`/`stop`/`configure`/`getSupportedGames`), FBNeo adapter, TOML game definitions | Local ROM scan + safe launch for one title | | **M5** | Matchmaking | Lobbies, game versions, server browser, matchmaking & room lifecycle | Create/join/spectate room e2e with two peers | -| **M6** | NAT & Relay | STUN, hole-punching, `openfight-relay` TURN fallback, RTT/loss/jitter, Network Test | Direct + relayed matches measured; relay Docker image | +| **M6** | NAT & Relay | STUN, hole-punching, `opencade-relay` TURN fallback, RTT/loss/jitter, Network Test | Direct + relayed matches measured; relay Docker image | | **M7** | MVP Release | Hardening, bans/reports, replay hooks, packaging, docs | Tagged MVP, signed artifacts, no proprietary content | --- ## Community -**Discord — where we discuss OpenFight and everything around it (not only OpenFight):** https://discord.gg/Y4rDyTScPe +**Discord — where we discuss OpenCade and everything around it (not only OpenCade):** https://discord.gg/Y4rDyTScPe General dev chat, architecture questions, emulator adapter ideas, and matchmaking talk — all in one place. @@ -172,4 +171,4 @@ Do not open public issues for sensitive vulnerabilities. See `SECURITY.md` for d ## License -Apache License 2.0 — see [LICENSE](LICENSE). Copyright 2026 OpenFight Contributors. +Apache License 2.0 — see [LICENSE](LICENSE). Copyright 2026 OpenCade Contributors. diff --git a/adapters/fbneo/Cargo.toml b/adapters/fbneo/Cargo.toml index c630fed..8c6ad37 100644 --- a/adapters/fbneo/Cargo.toml +++ b/adapters/fbneo/Cargo.toml @@ -1,13 +1,13 @@ [package] -name = "openfight-adapter-fbneo" +name = "opencade-adapter-fbneo" version = "0.1.0" edition = "2021" -description = "OpenFight FBNeo emulator adapter (placeholder)" +description = "OpenCade FBNeo emulator adapter (placeholder)" license = "Apache-2.0" -repository = "https://github.com/openfight/openfight" +repository = "https://github.com/opencade/opencade" rust-version = "1.78" [dependencies] serde = { version = "1", features = ["derive"] } thiserror = "1" -openfight-emulator-sdk = { path = "../../packages/emulator-sdk" } +opencade-emulator-sdk = { path = "../../packages/emulator-sdk" } diff --git a/adapters/fbneo/src/lib.rs b/adapters/fbneo/src/lib.rs index c1e6f2f..2f5a423 100644 --- a/adapters/fbneo/src/lib.rs +++ b/adapters/fbneo/src/lib.rs @@ -1,4 +1,4 @@ -use openfight_emulator_sdk::{ +use opencade_emulator_sdk::{ spawn_validated, AdapterCapabilities, AdapterError, DetectedEmulator, EmulatorAdapter, StdProcessLauncher, ValidationReport, }; @@ -69,7 +69,7 @@ impl EmulatorAdapter for FbneoAdapter { } fn validate(&self, rom_path: &Path) -> Result { - openfight_emulator_sdk::canonicalize_below(rom_path, &self.rom_root())?; + opencade_emulator_sdk::canonicalize_below(rom_path, &self.rom_root())?; if rom_path .extension() .and_then(|extension| extension.to_str()) @@ -131,7 +131,7 @@ mod tests { fn fixture() -> PathBuf { let root = std::env::temp_dir().join(format!( - "openfight-fbneo-{}-{}", + "opencade-fbneo-{}-{}", std::process::id(), FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed) )); diff --git a/apps/client/index.html b/apps/client/index.html index 35e1b18..6696265 100644 --- a/apps/client/index.html +++ b/apps/client/index.html @@ -3,7 +3,7 @@ - OpenFight + OpenCade
diff --git a/apps/client/package.json b/apps/client/package.json index 03492ae..92e3a02 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,5 +1,5 @@ { - "name": "@openfight/client", + "name": "@opencade/client", "private": true, "version": "0.1.0", "type": "module", @@ -12,7 +12,7 @@ "test": "vitest run" }, "dependencies": { - "@openfight/protocol": "workspace:*", + "@opencade/protocol": "workspace:*", "@tanstack/react-query": "^5.51.1", "@tauri-apps/api": "^1.6.0", "react": "^18.3.1", diff --git a/apps/client/src-tauri/Cargo.lock b/apps/client/src-tauri/Cargo.lock index 2a3ae59..3fb44a4 100644 --- a/apps/client/src-tauri/Cargo.lock +++ b/apps/client/src-tauri/Cargo.lock @@ -1670,20 +1670,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "openfight-adapter-fbneo" +name = "opencade-adapter-fbneo" version = "0.1.0" dependencies = [ - "openfight-emulator-sdk", + "opencade-emulator-sdk", "serde", "thiserror", ] [[package]] -name = "openfight-client" +name = "opencade-client" version = "0.1.0" dependencies = [ - "openfight-adapter-fbneo", - "openfight-emulator-sdk", + "opencade-adapter-fbneo", + "opencade-emulator-sdk", "serde", "serde_json", "tauri", @@ -1694,7 +1694,7 @@ dependencies = [ ] [[package]] -name = "openfight-emulator-sdk" +name = "opencade-emulator-sdk" version = "0.1.0" dependencies = [ "serde", diff --git a/apps/client/src-tauri/Cargo.toml b/apps/client/src-tauri/Cargo.toml index d97d6d3..d313eb8 100644 --- a/apps/client/src-tauri/Cargo.toml +++ b/apps/client/src-tauri/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "openfight-client" +name = "opencade-client" version = "0.1.0" edition = "2021" -description = "OpenFight desktop client (Tauri + Rust backend)" -authors = ["OpenFight Contributors"] +description = "OpenCade desktop client (Tauri + Rust backend)" +authors = ["OpenCade Contributors"] license = "Apache-2.0" -repository = "https://github.com/openfight/openfight" +repository = "https://github.com/opencade/opencade" rust-version = "1.78" [build-dependencies] @@ -18,8 +18,8 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -openfight-adapter-fbneo = { path = "../../../adapters/fbneo" } -openfight-emulator-sdk = { path = "../../../packages/emulator-sdk" } +opencade-adapter-fbneo = { path = "../../../adapters/fbneo" } +opencade-emulator-sdk = { path = "../../../packages/emulator-sdk" } [features] default = ["custom-protocol"] diff --git a/apps/client/src-tauri/src/commands/fs.rs b/apps/client/src-tauri/src/commands/fs.rs index d3286c6..ad39086 100644 --- a/apps/client/src-tauri/src/commands/fs.rs +++ b/apps/client/src-tauri/src/commands/fs.rs @@ -1,5 +1,5 @@ -use openfight_adapter_fbneo::FbneoAdapter; -use openfight_emulator_sdk::EmulatorAdapter; +use opencade_adapter_fbneo::FbneoAdapter; +use opencade_emulator_sdk::EmulatorAdapter; use serde::Serialize; use std::path::PathBuf; diff --git a/apps/client/src-tauri/src/commands/process.rs b/apps/client/src-tauri/src/commands/process.rs index 86ce7a0..f0b2bd9 100644 --- a/apps/client/src-tauri/src/commands/process.rs +++ b/apps/client/src-tauri/src/commands/process.rs @@ -1,5 +1,5 @@ -use openfight_adapter_fbneo::FbneoAdapter; -use openfight_emulator_sdk::EmulatorAdapter; +use opencade_adapter_fbneo::FbneoAdapter; +use opencade_emulator_sdk::EmulatorAdapter; use std::collections::HashMap; use std::path::PathBuf; use std::process::Child; diff --git a/apps/client/src-tauri/tauri.conf.json b/apps/client/src-tauri/tauri.conf.json index 769fd5a..5416d3d 100644 --- a/apps/client/src-tauri/tauri.conf.json +++ b/apps/client/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "distDir": "../dist" }, "package": { - "productName": "OpenFight", + "productName": "OpenCade", "version": "0.1.0" }, "tauri": { @@ -22,7 +22,7 @@ "bundle": { "active": true, "category": "Game", - "identifier": "com.openfight.client", + "identifier": "com.opencade.client", "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -39,7 +39,7 @@ "fullscreen": false, "height": 720, "resizable": true, - "title": "OpenFight", + "title": "OpenCade", "width": 1280 } ] diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 8c56baf..233048d 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { Envelope } from "@openfight/protocol"; +import type { Envelope } from "@opencade/protocol"; import DiagnosticsButton from "./components/DiagnosticsButton"; import { ApiError, api } from "./lib/api"; import { useSessionStore } from "./lib/store"; -import { OpenFightSocket, type ConnectionState } from "./lib/ws"; +import { OpenCadeSocket, type ConnectionState } from "./lib/ws"; import Auth from "./routes/Auth"; import Games from "./routes/Games"; import Lobby from "./routes/Lobby"; @@ -36,7 +36,7 @@ export default function App() { useEffect(() => { if (!token) return; - const socket = new OpenFightSocket(API_URL, token, setConnection); + const socket = new OpenCadeSocket(API_URL, token, setConnection); const unsubscribe = socket.subscribe((message: Envelope) => { if (message.type.startsWith("challenge.")) { void queryClient.invalidateQueries({ queryKey: ["challenges"] }); @@ -74,10 +74,10 @@ export default function App() {
{connection} diff --git a/apps/client/src/lib/api.ts b/apps/client/src/lib/api.ts index d6fcb2d..a1f7127 100644 --- a/apps/client/src/lib/api.ts +++ b/apps/client/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { Envelope, RoomPayload } from "@openfight/protocol"; +import type { Envelope, RoomPayload } from "@opencade/protocol"; export type User = { id: string; username: string; email?: string | null }; export type AuthPayload = { user: User; token: string; expires_at: string }; @@ -47,7 +47,7 @@ async function request(path: string, token?: string | null, init?: RequestIni try { response = await fetch(`${API_BASE}${path}`, { ...init, headers }); } catch { - throw new ApiError(0, "network_unavailable", "OpenFight server is unreachable"); + throw new ApiError(0, "network_unavailable", "OpenCade server is unreachable"); } const decoded: unknown = await response.json().catch(() => null); const envelope = isEnvelope(decoded) ? decoded : null; diff --git a/apps/client/src/lib/report.ts b/apps/client/src/lib/report.ts index 04f9340..2e608b7 100644 --- a/apps/client/src/lib/report.ts +++ b/apps/client/src/lib/report.ts @@ -1,4 +1,4 @@ -import type { RoomPayload } from "@openfight/protocol"; +import type { RoomPayload } from "@opencade/protocol"; export type MatchReport = { schema_version: 1; @@ -26,7 +26,7 @@ export function downloadMatchReport(room: RoomPayload): void { ); const link = document.createElement("a"); link.href = url; - link.download = `openfight-match-${room.id.slice(0, 8)}.json`; + link.download = `opencade-match-${room.id.slice(0, 8)}.json`; link.click(); URL.revokeObjectURL(url); } diff --git a/apps/client/src/lib/store.ts b/apps/client/src/lib/store.ts index 3df667f..2459f00 100644 --- a/apps/client/src/lib/store.ts +++ b/apps/client/src/lib/store.ts @@ -9,7 +9,7 @@ type SessionState = { clearSession: () => void; }; -const TOKEN_KEY = "openfight.session_token"; +const TOKEN_KEY = "opencade.session_token"; function storedToken(): string | null { try { diff --git a/apps/client/src/lib/ws.ts b/apps/client/src/lib/ws.ts index bf94b01..4d8bd15 100644 --- a/apps/client/src/lib/ws.ts +++ b/apps/client/src/lib/ws.ts @@ -1,4 +1,4 @@ -import { createEnvelope, parseEnvelope, type Envelope } from "@openfight/protocol"; +import { createEnvelope, parseEnvelope, type Envelope } from "@opencade/protocol"; export type ConnectionState = "idle" | "connecting" | "open" | "reconnecting" | "closed"; export type MessageListener = (message: Envelope) => void; @@ -7,7 +7,7 @@ export function reconnectDelay(attempt: number): number { return Math.min(30_000, 500 * 2 ** Math.max(0, attempt)); } -export class OpenFightSocket { +export class OpenCadeSocket { private socket: WebSocket | null = null; private attempt = 0; private stopped = false; @@ -59,7 +59,7 @@ export class OpenFightSocket { private open(state: ConnectionState): void { this.onState(state); const url = new URL("/ws", this.baseUrl.replace(/^http/, "ws")); - this.socket = new WebSocket(url, ["openfight.v1", `openfight.auth.${this.token}`]); + this.socket = new WebSocket(url, ["opencade.v1", `opencade.auth.${this.token}`]); this.socket.onopen = () => { this.attempt = 0; this.onState("open"); diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index ac9dd80..ba208de 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -9,7 +9,7 @@ const queryClient = new QueryClient({ }); const root = document.getElementById("root"); -if (!root) throw new Error("OpenFight root element is missing"); +if (!root) throw new Error("OpenCade root element is missing"); ReactDOM.createRoot(root).render( diff --git a/apps/client/src/routes/Auth.tsx b/apps/client/src/routes/Auth.tsx index 3d91053..362ec33 100644 --- a/apps/client/src/routes/Auth.tsx +++ b/apps/client/src/routes/Auth.tsx @@ -98,7 +98,7 @@ export default function Auth({

)} diff --git a/apps/server/Cargo.lock b/apps/server/Cargo.lock index 6de7017..d1a2006 100644 --- a/apps/server/Cargo.lock +++ b/apps/server/Cargo.lock @@ -1080,7 +1080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "openfight-protocol" +name = "opencade-protocol" version = "0.1.0" dependencies = [ "chrono", @@ -1090,7 +1090,7 @@ dependencies = [ ] [[package]] -name = "openfight-server" +name = "opencade-server" version = "0.1.0" dependencies = [ "argon2", @@ -1098,7 +1098,7 @@ dependencies = [ "chrono", "dashmap", "jsonwebtoken", - "openfight-protocol", + "opencade-protocol", "regex", "serde", "serde_json", diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index d362cc8..ccc74e9 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "openfight-server" +name = "opencade-server" version = "0.1.0" edition = "2021" -description = "OpenFight server (Axum + PostgreSQL)" -authors = ["OpenFight Contributors"] +description = "OpenCade server (Axum + PostgreSQL)" +authors = ["OpenCade Contributors"] license = "Apache-2.0" -repository = "https://github.com/openfight/openfight" +repository = "https://github.com/opencade/opencade" rust-version = "1.78" [dependencies] @@ -25,7 +25,7 @@ regex = "1" sha2 = "0.10" dashmap = "5" thiserror = "1" -openfight-protocol = { path = "../../packages/protocol" } +opencade-protocol = { path = "../../packages/protocol" } [dev-dependencies] futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 6aff98c..101d348 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -31,8 +31,8 @@ RUN mkdir -p apps/server/src packages/protocol/src packages/networking/src \ COPY . . # Build the server binary. -RUN cargo build --release --locked -p openfight-server \ - && ls -lh target/release/openfight-server* 2>/dev/null || ls -lh target/release/ +RUN cargo build --release --locked -p opencade-server \ + && ls -lh target/release/opencade-server* 2>/dev/null || ls -lh target/release/ # --- runtime --------------------------------------------------------------- FROM debian:bookworm-slim AS runtime @@ -41,21 +41,21 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* # Non-root user -RUN useradd -m -u 10001 openfight +RUN useradd -m -u 10001 opencade WORKDIR /app -COPY --from=builder /app/target/release/openfight-server /usr/local/bin/openfight-server +COPY --from=builder /app/target/release/opencade-server /usr/local/bin/opencade-server # Migrations / static assets if present COPY --from=builder /app/apps/server/migrations ./migrations -USER openfight +USER opencade EXPOSE 8080 ENV RUST_LOG=info -ENV DATABASE_URL=postgres://openfight:openfight@db:5432/openfight -ENV OPENFIGHT_ENV=production +ENV DATABASE_URL=postgres://opencade:opencade@db:5432/opencade +ENV OPENCADE_ENV=production HEALTHCHECK --interval=10s --timeout=5s --retries=5 --start-period=15s \ CMD wget -qO- http://localhost:8080/health || exit 1 -CMD ["openfight-server"] +CMD ["opencade-server"] diff --git a/apps/server/src/config.rs b/apps/server/src/config.rs index 5fd850b..bdd378b 100644 --- a/apps/server/src/config.rs +++ b/apps/server/src/config.rs @@ -23,7 +23,7 @@ pub struct Config { impl Config { pub fn from_env() -> Result { let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| { - "postgres://openfight:openfight@localhost:5432/openfight".to_string() + "postgres://opencade:opencade@localhost:5432/opencade".to_string() }); let session_secret = env::var("SESSION_SECRET") .unwrap_or_else(|_| "dev-session-secret-change-me".to_string()); @@ -36,7 +36,7 @@ impl Config { .ok_or(ConfigError::InvalidPort)?, Err(_) => 8080, }; - let production = env::var("OPENFIGHT_ENV") + let production = env::var("OPENCADE_ENV") .map(|value| value.eq_ignore_ascii_case("production")) .unwrap_or(false); if production && session_secret.len() < 32 { @@ -66,7 +66,7 @@ impl Config { pub fn for_test() -> Self { Self { - database_url: "postgres://openfight:openfight@localhost:5432/openfight_test".into(), + database_url: "postgres://opencade:opencade@localhost:5432/opencade_test".into(), session_secret: "test-session-secret-with-32-characters".into(), rust_log: "info".into(), port: 8080, @@ -86,7 +86,7 @@ mod tests { "SESSION_SECRET", "RUST_LOG", "PORT", - "OPENFIGHT_ENV", + "OPENCADE_ENV", "ALLOWED_ORIGINS", ]; @@ -120,7 +120,7 @@ mod tests { #[serial] fn rejects_weak_production_secret() { clear_env(); - env::set_var("OPENFIGHT_ENV", "production"); + env::set_var("OPENCADE_ENV", "production"); env::set_var("SESSION_SECRET", "weak"); assert_eq!(Config::from_env(), Err(ConfigError::WeakProductionSecret)); clear_env(); diff --git a/apps/server/src/error.rs b/apps/server/src/error.rs index 6606ab6..df9577a 100644 --- a/apps/server/src/error.rs +++ b/apps/server/src/error.rs @@ -6,7 +6,7 @@ use axum::{ response::{IntoResponse, Response}, Json, }; -use openfight_protocol::Envelope; +use opencade_protocol::Envelope; use serde_json::{json, Value}; /// Application-level errors. diff --git a/apps/server/src/health.rs b/apps/server/src/health.rs index efd9ce4..7f80dc7 100644 --- a/apps/server/src/health.rs +++ b/apps/server/src/health.rs @@ -1,5 +1,5 @@ use axum::{extract::State, http::StatusCode, Json}; -use openfight_protocol::{Envelope, PROTOCOL_VERSION}; +use opencade_protocol::{Envelope, PROTOCOL_VERSION}; use serde_json::{json, Value}; use crate::state::AppState; diff --git a/apps/server/src/lib.rs b/apps/server/src/lib.rs index b4959e1..fafa4d4 100644 --- a/apps/server/src/lib.rs +++ b/apps/server/src/lib.rs @@ -128,7 +128,7 @@ mod tests { #[tokio::test] async fn health_is_available_without_a_database_round_trip() { let pool = PgPoolOptions::new() - .connect_lazy("postgres://openfight:openfight@localhost/openfight_test") + .connect_lazy("postgres://opencade:opencade@localhost/opencade_test") .expect("valid test database URL"); let response = build_app(AppState::new(pool, Config::for_test())) .oneshot( @@ -145,7 +145,7 @@ mod tests { #[tokio::test] async fn protected_routes_reject_missing_session_before_database_access() { let pool = PgPoolOptions::new() - .connect_lazy("postgres://openfight:openfight@localhost/openfight_test") + .connect_lazy("postgres://opencade:opencade@localhost/opencade_test") .expect("valid test database URL"); let response = build_app(AppState::new(pool, Config::for_test())) .oneshot( diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 2dcdfb0..421721b 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -1,4 +1,4 @@ -use openfight_server::{build_app, shutdown_signal, AppState, Config}; +use opencade_server::{build_app, shutdown_signal, AppState, Config}; use sqlx::postgres::PgPoolOptions; use std::net::SocketAddr; use tracing::info; @@ -30,7 +30,7 @@ async fn main() -> Result<(), Box> { let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); let listener = tokio::net::TcpListener::bind(addr).await?; - info!(%addr, "openfight server listening"); + info!(%addr, "opencade server listening"); axum::serve(listener, build_app(AppState::new(pool, config))) .with_graceful_shutdown(shutdown_signal()) diff --git a/apps/server/src/room_state.rs b/apps/server/src/room_state.rs index 8eecadf..be8367a 100644 --- a/apps/server/src/room_state.rs +++ b/apps/server/src/room_state.rs @@ -1,4 +1,4 @@ -use openfight_protocol::RoomState; +use opencade_protocol::RoomState; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RoomEvent { diff --git a/apps/server/src/routes/auth.rs b/apps/server/src/routes/auth.rs index 1903473..4e1a760 100644 --- a/apps/server/src/routes/auth.rs +++ b/apps/server/src/routes/auth.rs @@ -1,7 +1,7 @@ use argon2::password_hash::{rand_core::OsRng, SaltString}; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use axum::{extract::State, http::StatusCode, Json}; -use openfight_protocol::Envelope; +use opencade_protocol::Envelope; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; diff --git a/apps/server/src/routes/challenges.rs b/apps/server/src/routes/challenges.rs index 689ba42..ef40efa 100644 --- a/apps/server/src/routes/challenges.rs +++ b/apps/server/src/routes/challenges.rs @@ -1,5 +1,5 @@ use axum::{extract::Path, extract::State, http::StatusCode, Json}; -use openfight_protocol::Envelope; +use opencade_protocol::Envelope; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sqlx::{Postgres, Row, Transaction}; @@ -206,7 +206,7 @@ pub async fn accept_challenge( .execute(&mut *transaction) .await?; let next = transition( - openfight_protocol::RoomState::Challenging, + opencade_protocol::RoomState::Challenging, RoomEvent::Accept, ) .map_err(|error| AppError::Conflict(error.to_string()))?; diff --git a/apps/server/src/routes/games.rs b/apps/server/src/routes/games.rs index 1a7c95a..17e3be6 100644 --- a/apps/server/src/routes/games.rs +++ b/apps/server/src/routes/games.rs @@ -1,5 +1,5 @@ use axum::{extract::Path, extract::State, Json}; -use openfight_protocol::Envelope; +use opencade_protocol::Envelope; use serde_json::{json, Value}; use sqlx::Row; diff --git a/apps/server/src/routes/lobbies.rs b/apps/server/src/routes/lobbies.rs index 96d7a4c..8f82a0f 100644 --- a/apps/server/src/routes/lobbies.rs +++ b/apps/server/src/routes/lobbies.rs @@ -1,5 +1,5 @@ use axum::{extract::Path, extract::State, Json}; -use openfight_protocol::Envelope; +use opencade_protocol::Envelope; use serde::Serialize; use serde_json::{json, Value}; use sqlx::Row; diff --git a/apps/server/src/routes/rooms.rs b/apps/server/src/routes/rooms.rs index 9a205ee..6eff427 100644 --- a/apps/server/src/routes/rooms.rs +++ b/apps/server/src/routes/rooms.rs @@ -1,5 +1,5 @@ use axum::{extract::Path, extract::State, http::StatusCode, Json}; -use openfight_protocol::{Envelope, RoomPayload, RoomState}; +use opencade_protocol::{Envelope, RoomPayload, RoomState}; use serde::Deserialize; use serde_json::{json, Value}; use sqlx::{Postgres, Row, Transaction}; diff --git a/apps/server/src/routes/servers.rs b/apps/server/src/routes/servers.rs index 8674647..433438c 100644 --- a/apps/server/src/routes/servers.rs +++ b/apps/server/src/routes/servers.rs @@ -1,5 +1,5 @@ use axum::{extract::State, Json}; -use openfight_protocol::Envelope; +use opencade_protocol::Envelope; use serde_json::{json, Value}; use sqlx::Row; diff --git a/apps/server/src/state.rs b/apps/server/src/state.rs index 8267534..f658af0 100644 --- a/apps/server/src/state.rs +++ b/apps/server/src/state.rs @@ -8,7 +8,7 @@ use sqlx::PgPool; use uuid::Uuid; use axum::extract::ws::Message; -use openfight_protocol::Envelope; +use opencade_protocol::Envelope; use serde_json::Value; use crate::config::Config; diff --git a/apps/server/src/ws.rs b/apps/server/src/ws.rs index e77df4a..c4dc82f 100644 --- a/apps/server/src/ws.rs +++ b/apps/server/src/ws.rs @@ -6,7 +6,7 @@ use axum::{ http::{header, HeaderMap}, response::{IntoResponse, Response}, }; -use openfight_protocol::{is_supported_version, Envelope, PROTOCOL_VERSION}; +use opencade_protocol::{is_supported_version, Envelope, PROTOCOL_VERSION}; use serde_json::{json, Value}; use sqlx::Row; use std::borrow::Cow; @@ -58,7 +58,7 @@ pub async fn ws_handler( .ok_or_else(|| AppError::Unauthorized("websocket authentication required".into()))?; let user = authenticate_token(&state, token).await?; Ok(upgrade - .protocols(["openfight.v1"]) + .protocols(["opencade.v1"]) .on_upgrade(move |socket| handle_socket(socket, state, user)) .into_response()) } @@ -70,7 +70,7 @@ fn websocket_token(headers: &HeaderMap) -> Option<&str> { .ok()? .split(',') .map(str::trim) - .find_map(|protocol| protocol.strip_prefix("openfight.auth.")) + .find_map(|protocol| protocol.strip_prefix("opencade.auth.")) .filter(|token| !token.is_empty()) } @@ -336,7 +336,7 @@ mod tests { let mut headers = HeaderMap::new(); headers.insert( header::SEC_WEBSOCKET_PROTOCOL, - "openfight.v1, openfight.auth.secret-token" + "opencade.v1, opencade.auth.secret-token" .parse() .expect("protocol header"), ); diff --git a/apps/server/tests/api_flow.rs b/apps/server/tests/api_flow.rs index f8d321f..a0575fb 100644 --- a/apps/server/tests/api_flow.rs +++ b/apps/server/tests/api_flow.rs @@ -4,8 +4,8 @@ use axum::{ Router, }; use futures_util::{SinkExt, StreamExt}; -use openfight_protocol::Envelope; -use openfight_server::{build_app, AppState, Config}; +use opencade_protocol::Envelope; +use opencade_server::{build_app, AppState, Config}; use serde_json::{json, Value}; use sqlx::PgPool; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -184,7 +184,7 @@ async fn authenticated_users_create_and_accept_a_room(pool: PgPool) { .expect("host websocket request"); host_request.headers_mut().insert( "Sec-WebSocket-Protocol", - format!("openfight.v1, openfight.auth.{host_token}") + format!("opencade.v1, opencade.auth.{host_token}") .parse() .expect("host protocol header"), ); @@ -193,7 +193,7 @@ async fn authenticated_users_create_and_accept_a_room(pool: PgPool) { .expect("guest websocket request"); guest_request.headers_mut().insert( "Sec-WebSocket-Protocol", - format!("openfight.v1, openfight.auth.{guest_token}") + format!("opencade.v1, opencade.auth.{guest_token}") .parse() .expect("guest protocol header"), ); diff --git a/docker-compose.yml b/docker-compose.yml index 81761b0..9584e93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,38 +1,38 @@ -name: openfight +name: opencade services: db: image: postgres:16-alpine restart: unless-stopped environment: - POSTGRES_USER: openfight - POSTGRES_PASSWORD: openfight - POSTGRES_DB: openfight + POSTGRES_USER: opencade + POSTGRES_PASSWORD: opencade + POSTGRES_DB: opencade ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U openfight -d openfight"] + test: ["CMD-SHELL", "pg_isready -U opencade -d opencade"] interval: 5s timeout: 5s retries: 10 start_period: 10s - openfight-server: + opencade-server: build: context: . dockerfile: ./apps/server/Dockerfile - image: openfight-server:local + image: opencade-server:local restart: unless-stopped depends_on: db: condition: service_healthy environment: - DATABASE_URL: postgres://openfight:openfight@db:5432/openfight + DATABASE_URL: postgres://opencade:opencade@db:5432/opencade RUST_LOG: info SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET to at least 32 random bytes} - OPENFIGHT_ENV: production + OPENCADE_ENV: production PORT: 8080 ports: - "8080:8080" @@ -43,13 +43,13 @@ services: retries: 5 start_period: 15s - # openfight-relay: TURN/relay placeholder for future M3. + # opencade-relay: TURN/relay placeholder for future M3. # Uncomment when services/relay implements the relay binary. # relay: # build: # context: . # dockerfile: ./services/relay/Dockerfile - # image: openfight-relay:local + # image: opencade-relay:local # restart: unless-stopped # depends_on: # db: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2ea7194..adf22d7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,8 +1,8 @@ -# OpenFight — Architecture +# OpenCade — Architecture > **Status:** Definitive. This document is the single source of truth for repository layout, system boundaries, and implementation contracts. Anything not described here is not decided. > -> **Monorepo root:** `D:/OpenFight` +> **Monorepo root:** `D:/OpenCade` > **Reference binary (read-only):** `D:/Fightcade` — proprietary installed distribution, `VERSION 2.1.45`. Never copied, never linked, never vendored. See §2 and §17. --- @@ -34,7 +34,7 @@ ## 1. Principles 1. **Correctness first, then maintainability 6 months out.** Boring technology wins. -2. **No proprietary copy.** `D:/Fightcade` is observation only. Every line of OpenFight is original or permissively licensed (Apache-2.0 dependency hygiene). +2. **No proprietary copy.** `D:/Fightcade` is observation only. Every line of OpenCade is original or permissively licensed (Apache-2.0 dependency hygiene). 3. **Single monolith server in MVP.** One Axum process, one Postgres, one deployable. No Redis, no microservices, no premature queue. 4. **Client is native, not a web wrapper.** Tauri replaces Electron/Nativefier. No `shell` exposure, least-privilege permissions, Rust owns process/fs/logging. 5. **Declarative over imperative.** Game support is data (TOML/JSON), not code branches. @@ -45,7 +45,7 @@ ## 2. Reference System — D:/Fightcade (Read-Only) -`D:/Fightcade` is the installed Fightcade 2 binary distribution (`VERSION.txt` → `2.1.45`). Per PRD §32–33 it is **read-only reference**. Findings below are observations only; no file is copied into `D:/OpenFight`. +`D:/Fightcade` is the installed Fightcade 2 binary distribution (`VERSION.txt` → `2.1.45`). Per PRD §32–33 it is **read-only reference**. Findings below are observations only; no file is copied into `D:/OpenCade`. ### 2.1 Electron / Nativefier wrapper — `fc2-electron/` @@ -96,7 +96,7 @@ fbneo-training-mode/ resources/{info,replay,stick}/ ``` -Module contract (the interface OpenFight's adapter may reference as a pattern, not copy): +Module contract (the interface OpenCade's adapter may reference as a pattern, not copy): ```lua -- memory helpers exposed by FBNeo Lua @@ -150,7 +150,7 @@ flowchart TB OBS[Observability placeholder
tracing / metrics / logs] end - subgraph Relay["services/relay — openfight-relay"] + subgraph Relay["services/relay — opencade-relay"] RELAY_WS[WS relay placeholder
TURN-like fallback] end @@ -194,7 +194,7 @@ flowchart TB ## 4. Repository Tree ``` -D:/OpenFight/ +D:/OpenCade/ ├── apps/ │ ├── client/ # Tauri + React + TypeScript │ │ ├── src/ @@ -263,7 +263,7 @@ D:/OpenFight/ │ │ └── launch.rs │ └── Cargo.toml / package.json ├── services/ -│ └── relay/ # openfight-relay — placeholder (WS relay / future TURN) +│ └── relay/ # opencade-relay — placeholder (WS relay / future TURN) │ ├── src/main.rs │ ├── Cargo.toml │ └── Dockerfile @@ -287,7 +287,7 @@ D:/OpenFight/ package.json ``` -All paths are relative to `D:/OpenFight`. No file from `D:/Fightcade` appears in this tree. +All paths are relative to `D:/OpenCade`. No file from `D:/Fightcade` appears in this tree. --- @@ -297,13 +297,13 @@ All paths are relative to `D:/OpenFight`. No file from `D:/Fightcade` appears in - **B (modular monorepo)** provides the package boundaries (`protocol`, `emulator-sdk`, `game-definitions`, `networking`, `shared`, `adapters/fbneo`) and the `apps/*` / `services/*` split. This is the dominant structure. - **Graft A (single Axum monolith, Postgres-only, WS in-process relay)** constrains the server: one deployable (`apps/server`), one database (PostgreSQL), no Redis in MVP, WebSocket relay lives inside the Axum process as the fallback path. Keeps ops trivial and eliminates distributed-state bugs early. -- **Graft C (Docker Compose, observability placeholder, openfight-relay placeholder)** adds the deployment and operational shell: `docker/docker-compose.yml` with `openfight-server + postgres + openfight-relay (placeholder)`, structured logging / tracing / metrics placeholders, and a relay service stub that can be promoted to a real TURN server without re-architecture. +- **Graft C (Docker Compose, observability placeholder, opencade-relay placeholder)** adds the deployment and operational shell: `docker-compose.yml` at root with `db + opencade-server` active and `opencade-relay` commented as placeholder, structured logging / tracing / metrics placeholders, and a relay service stub that can be promoted to a real TURN server without re-architecture. Consequences: -- MVP has **three containers**: `openfight-server`, `postgres`, `openfight-relay` (the last is a no-op/echo placeholder behind the same `/ws` fallback; peers prefer direct UDP). +- MVP has **two active containers**: `db` (`postgres:16-alpine`) + `opencade-server` on `8080`; `opencade-relay` is present as a commented placeholder behind the same `/ws` fallback (peers prefer direct UDP) until M6. - No Redis, no separate signaling service, no message queue in MVP. If load demands it, the WS relay and presence can be extracted _behind the same protocol_ — envelope versioning guarantees compatibility. -- `services/relay` is a real crate/binary from day one so `docker-compose.yml` and CI already wire it; its implementation is `TODO(relay): WS echo fallback` until M5. +- `services/relay` is a real crate/binary from day one so `docker-compose.yml` and CI can wire it; its implementation is `TODO(relay): WS echo fallback` until M5/M6 (currently commented in compose). --- @@ -313,15 +313,15 @@ Tauri replaces the Electron 8 + Nativefier 8.0.7 wrapper entirely. The web conte ### 6.1 Tauri responsibilities (Rust layer — `src-tauri/src/`) -| Concern | Implementation | -| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Process launch** | `std::process::Command` with explicit `arg` list, no `shell`, no string interpolation. `Command::new(path).args(sanitizedArgs)` only. Path is validated against an allow-list (adapter-provided binary). | -| **Filesystem** | Scoped FS via `tauri-plugin-fs` with capability grants. Allowed: read `emulator//ROMs/**`, read/write `config/**`, `savestates/**`, logs. Denied: everything else by default. | -| **Logging** | `tauri-plugin-log` → rotating file in OS log dir + console. Structured fields: `request_id`, `room_id`, `adapter`. | -| **Permissions** | `tauri.conf.json` + `capabilities/` — **no `shell` plugin**. No `http` to arbitrary hosts — only `https:///api/v1` and `wss:///ws`. | -| **Diagnostics** | Commands: `diagnose_roms`, `diagnose_network`, `diagnose_adapter`, `get_logs`. Each returns a serializable report (see §6.4). | -| **Updates** | `tauri-plugin-updater` (placeholder, disabled in MVP dev). | -| **Security** | CSP in `tauri.conf.json`, `dangerousUseHttpScheme` off, `withGlobalTauri` off in production. | +| Concern | Implementation | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Process launch** | `std::process::Command` with explicit `arg` list, no `shell`, no string interpolation. `Command::new(path).args(sanitizedArgs)` only. Path is validated against an allow-list (adapter-provided binary). | +| **Filesystem** | Scoped FS via `tauri-plugin-fs` with capability grants. Allowed: read `emulator//ROMs/**`, read/write `config/**`, `savestates/**`, logs. Denied: everything else by default. | +| **Logging** | `tauri-plugin-log` → rotating file in OS log dir + console. Structured fields: `request_id`, `room_id`, `adapter`. | +| **Permissions** | `tauri.conf.json` + `capabilities/` — minimal `shell` (only `shell.open` true, `shell.all` false). No `http` to arbitrary hosts — only `https:///api/v1` and `wss:///ws`. | +| **Diagnostics** | Commands: `diagnose_roms`, `diagnose_network`, `diagnose_adapter`, `get_logs`. Each returns a serializable report (see §6.4). | +| **Updates** | `tauri-plugin-updater` (placeholder, disabled in MVP dev). | +| **Security** | CSP in `tauri.conf.json`, `dangerousUseHttpScheme` off, `withGlobalTauri` off in production. | > **No shell, no injection.** Every emulator launch goes through `packages/emulator-sdk` `launch()` which escapes/validates before reaching `Command`. See §11. @@ -407,7 +407,7 @@ Every frame is a **versioned envelope** (see §8): ```json { "type": "presence.update", - "version": 1, + "version": "1.0", "request_id": "01H...", "timestamp": "2026-08-22T00:00:00.000Z", "payload": { "status": "online", "gameId": "kof98" } @@ -633,8 +633,8 @@ All three types carry `envelope.version = 1` and are validated: `roomId` must ex 1. **Direct UDP** — try peer's advertised `host:port` from signaling. 2. **Hole punching** — simultaneous `UDP` sends to each peer's reflexive address; 3 attempts, 500 ms apart. -3. **STUN** — query configured STUN server (`stun:stun.openfight.local:3478` in dev, public STUN in prod) to learn reflexive address; re-attempt. -4. **WS relay fallback** — keep `/ws` open; server forwards frames in-process. In MVP this _is_ the relay. `services/relay` (openfight-relay) is a placeholder WS echo that will become a real TURN relay post-MVP without protocol change. +3. **STUN** — query configured STUN server (`stun:stun.opencade.local:3478` in dev, public STUN in prod) to learn reflexive address; re-attempt. +4. **WS relay fallback** — keep `/ws` open; server forwards frames in-process. In MVP this _is_ the relay. `services/relay` (opencade-relay) is a placeholder WS echo that will become a real TURN relay post-MVP without protocol change. The client reports `natType` (`open | cone | symmetric | blocked`) from the STUN binding response. UI shows `Network Test` with this value. @@ -690,7 +690,7 @@ export interface EmulatorAdapter { } ``` -`detect()` scans well-known locations (`emulator/fbneo/fcadefbneo.exe` on Windows, `~/.openfight/emulators/fbneo/` on all platforms, plus user-configured path in Settings). `getVersion()` parses the binary's version string or a sidecar `version.txt`. +`detect()` scans well-known locations (`emulator/fbneo/fcadefbneo.exe` on Windows, `~/.opencade/emulators/fbneo/` on all platforms, plus user-configured path in Settings). `getVersion()` parses the binary's version string or a sidecar `version.txt`. ### 11.2 Safe launch @@ -823,65 +823,60 @@ Placeholder in MVP, real wiring without re-architecture. --- -## 16. Deployment — docker/ +## 16. Deployment — docker-compose.yml -### 16.1 docker-compose.yml +### 16.1 docker-compose.yml (root) + +Canonical compose is `docker-compose.yml` at repo root (mirrored under `docker/` if present). Active services: `db` (`postgres:16-alpine`) + `opencade-server` on `8080`. `opencade-relay` is commented as placeholder until `services/relay` implements the TURN relay (M6). ```yaml -# docker/docker-compose.yml +# docker-compose.yml — active +name: opencade services: - postgres: + db: image: postgres:16-alpine environment: - POSTGRES_DB: openfight - POSTGRES_USER: openfight - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?} + POSTGRES_USER: opencade + POSTGRES_PASSWORD: opencade + POSTGRES_DB: opencade + ports: ["5432:5432"] volumes: - pgdata:/var/lib/postgresql/data - - ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro - ports: ["5432:5432"] healthcheck: - test: ["CMD-SHELL", "pg_isready -U openfight"] + test: ["CMD-SHELL", "pg_isready -U opencade -d opencade"] interval: 5s retries: 10 - openfight-server: + opencade-server: build: - context: .. - dockerfile: apps/server/Dockerfile + context: . + dockerfile: ./apps/server/Dockerfile environment: - DATABASE_URL: postgres://openfight:${POSTGRES_PASSWORD}@postgres:5432/openfight - RUST_LOG: ${RUST_LOG:-info} - BIND_ADDR: "0.0.0.0:3000" - ports: ["3000:3000"] + DATABASE_URL: postgres://opencade:opencade@db:5432/opencade + RUST_LOG: info + PORT: 8080 + ports: ["8080:8080"] depends_on: - postgres: { condition: service_healthy } + db: { condition: service_healthy } healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] - interval: 5s - retries: 10 + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"] - openfight-relay: - build: - context: .. - dockerfile: services/relay/Dockerfile - environment: - RUST_LOG: ${RUST_LOG:-info} - BIND_ADDR: "0.0.0.0:3478" - SERVER_URL: http://openfight-server:3000 - ports: ["3478:3478", "3478:3478/udp"] - depends_on: [openfight-server] - # MVP: placeholder — logs and echoes WS relay fallback; no TURN yet - restart: unless-stopped + # opencade-relay: TURN/relay placeholder for future M6. + # Uncomment when services/relay implements the relay binary. + # relay: + # image: opencade-relay:local + # ports: ["3478:3478", "3478:3478/udp"] volumes: pgdata: ``` +Archival example with explicit relay (when enabled) listens on `3478`/`3478/udp` and proxies `SERVER_URL: http://opencade-server:8080`; until then in-process WS relay in `apps/server` is the fallback (`services/relay` placeholder, promoted to TURN later). + ### 16.2 Dockerfiles - `apps/server/Dockerfile` — `rust:1.78-slim` builder → `debian:bookworm-slim` runtime, `sqlx migrate run` on start. -- `services/relay/Dockerfile` — same pattern, binary `openfight-relay`. +- `services/relay/Dockerfile` — same pattern, binary `opencade-relay`. No `docker-compose.override.yml` in repo; devs create it locally if needed. @@ -891,7 +886,7 @@ No `docker-compose.override.yml` in repo; devs create it locally if needed. | Phase | Name | Exit Criteria | | ------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **M0** | Bootstrap | Monorepo builds (`cargo check`, `pnpm build`), `docker compose up` brings `postgres` + `openfight-server` (empty), `GET /health` returns `ok`, `research/` exists and is gitignored from shipping artifacts. | +| **M0** | Bootstrap | Monorepo builds (`cargo check`, `pnpm build`), `docker compose up` brings `postgres` + `opencade-server` (empty), `GET /health` returns `ok`, `research/` exists and is gitignored from shipping artifacts. | | **M1** | Protocol & DB | `packages/protocol` envelope round-trips Rust↔TS, migrations create all tables in §9, seed from `game-definitions` inserts ≥1 game, `cargo test` + `pnpm test` pass. | | **M2** | Auth & REST | `POST /auth/register`, `/login`, `/logout`, `GET /auth/me` work with Argon2id + opaque sessions, `GET /games`, `/servers`, `/lobbies` return seeded data, auth middleware rejects unauthenticated calls with `401`, rate limiting enforced. | | **M3** | Client shell | Tauri app launches, React Router renders all routes in §6.2 with mocked data, `tauri.conf.json` has no `shell` permission, `diagnose_*` commands return stub reports, `pnpm tauri dev` works on Windows. | @@ -956,7 +951,7 @@ research/ - **Errors**: Rust `thiserror` + `anyhow` at boundaries; TS `Result` via `neverthrow` or equivalent. Never `unwrap()` on user input. - **Testing**: `cargo test` (unit + `sqlx` integration with `#[sqlx::test]`), `vitest` (client), `playwright` for critical flows (login → lobby → challenge) — emulators mocked, no binary in CI. - **Config**: `dotenvy` for server, `tauri.conf.json` for client. No hardcoded URLs; `VITE_SERVER_URL` / `SERVER_URL` env. -- **Paths**: Windows dev uses `D:/OpenFight`; code uses `PathBuf` / `path.join` — never string-concatenated separators. +- **Paths**: Windows dev uses `D:/OpenCade`; code uses `PathBuf` / `path.join` — never string-concatenated separators. --- diff --git a/docs/NEXT_IMPLEMENTATIONS.md b/docs/NEXT_IMPLEMENTATIONS.md index eb1b5e6..fd21dfb 100644 --- a/docs/NEXT_IMPLEMENTATIONS.md +++ b/docs/NEXT_IMPLEMENTATIONS.md @@ -1,4 +1,4 @@ -# OpenFight Next Implementations +# OpenCade Next Implementations > Reviewed: 2026-08-23 > Scope: `README.md`, `AGENTS.md`, `docs/ARCHITECTURE.md`, clean-room guardrails, @@ -110,7 +110,7 @@ working lobby and launcher without producing netplay. ### Pricing test -Not applicable. OpenFight has no evidenced monetization or active usage baseline. The useful test is +Not applicable. OpenCade has no evidenced monetization or active usage baseline. The useful test is adoption: can 20 community testers complete 10 cross-machine matches with at least 80% successful connection-and-launch attempts? Monetization should be reconsidered only after that signal exists. @@ -125,7 +125,7 @@ connection-and-launch attempts? Monetization should be reconsidered only after t - **Competition:** proprietary incumbent behavior creates room for transparency and self-hosting; competitor timing is not assessed here and should not be invented from code. - **Complementors:** external emulators, public rollback literature, Docker, and community-operated - servers reduce how much OpenFight must own. + servers reduce how much OpenCade must own. - **Constraint:** emulator integration is the underpriced force. Matchmaking is conventional; producing a legal, maintainable netplay seam is the hard differentiator. @@ -147,7 +147,7 @@ contract-tested match kernel is more valuable than broad but disconnected featur ### Jobs: category definition -“OpenFight is the self-hostable, clean-room arcade netplay stack whose entire match lifecycle is +“OpenCade is the self-hostable, clean-room arcade netplay stack whose entire match lifecycle is executable and auditable.” This makes feature-count comparison less important. To make the statement true, say **no** to route @@ -318,7 +318,7 @@ observed alpha failure rates. | Cause of failure | Mitigation now | | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| FBNeo can launch a ROM but cannot consume OpenFight's session/input transport | Gate 0 before further product work; use a mock adapter and require a documented real-adapter path | +| FBNeo can launch a ROM but cannot consume OpenCade's session/input transport | Gate 0 before further product work; use a mock adapter and require a documented real-adapter path | | Stub success responses hide schema and state-machine failures | Remove fallbacks; test the composed router against migrated Postgres | | WebSocket signaling is mistaken for a suitable rollback data plane | Separate control/data contracts and measure the data plane independently | | Protocol copies drift across Rust, generated TS, and handwritten TS | One schema/type source plus a generation-diff CI gate | diff --git a/docs/adr/0001-proof-of-match-boundaries.md b/docs/adr/0001-proof-of-match-boundaries.md index 1a6939e..6ea63eb 100644 --- a/docs/adr/0001-proof-of-match-boundaries.md +++ b/docs/adr/0001-proof-of-match-boundaries.md @@ -6,7 +6,7 @@ ## Context -OpenFight currently specifies matchmaking/signaling and emulator launch as separate subsystems. A +OpenCade currently specifies matchmaking/signaling and emulator launch as separate subsystems. A successful signaling exchange does not itself make an emulator consume peer input, and launching an emulator with a ROM path does not make it join a synchronized match. Treating WebSocket signaling as the rollback transport would also couple latency-sensitive input delivery to the control plane. @@ -20,12 +20,12 @@ system must follow `research/GUARDRAILS.md` before it informs an adapter impleme ## Decision -OpenFight separates three boundaries. +OpenCade separates three boundaries. ### Control plane The Axum server owns authentication, challenges, room membership, room-state transitions, presence, -and exchange of transport candidates. Control messages use the versioned OpenFight envelope. +and exchange of transport candidates. Control messages use the versioned OpenCade envelope. The control plane never interprets game inputs. It may authorize creation of a relay allocation, but the allocation belongs to the data plane. @@ -36,7 +36,7 @@ the allocation belongs to the data plane. deterministic in-memory transport used by tests. The next implementation is direct UDP for LAN testing. Hole punching, STUN, and relay transports implement the same interface later. -The transport deals in OpenFight input frames, not SDP and not emulator-specific packets. A frame +The transport deals in OpenCade input frames, not SDP and not emulator-specific packets. A frame contains a monotonically increasing frame number, player identity, and opaque input bytes with a strict size limit. @@ -64,10 +64,10 @@ to an emulator process. An emulator integration is considered netplay-capable only when it can satisfy one of these public, documented contracts: -1. consume OpenFight frames through an original sidecar/plugin interface; +1. consume OpenCade frames through an original sidecar/plugin interface; 2. accept documented native netplay endpoint arguments; or 3. expose a permissively licensed public API that an adapter can call without linking incompatible - code into OpenFight. + code into OpenCade. Merely launching an emulator is `local_play` capability, not `netplay` capability. The adapter API must report capabilities so the UI and server never promise a match mode the adapter cannot provide. diff --git a/docs/alpha/LAN_TEST.md b/docs/alpha/LAN_TEST.md index a9eaca2..6fa54a1 100644 --- a/docs/alpha/LAN_TEST.md +++ b/docs/alpha/LAN_TEST.md @@ -1,6 +1,6 @@ # Proof-of-Match LAN test -Use this script only after the automated suite passes. It tests OpenFight's own control plane and +Use this script only after the automated suite passes. It tests OpenCade's own control plane and direct UDP frame transport; it does not claim FBNeo netplay support. ## Before the session diff --git a/docs/alpha/MATCH_REPORT_TEMPLATE.md b/docs/alpha/MATCH_REPORT_TEMPLATE.md index aff6d8e..61cd5ba 100644 --- a/docs/alpha/MATCH_REPORT_TEMPLATE.md +++ b/docs/alpha/MATCH_REPORT_TEMPLATE.md @@ -2,7 +2,7 @@ - Date/time (UTC): - Tester aliases: -- OpenFight revision: +- OpenCade revision: - Windows versions: - Game id: - Emulator adapter/version: diff --git a/docs/reference-fightcade-install.md b/docs/reference-fightcade-install.md index 2f652b5..8e21275 100644 --- a/docs/reference-fightcade-install.md +++ b/docs/reference-fightcade-install.md @@ -1,8 +1,8 @@ -# Repository Guidelines +# Reference — D:/Fightcade Install (Read-Only) -## Project Overview +> **Read-only reference.** `D:/Fightcade` (Fightcade 2 v2.1.45) is the installed proprietary distribution described here for observation only. No file from `D:/Fightcade` is copied into `D:/OpenCade`. See `docs/ARCHITECTURE.md §2` and `research/GUARDRAILS.md`. -Fightcade 2 (v2.1.45) — online matchmaking + GGPO rollback netplay for retro arcade/console games. This directory is the **installed binary distribution** (Windows), not a source monorepo. Frontend is an Electron/Nativefier wrapper around `https://web.fightcade.com`; matchmaking launches local emulator binaries with synchronized inputs. CHangelog spans client, FcadeFBNeo (0.2.97.44), Flycast Dojo (6.46), and frontend game definitions. +## Project Overview ## Architecture & Data Flow diff --git a/package.json b/package.json index a5ef7a4..ca44e11 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "name": "openfight", + "name": "opencade", "private": true, "version": "0.1.0", "packageManager": "pnpm@9.15.9", - "description": "OpenFight monorepo root", + "description": "OpenCade monorepo root", "scripts": { "format:check": "prettier --check .", "format": "prettier --write .", "build": "pnpm -r --if-present build", - "typecheck": "pnpm --filter @openfight/protocol build && pnpm -r --if-present typecheck", + "typecheck": "pnpm --filter @opencade/protocol build && pnpm -r --if-present typecheck", "test": "pnpm -r --if-present test" }, "devDependencies": { diff --git a/packages/emulator-sdk/Cargo.lock b/packages/emulator-sdk/Cargo.lock index a27dd1b..884b977 100644 --- a/packages/emulator-sdk/Cargo.lock +++ b/packages/emulator-sdk/Cargo.lock @@ -3,7 +3,7 @@ version = 3 [[package]] -name = "openfight-emulator-sdk" +name = "opencade-emulator-sdk" version = "0.1.0" dependencies = [ "serde", diff --git a/packages/emulator-sdk/Cargo.toml b/packages/emulator-sdk/Cargo.toml index 23f71a4..6d2f9ec 100644 --- a/packages/emulator-sdk/Cargo.toml +++ b/packages/emulator-sdk/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "openfight-emulator-sdk" +name = "opencade-emulator-sdk" version = "0.1.0" edition = "2021" -description = "OpenFight emulator adapter SDK — trait for pluggable emulator backends" +description = "OpenCade emulator adapter SDK — trait for pluggable emulator backends" license = "Apache-2.0" -repository = "https://github.com/openfight/openfight" +repository = "https://github.com/opencade/opencade" rust-version = "1.78" [dependencies] diff --git a/packages/emulator-sdk/src/lib.rs b/packages/emulator-sdk/src/lib.rs index 0f0f710..1969721 100644 --- a/packages/emulator-sdk/src/lib.rs +++ b/packages/emulator-sdk/src/lib.rs @@ -1,4 +1,4 @@ -//! OpenFight emulator adapter SDK — pluggable backends (FBNeo, Flycast, etc.) +//! OpenCade emulator adapter SDK — pluggable backends (FBNeo, Flycast, etc.) use serde::{Deserialize, Serialize}; use std::ffi::OsString; @@ -319,7 +319,7 @@ mod tests { #[test] fn safe_launch_preserves_paths_with_spaces_and_rejects_escape() { let fixture = std::env::temp_dir().join(format!( - "openfight sdk fixture {} {}", + "opencade sdk fixture {} {}", std::process::id(), std::thread::current().name().unwrap_or("test") )); diff --git a/packages/game-definitions/Cargo.toml b/packages/game-definitions/Cargo.toml index 5778374..2505563 100644 --- a/packages/game-definitions/Cargo.toml +++ b/packages/game-definitions/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "openfight-game-definitions" +name = "opencade-game-definitions" version = "0.1.0" edition = "2021" -description = "OpenFight declarative game definitions (TOML) — schema_version 1" +description = "OpenCade declarative game definitions (TOML) — schema_version 1" license = "Apache-2.0" -repository = "https://github.com/openfight/openfight" +repository = "https://github.com/opencade/opencade" rust-version = "1.78" [dependencies] diff --git a/packages/game-definitions/package.json b/packages/game-definitions/package.json index d189063..30d3000 100644 --- a/packages/game-definitions/package.json +++ b/packages/game-definitions/package.json @@ -1,7 +1,7 @@ { - "name": "@openfight/game-definitions", + "name": "@opencade/game-definitions", "version": "0.1.0", - "description": "OpenFight declarative game definitions — TOML catalog + JSON schema + TS loader", + "description": "OpenCade declarative game definitions — TOML catalog + JSON schema + TS loader", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/game-definitions/schema/game.schema.json b/packages/game-definitions/schema/game.schema.json index 58a7cf4..833406e 100644 --- a/packages/game-definitions/schema/game.schema.json +++ b/packages/game-definitions/schema/game.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "OpenFight Game Definition", + "title": "OpenCade Game Definition", "description": "Declarative per-game TOML converted to JSON schema for validation", "type": "object", "required": ["schema_version", "id", "name", "emulator"], diff --git a/packages/networking/Cargo.toml b/packages/networking/Cargo.toml index 31d0fea..d77f57b 100644 --- a/packages/networking/Cargo.toml +++ b/packages/networking/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "openfight-networking" +name = "opencade-networking" version = "0.1.0" edition = "2021" -description = "OpenFight networking — signaling, NAT traversal, relay (placeholder for M1)" +description = "OpenCade networking — signaling, NAT traversal, relay (placeholder for M1)" license = "Apache-2.0" -repository = "https://github.com/openfight/openfight" +repository = "https://github.com/opencade/opencade" rust-version = "1.78" [dependencies] @@ -14,4 +14,4 @@ thiserror = "1" tokio = { version = "1", features = ["macros", "net", "rt", "sync", "time"] } [dev-dependencies] -openfight-emulator-sdk = { path = "../emulator-sdk" } +opencade-emulator-sdk = { path = "../emulator-sdk" } diff --git a/packages/networking/src/lib.rs b/packages/networking/src/lib.rs index 2c77dea..e0b191f 100644 --- a/packages/networking/src/lib.rs +++ b/packages/networking/src/lib.rs @@ -50,7 +50,7 @@ pub enum TransportError { } /// Connected UDP transport for LAN proof runs. Authentication and endpoint negotiation remain in -/// the control plane; this type only carries bounded OpenFight input frames. +/// the control plane; this type only carries bounded OpenCade input frames. pub struct UdpPeer { socket: tokio::net::UdpSocket, } @@ -136,7 +136,7 @@ impl InMemoryPeer { #[cfg(test)] mod tests { use super::*; - use openfight_emulator_sdk::{ + use opencade_emulator_sdk::{ EmulatorAdapter, MatchDescriptor, MockAdapter, PeerRole, TransportKind, }; diff --git a/packages/protocol/Cargo.lock b/packages/protocol/Cargo.lock index 904d1ab..4904cab 100644 --- a/packages/protocol/Cargo.lock +++ b/packages/protocol/Cargo.lock @@ -175,7 +175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "openfight-protocol" +name = "opencade-protocol" version = "0.1.0" dependencies = [ "chrono", diff --git a/packages/protocol/Cargo.toml b/packages/protocol/Cargo.toml index a6a710a..62f87e2 100644 --- a/packages/protocol/Cargo.toml +++ b/packages/protocol/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "openfight-protocol" +name = "opencade-protocol" version = "0.1.0" edition = "2021" -description = "OpenFight versioned protocol envelope (shared client/server)" +description = "OpenCade versioned protocol envelope (shared client/server)" license = "Apache-2.0" -repository = "https://github.com/openfight/openfight" +repository = "https://github.com/opencade/opencade" rust-version = "1.78" [dependencies] serde = { version = "1", features = ["derive"] } diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 642f4b2..036f285 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,7 +1,7 @@ { - "name": "@openfight/protocol", + "name": "@opencade/protocol", "version": "0.1.0", - "description": "OpenFight versioned protocol envelope (TypeScript mirror of Rust crate)", + "description": "OpenCade versioned protocol envelope (TypeScript mirror of Rust crate)", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/protocol/src/lib.rs b/packages/protocol/src/lib.rs index 8cada3d..784fb05 100644 --- a/packages/protocol/src/lib.rs +++ b/packages/protocol/src/lib.rs @@ -1,4 +1,4 @@ -//! OpenFight protocol — versioned envelope for REST + WebSocket messages. +//! OpenCade protocol — versioned envelope for REST + WebSocket messages. //! Rust is the source of truth. TypeScript bindings under `src/generated/` are generated by //! `ts-rs` and checked for drift in CI. //! diff --git a/packages/shared/Cargo.toml b/packages/shared/Cargo.toml index fff58b0..d3369c3 100644 --- a/packages/shared/Cargo.toml +++ b/packages/shared/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "openfight-shared" +name = "opencade-shared" version = "0.1.0" edition = "2021" -description = "OpenFight shared utilities (placeholder)" +description = "OpenCade shared utilities (placeholder)" license = "Apache-2.0" -repository = "https://github.com/openfight/openfight" +repository = "https://github.com/opencade/opencade" rust-version = "1.78" [dependencies] diff --git a/packages/shared/src/lib.rs b/packages/shared/src/lib.rs index c00ec5e..564422d 100644 --- a/packages/shared/src/lib.rs +++ b/packages/shared/src/lib.rs @@ -1 +1 @@ -// openfight-shared placeholder. +// opencade-shared placeholder. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99e6993..25a621a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,7 +14,7 @@ importers: apps/client: dependencies: - '@openfight/protocol': + '@opencade/protocol': specifier: workspace:* version: link:../../packages/protocol '@tanstack/react-query': diff --git a/research/GUARDRAILS.md b/research/GUARDRAILS.md index 2ed9b5d..24f9a4f 100644 --- a/research/GUARDRAILS.md +++ b/research/GUARDRAILS.md @@ -1,4 +1,4 @@ -# OpenFight — Clean-Room Guardrails +# OpenCade — Clean-Room Guardrails > **Read this before touching `D:/Fightcade` or writing any protocol, networking, or emulator code.** > @@ -9,7 +9,7 @@ ## 1. Purpose -OpenFight is an **independent, open-source reimplementation** inspired by Fightcade, not a copy of it. The existing Fightcade installation at `D:/Fightcade` is a **research / reference environment** to be treated as a black box. We document what we observe and then write original code from those observations plus public specifications. +OpenCade is an **independent, open-source reimplementation** inspired by Fightcade, not a copy of it. The existing Fightcade installation at `D:/Fightcade` is a **research / reference environment** to be treated as a black box. We document what we observe and then write original code from those observations plus public specifications. The guardrails ensure: @@ -78,8 +78,8 @@ High | Medium | Low — and why. ## Implementation Implication - -Example: "OpenFight requires a persistent WebSocket after authentication + +Example: "OpenCade requires a persistent WebSocket after authentication for presence/chat/signaling; the exact frame format is TBD via public design." ## References @@ -93,7 +93,7 @@ for presence/chat/signaling; the exact frame format is TBD via public design." ### Step 3 — Design -- In `docs/` (ADR) or the PR description, propose an OpenFight design that cites **only** your observation note and public specs. +- In `docs/` (ADR) or the PR description, propose an OpenCade design that cites **only** your observation note and public specs. - Do not reference proprietary file paths, function names, or decompiled output. - Get maintainer feedback before coding non-trivial protocol changes. @@ -120,7 +120,7 @@ The following must **never** appear in any commit, PR, issue attachment, or rele | **Electron wrapper source** | `fc2-electron/resources/app/lib/main.js`, `fc2-electron/resources/app/lib/static/login.js`, any `*.asar` unpack | Proprietary client source | | **ROMs / disk images** | `*.zip`, `*.7z`, `*.chd`, `*.iso`, `*.cue`, `*.gdi` containing game data; `emulator/fbneo_roms.json` / `snes9x_roms.json` etc. copied verbatim | Copyrighted game content | | **Proprietary assets** | `assets/*.wav` (e.g. `kof98-challenge.wav`, `sfiii3-challenge.wav`), `assets/*.ico`, `assets/icon-128.png` from Fightcade | Proprietary media | -| **Credentials / secrets** | API keys, tokens, passwords, `.env` with real values, `%APPDATA%/OpenFight/` dumps, WebSocket auth headers with secrets | Security | +| **Credentials / secrets** | API keys, tokens, passwords, `.env` with real values, `%APPDATA%/OpenCade/` dumps, WebSocket auth headers with secrets | Security | | **Decompiled output** | Any file derived from decompiling / transcribing `fcade.exe`, `ggponet.dll`, or `lib/main.js` | Clean-room violation | | **Research binaries** | Anything under `research/binaries/` | Must stay local; directory is gitignored | @@ -133,7 +133,7 @@ ROMs/ roms/ rom/ *.zip *.chd *.iso assets/*.wav *.wav fcade*.exe ggponet.dll kailleraclient.dll -%APPDATA%/OpenFight/ +%APPDATA%/OpenCade/ .env *.pem *.key *.log ``` @@ -158,7 +158,7 @@ These exact files from `D:/Fightcade` (non-exhaustive): | Category | Allowed | Conditions | | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Original code** | All Rust / TypeScript you write from scratch for OpenFight | Must be your original work or properly attributed; licensed Apache-2.0 | +| **Original code** | All Rust / TypeScript you write from scratch for OpenCade | Must be your original work or properly attributed; licensed Apache-2.0 | | **Permissively licensed deps** | Crates / npm packages under Apache-2.0, MIT, BSD, ISC | Must be declared in `Cargo.toml` / `package.json`; `cargo deny` / `pnpm audit` clean | | **Public specifications** | GGPO rollback concepts (public docs), STUN **RFC 5389**, TURN **RFC 5766**, WebSocket **RFC 6455**, HTTP, etc. | Cite the RFC / public doc; do not cite proprietary source as authority | | **FBNeo training-mode Lua — shape reference only** | The _structure_ of open-source Lua training-mode scripts (e.g. function names, menu shape) as a reference for adapter design | **Shape only, under its own license.** Do not copy implementation. Link to the upstream repo and note the license. Prefer re-deriving from public FBNeo docs | @@ -191,7 +191,7 @@ When a behaviour can be explained by a public spec, cite the spec as the source Suggested importer invocation (local only): ```powershell -# From D:/OpenFight +# From D:/OpenCade cargo run -p game-definitions-importer -- --source "D:/Fightcade/emulator" --out "packages/game-definitions/games" --dry-run # Review the diff, then run without --dry-run for approved entries only ``` @@ -235,7 +235,7 @@ fi ## 7. Dependency Hygiene (Apache-2.0) -- All first-party OpenFight code is **Apache-2.0**. +- All first-party OpenCade code is **Apache-2.0**. - New dependencies must be **Apache-2.0, MIT, BSD, or ISC** compatible. Avoid GPL / AGPL / SSPL unless explicitly approved with a compatibility note. - Run before each PR that adds a dependency: From 2dcdf36f317504dbd4744bab8cee8868ab04744c Mon Sep 17 00:00:00 2001 From: Zendevve Date: Mon, 24 Aug 2026 08:49:38 +0800 Subject: [PATCH 2/3] fix(emulator-sdk): sanitize thread name for Windows fixture path - replaces :: and non-alnum in thread name to avoid InvalidFilename 123 - preserves spaces in ROM/emulator paths, rejects traversal Outside Fixes safe_launch_preserves_paths_with_spaces_and_rejects_escape on win32 --- packages/emulator-sdk/src/lib.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/emulator-sdk/src/lib.rs b/packages/emulator-sdk/src/lib.rs index 1969721..cee582a 100644 --- a/packages/emulator-sdk/src/lib.rs +++ b/packages/emulator-sdk/src/lib.rs @@ -318,10 +318,18 @@ mod tests { #[test] fn safe_launch_preserves_paths_with_spaces_and_rejects_escape() { + let raw_thread = std::thread::current() + .name() + .unwrap_or("test") + .replace("::", "_") + .replace( + |c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-', + "_", + ); let fixture = std::env::temp_dir().join(format!( - "opencade sdk fixture {} {}", + "opencade-sdk-fixture-{}-{}", std::process::id(), - std::thread::current().name().unwrap_or("test") + raw_thread )); let emulator_root = fixture.join("emulator root"); let rom_root = fixture.join("ROMs"); From 7860702390fd03b7ba4154df97e7e65b8d382a40 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Sun, 23 Aug 2026 21:52:13 -0300 Subject: [PATCH 3/3] feat: add two-node LAN match runner --- Cargo.lock | 234 +++++------- apps/client/src-tauri/Cargo.lock | 13 + apps/client/src-tauri/Cargo.toml | 2 + .../src-tauri/src/commands/match_probe.rs | 338 +++++++++++++++++ apps/client/src-tauri/src/commands/mod.rs | 1 + apps/client/src-tauri/src/main.rs | 4 + apps/client/src/App.tsx | 50 ++- apps/client/src/lib/match.test.ts | 67 ++++ apps/client/src/lib/match.ts | 45 +++ apps/client/src/lib/native.ts | 39 ++ apps/client/src/lib/report.test.ts | 35 ++ apps/client/src/lib/report.ts | 26 +- apps/client/src/lib/useLanMatchProbe.ts | 231 ++++++++++++ apps/client/src/routes/Match.tsx | 59 ++- apps/client/src/styles.css | 8 + apps/server/Cargo.toml | 2 +- apps/server/Dockerfile | 10 +- apps/server/src/ws.rs | 122 ++++++- apps/server/tests/api_flow.rs | 75 ++++ docs/IMPLEMENTATION_STATUS.md | 10 +- docs/alpha/LAN_TEST.md | 28 +- packages/networking/Cargo.toml | 2 +- .../src/bin/opencade-match-probe.rs | 104 ++++++ packages/networking/src/lib.rs | 135 ++++++- packages/networking/src/probe.rs | 340 ++++++++++++++++++ .../networking/tests/two_process_probe.rs | 76 ++++ .../src/generated/MatchEndpointPayload.ts | 6 + .../generated/MatchProbeCompletedPayload.ts | 10 + packages/protocol/src/index.ts | 2 + packages/protocol/src/lib.rs | 49 +++ packages/protocol/src/messages.ts | 12 + 31 files changed, 1959 insertions(+), 176 deletions(-) create mode 100644 apps/client/src-tauri/src/commands/match_probe.rs create mode 100644 apps/client/src/lib/match.test.ts create mode 100644 apps/client/src/lib/match.ts create mode 100644 apps/client/src/lib/useLanMatchProbe.ts create mode 100644 packages/networking/src/bin/opencade-match-probe.rs create mode 100644 packages/networking/src/probe.rs create mode 100644 packages/networking/tests/two_process_probe.rs create mode 100644 packages/protocol/src/generated/MatchEndpointPayload.ts create mode 100644 packages/protocol/src/generated/MatchProbeCompletedPayload.ts diff --git a/Cargo.lock b/Cargo.lock index 4a33f08..20bc95f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,19 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.5" @@ -91,7 +78,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", - "base64 0.22.1", + "base64", "bytes", "futures-util", "http", @@ -141,12 +128,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -365,7 +346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -381,9 +362,13 @@ dependencies = [ [[package]] name = "event-listener" -version = "2.5.3" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] [[package]] name = "fastrand" @@ -408,6 +393,12 @@ dependencies = [ "spin", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -526,34 +517,32 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashlink" -version = "0.8.4" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.15.5", ] [[package]] name = "heck" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -dependencies = [ - "unicode-segmentation", -] +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex" @@ -764,11 +753,10 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.27.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ - "cc", "pkg-config", "vcpkg", ] @@ -831,12 +819,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "mio" version = "1.2.2" @@ -848,16 +830,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -924,7 +896,7 @@ version = "0.1.0" dependencies = [ "opencade-emulator-sdk", "serde", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -932,7 +904,7 @@ name = "opencade-emulator-sdk" version = "0.1.0" dependencies = [ "serde", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -943,7 +915,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 1.0.69", "toml", ] @@ -954,7 +926,7 @@ dependencies = [ "opencade-emulator-sdk", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", ] @@ -985,7 +957,7 @@ dependencies = [ "serial_test", "sha2", "sqlx", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-tungstenite", "tower 0.4.13", @@ -1000,9 +972,15 @@ name = "opencade-shared" version = "0.1.0" dependencies = [ "serde", - "thiserror", + "thiserror 1.0.69", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1037,12 +1015,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1257,7 +1229,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1446,6 +1418,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -1476,21 +1451,11 @@ dependencies = [ "der", ] -[[package]] -name = "sqlformat" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" -dependencies = [ - "nom", - "unicode_categories", -] - [[package]] name = "sqlx" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", @@ -1501,38 +1466,33 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "ahash", - "atoi", - "byteorder", + "base64", "bytes", "chrono", "crc", "crossbeam-queue", "either", "event-listener", - "futures-channel", "futures-core", "futures-intrusive", "futures-io", "futures-util", + "hashbrown 0.15.5", "hashlink", - "hex", "indexmap", "log", "memchr", "once_cell", - "paste", "percent-encoding", "serde", "serde_json", "sha2", "smallvec", - "sqlformat", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -1542,22 +1502,22 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", - "syn 1.0.109", + "syn 2.0.119", ] [[package]] name = "sqlx-macros-core" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", @@ -1573,20 +1533,19 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 1.0.109", - "tempfile", + "syn 2.0.119", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64 0.21.7", + "base64", "bitflags", "byteorder", "bytes", @@ -1617,7 +1576,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.20", "tracing", "uuid", "whoami", @@ -1625,12 +1584,12 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64 0.21.7", + "base64", "bitflags", "byteorder", "chrono", @@ -1639,7 +1598,6 @@ dependencies = [ "etcetera", "futures-channel", "futures-core", - "futures-io", "futures-util", "hex", "hkdf", @@ -1657,7 +1615,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.20", "tracing", "uuid", "whoami", @@ -1665,9 +1623,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.7.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", @@ -1681,10 +1639,11 @@ dependencies = [ "log", "percent-encoding", "serde", + "serde_urlencoded", "sqlx-core", + "thiserror 2.0.20", "tracing", "url", - "urlencoding", "uuid", ] @@ -1705,17 +1664,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -1754,7 +1702,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1772,7 +1720,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", ] [[package]] @@ -1786,6 +1743,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "thread_local" version = "1.1.10" @@ -2031,7 +1999,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b44017f9f875786e543595076374b9ef7d13465a518dd93d6ccdbf5b432dde8c" dependencies = [ "chrono", - "thiserror", + "thiserror 1.0.69", "ts-rs-macros", "uuid", ] @@ -2062,7 +2030,7 @@ dependencies = [ "log", "rand", "sha1", - "thiserror", + "thiserror 1.0.69", "utf-8", ] @@ -2099,18 +2067,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode_categories" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" - [[package]] name = "url" version = "2.5.0" @@ -2122,12 +2078,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf-8" version = "0.7.6" @@ -2244,7 +2194,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/apps/client/src-tauri/Cargo.lock b/apps/client/src-tauri/Cargo.lock index 3fb44a4..afea69e 100644 --- a/apps/client/src-tauri/Cargo.lock +++ b/apps/client/src-tauri/Cargo.lock @@ -1684,6 +1684,7 @@ version = "0.1.0" dependencies = [ "opencade-adapter-fbneo", "opencade-emulator-sdk", + "opencade-networking", "serde", "serde_json", "tauri", @@ -1691,6 +1692,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -1701,6 +1703,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "opencade-networking" +version = "0.1.0" +dependencies = [ + "opencade-emulator-sdk", + "serde", + "serde_json", + "thiserror", + "tokio", +] + [[package]] name = "pango" version = "0.15.10" diff --git a/apps/client/src-tauri/Cargo.toml b/apps/client/src-tauri/Cargo.toml index d313eb8..6562f93 100644 --- a/apps/client/src-tauri/Cargo.toml +++ b/apps/client/src-tauri/Cargo.toml @@ -20,6 +20,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" opencade-adapter-fbneo = { path = "../../../adapters/fbneo" } opencade-emulator-sdk = { path = "../../../packages/emulator-sdk" } +opencade-networking = { path = "../../../packages/networking" } +uuid = { version = "1", features = ["v4"] } [features] default = ["custom-protocol"] diff --git a/apps/client/src-tauri/src/commands/match_probe.rs b/apps/client/src-tauri/src/commands/match_probe.rs new file mode 100644 index 0000000..2c70cd5 --- /dev/null +++ b/apps/client/src-tauri/src/commands/match_probe.rs @@ -0,0 +1,338 @@ +use opencade_emulator_sdk::{MatchDescriptor, PeerRole, TransportKind}; +use opencade_networking::{run_match_probe, MatchProbeConfig, MatchProbeReport, UdpPeer}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::future::Future; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::Duration; +use tokio::sync::{watch, Mutex}; +use uuid::Uuid; + +const MAX_ACTIVE_PROBES: usize = 8; + +#[derive(Default)] +pub struct MatchProbeState { + probes: Mutex>, +} + +enum MatchProbeSlot { + Prepared(PreparedProbe), + Running { + run_id: Uuid, + cancel: watch::Sender, + }, +} + +struct PreparedProbe { + peer: UdpPeer, + candidate: MatchEndpointCandidate, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MatchEndpointCandidate { + pub endpoint: SocketAddr, + pub nonce: String, +} + +#[derive(Debug, Deserialize)] +pub struct ReserveMatchProbeRequest { + pub room_id: String, + pub advertised_host: Option, + pub bind_port: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RunMatchProbeRequest { + pub room_id: String, + pub game_id: String, + pub local_user_id: String, + pub peer_user_id: String, + pub role: PeerRole, + pub peer_endpoint: SocketAddr, + pub peer_nonce: String, + #[serde(default = "default_frame_count")] + pub frame_count: u64, + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u64, +} + +fn default_frame_count() -> u64 { + 60 +} + +fn default_timeout_ms() -> u64 { + 5_000 +} + +#[tauri::command] +pub async fn reserve_match_probe( + state: tauri::State<'_, MatchProbeState>, + request: ReserveMatchProbeRequest, +) -> Result { + validate_room_id(&request.room_id)?; + if let Some(slot) = state.probes.lock().await.get(&request.room_id) { + return match slot { + MatchProbeSlot::Prepared(prepared) => Ok(prepared.candidate.clone()), + MatchProbeSlot::Running { .. } => { + Err("match probe is already running for this room".into()) + } + }; + } + + let bind_address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, request.bind_port.unwrap_or(0))); + let peer = UdpPeer::bind_unconnected(bind_address) + .await + .map_err(|error| error.to_string())?; + let port = peer.local_addr().map_err(|error| error.to_string())?.port(); + let host = match request.advertised_host { + Some(host) if !host.is_unspecified() => host, + Some(_) => return Err("advertised_host must not be unspecified".into()), + None => discover_local_ipv4()?, + }; + let candidate = MatchEndpointCandidate { + endpoint: SocketAddr::from((host, port)), + nonce: Uuid::new_v4().to_string(), + }; + let mut probes = state.probes.lock().await; + if let Some(existing) = probes.get(&request.room_id) { + return match existing { + MatchProbeSlot::Prepared(prepared) => Ok(prepared.candidate.clone()), + MatchProbeSlot::Running { .. } => { + Err("match probe is already running for this room".into()) + } + }; + } + if probes.len() >= MAX_ACTIVE_PROBES { + return Err("too many active match probes; cancel an older room first".into()); + } + probes.insert( + request.room_id, + MatchProbeSlot::Prepared(PreparedProbe { + peer, + candidate: candidate.clone(), + }), + ); + Ok(candidate) +} + +#[tauri::command] +pub async fn run_reserved_match_probe( + state: tauri::State<'_, MatchProbeState>, + request: RunMatchProbeRequest, +) -> Result { + validate_room_id(&request.room_id)?; + let room_id = request.room_id.clone(); + let run_id = Uuid::new_v4(); + let (cancel_tx, cancel_rx) = watch::channel(false); + let prepared = { + let mut probes = state.probes.lock().await; + let slot = probes + .remove(&request.room_id) + .ok_or_else(|| "match probe is not reserved for this room".to_string())?; + let MatchProbeSlot::Prepared(prepared) = slot else { + probes.insert(request.room_id.clone(), slot); + return Err("match probe is already running for this room".into()); + }; + probes.insert( + request.room_id.clone(), + MatchProbeSlot::Running { + run_id, + cancel: cancel_tx, + }, + ); + prepared + }; + let probe = async move { + let descriptor = MatchDescriptor { + room_id: request.room_id, + game_id: request.game_id, + local_user_id: request.local_user_id, + peer_user_id: request.peer_user_id, + role: request.role, + transport: TransportKind::DirectUdp, + local_endpoint: prepared.candidate.endpoint, + peer_endpoint: request.peer_endpoint, + input_delay_frames: 2, + }; + let session_key = combined_session_key(&prepared.candidate.nonce, &request.peer_nonce)?; + let config = MatchProbeConfig::new( + descriptor, + session_key, + request.frame_count, + Duration::from_millis(request.timeout_ms), + ) + .map_err(|error| error.to_string())?; + let peer = prepared + .peer + .connect(request.peer_endpoint) + .await + .map_err(|error| error.to_string())?; + run_match_probe(&peer, &config) + .await + .map_err(|error| error.to_string()) + }; + let result = cancel_or_complete(probe, cancel_rx).await; + remove_running_probe(&state, &room_id, run_id).await; + let report = result?; + tracing::info!( + room_id = %report.room_id, + frames = report.frames_received, + elapsed_ms = report.elapsed_ms, + "LAN match probe completed" + ); + Ok(report) +} + +#[tauri::command] +pub async fn cancel_match_probe( + state: tauri::State<'_, MatchProbeState>, + room_id: String, +) -> Result<(), String> { + validate_room_id(&room_id)?; + cancel_probe(&state, &room_id).await; + Ok(()) +} + +async fn cancel_probe(state: &MatchProbeState, room_id: &str) { + if let Some(MatchProbeSlot::Running { cancel, .. }) = state.probes.lock().await.remove(room_id) { + let _ = cancel.send(true); + } +} + +async fn remove_running_probe(state: &MatchProbeState, room_id: &str, run_id: Uuid) { + let mut probes = state.probes.lock().await; + if matches!( + probes.get(room_id), + Some(MatchProbeSlot::Running { + run_id: current_id, + .. + }) if *current_id == run_id + ) { + probes.remove(room_id); + } +} + +async fn cancel_or_complete(future: F, mut cancel: watch::Receiver) -> Result +where + F: Future>, +{ + if *cancel.borrow() { + return Err("match probe cancelled".into()); + } + tokio::select! { + result = future => result, + _ = cancel.changed() => Err("match probe cancelled".into()), + } +} + +fn validate_room_id(room_id: &str) -> Result<(), String> { + Uuid::parse_str(room_id) + .map(|_| ()) + .map_err(|_| "room_id must be a UUID".into()) +} + +fn discover_local_ipv4() -> Result { + let socket = std::net::UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) + .map_err(|error| format!("failed to inspect local network: {error}"))?; + socket + .connect((Ipv4Addr::new(192, 0, 2, 1), 9)) + .map_err(|error| format!("failed to select a LAN interface: {error}"))?; + match socket + .local_addr() + .map_err(|error| format!("failed to read LAN address: {error}"))? + .ip() + { + IpAddr::V4(address) if !address.is_unspecified() => Ok(address), + _ => Err("no usable IPv4 LAN address was detected".into()), + } +} + +fn combined_session_key(local_nonce: &str, peer_nonce: &str) -> Result { + let local = Uuid::parse_str(local_nonce).map_err(|_| "local nonce is invalid")?; + let peer = Uuid::parse_str(peer_nonce).map_err(|_| "peer nonce is invalid")?; + if local == peer { + return Err("peer nonce must differ from local nonce".into()); + } + let (first, second) = if local.as_bytes() < peer.as_bytes() { + (local, peer) + } else { + (peer, local) + }; + Ok(format!("{first}:{second}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_key_is_independent_of_peer_order() { + let first = "8a1110d5-8dd2-4ad2-9c88-ad9768bc4905"; + let second = "7fa7b981-f41c-4d40-a4c9-d2170067f12f"; + assert_eq!( + combined_session_key(first, second), + combined_session_key(second, first) + ); + } + + #[test] + fn session_key_rejects_duplicate_nonces() { + let nonce = "8a1110d5-8dd2-4ad2-9c88-ad9768bc4905"; + assert!(combined_session_key(nonce, nonce).is_err()); + } + + #[tokio::test] + async fn cancellation_interrupts_pending_probe() { + let (cancel_tx, cancel_rx) = watch::channel(false); + cancel_tx.send(true).expect("receiver remains active"); + let result = cancel_or_complete(std::future::pending::>(), cancel_rx).await; + assert_eq!(result, Err("match probe cancelled".into())); + } + + #[tokio::test] + async fn completed_probe_returns_its_result() { + let (_cancel_tx, cancel_rx) = watch::channel(false); + let result = cancel_or_complete(async { Ok::<_, String>(42) }, cancel_rx).await; + assert_eq!(result, Ok(42)); + } + + #[tokio::test] + async fn cancelling_running_probe_signals_and_removes_it() { + let state = MatchProbeState::default(); + let (cancel, cancel_rx) = watch::channel(false); + state.probes.lock().await.insert( + "room".into(), + MatchProbeSlot::Running { + run_id: Uuid::new_v4(), + cancel, + }, + ); + + cancel_probe(&state, "room").await; + + assert!(*cancel_rx.borrow()); + assert!(!state.probes.lock().await.contains_key("room")); + } + + #[tokio::test] + async fn old_run_cleanup_preserves_newer_run() { + let state = MatchProbeState::default(); + let old_run_id = Uuid::new_v4(); + let new_run_id = Uuid::new_v4(); + let (cancel, _cancel_rx) = watch::channel(false); + state.probes.lock().await.insert( + "room".into(), + MatchProbeSlot::Running { + run_id: new_run_id, + cancel, + }, + ); + + remove_running_probe(&state, "room", old_run_id).await; + + assert!(matches!( + state.probes.lock().await.get("room"), + Some(MatchProbeSlot::Running { run_id, .. }) if *run_id == new_run_id + )); + } +} diff --git a/apps/client/src-tauri/src/commands/mod.rs b/apps/client/src-tauri/src/commands/mod.rs index a3dc93f..565bb65 100644 --- a/apps/client/src-tauri/src/commands/mod.rs +++ b/apps/client/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ pub mod diag; pub mod fs; +pub mod match_probe; pub mod process; diff --git a/apps/client/src-tauri/src/main.rs b/apps/client/src-tauri/src/main.rs index 6c45031..cfa2a0a 100644 --- a/apps/client/src-tauri/src/main.rs +++ b/apps/client/src-tauri/src/main.rs @@ -6,11 +6,15 @@ fn main() { tracing_subscriber::fmt::init(); tauri::Builder::default() .manage(commands::process::ProcessState::default()) + .manage(commands::match_probe::MatchProbeState::default()) .invoke_handler(tauri::generate_handler![ commands::fs::scan_game, commands::process::launch_game, commands::process::stop_game, commands::diag::network_test, + commands::match_probe::reserve_match_probe, + commands::match_probe::run_reserved_match_probe, + commands::match_probe::cancel_match_probe, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 233048d..3abfd99 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -1,8 +1,14 @@ import { useEffect, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { Envelope } from "@opencade/protocol"; +import type { + Envelope, + MatchEndpointPayload, + MatchProbeCompletedPayload, +} from "@opencade/protocol"; import DiagnosticsButton from "./components/DiagnosticsButton"; import { ApiError, api } from "./lib/api"; +import { parseMatchCompletion, parseMatchEndpoint } from "./lib/match"; +import { cancelMatchProbe } from "./lib/native"; import { useSessionStore } from "./lib/store"; import { OpenCadeSocket, type ConnectionState } from "./lib/ws"; import Auth from "./routes/Auth"; @@ -19,6 +25,9 @@ export default function App() { const queryClient = useQueryClient(); const [view, setView] = useState({ name: "games" }); const [connection, setConnection] = useState("idle"); + const [activeSocket, setActiveSocket] = useState(null); + const [peerEndpoint, setPeerEndpoint] = useState(null); + const [peerCompletion, setPeerCompletion] = useState(null); const me = useQuery({ queryKey: ["me", token], queryFn: () => { @@ -37,6 +46,7 @@ export default function App() { useEffect(() => { if (!token) return; const socket = new OpenCadeSocket(API_URL, token, setConnection); + setActiveSocket(socket); const unsubscribe = socket.subscribe((message: Envelope) => { if (message.type.startsWith("challenge.")) { void queryClient.invalidateQueries({ queryKey: ["challenges"] }); @@ -45,11 +55,22 @@ export default function App() { const roomId = roomIdFromPayload(message.payload); if (roomId) setView({ name: "match", roomId }); } + if (message.type === "match.endpoint") { + const endpoint = parseMatchEndpoint(message.payload); + if (endpoint) { + setPeerEndpoint(endpoint); + } + } + if (message.type === "match.probe.completed") { + const completion = parseMatchCompletion(message.payload); + if (completion) setPeerCompletion(completion); + } }); socket.connect(); return () => { unsubscribe(); socket.close(); + setActiveSocket((current) => (current === socket ? null : current)); }; }, [token, queryClient]); @@ -63,19 +84,22 @@ export default function App() { } const logout = async () => { try { + if (view.name === "match") await cancelMatchProbe(view.roomId); await api.logout(token); } finally { clearSession(); } }; + const returnToGames = () => { + if (view.name === "match") void cancelMatchProbe(view.roomId); + setPeerEndpoint(null); + setPeerCompletion(null); + setView({ name: "games" }); + }; return (
- @@ -102,7 +126,19 @@ export default function App() { /> )} {view.name === "match" && ( - setView({ name: "games" })} /> + { + setPeerEndpoint(null); + setPeerCompletion(null); + }} + onDone={returnToGames} + /> )}
diff --git a/apps/client/src/lib/match.test.ts b/apps/client/src/lib/match.test.ts new file mode 100644 index 0000000..e75826f --- /dev/null +++ b/apps/client/src/lib/match.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { RoomPayload } from "@opencade/protocol"; +import { matchParticipants, parseMatchCompletion, parseMatchEndpoint } from "./match"; + +const room = (guestId: string | null = "guest"): RoomPayload => ({ + id: "room-1", + game_id: "sfiii3", + host_id: "host", + guest_id: guestId, + state: "connecting", +}); + +describe("parseMatchEndpoint", () => { + it("accepts the transport candidate fields", () => { + expect( + parseMatchEndpoint({ + room_id: "room-1", + endpoint: "192.168.1.20:42000", + nonce: "nonce-1", + }) + ).toEqual({ + room_id: "room-1", + endpoint: "192.168.1.20:42000", + nonce: "nonce-1", + }); + }); + + it("rejects missing or non-string fields", () => { + expect(parseMatchEndpoint(null)).toBeUndefined(); + expect( + parseMatchEndpoint({ room_id: "room-1", endpoint: 42000, nonce: "nonce-1" }) + ).toBeUndefined(); + expect(parseMatchEndpoint({ room_id: "room-1", endpoint: "127.0.0.1:1" })).toBeUndefined(); + }); +}); + +describe("matchParticipants", () => { + it("derives complementary host and guest peers", () => { + expect(matchParticipants(room(), "host")).toEqual({ role: "host", peerUserId: "guest" }); + expect(matchParticipants(room(), "guest")).toEqual({ role: "guest", peerUserId: "host" }); + }); + + it("rejects incomplete rooms and non-members", () => { + expect(matchParticipants(room(null), "host")).toBeUndefined(); + expect(matchParticipants(room(), "outsider")).toBeUndefined(); + }); +}); + +describe("parseMatchCompletion", () => { + it("accepts a peer transcript result", () => { + expect( + parseMatchCompletion({ + room_id: "room-1", + frames_received: 60, + transcript_checksum: "0376c2e852f4fd25", + }) + ).toEqual({ + room_id: "room-1", + frames_received: 60, + transcript_checksum: "0376c2e852f4fd25", + }); + }); + + it("rejects incomplete peer results", () => { + expect(parseMatchCompletion({ room_id: "room-1", frames_received: "60" })).toBeUndefined(); + }); +}); diff --git a/apps/client/src/lib/match.ts b/apps/client/src/lib/match.ts new file mode 100644 index 0000000..2369f40 --- /dev/null +++ b/apps/client/src/lib/match.ts @@ -0,0 +1,45 @@ +import type { + MatchEndpointPayload, + MatchProbeCompletedPayload, + RoomPayload, +} from "@opencade/protocol"; + +export function parseMatchEndpoint(payload: unknown): MatchEndpointPayload | undefined { + if (typeof payload !== "object" || payload === null) return undefined; + const roomId = Reflect.get(payload, "room_id"); + const endpoint = Reflect.get(payload, "endpoint"); + const nonce = Reflect.get(payload, "nonce"); + if (typeof roomId !== "string" || typeof endpoint !== "string" || typeof nonce !== "string") { + return undefined; + } + return { room_id: roomId, endpoint, nonce }; +} + +export function parseMatchCompletion(payload: unknown): MatchProbeCompletedPayload | undefined { + if (typeof payload !== "object" || payload === null) return undefined; + const roomId = Reflect.get(payload, "room_id"); + const framesReceived = Reflect.get(payload, "frames_received"); + const transcriptChecksum = Reflect.get(payload, "transcript_checksum"); + if ( + typeof roomId !== "string" || + typeof framesReceived !== "number" || + typeof transcriptChecksum !== "string" + ) { + return undefined; + } + return { + room_id: roomId, + frames_received: framesReceived, + transcript_checksum: transcriptChecksum, + }; +} + +export function matchParticipants( + room: RoomPayload, + localUserId: string +): { role: "host" | "guest"; peerUserId: string } | undefined { + if (!room.guest_id) return undefined; + if (room.host_id === localUserId) return { role: "host", peerUserId: room.guest_id }; + if (room.guest_id === localUserId) return { role: "guest", peerUserId: room.host_id }; + return undefined; +} diff --git a/apps/client/src/lib/native.ts b/apps/client/src/lib/native.ts index d2721c2..853f90a 100644 --- a/apps/client/src/lib/native.ts +++ b/apps/client/src/lib/native.ts @@ -6,6 +6,18 @@ export type NetworkDiagnostics = { rtt_ms: number | null; relay_reachable: boolean; }; +export type MatchEndpointCandidate = { endpoint: string; nonce: string }; +export type MatchProbeReport = { + room_id: string; + local_user_id: string; + peer_user_id: string; + role: "host" | "guest"; + transport: "direct_udp"; + frames_sent: number; + frames_received: number; + transcript_checksum: string; + elapsed_ms: number; +}; export function isDesktopRuntime(): boolean { return "__TAURI_IPC__" in window; @@ -34,3 +46,30 @@ export async function runNetworkTest(): Promise { } return invoke("network_test"); } + +export async function reserveMatchProbe(roomId: string): Promise { + if (!isDesktopRuntime()) throw new Error("LAN match probe requires the desktop client"); + return invoke("reserve_match_probe", { + request: { room_id: roomId }, + }); +} + +export async function runReservedMatchProbe(request: { + room_id: string; + game_id: string; + local_user_id: string; + peer_user_id: string; + role: "host" | "guest"; + peer_endpoint: string; + peer_nonce: string; + frame_count?: number; + timeout_ms?: number; +}): Promise { + if (!isDesktopRuntime()) throw new Error("LAN match probe requires the desktop client"); + return invoke("run_reserved_match_probe", { request }); +} + +export async function cancelMatchProbe(roomId: string): Promise { + if (!isDesktopRuntime()) return; + await invoke("cancel_match_probe", { roomId }); +} diff --git a/apps/client/src/lib/report.test.ts b/apps/client/src/lib/report.test.ts index 608f6f7..66917d4 100644 --- a/apps/client/src/lib/report.test.ts +++ b/apps/client/src/lib/report.test.ts @@ -19,4 +19,39 @@ describe("buildMatchReport", () => { expect(serialized).not.toContain("token"); expect(serialized).not.toContain("rom_path"); }); + + it("includes useful UDP evidence without private session material", () => { + const report = buildMatchReport( + { + id: "room-123", + game_id: "sfiii3", + host_id: "host", + guest_id: "guest", + state: "finished", + }, + new Date("2026-08-23T12:00:00Z"), + { + room_id: "room-123", + local_user_id: "host", + peer_user_id: "guest", + role: "host", + transport: "direct_udp", + frames_sent: 60, + frames_received: 60, + transcript_checksum: "0123456789abcdef", + elapsed_ms: 240, + } + ); + + expect(report.probe).toEqual({ + transport: "direct_udp", + frames_sent: 60, + frames_received: 60, + transcript_checksum: "0123456789abcdef", + elapsed_ms: 240, + }); + expect(JSON.stringify(report.probe)).not.toContain("user_id"); + expect(JSON.stringify(report.probe)).not.toContain("nonce"); + expect(JSON.stringify(report.probe)).not.toContain("endpoint"); + }); }); diff --git a/apps/client/src/lib/report.ts b/apps/client/src/lib/report.ts index 2e608b7..d23f1e5 100644 --- a/apps/client/src/lib/report.ts +++ b/apps/client/src/lib/report.ts @@ -1,17 +1,37 @@ import type { RoomPayload } from "@opencade/protocol"; +import type { MatchProbeReport } from "./native"; + +export type MatchProbeEvidence = Pick< + MatchProbeReport, + "transport" | "frames_sent" | "frames_received" | "transcript_checksum" | "elapsed_ms" +>; export type MatchReport = { schema_version: 1; exported_at: string; room: RoomPayload; + probe?: MatchProbeEvidence; client: { platform: string; user_agent: string }; }; -export function buildMatchReport(room: RoomPayload, now = new Date()): MatchReport { +export function buildMatchReport( + room: RoomPayload, + now = new Date(), + probe?: MatchProbeReport +): MatchReport { return { schema_version: 1, exported_at: now.toISOString(), room, + ...(probe && { + probe: { + transport: probe.transport, + frames_sent: probe.frames_sent, + frames_received: probe.frames_received, + transcript_checksum: probe.transcript_checksum, + elapsed_ms: probe.elapsed_ms, + }, + }), client: { platform: typeof navigator === "undefined" ? "unknown" : navigator.platform, user_agent: typeof navigator === "undefined" ? "unknown" : navigator.userAgent, @@ -19,8 +39,8 @@ export function buildMatchReport(room: RoomPayload, now = new Date()): MatchRepo }; } -export function downloadMatchReport(room: RoomPayload): void { - const report = buildMatchReport(room); +export function downloadMatchReport(room: RoomPayload, probe?: MatchProbeReport): void { + const report = buildMatchReport(room, new Date(), probe); const url = URL.createObjectURL( new Blob([JSON.stringify(report, null, 2)], { type: "application/json" }) ); diff --git a/apps/client/src/lib/useLanMatchProbe.ts b/apps/client/src/lib/useLanMatchProbe.ts new file mode 100644 index 0000000..1a0d872 --- /dev/null +++ b/apps/client/src/lib/useLanMatchProbe.ts @@ -0,0 +1,231 @@ +import { useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { + MatchEndpointPayload, + MatchProbeCompletedPayload, + RoomPayload, +} from "@opencade/protocol"; +import { api } from "./api"; +import { matchParticipants } from "./match"; +import { + cancelMatchProbe, + reserveMatchProbe, + runReservedMatchProbe, + type MatchEndpointCandidate, + type MatchProbeReport, +} from "./native"; +import type { OpenCadeSocket } from "./ws"; + +type LanMatchProbeOptions = { + token: string; + userId: string; + roomId: string; + room?: RoomPayload; + socket: OpenCadeSocket | null; + peerEndpoint?: MatchEndpointPayload; + peerCompletion?: MatchProbeCompletedPayload; + onRetry: () => void; +}; + +export function useLanMatchProbe({ + token, + userId, + roomId, + room, + socket, + peerEndpoint, + peerCompletion, + onRetry, +}: LanMatchProbeOptions) { + const queryClient = useQueryClient(); + const [localEndpoint, setLocalEndpoint] = useState(null); + const [probeReport, setProbeReport] = useState(null); + const [probeError, setProbeError] = useState(null); + const [candidateRelayed, setCandidateRelayed] = useState(false); + const [isResetting, setIsResetting] = useState(false); + const probeStarted = useRef(false); + const roomAdvanced = useRef(false); + const resetStarted = useRef(false); + const probeAttempt = useRef(0); + const preparationGeneration = useRef(0); + const reservationInFlight = useRef | null>(null); + + useEffect(() => { + if (!socket || !room || room.state !== "connecting" || localEndpoint) return; + const attempt = probeAttempt.current; + const preparation = ++preparationGeneration.current; + let cancelled = false; + const prepare = async () => { + try { + const reservation = reservationInFlight.current ?? reserveMatchProbe(roomId); + if (!reservationInFlight.current) reservationInFlight.current = reservation; + let candidate: MatchEndpointCandidate; + try { + candidate = await reservation; + } finally { + if (reservationInFlight.current === reservation) reservationInFlight.current = null; + } + if (cancelled || probeAttempt.current !== attempt) { + if (preparationGeneration.current === preparation) await cancelMatchProbe(roomId); + return; + } + setLocalEndpoint(candidate); + const delivered = await relayUntilDelivered( + socket, + "match.endpoint", + { + room_id: roomId, + endpoint: candidate.endpoint, + nonce: candidate.nonce, + }, + () => cancelled || probeAttempt.current !== attempt, + (message) => { + if (!cancelled && probeAttempt.current === attempt) setProbeError(message); + } + ); + if (delivered && !cancelled && probeAttempt.current === attempt) { + setCandidateRelayed(true); + setProbeError(null); + } + } catch (error) { + if (!cancelled && probeAttempt.current === attempt) { + setProbeError(errorMessage(error, "Failed to reserve LAN probe")); + } + } + }; + void prepare(); + return () => { + cancelled = true; + }; + }, [localEndpoint, room, roomId, socket]); + + useEffect(() => { + if ( + !room || + !socket || + !localEndpoint || + !candidateRelayed || + !peerEndpoint || + peerEndpoint.room_id !== roomId || + probeStarted.current + ) { + return; + } + const participants = matchParticipants(room, userId); + if (!participants) return; + const attempt = probeAttempt.current; + probeStarted.current = true; + setProbeError(null); + let cancelled = false; + const run = async () => { + try { + const report = await runReservedMatchProbe({ + room_id: roomId, + game_id: room.game_id, + local_user_id: userId, + peer_user_id: participants.peerUserId, + role: participants.role, + peer_endpoint: peerEndpoint.endpoint, + peer_nonce: peerEndpoint.nonce, + }); + if (cancelled || probeAttempt.current !== attempt) return; + setProbeReport(report); + await relayUntilDelivered( + socket, + "match.probe.completed", + { + room_id: roomId, + frames_received: report.frames_received, + transcript_checksum: report.transcript_checksum, + }, + () => cancelled || probeAttempt.current !== attempt, + (message) => { + if (!cancelled && probeAttempt.current === attempt) setProbeError(message); + } + ); + } catch (error) { + if (!cancelled && probeAttempt.current === attempt) { + setProbeError(errorMessage(error, "LAN match probe failed")); + } + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [candidateRelayed, localEndpoint, peerEndpoint, room, roomId, socket, userId]); + + useEffect(() => { + if (!probeReport || !peerCompletion || !room || roomAdvanced.current) return; + const participants = matchParticipants(room, userId); + if (!participants || participants.role !== "host") return; + if ( + peerCompletion.frames_received !== probeReport.frames_received || + peerCompletion.transcript_checksum !== probeReport.transcript_checksum + ) { + setProbeError("Peer transcript does not match the local LAN probe"); + return; + } + roomAdvanced.current = true; + const advance = async () => { + try { + await api.startRoom(token, roomId); + await api.finishRoom(token, roomId); + await queryClient.invalidateQueries({ queryKey: ["room", roomId] }); + } catch (error) { + roomAdvanced.current = false; + setProbeError(errorMessage(error, "Failed to finish match room")); + } + }; + void advance(); + }, [peerCompletion, probeReport, queryClient, room, roomId, token, userId]); + + const retry = async () => { + if (resetStarted.current) return; + resetStarted.current = true; + probeAttempt.current += 1; + setIsResetting(true); + try { + await reservationInFlight.current?.catch(() => undefined); + await cancelMatchProbe(roomId); + probeStarted.current = false; + roomAdvanced.current = false; + setProbeError(null); + setProbeReport(null); + setCandidateRelayed(false); + setLocalEndpoint(null); + onRetry(); + } catch (error) { + setProbeError(errorMessage(error, "Could not reset the LAN probe; try again")); + } finally { + resetStarted.current = false; + setIsResetting(false); + } + }; + + return { localEndpoint, probeReport, probeError, isResetting, retry }; +} + +async function relayUntilDelivered( + socket: OpenCadeSocket, + type: string, + payload: unknown, + cancelled: () => boolean, + onWaiting: (message: string | null) => void +): Promise { + while (!cancelled()) { + try { + await socket.send(type, payload); + onWaiting(null); + return true; + } catch (error) { + onWaiting(`${errorMessage(error, "Peer unavailable")}; waiting for peer connection`); + await new Promise((resolve) => window.setTimeout(resolve, 1_000)); + } + } + return false; +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} diff --git a/apps/client/src/routes/Match.tsx b/apps/client/src/routes/Match.tsx index 1363f9b..6e2f86d 100644 --- a/apps/client/src/routes/Match.tsx +++ b/apps/client/src/routes/Match.tsx @@ -1,14 +1,27 @@ import { useQuery } from "@tanstack/react-query"; +import type { MatchEndpointPayload, MatchProbeCompletedPayload } from "@opencade/protocol"; import { api } from "../lib/api"; import { downloadMatchReport } from "../lib/report"; +import { useLanMatchProbe } from "../lib/useLanMatchProbe"; +import type { OpenCadeSocket } from "../lib/ws"; export default function Match({ token, + userId, roomId, + socket, + peerEndpoint, + peerCompletion, + onProbeRetry, onDone, }: { token: string; + userId: string; roomId: string; + socket: OpenCadeSocket | null; + peerEndpoint?: MatchEndpointPayload; + peerCompletion?: MatchProbeCompletedPayload; + onProbeRetry: () => void; onDone: () => void; }) { const room = useQuery({ @@ -16,6 +29,16 @@ export default function Match({ queryFn: () => api.room(token, roomId), refetchInterval: 2_000, }); + const { localEndpoint, probeReport, probeError, isResetting, retry } = useLanMatchProbe({ + token, + userId, + roomId, + room: room.data, + socket, + peerEndpoint, + peerCompletion, + onRetry: onProbeRetry, + }); const state = room.data?.state ?? "connecting"; const steps = ["connecting", "playing", "finished"]; const active = Math.max(0, steps.indexOf(state)); @@ -31,22 +54,50 @@ export default function Match({

Room {roomId.slice(0, 8)}

{heading}

-
+ -
    +
      {steps.map((step, index) => (
    1. {step}
    2. ))}
    - {room.isError &&

    {room.error.message}

    } + {localEndpoint && !probeReport && ( +

    + LAN endpoint reserved at {localEndpoint.endpoint} +

    + )} + {probeReport && ( +

    + Direct UDP verified: {probeReport.frames_received} frames in {probeReport.elapsed_ms} ms · + transcript {probeReport.transcript_checksum} +

    + )} + {probeError && ( +

    + {probeError} +

    + )} + {room.isError && ( +

    + {room.error.message} +

    + )}
    + {probeError && ( + + )} {room.data && ( - )} diff --git a/apps/client/src/styles.css b/apps/client/src/styles.css index 9969dbe..0f5a5cb 100644 --- a/apps/client/src/styles.css +++ b/apps/client/src/styles.css @@ -278,6 +278,10 @@ input { border-color: #45464b; color: #e8e5df; } +.secondary:disabled { + opacity: 0.5; + cursor: wait; +} .form-error { color: #ff8e86; margin: 0; @@ -374,8 +378,12 @@ input { } .match-actions { display: flex; + flex-wrap: wrap; gap: 12px; } +.match-actions .secondary { + min-height: 44px; +} @media (max-width: 760px) { .auth-layout { grid-template-columns: 1fr; diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index ccc74e9..d1908a6 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -15,7 +15,7 @@ tower = { version = "0.4", features = ["util"] } tower-http = { version = "0.5", features = ["cors", "trace"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono", "uuid", "migrate", "macros"] } +sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "postgres", "chrono", "uuid", "migrate", "macros"] } argon2 = "0.5" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde"] } diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 101d348..1f9f046 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -25,14 +25,16 @@ RUN mkdir -p apps/server/src packages/protocol/src packages/networking/src \ && echo "" > packages/shared/src/lib.rs \ && echo "" > packages/game-definitions/src/lib.rs \ && echo "" > adapters/fbneo/src/lib.rs \ - && cargo build --release 2>/dev/null || true + && cargo build --release --locked # Copy the real sources (invalidates cache only when source changes). COPY . . -# Build the server binary. -RUN cargo build --release --locked -p opencade-server \ - && ls -lh target/release/opencade-server* 2>/dev/null || ls -lh target/release/ +# Remove only the dummy workspace artifacts before compiling the real sources. Dependency +# artifacts remain cached, while Cargo can no longer mistake a stub crate for the application. +RUN cargo clean --release -p opencade-server -p opencade-protocol \ + && cargo build --release --locked -p opencade-server \ + && test -x target/release/opencade-server # --- runtime --------------------------------------------------------------- FROM debian:bookworm-slim AS runtime diff --git a/apps/server/src/ws.rs b/apps/server/src/ws.rs index c4dc82f..a7d4b01 100644 --- a/apps/server/src/ws.rs +++ b/apps/server/src/ws.rs @@ -6,7 +6,10 @@ use axum::{ http::{header, HeaderMap}, response::{IntoResponse, Response}, }; -use opencade_protocol::{is_supported_version, Envelope, PROTOCOL_VERSION}; +use opencade_protocol::{ + is_supported_version, Envelope, MatchEndpointPayload, MatchProbeCompletedPayload, + PROTOCOL_VERSION, +}; use serde_json::{json, Value}; use sqlx::Row; use std::borrow::Cow; @@ -181,7 +184,32 @@ async fn handle_text( ) .await } - "signaling.offer" | "signaling.answer" | "signaling.candidate" => { + "signaling.offer" + | "signaling.answer" + | "signaling.candidate" + | "match.endpoint" + | "match.probe.completed" => { + if envelope.msg_type == "match.endpoint" && validate_match_endpoint(&envelope).is_err() + { + return send_error( + socket, + "invalid_candidate", + "match endpoint candidate is invalid", + Some(&envelope.request_id), + ) + .await; + } + if envelope.msg_type == "match.probe.completed" + && validate_match_completion(&envelope).is_err() + { + return send_error( + socket, + "invalid_probe_report", + "match probe completion is invalid", + Some(&envelope.request_id), + ) + .await; + } if let Err(error) = relay_to_room_members(state, user.id, &envelope, text).await { return send_error( socket, @@ -194,7 +222,11 @@ async fn handle_text( send_envelope( socket, Envelope::reply( - "signaling.relayed", + match envelope.msg_type.as_str() { + "match.endpoint" => "match.endpoint.relayed", + "match.probe.completed" => "match.probe.completed.relayed", + _ => "signaling.relayed", + }, envelope.request_id, json!({ "status": "relayed" }), ), @@ -213,6 +245,34 @@ async fn handle_text( } } +fn validate_match_endpoint(envelope: &Envelope) -> Result<(), ()> { + let candidate: MatchEndpointPayload = + serde_json::from_value(envelope.payload.clone()).map_err(|_| ())?; + candidate + .endpoint + .parse::() + .map_err(|_| ())?; + Uuid::parse_str(&candidate.nonce).map_err(|_| ())?; + Ok(()) +} + +fn validate_match_completion(envelope: &Envelope) -> Result<(), ()> { + let completion: MatchProbeCompletedPayload = + serde_json::from_value(envelope.payload.clone()).map_err(|_| ())?; + if completion.frames_received == 0 || completion.frames_received > 10_000 { + return Err(()); + } + if completion.transcript_checksum.len() != 16 + || !completion + .transcript_checksum + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err(()); + } + Ok(()) +} + async fn relay_to_room_members( state: &AppState, sender_id: Uuid, @@ -342,4 +402,60 @@ mod tests { ); assert_eq!(websocket_token(&headers), Some("secret-token")); } + + #[test] + fn match_endpoint_requires_a_socket_address_and_uuid_nonce() { + let valid = Envelope::new( + "match.endpoint", + json!({ + "room_id": Uuid::new_v4(), + "endpoint": "192.168.1.20:42000", + "nonce": Uuid::new_v4() + }), + ); + assert!(validate_match_endpoint(&valid).is_ok()); + + let invalid_endpoint = Envelope::new( + "match.endpoint", + json!({ + "room_id": Uuid::new_v4(), + "endpoint": "not-an-endpoint", + "nonce": Uuid::new_v4() + }), + ); + assert!(validate_match_endpoint(&invalid_endpoint).is_err()); + + let invalid_nonce = Envelope::new( + "match.endpoint", + json!({ + "room_id": Uuid::new_v4(), + "endpoint": "192.168.1.20:42000", + "nonce": "predictable" + }), + ); + assert!(validate_match_endpoint(&invalid_nonce).is_err()); + } + + #[test] + fn match_completion_requires_bounded_frames_and_a_checksum() { + let valid = Envelope::new( + "match.probe.completed", + json!({ + "room_id": Uuid::new_v4(), + "frames_received": 60, + "transcript_checksum": "0376c2e852f4fd25" + }), + ); + assert!(validate_match_completion(&valid).is_ok()); + + let invalid = Envelope::new( + "match.probe.completed", + json!({ + "room_id": Uuid::new_v4(), + "frames_received": 0, + "transcript_checksum": "not-a-checksum" + }), + ); + assert!(validate_match_completion(&invalid).is_err()); + } } diff --git a/apps/server/tests/api_flow.rs b/apps/server/tests/api_flow.rs index a0575fb..7ef2db3 100644 --- a/apps/server/tests/api_flow.rs +++ b/apps/server/tests/api_flow.rs @@ -241,6 +241,81 @@ async fn authenticated_users_create_and_accept_a_room(pool: PgPool) { assert_eq!(acknowledgement.msg_type, "signaling.relayed"); assert_eq!(acknowledgement.request_id, request_id); + let endpoint_request_id = "endpoint-integration-1"; + let endpoint = json!({ + "type": "match.endpoint", + "version": "1.0", + "request_id": endpoint_request_id, + "timestamp": chrono::Utc::now(), + "payload": { + "room_id": room_id, + "endpoint": "192.168.1.20:42000", + "nonce": "8a1110d5-8dd2-4ad2-9c88-ad9768bc4905" + } + }); + host_socket + .send(Message::Text(endpoint.to_string())) + .await + .expect("send endpoint"); + let relayed_endpoint = guest_socket + .next() + .await + .expect("relayed endpoint") + .expect("guest endpoint frame"); + let relayed_endpoint: Envelope = + serde_json::from_str(relayed_endpoint.to_text().expect("text endpoint")) + .expect("endpoint envelope"); + assert_eq!(relayed_endpoint.msg_type, "match.endpoint"); + assert_eq!(relayed_endpoint.request_id, endpoint_request_id); + assert_eq!(relayed_endpoint.payload["endpoint"], "192.168.1.20:42000"); + let endpoint_ack = host_socket + .next() + .await + .expect("endpoint ack") + .expect("host endpoint frame"); + let endpoint_ack: Envelope = + serde_json::from_str(endpoint_ack.to_text().expect("text endpoint ack")) + .expect("endpoint ack envelope"); + assert_eq!(endpoint_ack.msg_type, "match.endpoint.relayed"); + assert_eq!(endpoint_ack.request_id, endpoint_request_id); + + let completion_request_id = "completion-integration-1"; + let completion = json!({ + "type": "match.probe.completed", + "version": "1.0", + "request_id": completion_request_id, + "timestamp": chrono::Utc::now(), + "payload": { + "room_id": room_id, + "frames_received": 60, + "transcript_checksum": "0376c2e852f4fd25" + } + }); + guest_socket + .send(Message::Text(completion.to_string())) + .await + .expect("send completion"); + let relayed_completion = host_socket + .next() + .await + .expect("relayed completion") + .expect("host completion frame"); + let relayed_completion: Envelope = + serde_json::from_str(relayed_completion.to_text().expect("text completion")) + .expect("completion envelope"); + assert_eq!(relayed_completion.msg_type, "match.probe.completed"); + assert_eq!(relayed_completion.request_id, completion_request_id); + let completion_ack = guest_socket + .next() + .await + .expect("completion ack") + .expect("guest completion frame"); + let completion_ack: Envelope = + serde_json::from_str(completion_ack.to_text().expect("text completion ack")) + .expect("completion ack envelope"); + assert_eq!(completion_ack.msg_type, "match.probe.completed.relayed"); + assert_eq!(completion_ack.request_id, completion_request_id); + let (start_status, started) = request( &app, Method::POST, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index d33dcae..9f85e2f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -12,11 +12,15 @@ Updated 2026-08-23. acknowledgements and errors. - Rust-authoritative protocol payloads and generated TypeScript bindings with a CI drift gate. - React/Tauri login, games, lobby, challenge, room status, reconnect, local availability scan, - diagnostics, and redacted report export. + diagnostics, and redacted report export with direct-UDP frame/checksum evidence. - Safe process abstraction, canonical root checks, `PathBuf`/`OsString` arguments, process tracking, and FBNeo local detection/validation/launch. -- Deterministic mock adapter, bounded in-memory input transport, and connected direct UDP transport. -- PostgreSQL, WebSocket, lifecycle, safe-launch, mock-match, UDP, TypeScript, and MSRV checks in CI. +- Deterministic mock adapter, bounded in-memory input transport, and a nonce-bound direct-UDP match + runner wired through authenticated endpoint exchange and the desktop match screen. +- A standalone two-node probe CLI plus a real two-process localhost test that verifies identical + 60-frame transcripts and machine-readable reports. +- PostgreSQL, WebSocket, lifecycle, safe-launch, mock-match, UDP, two-process, TypeScript, and MSRV + checks in CI. ## Deliberately not claimed diff --git a/docs/alpha/LAN_TEST.md b/docs/alpha/LAN_TEST.md index 6fa54a1..9af44e4 100644 --- a/docs/alpha/LAN_TEST.md +++ b/docs/alpha/LAN_TEST.md @@ -18,17 +18,35 @@ direct UDP frame transport; it does not claim FBNeo netplay support. 1. Register two separate users and select the same game. 2. Keep both lobby screens open until each user is visible. 3. User A sends a challenge; user B accepts it. -4. Confirm both clients reach `connecting` and exchange a correlated signaling message. -5. Run the direct-UDP proof harness on the selected LAN addresses and record connection time, - frame count, packet loss, and disconnect reason. -6. Transition the test room to `playing`, then `finished`. -7. Export the redacted report from each client and compare `room.id`, `game_id`, and final state. +4. Confirm both clients reach `connecting`. Each desktop client reserves a UDP port and exchanges a + nonce-bound `match.endpoint` candidate through the authenticated WebSocket. +5. Wait for both clients to report `Direct UDP verified`, with 60 received frames and the same + transcript checksum. The host then transitions the room to `playing` and `finished`. +6. If either side reports a firewall or timeout error, allow the advertised UDP port through the + firewall and select `Retry LAN probe` on both clients. +7. Export the redacted report from each client and compare `room.id`, `game_id`, final state, + `probe.frames_received`, and `probe.transcript_checksum`. Probe evidence deliberately omits + nonces, peer endpoints, and local user identifiers. + +For transport-only diagnosis without the desktop flow, build `opencade-match-probe` and run one +process on each host with complementary arguments: + +```bash +cargo run -p opencade-networking --bin opencade-match-probe -- \ + --local 192.168.1.10:42000 --peer 192.168.1.11:42000 \ + --room lan-test --game sfiii3 --local-user host --peer-user guest \ + --role host --session-key shared-test-key --frames 60 --timeout-ms 5000 +``` + +The other host swaps local/peer addresses and users and uses `--role guest` with the same room, +game, session key, frame count, and timeout. The command prints a machine-readable JSON report. ## Pass criteria - Both clients agree on the room and users. - No non-member can mutate or signal into the room. - The UDP transcript is ordered and identical at both endpoints. +- Both reports contain the same `transcript_checksum` and `frames_received = 60`. - The match row has `started_at` and `ended_at`. - Reports contain no session token, password, full ROM path, or emulator binary. diff --git a/packages/networking/Cargo.toml b/packages/networking/Cargo.toml index d77f57b..3539d7a 100644 --- a/packages/networking/Cargo.toml +++ b/packages/networking/Cargo.toml @@ -8,10 +8,10 @@ repository = "https://github.com/opencade/opencade" rust-version = "1.78" [dependencies] +opencade-emulator-sdk = { path = "../emulator-sdk" } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" tokio = { version = "1", features = ["macros", "net", "rt", "sync", "time"] } [dev-dependencies] -opencade-emulator-sdk = { path = "../emulator-sdk" } diff --git a/packages/networking/src/bin/opencade-match-probe.rs b/packages/networking/src/bin/opencade-match-probe.rs new file mode 100644 index 0000000..b556119 --- /dev/null +++ b/packages/networking/src/bin/opencade-match-probe.rs @@ -0,0 +1,104 @@ +use opencade_emulator_sdk::{MatchDescriptor, PeerRole, TransportKind}; +use opencade_networking::{run_match_probe, MatchProbeConfig, UdpPeer}; +use std::env; +use std::net::SocketAddr; +use std::time::Duration; + +struct Arguments { + local: SocketAddr, + peer: SocketAddr, + room_id: String, + game_id: String, + local_user_id: String, + peer_user_id: String, + role: PeerRole, + session_key: String, + frames: u64, + timeout: Duration, +} + +impl Arguments { + fn parse() -> Result { + let mut args = env::args().skip(1); + let mut value = |flag: &str| -> Result { + let actual = args + .next() + .ok_or_else(|| format!("missing {flag}; {}", usage()))?; + if actual != flag { + return Err(format!("expected {flag}, found {actual}; {}", usage())); + } + args.next() + .ok_or_else(|| format!("missing value for {flag}; {}", usage())) + }; + + let local = value("--local")? + .parse() + .map_err(|_| "--local must be a socket address".to_string())?; + let peer = value("--peer")? + .parse() + .map_err(|_| "--peer must be a socket address".to_string())?; + let room_id = value("--room")?; + let game_id = value("--game")?; + let local_user_id = value("--local-user")?; + let peer_user_id = value("--peer-user")?; + let role = match value("--role")?.as_str() { + "host" => PeerRole::Host, + "guest" => PeerRole::Guest, + _ => return Err("--role must be host or guest".into()), + }; + let session_key = value("--session-key")?; + let frames = value("--frames")? + .parse() + .map_err(|_| "--frames must be an integer".to_string())?; + let timeout_ms: u64 = value("--timeout-ms")? + .parse() + .map_err(|_| "--timeout-ms must be an integer".to_string())?; + if let Some(extra) = args.next() { + return Err(format!("unexpected argument {extra}; {}", usage())); + } + Ok(Self { + local, + peer, + room_id, + game_id, + local_user_id, + peer_user_id, + role, + session_key, + frames, + timeout: Duration::from_millis(timeout_ms), + }) + } +} + +fn usage() -> &'static str { + "usage: opencade-match-probe --local IP:PORT --peer IP:PORT --room ID --game ID \ + --local-user ID --peer-user ID --role host|guest --session-key KEY --frames N --timeout-ms N" +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let arguments = Arguments::parse() + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + let descriptor = MatchDescriptor { + room_id: arguments.room_id, + game_id: arguments.game_id, + local_user_id: arguments.local_user_id, + peer_user_id: arguments.peer_user_id, + role: arguments.role, + transport: TransportKind::DirectUdp, + local_endpoint: arguments.local, + peer_endpoint: arguments.peer, + input_delay_frames: 2, + }; + let config = MatchProbeConfig::new( + descriptor, + arguments.session_key, + arguments.frames, + arguments.timeout, + )?; + let peer = UdpPeer::bind(arguments.local, arguments.peer).await?; + let report = run_match_probe(&peer, &config).await?; + println!("{}", serde_json::to_string(&report)?); + Ok(()) +} diff --git a/packages/networking/src/lib.rs b/packages/networking/src/lib.rs index e0b191f..9ad0aca 100644 --- a/packages/networking/src/lib.rs +++ b/packages/networking/src/lib.rs @@ -2,8 +2,13 @@ use serde::{Deserialize, Serialize}; use std::net::SocketAddr; use tokio::sync::mpsc; +mod probe; + +pub use probe::{run_match_probe, MatchProbeConfig, MatchProbeReport, MAX_PROBE_FRAMES}; + pub const MAX_INPUT_BYTES: usize = 256; pub const INPUT_QUEUE_CAPACITY: usize = 120; +const MAX_DATAGRAM_BYTES: usize = 2_048; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InputFrame { @@ -47,6 +52,15 @@ pub enum TransportError { Serialization(String), #[error("udp transport failed: {0}")] Io(String), + #[error("invalid match probe configuration: {0}")] + InvalidConfiguration(String), + #[error("received a datagram for a different match or peer")] + PeerMismatch, + #[error("match probe timed out after receiving {received_frames} of {expected_frames} frames")] + Timeout { + received_frames: u64, + expected_frames: u64, + }, } /// Connected UDP transport for LAN proof runs. Authentication and endpoint negotiation remain in @@ -57,14 +71,23 @@ pub struct UdpPeer { impl UdpPeer { pub async fn bind(local: SocketAddr, peer: SocketAddr) -> Result { + let socket = Self::bind_unconnected(local).await?; + socket.connect(peer).await + } + + pub async fn bind_unconnected(local: SocketAddr) -> Result { let socket = tokio::net::UdpSocket::bind(local) .await .map_err(|error| TransportError::Io(error.to_string()))?; - socket + Ok(Self { socket }) + } + + pub async fn connect(self, peer: SocketAddr) -> Result { + self.socket .connect(peer) .await .map_err(|error| TransportError::Io(error.to_string()))?; - Ok(Self { socket }) + Ok(self) } pub fn local_addr(&self) -> Result { @@ -87,7 +110,7 @@ impl UdpPeer { } pub async fn receive(&self) -> Result { - let mut buffer = [0_u8; 1024]; + let mut buffer = [0_u8; MAX_DATAGRAM_BYTES]; let received = self .socket .recv(&mut buffer) @@ -97,6 +120,32 @@ impl UdpPeer { .map_err(|error| TransportError::Serialization(error.to_string()))?; InputFrame::new(frame.frame, frame.player_id, frame.input) } + + async fn send_packet(&self, packet: &probe::ProbePacket) -> Result<(), TransportError> { + let encoded = serde_json::to_vec(packet) + .map_err(|error| TransportError::Serialization(error.to_string()))?; + if encoded.len() > MAX_DATAGRAM_BYTES { + return Err(TransportError::Serialization( + "match probe datagram exceeds maximum size".into(), + )); + } + self.socket + .send(&encoded) + .await + .map_err(|error| TransportError::Io(error.to_string()))?; + Ok(()) + } + + async fn receive_packet(&self) -> Result { + let mut buffer = [0_u8; MAX_DATAGRAM_BYTES]; + let received = self + .socket + .recv(&mut buffer) + .await + .map_err(|error| TransportError::Io(error.to_string()))?; + serde_json::from_slice(&buffer[..received]) + .map_err(|error| TransportError::Serialization(error.to_string())) + } } /// Deterministic, bounded transport used to prove the match contract without a network. @@ -139,6 +188,7 @@ mod tests { use opencade_emulator_sdk::{ EmulatorAdapter, MatchDescriptor, MockAdapter, PeerRole, TransportKind, }; + use std::time::Duration; fn descriptor(role: PeerRole) -> MatchDescriptor { let (local_user_id, peer_user_id, local_endpoint, peer_endpoint) = match role { @@ -249,4 +299,83 @@ mod tests { .expect("udp receive"); assert_eq!(received, expected); } + + #[tokio::test] + async fn udp_match_probes_agree_on_a_deterministic_transcript() { + let reserve_host = tokio::net::UdpSocket::bind("127.0.0.1:0") + .await + .expect("reserve host"); + let reserve_guest = tokio::net::UdpSocket::bind("127.0.0.1:0") + .await + .expect("reserve guest"); + let host_address = reserve_host.local_addr().expect("host address"); + let guest_address = reserve_guest.local_addr().expect("guest address"); + drop(reserve_host); + drop(reserve_guest); + + let host_peer = UdpPeer::bind(host_address, guest_address) + .await + .expect("host peer"); + let guest_peer = UdpPeer::bind(guest_address, host_address) + .await + .expect("guest peer"); + let host_config = MatchProbeConfig::new( + direct_descriptor(PeerRole::Host, host_address, guest_address), + "shared-session", + 60, + Duration::from_secs(2), + ) + .expect("host config"); + let guest_config = MatchProbeConfig::new( + direct_descriptor(PeerRole::Guest, guest_address, host_address), + "shared-session", + 60, + Duration::from_secs(2), + ) + .expect("guest config"); + + let (host, guest) = tokio::join!( + run_match_probe(&host_peer, &host_config), + run_match_probe(&guest_peer, &guest_config) + ); + let host = host.expect("host report"); + let guest = guest.expect("guest report"); + assert_eq!(host.frames_received, 60); + assert_eq!(guest.frames_received, 60); + assert_eq!(host.transcript_checksum, guest.transcript_checksum); + } + + #[test] + fn match_probe_rejects_an_in_memory_descriptor() { + let error = MatchProbeConfig::new( + descriptor(PeerRole::Host), + "shared-session", + 60, + Duration::from_secs(2), + ) + .expect_err("in-memory descriptor must be rejected"); + assert!(matches!(error, TransportError::InvalidConfiguration(_))); + } + + fn direct_descriptor( + role: PeerRole, + local_endpoint: SocketAddr, + peer_endpoint: SocketAddr, + ) -> MatchDescriptor { + let (local_user_id, peer_user_id) = match role { + PeerRole::Host => ("host", "guest"), + PeerRole::Guest => ("guest", "host"), + }; + MatchDescriptor { + room_id: "proof-room".into(), + game_id: "sfiii3".into(), + local_user_id: local_user_id.into(), + peer_user_id: peer_user_id.into(), + role, + transport: TransportKind::DirectUdp, + local_endpoint, + peer_endpoint, + input_delay_frames: 2, + } + } } diff --git a/packages/networking/src/probe.rs b/packages/networking/src/probe.rs new file mode 100644 index 0000000..eb57738 --- /dev/null +++ b/packages/networking/src/probe.rs @@ -0,0 +1,340 @@ +use super::{InputFrame, TransportError, UdpPeer, MAX_INPUT_BYTES}; +use opencade_emulator_sdk::{MatchDescriptor, PeerRole, TransportKind}; +use serde::{Deserialize, Serialize}; +use std::time::{Duration, Instant}; + +pub const MAX_PROBE_FRAMES: u64 = 10_000; +const RETRY_INTERVAL: Duration = Duration::from_millis(25); +const COMPLETION_GRACE: Duration = Duration::from_millis(250); + +// Kept private to the crate so the proof protocol can evolve independently of the data-plane API. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum ProbePacket { + Input { + room_id: String, + session_key: String, + frame: InputFrame, + }, +} + +#[derive(Debug, Clone)] +pub struct MatchProbeConfig { + descriptor: MatchDescriptor, + session_key: String, + frame_count: u64, + timeout: Duration, +} + +impl MatchProbeConfig { + pub fn new( + descriptor: MatchDescriptor, + session_key: impl Into, + frame_count: u64, + timeout: Duration, + ) -> Result { + descriptor + .validate() + .map_err(|error| TransportError::InvalidConfiguration(error.to_string()))?; + if descriptor.transport != TransportKind::DirectUdp { + return Err(TransportError::InvalidConfiguration( + "match probe requires direct_udp transport".into(), + )); + } + let session_key = session_key.into(); + if session_key.trim().is_empty() || session_key.len() > 128 { + return Err(TransportError::InvalidConfiguration( + "session key must contain between 1 and 128 bytes".into(), + )); + } + if !(1..=MAX_PROBE_FRAMES).contains(&frame_count) { + return Err(TransportError::InvalidConfiguration(format!( + "frame count must be between 1 and {MAX_PROBE_FRAMES}" + ))); + } + if timeout.is_zero() { + return Err(TransportError::InvalidConfiguration( + "timeout must be greater than zero".into(), + )); + } + Ok(Self { + descriptor, + session_key, + frame_count, + timeout, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MatchProbeReport { + pub room_id: String, + pub local_user_id: String, + pub peer_user_id: String, + pub role: PeerRole, + pub transport: TransportKind, + pub frames_sent: u64, + pub frames_received: u64, + pub transcript_checksum: String, + pub elapsed_ms: u128, +} + +/// Runs a bounded deterministic frame exchange over a connected UDP peer. +/// +/// A peer repeats its current frame until the opposite frame arrives. Receiving an older frame +/// causes the matching local frame to be retransmitted, allowing either process to start first. +pub async fn run_match_probe( + peer: &UdpPeer, + config: &MatchProbeConfig, +) -> Result { + let started = Instant::now(); + let deadline = started + config.timeout; + let mut checksum = FNV_OFFSET_BASIS; + let mut frames_sent = 0; + + for frame_number in 0..config.frame_count { + let local_frame = deterministic_frame( + frame_number, + &config.descriptor.local_user_id, + config.descriptor.role, + )?; + send_probe_frame(peer, config, local_frame.clone()).await?; + frames_sent += 1; + + loop { + let now = Instant::now(); + if now >= deadline { + return Err(TransportError::Timeout { + received_frames: frame_number, + expected_frames: config.frame_count, + }); + } + let wait = RETRY_INTERVAL.min(deadline.saturating_duration_since(now)); + match tokio::time::timeout(wait, peer.receive_packet()).await { + Err(_) => { + send_probe_frame(peer, config, local_frame.clone()).await?; + frames_sent += 1; + } + Ok(Err(error)) => return Err(error), + Ok(Ok(packet)) => { + let remote = validated_remote_frame(packet, config)?; + if remote.frame < frame_number { + let previous = deterministic_frame( + remote.frame, + &config.descriptor.local_user_id, + config.descriptor.role, + )?; + send_probe_frame(peer, config, previous).await?; + frames_sent += 1; + continue; + } + if remote.frame > frame_number { + send_probe_frame(peer, config, local_frame.clone()).await?; + frames_sent += 1; + continue; + } + update_transcript_checksum( + &mut checksum, + config.descriptor.role, + &local_frame, + &remote, + ); + break; + } + } + } + } + + linger_for_peer(peer, config, &mut frames_sent).await?; + Ok(MatchProbeReport { + room_id: config.descriptor.room_id.clone(), + local_user_id: config.descriptor.local_user_id.clone(), + peer_user_id: config.descriptor.peer_user_id.clone(), + role: config.descriptor.role, + transport: config.descriptor.transport, + frames_sent, + frames_received: config.frame_count, + transcript_checksum: format!("{checksum:016x}"), + elapsed_ms: started.elapsed().as_millis(), + }) +} + +async fn send_probe_frame( + peer: &UdpPeer, + config: &MatchProbeConfig, + frame: InputFrame, +) -> Result<(), TransportError> { + peer.send_packet(&ProbePacket::Input { + room_id: config.descriptor.room_id.clone(), + session_key: config.session_key.clone(), + frame, + }) + .await +} + +fn validated_remote_frame( + packet: ProbePacket, + config: &MatchProbeConfig, +) -> Result { + let ProbePacket::Input { + room_id, + session_key, + frame, + } = packet; + if room_id != config.descriptor.room_id + || session_key != config.session_key + || frame.player_id != config.descriptor.peer_user_id + || frame.input.len() > MAX_INPUT_BYTES + { + return Err(TransportError::PeerMismatch); + } + Ok(frame) +} + +async fn linger_for_peer( + peer: &UdpPeer, + config: &MatchProbeConfig, + frames_sent: &mut u64, +) -> Result<(), TransportError> { + let final_frame_number = config.frame_count - 1; + let final_frame = deterministic_frame( + final_frame_number, + &config.descriptor.local_user_id, + config.descriptor.role, + )?; + let deadline = Instant::now() + COMPLETION_GRACE; + while Instant::now() < deadline { + let wait = RETRY_INTERVAL.min(deadline.saturating_duration_since(Instant::now())); + match tokio::time::timeout(wait, peer.receive_packet()).await { + Err(_) => { + send_probe_frame(peer, config, final_frame.clone()).await?; + *frames_sent += 1; + } + Ok(Err(error)) => return Err(error), + Ok(Ok(packet)) => { + let remote = validated_remote_frame(packet, config)?; + let response = deterministic_frame( + remote.frame.min(final_frame_number), + &config.descriptor.local_user_id, + config.descriptor.role, + )?; + send_probe_frame(peer, config, response).await?; + *frames_sent += 1; + } + } + } + Ok(()) +} + +fn deterministic_frame( + frame: u64, + player_id: &str, + role: PeerRole, +) -> Result { + let input = match role { + PeerRole::Host => (frame % 4) as u8, + PeerRole::Guest => ((frame + 2) % 4) as u8, + }; + InputFrame::new(frame, player_id, vec![input]) +} + +const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325; +const FNV_PRIME: u64 = 0x100000001b3; + +fn update_transcript_checksum( + checksum: &mut u64, + role: PeerRole, + local: &InputFrame, + remote: &InputFrame, +) { + let (host, guest) = match role { + PeerRole::Host => (local, remote), + PeerRole::Guest => (remote, local), + }; + for byte in host.input.iter().chain(&guest.input) { + *checksum ^= u64::from(*byte); + *checksum = checksum.wrapping_mul(FNV_PRIME); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::SocketAddr; + + fn config() -> MatchProbeConfig { + MatchProbeConfig::new( + MatchDescriptor { + room_id: "room-1".into(), + game_id: "sfiii3".into(), + local_user_id: "host".into(), + peer_user_id: "guest".into(), + role: PeerRole::Host, + transport: TransportKind::DirectUdp, + local_endpoint: "127.0.0.1:41000" + .parse::() + .expect("local endpoint"), + peer_endpoint: "127.0.0.1:41001" + .parse::() + .expect("peer endpoint"), + input_delay_frames: 2, + }, + "session-1", + 60, + Duration::from_secs(1), + ) + .expect("probe config") + } + + #[test] + fn rejects_packets_from_another_room_session_or_player() { + let config = config(); + let valid_frame = InputFrame::new(0, "guest", vec![2]).expect("frame"); + let wrong_room = ProbePacket::Input { + room_id: "room-2".into(), + session_key: "session-1".into(), + frame: valid_frame.clone(), + }; + let wrong_session = ProbePacket::Input { + room_id: "room-1".into(), + session_key: "session-2".into(), + frame: valid_frame, + }; + let wrong_player = ProbePacket::Input { + room_id: "room-1".into(), + session_key: "session-1".into(), + frame: InputFrame::new(0, "intruder", vec![2]).expect("frame"), + }; + + assert_eq!( + validated_remote_frame(wrong_room, &config), + Err(TransportError::PeerMismatch) + ); + assert_eq!( + validated_remote_frame(wrong_session, &config), + Err(TransportError::PeerMismatch) + ); + assert_eq!( + validated_remote_frame(wrong_player, &config), + Err(TransportError::PeerMismatch) + ); + } + + #[test] + fn configuration_rejects_empty_and_oversized_probe_values() { + let descriptor = config().descriptor; + let zero_frames = + MatchProbeConfig::new(descriptor.clone(), "session-1", 0, Duration::from_secs(1)); + assert!(matches!( + zero_frames, + Err(TransportError::InvalidConfiguration(_)) + )); + + let oversized_key = + MatchProbeConfig::new(descriptor, "x".repeat(129), 1, Duration::from_secs(1)); + assert!(matches!( + oversized_key, + Err(TransportError::InvalidConfiguration(_)) + )); + } +} diff --git a/packages/networking/tests/two_process_probe.rs b/packages/networking/tests/two_process_probe.rs new file mode 100644 index 0000000..9cd7dd7 --- /dev/null +++ b/packages/networking/tests/two_process_probe.rs @@ -0,0 +1,76 @@ +use opencade_networking::MatchProbeReport; +use std::net::{SocketAddr, UdpSocket}; +use std::process::{Child, Command, Output, Stdio}; + +fn reserve_addresses() -> (SocketAddr, SocketAddr) { + let host = UdpSocket::bind("127.0.0.1:0").expect("reserve host address"); + let guest = UdpSocket::bind("127.0.0.1:0").expect("reserve guest address"); + let host_address = host.local_addr().expect("read host address"); + let guest_address = guest.local_addr().expect("read guest address"); + (host_address, guest_address) +} + +fn spawn_probe( + local: SocketAddr, + peer: SocketAddr, + local_user: &str, + peer_user: &str, + role: &str, +) -> Child { + Command::new(env!("CARGO_BIN_EXE_opencade-match-probe")) + .args([ + "--local", + &local.to_string(), + "--peer", + &peer.to_string(), + "--room", + "two-process-room", + "--game", + "sfiii3", + "--local-user", + local_user, + "--peer-user", + peer_user, + "--role", + role, + "--session-key", + "two-process-session", + "--frames", + "60", + "--timeout-ms", + "5000", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn match probe") +} + +fn successful_report(output: Output) -> MatchProbeReport { + assert!( + output.status.success(), + "probe failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("valid probe report") +} + +#[test] +fn independent_processes_complete_the_same_udp_transcript() { + let (host_address, guest_address) = reserve_addresses(); + assert_ne!(host_address, guest_address); + + let host = spawn_probe(host_address, guest_address, "host", "guest", "host"); + let guest = spawn_probe(guest_address, host_address, "guest", "host", "guest"); + let host_report = successful_report(host.wait_with_output().expect("host output")); + let guest_report = successful_report(guest.wait_with_output().expect("guest output")); + + assert_eq!(host_report.frames_received, 60); + assert_eq!(guest_report.frames_received, 60); + assert_eq!( + host_report.transcript_checksum, + guest_report.transcript_checksum + ); + assert_eq!(host_report.room_id, "two-process-room"); + assert_eq!(guest_report.room_id, "two-process-room"); +} diff --git a/packages/protocol/src/generated/MatchEndpointPayload.ts b/packages/protocol/src/generated/MatchEndpointPayload.ts new file mode 100644 index 0000000..2ca8c87 --- /dev/null +++ b/packages/protocol/src/generated/MatchEndpointPayload.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Direct-UDP endpoint candidate exchanged between authenticated room members. + */ +export type MatchEndpointPayload = { room_id: string; endpoint: string; nonce: string }; diff --git a/packages/protocol/src/generated/MatchProbeCompletedPayload.ts b/packages/protocol/src/generated/MatchProbeCompletedPayload.ts new file mode 100644 index 0000000..92fd040 --- /dev/null +++ b/packages/protocol/src/generated/MatchProbeCompletedPayload.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Result of a completed direct-UDP proof, relayed to the other room member. + */ +export type MatchProbeCompletedPayload = { + room_id: string; + frames_received: number; + transcript_checksum: string; +}; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 577667b..a0f246e 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -4,6 +4,8 @@ export { createEnvelope, validateEnvelope, parseEnvelope, serializeEnvelope } fr export type { PresencePayload, ChatPayload, + MatchEndpointPayload, + MatchProbeCompletedPayload, ChallengePayload, ChallengeState, SessionOfferPayload, diff --git a/packages/protocol/src/lib.rs b/packages/protocol/src/lib.rs index 784fb05..0021a6c 100644 --- a/packages/protocol/src/lib.rs +++ b/packages/protocol/src/lib.rs @@ -157,6 +157,26 @@ pub struct SessionCandidatePayload { pub sdp_mid: Option, pub sdp_mline_index: Option, } + +/// Direct-UDP endpoint candidate exchanged between authenticated room members. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/MatchEndpointPayload.ts")] +#[serde(rename_all = "snake_case")] +pub struct MatchEndpointPayload { + pub room_id: String, + pub endpoint: String, + pub nonce: String, +} + +/// Result of a completed direct-UDP proof, relayed to the other room member. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/MatchProbeCompletedPayload.ts")] +#[serde(rename_all = "snake_case")] +pub struct MatchProbeCompletedPayload { + pub room_id: String, + pub frames_received: u32, + pub transcript_checksum: String, +} /// Room lifecycle states. Serialized as `snake_case` strings. /// ARCHITECTURE.md §9 DB stores WAITING/READY/PLAYING/FINISHED/CANCELLED (upper); /// AGENTS.md describes WAITING→CHALLENGING→CONNECTING→PLAYING→FINISHED|CANCELLED. @@ -274,6 +294,35 @@ mod tests { assert_eq!(candidate_back.payload, candidate); } + #[test] + fn roundtrip_match_endpoint() { + let payload = MatchEndpointPayload { + room_id: "room-1".into(), + endpoint: "192.168.1.20:42000".into(), + nonce: "8a1110d5-8dd2-4ad2-9c88-ad9768bc4905".into(), + }; + let encoded = serde_json::to_string(&Envelope::new("match.endpoint", payload.clone())) + .expect("serialize endpoint"); + let decoded: Envelope = + serde_json::from_str(&encoded).expect("deserialize endpoint"); + assert_eq!(decoded.payload, payload); + } + + #[test] + fn roundtrip_match_probe_completion() { + let payload = MatchProbeCompletedPayload { + room_id: "room-1".into(), + frames_received: 60, + transcript_checksum: "0376c2e852f4fd25".into(), + }; + let encoded = + serde_json::to_string(&Envelope::new("match.probe.completed", payload.clone())) + .expect("serialize completion"); + let decoded: Envelope = + serde_json::from_str(&encoded).expect("deserialize completion"); + assert_eq!(decoded.payload, payload); + } + #[test] fn roundtrip_room() { let payload = RoomPayload { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 19f4399..f952105 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,5 +1,7 @@ import type { ChallengePayload } from "./generated/ChallengePayload.js"; import type { ChatPayload } from "./generated/ChatPayload.js"; +import type { MatchEndpointPayload } from "./generated/MatchEndpointPayload.js"; +import type { MatchProbeCompletedPayload } from "./generated/MatchProbeCompletedPayload.js"; import type { PresencePayload } from "./generated/PresencePayload.js"; import type { RoomPayload } from "./generated/RoomPayload.js"; import type { SessionAnswerPayload } from "./generated/SessionAnswerPayload.js"; @@ -9,6 +11,8 @@ import type { SessionOfferPayload } from "./generated/SessionOfferPayload.js"; export type { ChallengePayload, ChatPayload, + MatchEndpointPayload, + MatchProbeCompletedPayload, PresencePayload, RoomPayload, SessionAnswerPayload, @@ -40,6 +44,10 @@ export type EnvelopeType = | "signaling.answer" | "signaling.candidate" | "signaling.relayed" + | "match.endpoint" + | "match.endpoint.relayed" + | "match.probe.completed" + | "match.probe.completed.relayed" | "room.state" | "connection.hello" | "error" @@ -58,6 +66,10 @@ const KNOWN_ENVELOPE_TYPES: ReadonlySet = new Set([ "signaling.answer", "signaling.candidate", "signaling.relayed", + "match.endpoint", + "match.endpoint.relayed", + "match.probe.completed", + "match.probe.completed.relayed", "room.state", "connection.hello", "error",