Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,23 @@ jobs:
- name: Cache Cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: apps/client/src-tauri
workspaces: |
.
apps/client/src-tauri

- name: Build Windows LAN alpha tools
run: cargo build -p opencade-networking --bins --release --locked

- name: Upload Windows LAN alpha tools
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: opencade-lan-tools-windows
path: |
target/release/opencade-match-probe.exe
target/release/opencade-match-verify.exe
if-no-files-found: error
retention-days: 14

- name: Check desktop backend
run: cargo check --manifest-path apps/client/src-tauri/Cargo.toml --all-targets --locked
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 50 additions & 1 deletion apps/client/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 22 additions & 14 deletions apps/client/src/lib/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,19 @@ import { describe, expect, it } from "vitest";
import { buildMatchReport } from "./report.js";

describe("buildMatchReport", () => {
it("contains room correlation without credentials or local paths", () => {
const probe = {
room_id: "room-123",
local_user_id: "host",
peer_user_id: "guest",
role: "host" as const,
transport: "direct_udp" as const,
frames_sent: 64,
frames_received: 60,
transcript_checksum: "0123456789abcdef",
elapsed_ms: 240,
};

it("contains room correlation without identities, credentials, or local paths", () => {
const report = buildMatchReport(
{
id: "room-123",
Expand All @@ -11,11 +23,16 @@ describe("buildMatchReport", () => {
guest_id: "guest",
state: "finished",
},
probe,
new Date("2026-08-23T12:00:00Z")
);
const serialized = JSON.stringify(report);
expect(report.exported_at).toBe("2026-08-23T12:00:00.000Z");
expect(serialized).toContain("room-123");
expect(serialized).not.toContain("local_user_id");
expect(serialized).not.toContain("peer_user_id");
expect(serialized).not.toContain("host_id");
expect(serialized).not.toContain("guest_id");
expect(serialized).not.toContain("token");
expect(serialized).not.toContain("rom_path");
});
Expand All @@ -29,23 +46,14 @@ describe("buildMatchReport", () => {
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,
}
probe,
new Date("2026-08-23T12:00:00Z")
);

expect(report.probe).toEqual({
role: "host",
transport: "direct_udp",
frames_sent: 60,
frames_sent: 64,
frames_received: 60,
transcript_checksum: "0123456789abcdef",
elapsed_ms: 240,
Expand Down
42 changes: 14 additions & 28 deletions apps/client/src/lib/report.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,32 @@
import type { RoomPayload } from "@opencade/protocol";
import type { MatchReport, 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(),
probe?: MatchProbeReport
probe: MatchProbeReport,
now = new Date()
): 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,
},
}),
room: { id: room.id, game_id: room.game_id, state: room.state },
probe: {
role: probe.role,
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,
},
};
}

export function downloadMatchReport(room: RoomPayload, probe?: MatchProbeReport): void {
const report = buildMatchReport(room, new Date(), probe);
export function downloadMatchReport(room: RoomPayload, probe: MatchProbeReport): void {
const report = buildMatchReport(room, probe, new Date());
const url = URL.createObjectURL(
new Blob([JSON.stringify(report, null, 2)], { type: "application/json" })
);
Expand Down
5 changes: 3 additions & 2 deletions apps/client/src/routes/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export default function Match({
: state === "finished"
? "Match complete"
: `Room ${state}`;
const completedRoom = room.data?.state === "finished" ? room.data : undefined;
return (
<section className="match-stage">
<p className="eyebrow">Room {roomId.slice(0, 8)}</p>
Expand Down Expand Up @@ -93,10 +94,10 @@ export default function Match({
{isResetting ? "Resetting LAN probe…" : "Retry LAN probe"}
</button>
)}
{room.data && (
{completedRoom && probeReport && (
<button
className="secondary"
onClick={() => downloadMatchReport(room.data, probeReport ?? undefined)}
onClick={() => downloadMatchReport(completedRoom, probeReport)}
>
Export report
</button>
Expand Down
2 changes: 2 additions & 0 deletions docs/IMPLEMENTATION_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Updated 2026-08-23.
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.
- One canonical, privacy-minimized desktop/CLI evidence format, a fail-closed paired-report
verifier, and CI-built Windows LAN alpha tools.
- PostgreSQL, WebSocket, lifecycle, safe-launch, mock-match, UDP, two-process, TypeScript, and MSRV
checks in CI.

Expand Down
23 changes: 18 additions & 5 deletions docs/alpha/LAN_TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,15 @@ direct UDP frame transport; it does not claim FBNeo netplay support.
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.
7. Export the redacted report from each client. Copy both reports to one machine and verify them:

```bash
opencade-match-verify host-report.json guest-report.json
```

A pass prints JSON with `"verified":true`; a mismatch prints a stable error code to stderr and
exits non-zero. Reports deliberately omit nonces, endpoints, user identifiers, session material,
and local paths.

For transport-only diagnosis without the desktop flow, build `opencade-match-probe` and run one
process on each host with complementary arguments:
Expand All @@ -39,14 +45,21 @@ cargo run -p opencade-networking --bin opencade-match-probe -- \
```

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.
game, session key, frame count, and timeout. The command prints the same canonical, redacted JSON
format as the desktop client. Download both Windows tools from the `opencade-lan-tools-windows`
artifact on a successful `main` CI run, or build them with:

```bash
cargo build -p opencade-networking --bins --release --locked
```

## 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`.
- `opencade-match-verify` accepts the host/guest report pair: same room, game, checksum, finished
state, opposite roles, direct UDP, and exactly 60 received frames.
- The match row has `started_at` and `ended_at`.
- Reports contain no session token, password, full ROM path, or emulator binary.

Expand Down
22 changes: 14 additions & 8 deletions docs/alpha/reports/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# LAN Alpha Reports — Manual Gate

This directory is intentionally empty in the repository. Each physical LAN attempt produces one redacted JSON report via:
This directory is intentionally empty in the repository. Each physical LAN attempt produces a pair
of canonical schema-v1 redacted JSON reports via:

- Desktop: `Export Report` in Match screen (redacted `room_id`, `game_id`, `local/peer_user_id`, `role`, `transport:direct_udp`, `frames_received:60`, `transcript_checksum`, `room_state:FINISHED`)
- CLI probe: `cargo run -p opencade-networking --bin opencade-match-probe -- --local <ip:port> --peer <ip:port> --room <uuid> --game sfiii3 --local-user host --peer-user guest --role host --session-key <key> --frames 60 --timeout-ms 5000` (prints JSON)
- Desktop: `Export report` after the room reaches `finished`.
- CLI probe: `opencade-match-probe --local <ip:port> --peer <ip:port> --room <uuid> --game sfiii3 --local-user host --peer-user guest --role host --session-key <key> --frames 60 --timeout-ms 5000`.

Both producers omit identities, endpoints, nonces, session material, credentials, and local paths.

Local 10/10 proof (single box, loopback) is automated:

Expand All @@ -13,12 +16,15 @@ for i in 1..10; do cargo test -p opencade-networking --test two_process_probe --

Result 10/10 on 2026-08-24 (see CI).

Physical LAN requires 2× Windows 10/11 on same subnet, `docker compose up --build -d`, `VITE_API_URL=http://<host-lan-ip>:8080`, firewall TCP 8080 + UDP probe ports. Follow `docs/alpha/LAN_TEST.md` and save each attempt as `report-01.json` … `report-10.json` then `jq` check:
Physical LAN requires 2× Windows 10/11 on the same subnet, `docker compose up --build -d`,
`VITE_API_URL=http://<host-lan-ip>:8080`, firewall TCP 8080 + UDP probe ports. Follow
`docs/alpha/LAN_TEST.md`, save each attempt as `attempt-01-host.json` and
`attempt-01-guest.json`, then verify every pair:

```bash
ls docs/alpha/reports/*.json | wc -l # expect 10
jq -e '.room_id and .game_id=="sfiii3" and .frames_received==60 and .transcript_checksum' docs/alpha/reports/*.json
# per pair checksums must match
opencade-match-verify attempt-01-host.json attempt-01-guest.json
```

Pass is ≥8/10 COMPLETED with ordered 60-frame transcripts. Do not fabricate reports — they must come from real 2-machine runs. Until then, this gate is `HALT: physical LAN not available in this single-box env — local 10/10 + tooling ready`.
Pass is at least 8 verified attempts out of 10. Do not fabricate reports—they must come from real
two-machine runs. Until then, this gate is `HALT: physical LAN not available in this single-box
environment—local 10/10 + evidence tooling ready`.
2 changes: 2 additions & 0 deletions packages/networking/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ repository = "https://github.com/opencade/opencade"
rust-version = "1.78"

[dependencies]
chrono = "0.4"
opencade-emulator-sdk = { path = "../emulator-sdk" }
opencade-protocol = { path = "../protocol" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
Expand Down
Loading
Loading