diff --git a/.env.example b/.env.example
index 1852c903..cf55e777 100644
--- a/.env.example
+++ b/.env.example
@@ -24,3 +24,9 @@ T2_ACCOUNT_PASSWORD=
# Relay URL for the browser client (default: ws://localhost:8765)
# NEXT_PUBLIC_RELAY_URL=ws://localhost:8765
+
+# Logging (comma-separated). Bare level sets global default; module:level
+# pairs override specific modules. If only modules given, rest are silent.
+# NEXT_PUBLIC_LOG=debug
+# NEXT_PUBLIC_LOG=liveStreaming:debug,DebugSuspense:trace
+# NEXT_PUBLIC_LOG=warn,liveStreaming:debug
diff --git a/README.md b/README.md
index 38e3a348..af1799e6 100644
--- a/README.md
+++ b/README.md
@@ -119,12 +119,12 @@ This only needs to be done once — the volume persists across deploys.
**Environment variables** (all optional, with defaults):
-| Variable | Default | Description |
-| --- | --- | --- |
-| `RELAY_PORT` | `8765` | WebSocket listen port |
-| `GAME_BASE_PATH` | `docs/base` relative to relay | Path to extracted game assets |
-| `MANIFEST_PATH` | `public/manifest.json` relative to project root | Path to resource manifest |
-| `T2_MASTER_SERVER` | `master.tribesnext.com` | Master server for server list queries |
+| Variable | Default | Description |
+| ------------------ | ----------------------------------------------- | ------------------------------------- |
+| `RELAY_PORT` | `8765` | WebSocket listen port |
+| `GAME_BASE_PATH` | `docs/base` relative to relay | Path to extracted game assets |
+| `MANIFEST_PATH` | `public/manifest.json` relative to project root | Path to resource manifest |
+| `T2_MASTER_SERVER` | `master.tribesnext.com` | Master server for server list queries |
### Running scripts
diff --git a/app/layout.tsx b/app/layout.tsx
index b4d43093..c7f9f897 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -18,11 +18,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
-
+
{children}
diff --git a/app/page.tsx b/app/page.tsx
index a2ba1bfa..5abd2ec6 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,521 +1,21 @@
"use client";
-import {
- useState,
- useEffect,
- useCallback,
- Suspense,
- useRef,
- lazy,
-} from "react";
-import { Canvas, GLProps } from "@react-three/fiber";
-import { NoToneMapping, SRGBColorSpace, PCFShadowMap, Camera } from "three";
-import { Mission } from "@/src/components/Mission";
+import { Suspense } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import {
- ObserverControls,
- KEYBOARD_CONTROLS,
-} from "@/src/components/ObserverControls";
-import { KeyboardOverlay } from "@/src/components/KeyboardOverlay";
-import {
- TouchJoystick,
- TouchCameraMovement,
- type JoystickState,
-} from "@/src/components/TouchControls";
-import { KeyboardControls } from "@react-three/drei";
-import { InspectorControls } from "@/src/components/InspectorControls";
-import { useTouchDevice } from "@/src/components/useTouchDevice";
-import {
- SettingsProvider,
- useSettings,
-} from "@/src/components/SettingsProvider";
-import { ObserverCamera } from "@/src/components/ObserverCamera";
-import { AudioProvider } from "@/src/components/AudioContext";
-import { DebugElements } from "@/src/components/DebugElements";
-import { CamerasProvider } from "@/src/components/CamerasProvider";
-import {
- RecordingProvider,
- usePlaybackActions,
- useRecording,
-} from "@/src/components/RecordingProvider";
-import { EntityScene } from "@/src/components/EntityScene";
-import { TickProvider } from "@/src/components/TickProvider";
-import { SceneLighting } from "@/src/components/SceneLighting";
-import { PlayerHUD } from "@/src/components/PlayerHUD";
-import { LiveConnectionProvider } from "@/src/components/LiveConnection";
-import { useLiveSelector } from "@/src/state/liveConnectionStore";
-import { ServerBrowser } from "@/src/components/ServerBrowser";
-import {
- FeaturesProvider,
- useFeatures,
-} from "@/src/components/FeaturesProvider";
-
-// Lazy-load demo and live streaming modules — they pull in heavy dependencies
-// (demo parser, streaming engine, particles) that aren't needed for mission-only mode.
-const StreamPlayback = lazy(() =>
- import("@/src/components/StreamPlayback").then((mod) => ({
- default: mod.StreamPlayback,
- })),
-);
-const DemoPlaybackControls = lazy(() =>
- import("@/src/components/DemoPlaybackControls").then((mod) => ({
- default: mod.DemoPlaybackControls,
- })),
-);
-const LiveObserver = lazy(() =>
- import("@/src/components/LiveObserver").then((mod) => ({
- default: mod.LiveObserver,
- })),
-);
-const ChatSoundPlayer = lazy(() =>
- import("@/src/components/ChatSoundPlayer").then((mod) => ({
- default: mod.ChatSoundPlayer,
- })),
-);
-import {
- getMissionList,
- getMissionInfo,
-} from "@/src/manifest";
-import { createParser, parseAsBoolean, useQueryState } from "nuqs";
-import styles from "./page.module.css";
-
-const MapInfoDialog = lazy(() =>
- import("@/src/components/MapInfoDialog").then((mod) => ({
- default: mod.MapInfoDialog,
- })),
-);
+// import { LiveConnectionProvider } from "@/src/components/LiveConnection";
+import { FeaturesProvider } from "@/src/components/FeaturesProvider";
+import { MapInspector } from "@/src/components/MapInspector";
// Three.js has its own loaders for textures and models, but we need to load other
// stuff too, e.g. missions, terrains, and more. This client is used for those.
const queryClient = new QueryClient();
-// Renderer settings to match Tribes 2's simple rendering pipeline.
-// Tribes 2 (Torque engine, 2001) worked entirely in gamma/sRGB space with no HDR
-// or tone mapping. We disable tone mapping and ensure proper sRGB output.
-const glSettings: GLProps = {
- toneMapping: NoToneMapping,
- outputColorSpace: SRGBColorSpace,
-};
-
-type CurrentMission = {
- missionName: string;
- missionType?: string;
-};
-
-const defaultMission: CurrentMission = {
- missionName: "RiverDance",
- missionType: "CTF",
-};
-
-const parseAsMissionWithType = createParser({
- parse(query: string) {
- const [missionName, missionType] = query.split("~");
- let selectedMissionType = missionType;
- const availableMissionTypes = getMissionInfo(missionName).missionTypes;
- if (!missionType || !availableMissionTypes.includes(missionType)) {
- selectedMissionType = availableMissionTypes[0];
- }
- return { missionName, missionType: selectedMissionType };
- },
- serialize({ missionName, missionType }): string {
- const availableMissionTypes = getMissionInfo(missionName).missionTypes;
- if (availableMissionTypes.length === 1) {
- return missionName;
- }
- return `${missionName}~${missionType}`;
- },
- eq(a, b) {
- return a.missionName === b.missionName && a.missionType === b.missionType;
- },
-}).withDefault(defaultMission);
-
-function MapInspector() {
- const [currentMission, setCurrentMission] = useQueryState(
- "mission",
- parseAsMissionWithType,
- );
- const [fogEnabledOverride, setFogEnabledOverride] = useQueryState(
- "fog",
- parseAsBoolean,
- );
-
- const clearFogEnabledOverride = useCallback(() => {
- setFogEnabledOverride(null);
- }, [setFogEnabledOverride]);
-
- const changeMission = useCallback(
- (mission: CurrentMission) => {
- window.location.hash = "";
- clearFogEnabledOverride();
- setCurrentMission(mission);
- },
- [setCurrentMission, clearFogEnabledOverride],
- );
-
- const isTouch = useTouchDevice();
- const features = useFeatures();
- const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
- const liveReady = useLiveSelector((s) => s.liveReady);
- const gameStatus = useLiveSelector((s) => s.gameStatus);
- const { missionName, missionType } = currentMission;
- const [mapInfoOpen, setMapInfoOpen] = useState(false);
- const [serverBrowserOpen, setServerBrowserOpen] = useState(false);
- const [missionLoadingProgress, setMissionLoadingProgress] = useState(0);
- const [showLoadingIndicator, setShowLoadingIndicator] = useState(true);
-
- // During live join, show progress based on connection status.
- // Relay status order: connecting → challenging → authenticating → connected.
- // Once liveReady (first ghost arrives), loading is complete.
- const liveLoadingProgress = hasLiveAdapter
- ? liveReady
- ? 1
- : gameStatus === "connected" ? 0.8
- : gameStatus === "authenticating" ? 0.6
- : gameStatus === "challenging" ? 0.3
- : gameStatus === "connecting" ? 0.2
- : 0.1
- : null;
-
- // Reset stale mission progress when live mode takes over, so it can't
- // flash through if liveLoadingProgress briefly becomes null.
- useEffect(() => {
- if (liveLoadingProgress != null) {
- setMissionLoadingProgress(0);
- }
- }, [liveLoadingProgress != null]); // eslint-disable-line react-hooks/exhaustive-deps
-
- const loadingProgress = liveLoadingProgress ?? missionLoadingProgress;
- const isLoading = loadingProgress < 1;
-
- // Keep the loading indicator visible briefly after reaching 100%
- useEffect(() => {
- if (isLoading) {
- setShowLoadingIndicator(true);
- } else {
- const timer = setTimeout(() => setShowLoadingIndicator(false), 500);
- return () => clearTimeout(timer);
- }
- }, [isLoading]);
-
- useEffect(() => {
- // For automation, like the t2-maps app!
- window.setMissionName = (missionName: string) => {
- const availableMissionTypes = getMissionInfo(missionName).missionTypes;
- changeMission({
- missionName,
- missionType: availableMissionTypes[0],
- });
- };
- window.getMissionList = getMissionList;
- window.getMissionInfo = getMissionInfo;
-
- return () => {
- delete window.setMissionName;
- delete window.getMissionList;
- delete window.getMissionInfo;
- };
- }, [changeMission]);
-
- useEffect(() => {
- const handleKey = (e: KeyboardEvent) => {
- if (e.code !== "KeyI" || e.metaKey || e.ctrlKey || e.altKey) return;
- const target = e.target as HTMLElement;
- if (
- target.tagName === "INPUT" ||
- target.tagName === "TEXTAREA" ||
- target.isContentEditable
- ) {
- return;
- }
- setMapInfoOpen(true);
- };
- window.addEventListener("keydown", handleKey);
- return () => window.removeEventListener("keydown", handleKey);
- }, []);
-
- const handleLoadingChange = useCallback(
- (_loading: boolean, progress: number = 0) => {
- setMissionLoadingProgress(progress);
- },
- [],
- );
-
- const cameraRef = useRef(null);
- const joystickStateRef = useRef({ angle: 0, force: 0 });
- const joystickZoneRef = useRef(null);
- const lookJoystickStateRef = useRef({ angle: 0, force: 0 });
- const lookJoystickZoneRef = useRef(null);
-
- return (
-
-
-
-
-
-
- {showLoadingIndicator && (
-
-
-
-
- {Math.round(loadingProgress * 100)}%
-
-
- )}
-
-
-
- {isTouch && (
-
- )}
- {isTouch === false && }
- setMapInfoOpen(true)}
- onOpenServerBrowser={features.live ? () => setServerBrowserOpen(true) : undefined}
- cameraRef={cameraRef}
- isTouch={isTouch}
- />
- {mapInfoOpen && (
-
- setMapInfoOpen(false)}
- missionName={missionName}
- missionType={missionType ?? ""}
- />
-
- )}
- setServerBrowserOpen(false)}
- />
-
-
-
-
-
-
-
- );
-}
-
-/**
- * Only mount Mission (TorqueScript runtime, .mis loading) when NOT streaming.
- * During demo/live playback, all scene data comes from ghosts — no need for
- * the heavy TorqueScript execution pipeline.
- */
-function MissionWhenIdle({
- missionName,
- missionType,
- onLoadingChange,
-}: {
- missionName: string;
- missionType: string;
- onLoadingChange: (isLoading: boolean, progress?: number) => void;
-}) {
- const recording = useRecording();
- const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
- const isStreaming = recording != null || hasLiveAdapter;
-
- if (isStreaming) return null;
-
- return (
-
- );
-}
-
-/**
- * In-Canvas components that depend on streaming mode. Mounts the appropriate
- * controller (StreamPlayback or LiveObserver) and disables observer controls
- * during streaming.
- */
-function StreamingComponents({
- isTouch,
- joystickStateRef,
- joystickZoneRef,
- lookJoystickStateRef,
- lookJoystickZoneRef,
-}: {
- isTouch: boolean | null;
- joystickStateRef: React.RefObject;
- joystickZoneRef: React.RefObject;
- lookJoystickStateRef: React.RefObject;
- lookJoystickZoneRef: React.RefObject;
-}) {
- const recording = useRecording();
- const isLive = useLiveSelector((s) => s.adapter != null);
- const isStreaming = recording != null || isLive;
-
- // Show ObserverControls for: non-streaming mode, OR live mode.
- // During live, ObserverControls provides the same camera controls
- // (pointer lock, drag-to-rotate, WASD fly) and LiveObserver intercepts
- // click-while-locked to cycle observed players instead of nextCamera.
- // During demo playback, the demo drives the camera so no controls needed.
- const showObserverControls = !isStreaming || isLive;
-
- return (
- <>
- {recording && (
-
-
-
- )}
- {isLive && (
-
-
-
- )}
- {isStreaming && (
-
-
-
- )}
- {showObserverControls && isTouch !== null && (
- isTouch ? (
-
- ) : (
-
- )
- )}
- >
- );
-}
-
-/** HUD overlay — shown during streaming (demo or live). */
-function StreamingHUD() {
- const recording = useRecording();
- const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
- if (!recording && !hasLiveAdapter) return null;
- return ;
-}
-
-/** Playback controls overlay — only shown during demo playback. */
-function StreamingOverlay() {
- const recording = useRecording();
- const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
- if (!recording || hasLiveAdapter) return null;
- return (
-
-
-
- );
-}
-
-/** Server browser dialog connected to live state. */
-function ServerBrowserDialog({
- open,
- onClose,
-}: {
- open: boolean;
- onClose: () => void;
-}) {
- const servers = useLiveSelector((s) => s.servers);
- const serversLoading = useLiveSelector((s) => s.serversLoading);
- const browserToRelayPing = useLiveSelector((s) => s.browserToRelayPing);
- const listServers = useLiveSelector((s) => s.listServers);
- const joinServer = useLiveSelector((s) => s.joinServer);
- const settings = useSettings();
- const handleJoin = useCallback(
- (address: string) => {
- joinServer(address, settings?.warriorName);
- },
- [joinServer, settings?.warriorName],
- );
- return (
- settings?.setWarriorName(name)}
- />
- );
-}
-
-/** Exposes `window.loadDemoRecording` for automation/testing. */
-function DemoWindowAPI() {
- const { setRecording } = usePlaybackActions();
-
- useEffect(() => {
- window.loadDemoRecording = setRecording;
- return () => {
- delete window.loadDemoRecording;
- };
- }, [setRecording]);
-
- return null;
-}
-
export default function HomePage() {
return (
-
+
-
+
);
diff --git a/app/shapes/page.tsx b/app/shapes/page.tsx
index eeb58ba7..76222092 100644
--- a/app/shapes/page.tsx
+++ b/app/shapes/page.tsx
@@ -13,16 +13,13 @@ import * as THREE from "three";
import { NoToneMapping, SRGBColorSpace, PCFShadowMap } from "three";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { OrbitControls, Center, Bounds, useBounds } from "@react-three/drei";
-import {
- SettingsProvider,
- useDebug,
-} from "@/src/components/SettingsProvider";
+import { SettingsProvider, useDebug } from "@/src/components/SettingsProvider";
import { ShapeRenderer, useStaticShape } from "@/src/components/GenericShape";
import { ShapeInfoProvider } from "@/src/components/ShapeInfoProvider";
import { DebugElements } from "@/src/components/DebugElements";
import { TickProvider } from "@/src/components/TickProvider";
import { ShapeSelect } from "@/src/components/ShapeSelect";
-import { engineStore, useEngineSelector } from "@/src/state";
+import { engineStore, useEngineSelector } from "@/src/state/engineStore";
import {
getResourceList,
getResourceMap,
@@ -328,7 +325,7 @@ function ShapeInspector() {
-
+
///
-import "./.next/types/routes.d.ts";
+import "./docs/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/next.config.ts b/next.config.ts
index 2958a932..dc957e10 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -10,6 +10,7 @@ const nextConfig = (phase, { defaultConfig }): NextConfig => {
assetPrefix: "/t2-mapper/",
trailingSlash: true,
reactCompiler: true,
+ experimental: { viewTransition: true },
headers:
// TorqueScript files should be served as text. This won't affect what
// GitHub Pages does with the static export, but it'll at least improve
diff --git a/package-lock.json b/package-lock.json
index 5508e614..50d4ea13 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@ariakit/react": "^0.4.21",
+ "@radix-ui/react-accordion": "^1.2.12",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tanstack/react-query": "^5.90.21",
@@ -1964,6 +1965,279 @@
"node": ">=10"
}
},
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-accordion": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
+ "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collapsible": "1.1.12",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collapsible": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
+ "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
+ "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+ "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
+ "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@react-three/drei": {
"version": "10.7.7",
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
diff --git a/package.json b/package.json
index a3f6125f..3a8050ac 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
},
"dependencies": {
"@ariakit/react": "^0.4.21",
+ "@radix-ui/react-accordion": "^1.2.12",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tanstack/react-query": "^5.90.21",
diff --git a/relay/HuffmanWriter.ts b/relay/HuffmanWriter.ts
index 84b23ca0..c9518056 100644
--- a/relay/HuffmanWriter.ts
+++ b/relay/HuffmanWriter.ts
@@ -2,32 +2,24 @@ import { BitStreamWriter } from "./BitStreamWriter.js";
/** Hardcoded character frequency table from the V12 engine (bitStream.cc). */
const CSM_CHAR_FREQS: number[] = [
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 329, 21, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
- 2809, 68, 0, 27, 0, 58, 3, 62, 4, 7, 0, 0, 15, 65, 554, 3,
- 394, 404, 189, 117, 30, 51, 27, 15, 34, 32, 80, 1, 142, 3, 142, 39,
- 0, 144, 125, 44, 122, 275, 70, 135, 61, 127, 8, 12, 113, 246, 122, 36,
- 185, 1, 149, 309, 335, 12, 11, 14, 54, 151, 0, 0, 2, 0, 0, 211,
- 0, 2090, 344, 736, 993, 2872, 701, 605, 646, 1552, 328, 305, 1240, 735, 1533, 1713,
- 562, 3, 1775, 1149, 1469, 979, 407, 553, 59, 279, 31, 0, 0, 0, 68, 0,
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 329, 21, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 2809, 68, 0, 27, 0, 58, 3, 62, 4, 7, 0, 0, 15, 65, 554,
+ 3, 394, 404, 189, 117, 30, 51, 27, 15, 34, 32, 80, 1, 142, 3, 142, 39, 0, 144,
+ 125, 44, 122, 275, 70, 135, 61, 127, 8, 12, 113, 246, 122, 36, 185, 1, 149,
+ 309, 335, 12, 11, 14, 54, 151, 0, 0, 2, 0, 0, 211, 0, 2090, 344, 736, 993,
+ 2872, 701, 605, 646, 1552, 328, 305, 1240, 735, 1533, 1713, 562, 3, 1775,
+ 1149, 1469, 979, 407, 553, 59, 279, 31, 0, 0, 0, 68, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
];
const PROB_BOOST = 1;
function isAlphaNumeric(c: number): boolean {
- return (
- (c >= 48 && c <= 57) ||
- (c >= 65 && c <= 90) ||
- (c >= 97 && c <= 122)
- );
+ return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
}
interface HuffLeaf {
@@ -62,7 +54,8 @@ function buildTables(): void {
leaves = [];
for (let i = 0; i < 256; i++) {
leaves.push({
- pop: CSM_CHAR_FREQS[i] + (isAlphaNumeric(i) ? PROB_BOOST : 0) + PROB_BOOST,
+ pop:
+ CSM_CHAR_FREQS[i] + (isAlphaNumeric(i) ? PROB_BOOST : 0) + PROB_BOOST,
symbol: i,
numBits: 0,
code: 0,
diff --git a/relay/auth.ts b/relay/auth.ts
index 13da7616..bec39842 100644
--- a/relay/auth.ts
+++ b/relay/auth.ts
@@ -317,7 +317,10 @@ export class T2csriAuth {
// Sanitize: must be hex only
const challenge = this.encryptedChallenge.toLowerCase();
authLog.info(
- { challengeLen: challenge.length, clientChallengeLen: this.clientChallenge.length },
+ {
+ challengeLen: challenge.length,
+ clientChallengeLen: this.clientChallenge.length,
+ },
"Auth: starting challenge decryption",
);
for (let i = 0; i < challenge.length; i++) {
@@ -339,7 +342,11 @@ export class T2csriAuth {
const modulusHex = fields[3];
authLog.debug(
- { encryptedLen: challenge.length, modulusLen: modulusHex?.length, privateKeyLen: this.credentials.privateKey.length },
+ {
+ encryptedLen: challenge.length,
+ modulusLen: modulusHex?.length,
+ privateKeyLen: this.credentials.privateKey.length,
+ },
"Auth: RSA parameters",
);
diff --git a/relay/crc.ts b/relay/crc.ts
index f6600105..42a599dc 100644
--- a/relay/crc.ts
+++ b/relay/crc.ts
@@ -49,7 +49,9 @@ export interface CRCDataBlock {
}
// Manifest types (mirrored from src/manifest.ts)
-type SourceTuple = [sourcePath: string] | [sourcePath: string, actualPath: string];
+type SourceTuple =
+ | [sourcePath: string]
+ | [sourcePath: string, actualPath: string];
type ResourceEntry = [firstSeenPath: string, ...SourceTuple[]];
interface Manifest {
resources: Record;
@@ -120,8 +122,8 @@ export async function computeGameCRC(
console.log(
`[crc] starting computation: seed=0x${(seed >>> 0).toString(16)}, ` +
- `${sorted.length} ShapeBaseData datablocks (of ${datablocks.length} total), ` +
- `includeTextures=${includeTextures}`,
+ `${sorted.length} ShapeBaseData datablocks (of ${datablocks.length} total), ` +
+ `includeTextures=${includeTextures}`,
);
for (const db of sorted) {
@@ -153,7 +155,7 @@ export async function computeGameCRC(
console.log(
`[crc] #${filesFound} id=${db.objectId} ${db.className} "${db.shapeName}" ` +
- `size=${data.length} crc=0x${prevCrc.toString(16)}→0x${crc.toString(16)}`,
+ `size=${data.length} crc=0x${prevCrc.toString(16)}→0x${crc.toString(16)}`,
);
// TODO: If includeTextures && db.className !== "PlayerData",
@@ -172,7 +174,7 @@ export async function computeGameCRC(
console.log(
`[crc] RESULT: ${filesFound} files CRC'd, ${filesMissing} missing, ` +
- `crc=0x${crc.toString(16)}, totalSize=${totalSize}, elapsed=${elapsed.toFixed(0)}ms`,
+ `crc=0x${crc.toString(16)}, totalSize=${totalSize}, elapsed=${elapsed.toFixed(0)}ms`,
);
return { crc, totalSize };
diff --git a/relay/gameConnection.ts b/relay/gameConnection.ts
index 3a5567a4..fec2e255 100644
--- a/relay/gameConnection.ts
+++ b/relay/gameConnection.ts
@@ -58,7 +58,10 @@ export class GameConnection extends EventEmitter {
private nextSendEventSeq = 0;
private pendingEvents: ClientEvent[] = [];
/** Events sent but not yet acked, keyed by packet sequence number. */
- private sentEventsByPacket = new Map();
+ private sentEventsByPacket = new Map<
+ number,
+ { seq: number; event: ClientEvent }[]
+ >();
/** Events waiting to be sent (new or retransmitted from lost packets). */
private eventSendQueue: { seq: number; event: ClientEvent }[] = [];
private stringTable = new ClientNetStringTable();
@@ -178,7 +181,11 @@ export class GameConnection extends EventEmitter {
this.rawMessageCount++;
if (this.rawMessageCount <= 30 || this.rawMessageCount % 50 === 0) {
connLog.debug(
- { bytes: msg.length, firstByte: msg[0], rawTotal: this.rawMessageCount },
+ {
+ bytes: msg.length,
+ firstByte: msg[0],
+ rawTotal: this.rawMessageCount,
+ },
"Raw UDP message received",
);
}
@@ -225,11 +232,16 @@ export class GameConnection extends EventEmitter {
case 36: // ConnectAccept
this.handleConnectAccept(msg);
break;
- case 38: { // Disconnect — U8(type) + U32(seq1) + U32(seq2) + HuffString(reason)
+ case 38: {
+ // Disconnect — U8(type) + U32(seq1) + U32(seq2) + HuffString(reason)
let reason = "Server disconnected";
if (msg.length > 9) {
try {
- const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
+ const data = new Uint8Array(
+ msg.buffer,
+ msg.byteOffset,
+ msg.byteLength,
+ );
// Skip 9-byte header (1 type + 4 connectSeq + 4 connectSeq2).
// Reason is Huffman-encoded via BitStream::writeString (no stringBuffer).
const bs = new BitStream(data.subarray(9));
@@ -239,7 +251,10 @@ export class GameConnection extends EventEmitter {
// Fall back to default reason
}
}
- connLog.warn({ reason, bytes: msg.length }, "Server sent Disconnect packet");
+ connLog.warn(
+ { reason, bytes: msg.length },
+ "Server sent Disconnect packet",
+ );
this.setStatus("disconnected", reason);
this.disconnect();
break;
@@ -249,16 +264,24 @@ export class GameConnection extends EventEmitter {
}
}
- /** Handle ChallengeReject (type 28): U8(28) + U32(connectSeq) + ASCII reason. */
+ /** Handle ChallengeReject (type 28): U8(28) + U32(clientSeq) + HuffString(reason). */
private handleChallengeReject(msg: Buffer): void {
+ if (msg.length < 5) return;
+ const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
+ const seq = dv.getUint32(1, true);
+ if (seq !== this.clientConnectSequence) {
+ connLog.debug({ expected: this.clientConnectSequence, got: seq }, "ChallengeReject sequence mismatch, ignoring");
+ return;
+ }
let reason = "Challenge rejected";
if (msg.length > 5) {
- const chars: number[] = [];
- for (let i = 5; i < msg.length && msg[i] !== 0; i++) {
- chars.push(msg[i]);
- }
- if (chars.length > 0) {
- reason = String.fromCharCode(...chars);
+ try {
+ const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
+ const bs = new BitStream(data.subarray(5));
+ const parsed = bs.readString();
+ if (parsed) reason = parsed;
+ } catch {
+ // Fall back to default reason
}
}
connLog.warn({ reason }, "ChallengeReject received");
@@ -269,18 +292,11 @@ export class GameConnection extends EventEmitter {
/** Handle ConnectChallengeResponse. */
private handleChallengeResponse(msg: Buffer): void {
if (msg.length < 14) {
- connLog.error(
- { bytes: msg.length },
- "ChallengeResponse too short",
- );
+ connLog.error({ bytes: msg.length }, "ChallengeResponse too short");
return;
}
- const dv = new DataView(
- msg.buffer,
- msg.byteOffset,
- msg.byteLength,
- );
+ const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
const serverProtocolVersion = dv.getUint32(1, true);
this.serverConnectSequence = dv.getUint32(5, true);
const echoedClientSeq = dv.getUint32(9, true);
@@ -354,14 +370,30 @@ export class GameConnection extends EventEmitter {
}
/** Handle ConnectReject. */
+ /** Handle ConnectReject (type 34): U8(34) + U32(serverSeq) + U32(clientSeq) + HuffString(reason). */
private handleConnectReject(msg: Buffer): void {
+ if (msg.length < 9) return;
+ const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
+ const serverSeq = dv.getUint32(1, true);
+ const clientSeq = dv.getUint32(5, true);
+ if (serverSeq !== this.serverConnectSequence || clientSeq !== this.clientConnectSequence) {
+ connLog.debug(
+ { expectedServer: this.serverConnectSequence, gotServer: serverSeq,
+ expectedClient: this.clientConnectSequence, gotClient: clientSeq },
+ "ConnectReject sequence mismatch, ignoring",
+ );
+ return;
+ }
let reason = "Connection rejected";
- if (msg.length > 1) {
- const chars: number[] = [];
- for (let i = 1; i < msg.length && msg[i] !== 0; i++) {
- chars.push(msg[i]);
+ if (msg.length > 9) {
+ try {
+ const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
+ const bs = new BitStream(data.subarray(9));
+ const parsed = bs.readString();
+ if (parsed) reason = parsed;
+ } catch {
+ // Fall back to default reason
}
- reason = String.fromCharCode(...chars);
}
connLog.warn({ reason }, "ConnectReject received");
this.setStatus("disconnected", reason);
@@ -385,7 +417,6 @@ export class GameConnection extends EventEmitter {
// We still need to process the dnet header locally to track ack state
this.processPacketForAcks(data);
-
}
/** Process a packet's dnet header to maintain ack state. */
@@ -415,7 +446,10 @@ export class GameConnection extends EventEmitter {
// The server's processRawPacket calls sendPingResponse on receiving a
// PingPacket. Without this response, the server may time us out.
if (packetType === 1) {
- connLog.debug({ seq: seqNumber }, "Received PingPacket, sending ping response");
+ connLog.debug(
+ { seq: seqNumber },
+ "Received PingPacket, sending ping response",
+ );
const pingResponse = this.protocol.buildPingPacket();
this.sendRaw(pingResponse);
}
@@ -472,19 +506,15 @@ export class GameConnection extends EventEmitter {
}
/** Handle a parsed T2csri event from the browser. */
- handleAuthEvent(
- eventName: string,
- args: string[],
- ): void {
+ handleAuthEvent(eventName: string, args: string[]): void {
if (!this.auth) return;
switch (eventName) {
case "t2csri_pokeClient": {
- connLog.info("Auth: received pokeClient, sending certificate + challenge");
- const result = this.auth.onPokeClient(
- args[0] || "",
- this.host,
+ connLog.info(
+ "Auth: received pokeClient, sending certificate + challenge",
);
+ const result = this.auth.onPokeClient(args[0] || "", this.host);
for (const cmd of result.commands) {
this.sendCommand(cmd.name, ...cmd.args);
}
@@ -512,10 +542,7 @@ export class GameConnection extends EventEmitter {
this.authDelayTimer = setTimeout(() => {
this.authDelayTimer = null;
if (this._status !== "authenticating") return;
- this.sendCommand(
- result.command.name,
- ...result.command.args,
- );
+ this.sendCommand(result.command.name, ...result.command.args);
this.enforceObserver();
this.setStatus("connected");
}, delay);
@@ -553,11 +580,20 @@ export class GameConnection extends EventEmitter {
basePath: string,
): Promise {
connLog.info(
- { seed: `0x${(seed >>> 0).toString(16)}`, datablocks: datablocks.length, includeTextures },
+ {
+ seed: `0x${(seed >>> 0).toString(16)}`,
+ datablocks: datablocks.length,
+ includeTextures,
+ },
"Computing CRC over game files",
);
try {
- const { crc, totalSize } = await computeGameCRC(seed, datablocks, basePath, includeTextures);
+ const { crc, totalSize } = await computeGameCRC(
+ seed,
+ datablocks,
+ basePath,
+ includeTextures,
+ );
connLog.info(
{ crc: `0x${(crc >>> 0).toString(16)}`, totalSize },
"CRC computed, sending response",
@@ -586,7 +622,10 @@ export class GameConnection extends EventEmitter {
/** Send a commandToServer as a RemoteCommandEvent. */
sendCommand(command: string, ...args: string[]): void {
- connLog.debug({ command, args, eventSeq: this.nextSendEventSeq }, "Sending commandToServer");
+ connLog.debug(
+ { command, args, eventSeq: this.nextSendEventSeq },
+ "Sending commandToServer",
+ );
const events = buildRemoteCommandEvent(this.stringTable, command, ...args);
this.pendingEvents.push(...events);
this.flushEvents();
@@ -608,9 +647,7 @@ export class GameConnection extends EventEmitter {
* Build and send a data packet that includes events from the send queue.
* Events stay tracked per-packet so they can be re-queued on loss.
*/
- private sendDataPacketWithEvents(
- move?: ClientMoveData,
- ): void {
+ private sendDataPacketWithEvents(move?: ClientMoveData): void {
const events = this.eventSendQueue.splice(0);
if (events.length === 0) return;
@@ -631,8 +668,12 @@ export class GameConnection extends EventEmitter {
this.sentEventsByPacket.set(packetSeq, events);
const moveData = move ?? {
- x: 0, y: 0, z: 0,
- yaw: 0, pitch: 0, roll: 0,
+ x: 0,
+ y: 0,
+ z: 0,
+ yaw: 0,
+ pitch: 0,
+ roll: 0,
freeLook: false,
trigger: [false, false, false, false, false, false],
};
@@ -702,7 +743,14 @@ export class GameConnection extends EventEmitter {
this.sendMoveCount++;
if (this.sendMoveCount <= 5 || this.sendMoveCount % 100 === 0) {
connLog.debug(
- { yaw: move.yaw, pitch: move.pitch, x: move.x, y: move.y, z: move.z, total: this.sendMoveCount },
+ {
+ yaw: move.yaw,
+ pitch: move.pitch,
+ x: move.x,
+ y: move.y,
+ z: move.z,
+ total: this.sendMoveCount,
+ },
"Sending move",
);
}
@@ -779,7 +827,8 @@ export class GameConnection extends EventEmitter {
let keepaliveCount = 0;
this.keepaliveTimer = setInterval(() => {
keepaliveCount++;
- if (keepaliveCount % 300 === 0) { // ~10s at 32ms tick rate
+ if (keepaliveCount % 300 === 0) {
+ // ~10s at 32ms tick rate
connLog.info(
{
dataPackets: this.dataPacketCount,
diff --git a/relay/masterQuery.ts b/relay/masterQuery.ts
index f905f945..e22175fa 100644
--- a/relay/masterQuery.ts
+++ b/relay/masterQuery.ts
@@ -133,7 +133,12 @@ async function queryServers(addresses: string[]): Promise {
if (info) {
pingResults.set(addr, info);
masterLog.debug(
- { addr, name: info.name, build: info.buildVersion, ping: info.ping },
+ {
+ addr,
+ name: info.name,
+ build: info.buildVersion,
+ ping: info.ping,
+ },
"Ping response",
);
}
@@ -246,10 +251,7 @@ async function queryServers(addresses: string[]): Promise {
* U32 buildVersion (e.g. 25034)
* HuffString serverName (24 chars max)
*/
-function parsePingResponse(
- data: Buffer,
- sendTime?: number,
-): PingInfo | null {
+function parsePingResponse(data: Buffer, sendTime?: number): PingInfo | null {
if (data.length < 7 || data[0] !== 16) return null;
try {
const bs = new BitStream(
@@ -301,7 +303,15 @@ function parseInfoResponse(data: Buffer): GameInfo | null {
const playerCount = bs.readU8();
const maxPlayers = bs.readU8();
const botCount = bs.readU8();
- return { mod, gameType, mapName, status, playerCount, maxPlayers, botCount };
+ return {
+ mod,
+ gameType,
+ mapName,
+ status,
+ playerCount,
+ maxPlayers,
+ botCount,
+ };
} catch {
return null;
}
diff --git a/relay/protocol.ts b/relay/protocol.ts
index 7e040c4f..9cec6948 100644
--- a/relay/protocol.ts
+++ b/relay/protocol.ts
@@ -29,9 +29,7 @@ export class ConnectionProtocol {
private _sendCount = 0;
- buildSendPacketHeader(
- packetType: number = DataPacket,
- ): BitStreamWriter {
+ buildSendPacketHeader(packetType: number = DataPacket): BitStreamWriter {
const bs = new BitStreamWriter(1500);
// gameFlag — always true for data connection packets
@@ -42,8 +40,7 @@ export class ConnectionProtocol {
// Increment send sequence
this.lastSendSeq = (this.lastSendSeq + 1) >>> 0;
- this.lastSeqRecvdAtSend[this.lastSendSeq & 0x1f] =
- this.lastSeqRecvd >>> 0;
+ this.lastSeqRecvdAtSend[this.lastSendSeq & 0x1f] = this.lastSeqRecvd >>> 0;
// seqNumber (9 bits)
bs.writeInt(this.lastSendSeq & 0x1ff, 9);
@@ -71,12 +68,13 @@ export class ConnectionProtocol {
this._sendCount++;
if (this._sendCount <= 30 || this._sendCount % 50 === 0) {
- const typeName = packetType === 0 ? "data" : packetType === 1 ? "ping" : "ack";
+ const typeName =
+ packetType === 0 ? "data" : packetType === 1 ? "ping" : "ack";
console.log(
`[proto] SEND #${this._sendCount} seq=${this.lastSendSeq} ` +
- `highestAck=${this.lastSeqRecvd} type=${typeName} ` +
- `ackBytes=${ackByteCount} mask=0x${mask.toString(16).padStart(8, "0")} ` +
- `(${mask.toString(2).replace(/^0+/, "") || "0"})`,
+ `highestAck=${this.lastSeqRecvd} type=${typeName} ` +
+ `ackBytes=${ackByteCount} mask=0x${mask.toString(16).padStart(8, "0")} ` +
+ `(${mask.toString(2).replace(/^0+/, "") || "0"})`,
);
}
@@ -131,8 +129,7 @@ export class ConnectionProtocol {
const isAcked =
(header.ackMask & (1 << ((highestAck - ackSeq) & 0x1f))) !== 0;
if (isAcked) {
- this.lastRecvAckAck =
- this.lastSeqRecvdAtSend[ackSeq & 0x1f] >>> 0;
+ this.lastRecvAckAck = this.lastSeqRecvdAtSend[ackSeq & 0x1f] >>> 0;
}
if (this.onNotify) {
this.onNotify(ackSeq, isAcked);
@@ -144,8 +141,7 @@ export class ConnectionProtocol {
this.highestAckedSeq = highestAck;
const dispatchData =
- this.lastSeqRecvd !== seqNumber &&
- header.packetType === DataPacket;
+ this.lastSeqRecvd !== seqNumber && header.packetType === DataPacket;
this.lastSeqRecvd = seqNumber;
return { accepted: true, dispatchData };
@@ -168,9 +164,7 @@ export class ConnectionProtocol {
* The caller provides a callback that writes game data to the stream
* after the dnet header.
*/
- buildDataPacket(
- writePayload: (bs: BitStreamWriter) => void,
- ): Uint8Array {
+ buildDataPacket(writePayload: (bs: BitStreamWriter) => void): Uint8Array {
const bs = this.buildSendPacketHeader(DataPacket);
writePayload(bs);
return bs.getBuffer();
@@ -343,10 +337,7 @@ export class ClientNetStringTable {
}
/** Build a NetStringEvent to register a string with the server. */
-export function buildNetStringEvent(
- id: number,
- value: string,
-): ClientEvent {
+export function buildNetStringEvent(id: number, value: string): ClientEvent {
return {
classId: NetStringEventClassId,
write(bs: BitStreamWriter) {
@@ -489,9 +480,7 @@ export function buildConnectRequest(
}
/** Build a Disconnect (type 38) OOB packet. */
-export function buildDisconnectPacket(
- connectSequence: number,
-): Uint8Array {
+export function buildDisconnectPacket(connectSequence: number): Uint8Array {
const bs = new BitStreamWriter(64);
bs.writeU8(38); // Disconnect type
bs.writeU32(connectSequence);
diff --git a/relay/server.ts b/relay/server.ts
index 99bf3de3..d47c67a5 100644
--- a/relay/server.ts
+++ b/relay/server.ts
@@ -19,8 +19,7 @@ const MANIFEST_PATH =
path.resolve(GAME_BASE_PATH, "..", "..", "public", "manifest.json");
const RELAY_PORT = parseInt(process.env.RELAY_PORT || "8765", 10);
-const MASTER_SERVER =
- process.env.T2_MASTER_SERVER || "master.tribesnext.com";
+const MASTER_SERVER = process.env.T2_MASTER_SERVER || "master.tribesnext.com";
/** HTTP server for health checks; WebSocket upgrades are handled separately. */
const httpServer = http.createServer(async (req, res) => {
@@ -58,7 +57,9 @@ const httpServer = http.createServer(async (req, res) => {
const allOk = Object.values(checks).every((c) => c.ok);
res.writeHead(allOk ? 200 : 503, { "Content-Type": "application/json" });
- res.end(JSON.stringify({ status: allOk ? "ok" : "degraded", checks }, null, 2));
+ res.end(
+ JSON.stringify({ status: allOk ? "ok" : "degraded", checks }, null, 2),
+ );
return;
}
@@ -87,7 +88,11 @@ wss.on("connection", (ws) => {
const RETRY_DELAY_MS = 6000;
const RETRYABLE_REASONS = ["Server is cycling mission"];
- async function connectToServer(ws: WebSocket, address: string, warriorName?: string): Promise {
+ async function connectToServer(
+ ws: WebSocket,
+ address: string,
+ warriorName?: string,
+ ): Promise {
if (gameConnection) {
gameConnection.disconnect();
}
@@ -95,9 +100,7 @@ wss.on("connection", (ws) => {
gameConnection = new GameConnection(address, { warriorName });
// Set mapName from the cached server list if available.
- const cachedServer = cachedServers.find(
- (s) => s.address === address,
- );
+ const cachedServer = cachedServers.find((s) => s.address === address);
if (cachedServer?.mapName) {
gameConnection.setMapName(cachedServer.mapName);
}
@@ -123,7 +126,11 @@ wss.on("connection", (ws) => {
) {
retryCount++;
relayLog.info(
- { attempt: retryCount, maxRetries: MAX_RETRIES, delay: RETRY_DELAY_MS },
+ {
+ attempt: retryCount,
+ maxRetries: MAX_RETRIES,
+ delay: RETRY_DELAY_MS,
+ },
"Retryable disconnect — will reconnect",
);
sendToClient(ws, {
@@ -243,7 +250,10 @@ wss.on("connection", (ws) => {
}
case "joinServer": {
- relayLog.info({ address: message.address, warriorName: message.warriorName }, "Join server requested");
+ relayLog.info(
+ { address: message.address, warriorName: message.warriorName },
+ "Join server requested",
+ );
if (gameConnection) {
relayLog.info("Disconnecting existing game connection");
gameConnection.disconnect();
@@ -285,10 +295,7 @@ wss.on("connection", (ws) => {
{ event: message.command },
"Forwarding auth event from browser",
);
- gameConnection.handleAuthEvent(
- message.command,
- message.args,
- );
+ gameConnection.handleAuthEvent(message.command, message.args);
} else {
relayLog.debug(
{ command: message.command },
@@ -315,7 +322,10 @@ wss.on("connection", (ws) => {
case "sendCRCCompute": {
if (gameConnection) {
relayLog.info(
- { datablocks: message.datablocks.length, includeTextures: message.includeTextures },
+ {
+ datablocks: message.datablocks.length,
+ includeTextures: message.includeTextures,
+ },
"Computing CRC from game files",
);
gameConnection.computeAndSendCRC(
diff --git a/relay/types.ts b/relay/types.ts
index 4e368f45..605fe265 100644
--- a/relay/types.ts
+++ b/relay/types.ts
@@ -5,15 +5,32 @@ export type ClientMessage =
| { type: "disconnect" }
| { type: "sendMove"; move: ClientMove }
| { type: "sendCommand"; command: string; args: string[] }
- | { type: "sendCRCResponse"; crcValue: number; field1: number; field2: number }
- | { type: "sendCRCCompute"; seed: number; field2: number; includeTextures: boolean; datablocks: { objectId: number; className: string; shapeName: string }[] }
+ | {
+ type: "sendCRCResponse";
+ crcValue: number;
+ field1: number;
+ field2: number;
+ }
+ | {
+ type: "sendCRCCompute";
+ seed: number;
+ field2: number;
+ includeTextures: boolean;
+ datablocks: { objectId: number; className: string; shapeName: string }[];
+ }
| { type: "sendGhostAck"; sequence: number; ghostCount: number }
| { type: "wsPing"; ts: number };
/** Messages from relay server to browser client. */
export type ServerMessage =
| { type: "serverList"; servers: ServerInfo[] }
- | { type: "status"; status: ConnectionStatus; message?: string; connectSequence?: number; mapName?: string }
+ | {
+ type: "status";
+ status: ConnectionStatus;
+ message?: string;
+ connectSequence?: number;
+ mapName?: string;
+ }
| { type: "gamePacket"; data: Uint8Array }
| { type: "ping"; ms: number }
| { type: "wsPong"; ts: number }
diff --git a/scripts/check-mount-points.ts b/scripts/check-mount-points.ts
index a73f26ac..74874d8f 100644
--- a/scripts/check-mount-points.ts
+++ b/scripts/check-mount-points.ts
@@ -215,7 +215,9 @@ for (const rel of weaponModels) {
});
if (mpIdx === -1) {
- console.log(`${name}: NO Mountpoint node. Nodes: [${nodeNames.join(", ")}]`);
+ console.log(
+ `${name}: NO Mountpoint node. Nodes: [${nodeNames.join(", ")}]`,
+ );
continue;
}
diff --git a/scripts/compute-mount-world.ts b/scripts/compute-mount-world.ts
index 53719b2b..b00671dc 100644
--- a/scripts/compute-mount-world.ts
+++ b/scripts/compute-mount-world.ts
@@ -111,10 +111,10 @@ function fmt(v: number[]): string {
async function main() {
const playerBuf = await fs.readFile(
- "docs/base/@vl2/shapes.vl2/shapes/light_male.glb"
+ "docs/base/@vl2/shapes.vl2/shapes/light_male.glb",
);
const weaponBuf = await fs.readFile(
- "docs/base/@vl2/shapes.vl2/shapes/weapon_disc.glb"
+ "docs/base/@vl2/shapes.vl2/shapes/weapon_disc.glb",
);
const playerDoc = parseGlb(playerBuf);
diff --git a/scripts/convert-wav.ts b/scripts/convert-wav.ts
index f6c2a93f..6fba7bbf 100644
--- a/scripts/convert-wav.ts
+++ b/scripts/convert-wav.ts
@@ -26,7 +26,9 @@ async function run({
try {
await fs.stat(oggFile);
continue; // .ogg already exists, skip
- } catch { /* expected */ }
+ } catch {
+ /* expected */
+ }
}
inputFiles.push(wavFile);
}
diff --git a/scripts/inspect-glb-nodes.ts b/scripts/inspect-glb-nodes.ts
index 40fb658b..0dbaca3f 100644
--- a/scripts/inspect-glb-nodes.ts
+++ b/scripts/inspect-glb-nodes.ts
@@ -72,7 +72,7 @@ function printNodeTree(
doc: GltfDocument,
nodeIndex: number,
depth: number,
- visited: Set
+ visited: Set,
): void {
if (visited.has(nodeIndex)) return;
visited.add(nodeIndex);
@@ -101,12 +101,16 @@ function printNodeTree(
// Only show scale if non-identity
const s = node.scale;
if (s && (s[0] !== 1 || s[1] !== 1 || s[2] !== 1)) {
- console.log(`${indent} S: ${formatVec3(node.scale as [number, number, number])}`);
+ console.log(
+ `${indent} S: ${formatVec3(node.scale as [number, number, number])}`,
+ );
}
// Show matrix if present
if (node.matrix) {
- console.log(`${indent} Matrix: [${node.matrix.map((n) => n.toFixed(4)).join(", ")}]`);
+ console.log(
+ `${indent} Matrix: [${node.matrix.map((n) => n.toFixed(4)).join(", ")}]`,
+ );
}
if (node.children) {
@@ -130,18 +134,20 @@ async function inspectGlb(filePath: string): Promise {
const skinCount = doc.skins?.length ?? 0;
const animCount = doc.animations?.length ?? 0;
- console.log(`Nodes: ${nodeCount}, Meshes: ${meshCount}, Skins: ${skinCount}, Animations: ${animCount}`);
+ console.log(
+ `Nodes: ${nodeCount}, Meshes: ${meshCount}, Skins: ${skinCount}, Animations: ${animCount}`,
+ );
// Show skins (skeletons)
if (doc.skins && doc.skins.length > 0) {
console.log(`\n--- Skins ---`);
for (let i = 0; i < doc.skins.length; i++) {
const skin = doc.skins[i];
- console.log(`Skin ${i}: "${skin.name ?? "(unnamed)"}" - ${skin.joints.length} joints`);
- console.log(` Root skeleton node: ${skin.skeleton ?? "unset"}`);
console.log(
- ` Joint node indices: [${skin.joints.join(", ")}]`
+ `Skin ${i}: "${skin.name ?? "(unnamed)"}" - ${skin.joints.length} joints`,
);
+ console.log(` Root skeleton node: ${skin.skeleton ?? "unset"}`);
+ console.log(` Joint node indices: [${skin.joints.join(", ")}]`);
}
}
@@ -150,7 +156,9 @@ async function inspectGlb(filePath: string): Promise {
console.log(`\n--- Animations ---`);
for (let i = 0; i < doc.animations.length; i++) {
const anim = doc.animations[i];
- console.log(` [${i}] "${anim.name ?? "(unnamed)"}" (${anim.channels?.length ?? 0} channels)`);
+ console.log(
+ ` [${i}] "${anim.name ?? "(unnamed)"}" (${anim.channels?.length ?? 0} channels)`,
+ );
}
}
@@ -178,7 +186,18 @@ async function inspectGlb(filePath: string): Promise {
}
// Highlight interesting nodes
- const keywords = ["eye", "mount", "hand", "cam", "head", "weapon", "muzzle", "node", "jet", "contrail"];
+ const keywords = [
+ "eye",
+ "mount",
+ "hand",
+ "cam",
+ "head",
+ "weapon",
+ "muzzle",
+ "node",
+ "jet",
+ "contrail",
+ ];
const interesting: { index: number; name: string; node: GltfNode }[] = [];
if (doc.nodes) {
for (let i = 0; i < doc.nodes.length; i++) {
@@ -191,7 +210,9 @@ async function inspectGlb(filePath: string): Promise {
}
if (interesting.length > 0) {
- console.log(`\n--- Interesting Nodes (matching: ${keywords.join(", ")}) ---`);
+ console.log(
+ `\n--- Interesting Nodes (matching: ${keywords.join(", ")}) ---`,
+ );
for (const { index, name, node } of interesting) {
console.log(` [${index}] "${name}"`);
console.log(` Translation: ${formatVec3(node.translation)}`);
@@ -199,13 +220,17 @@ async function inspectGlb(filePath: string): Promise {
console.log(` Rotation: ${formatQuat(node.rotation)}`);
}
if (node.scale) {
- console.log(` Scale: ${formatVec3(node.scale as [number, number, number])}`);
+ console.log(
+ ` Scale: ${formatVec3(node.scale as [number, number, number])}`,
+ );
}
// Find parent
if (doc.nodes) {
for (let j = 0; j < doc.nodes.length; j++) {
if (doc.nodes[j].children?.includes(index)) {
- console.log(` Parent: [${j}] "${doc.nodes[j].name || "(unnamed)"}"`);
+ console.log(
+ ` Parent: [${j}] "${doc.nodes[j].name || "(unnamed)"}"`,
+ );
break;
}
}
@@ -218,7 +243,9 @@ async function inspectGlb(filePath: string): Promise {
if (doc.nodes) {
for (let i = 0; i < doc.nodes.length; i++) {
const node = doc.nodes[i];
- console.log(` [${i}] "${node.name || "(unnamed)"}" T:${formatVec3(node.translation)}`);
+ console.log(
+ ` [${i}] "${node.name || "(unnamed)"}" T:${formatVec3(node.translation)}`,
+ );
}
}
}
diff --git a/scripts/play-demo.ts b/scripts/play-demo.ts
index 3ea012e9..86004374 100644
--- a/scripts/play-demo.ts
+++ b/scripts/play-demo.ts
@@ -38,7 +38,9 @@ if (!demoPath) {
console.error();
console.error("Options:");
console.error(" --no-headless Show the browser window");
- console.error(" --wait, -w Seconds to wait after loading (default: 10)");
+ console.error(
+ " --wait, -w Seconds to wait after loading (default: 10)",
+ );
console.error(" --screenshot, -s Take a screenshot after loading");
console.error();
console.error("Examples:");
diff --git a/scripts/t2-login.ts b/scripts/t2-login.ts
index 05bb9ea1..7496f80a 100644
--- a/scripts/t2-login.ts
+++ b/scripts/t2-login.ts
@@ -185,7 +185,9 @@ async function downloadAccount(
const trimmed = buffer.trim();
if (trimmed === "RECOVERERROR") {
- reject(new Error("Auth server returned RECOVERERROR (malformed request)"));
+ reject(
+ new Error("Auth server returned RECOVERERROR (malformed request)"),
+ );
return;
}
if (trimmed === "NOTFOUND") {
@@ -202,7 +204,11 @@ async function downloadAccount(
// Line 2: EXP:
const lines = trimmed.split("\n");
if (lines.length < 2) {
- reject(new Error(`Unexpected response from auth server: ${trimmed.slice(0, 200)}`));
+ reject(
+ new Error(
+ `Unexpected response from auth server: ${trimmed.slice(0, 200)}`,
+ ),
+ );
return;
}
@@ -300,7 +306,9 @@ async function main() {
const existingLines = await readEnvLines(envFilePath);
const updatedLines = updateEnvLines(
- existingLines.length > 0 ? existingLines : ["# Generated by scripts/t2-login.ts"],
+ existingLines.length > 0
+ ? existingLines
+ : ["# Generated by scripts/t2-login.ts"],
{
T2_ACCOUNT_NAME: username,
T2_ACCOUNT_PASSWORD: password,
diff --git a/scripts/t2-server-list.ts b/scripts/t2-server-list.ts
index 0058aadf..a8d83489 100644
--- a/scripts/t2-server-list.ts
+++ b/scripts/t2-server-list.ts
@@ -9,8 +9,7 @@
import { queryServerList } from "../relay/masterQuery.js";
-const MASTER_SERVER =
- process.env.T2_MASTER_SERVER || "master.tribesnext.com";
+const MASTER_SERVER = process.env.T2_MASTER_SERVER || "master.tribesnext.com";
async function main() {
console.log(`Master server: ${MASTER_SERVER}`);
@@ -27,10 +26,7 @@ async function main() {
console.log(`Found ${servers.length} server(s):\n`);
// Print as a table
- const nameWidth = Math.max(
- 11,
- ...servers.map((s) => s.name.length),
- );
+ const nameWidth = Math.max(11, ...servers.map((s) => s.name.length));
const header = [
"Server Name".padEnd(nameWidth),
"Map".padEnd(20),
diff --git a/src/components/Accordion.module.css b/src/components/Accordion.module.css
new file mode 100644
index 00000000..3685abf0
--- /dev/null
+++ b/src/components/Accordion.module.css
@@ -0,0 +1,67 @@
+.AccordionGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.Trigger {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ background: rgb(255, 255, 255, 0.1);
+ padding: 6px 8px;
+ color: #fff;
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: normal;
+ text-align: left;
+ border: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.0417em;
+ gap: 4px;
+}
+
+.TriggerIcon {
+ font-size: 12px;
+ transition: transform 0.2s;
+ transform: rotate(0deg);
+ opacity: 0.5;
+}
+
+.Trigger[data-state="open"] .TriggerIcon {
+ transform: rotate(90deg);
+}
+
+.Content {
+ overflow: hidden;
+}
+
+.Content[data-state="open"] {
+ animation: slideDown 300ms;
+}
+
+.Content[data-state="closed"] {
+ animation: slideUp 300ms;
+}
+
+.Body {
+ padding: 10px;
+}
+
+@keyframes slideDown {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--radix-accordion-content-height);
+ }
+}
+
+@keyframes slideUp {
+ from {
+ height: var(--radix-accordion-content-height);
+ }
+ to {
+ height: 0;
+ }
+}
diff --git a/src/components/Accordion.tsx b/src/components/Accordion.tsx
new file mode 100644
index 00000000..708793a3
--- /dev/null
+++ b/src/components/Accordion.tsx
@@ -0,0 +1,29 @@
+import * as RadixAccordion from "@radix-ui/react-accordion";
+import { ReactNode } from "react";
+import { IoCaretForward } from "react-icons/io5";
+import styles from "./Accordion.module.css";
+
+export function AccordionGroup(props) {
+ return ;
+}
+
+export function Accordion({
+ value,
+ label,
+ children,
+}: {
+ value: string;
+ label: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/AudioContext.tsx b/src/components/AudioContext.tsx
index 77ac73e4..63e06329 100644
--- a/src/components/AudioContext.tsx
+++ b/src/components/AudioContext.tsx
@@ -7,7 +7,8 @@ import {
} from "react";
import { useThree } from "@react-three/fiber";
import { AudioListener, AudioLoader } from "three";
-import { engineStore } from "../state";
+import { engineStore } from "../state/engineStore";
+import { useSettings } from "./SettingsProvider";
interface AudioContextType {
audioLoader: AudioLoader | null;
@@ -21,7 +22,8 @@ const AudioContext = createContext(undefined);
* Must be rendered inside the Canvas component.
*/
export function AudioProvider({ children }: { children: ReactNode }) {
- const { camera } = useThree();
+ const camera = useThree((state) => state.camera);
+ const { audioVolume } = useSettings();
const [audioContext, setAudioContext] = useState({
audioLoader: null,
audioListener: null,
@@ -41,8 +43,6 @@ export function AudioProvider({ children }: { children: ReactNode }) {
camera.add(listener);
}
- listener.setMasterVolume(0.8);
-
setAudioContext({
audioLoader,
audioListener: listener,
@@ -51,7 +51,7 @@ export function AudioProvider({ children }: { children: ReactNode }) {
// Resume the AudioContext on user interaction to satisfy browser autoplay
// policy. Without this, sounds won't play until the user clicks/taps.
const resumeOnGesture = () => {
- const ctx = listener?.context;
+ const ctx = listener.context;
if (!ctx || ctx.state !== "suspended") return;
ctx.resume().finally(() => {
document.removeEventListener("click", resumeOnGesture);
@@ -67,7 +67,7 @@ export function AudioProvider({ children }: { children: ReactNode }) {
const unsubscribe = engineStore.subscribe(
(state) => state.playback.status,
(status) => {
- const ctx = listener?.context;
+ const ctx = listener.context;
if (!ctx) return;
if (status === "paused") {
ctx.suspend();
@@ -85,6 +85,10 @@ export function AudioProvider({ children }: { children: ReactNode }) {
};
}, [camera]);
+ useEffect(() => {
+ audioContext.audioListener?.setMasterVolume(audioVolume);
+ }, [audioVolume, audioContext.audioListener]);
+
return (
{children}
diff --git a/src/components/AudioEmitter.tsx b/src/components/AudioEmitter.tsx
index e875b439..b0eba79f 100644
--- a/src/components/AudioEmitter.tsx
+++ b/src/components/AudioEmitter.tsx
@@ -8,11 +8,15 @@ import {
PositionalAudio,
Vector3,
} from "three";
+import { createLogger } from "../logger";
import { audioToUrl } from "../loaders";
import { useAudio } from "./AudioContext";
import { useDebug, useSettings } from "./SettingsProvider";
import { FloatingLabel } from "./FloatingLabel";
-import { engineStore } from "../state";
+import { engineStore } from "../state/engineStore";
+import { AudioEmitterEntity } from "../state/gameEntityTypes";
+
+const log = createLogger("AudioEmitter");
// Global audio buffer cache shared across all audio components.
export const audioBufferCache = new Map();
@@ -50,8 +54,16 @@ export function getSoundGeneration(): number {
export function stopAllTrackedSounds(): void {
_soundGeneration++;
for (const [sound] of _activeSounds) {
- try { sound.stop(); } catch { /* already stopped */ }
- try { sound.disconnect(); } catch { /* already disconnected */ }
+ try {
+ sound.stop();
+ } catch {
+ /* already stopped */
+ }
+ try {
+ sound.disconnect();
+ } catch {
+ /* already disconnected */
+ }
}
_activeSounds.clear();
}
@@ -148,7 +160,11 @@ export function playOneShotSound(
sound.play();
sound.source!.onended = () => {
_activeSounds.delete(sound);
- try { sound.disconnect(); } catch { /* already disconnected */ }
+ try {
+ sound.disconnect();
+ } catch {
+ /* already disconnected */
+ }
parent.remove(sound);
};
} else {
@@ -160,7 +176,11 @@ export function playOneShotSound(
sound.play();
sound.source!.onended = () => {
_activeSounds.delete(sound);
- try { sound.disconnect(); } catch { /* already disconnected */ }
+ try {
+ sound.disconnect();
+ } catch {
+ /* already disconnected */
+ }
};
}
} catch {
@@ -185,7 +205,7 @@ export function getCachedAudioBuffer(
},
undefined,
(err: any) => {
- console.error("Audio load error", audioUrl, err);
+ log.error("Audio load error %s: %o", audioUrl, err);
},
);
}
@@ -194,17 +214,7 @@ export function getCachedAudioBuffer(
export const AudioEmitter = memo(function AudioEmitter({
entity,
}: {
- entity: {
- audioFileName?: string;
- audioVolume?: number;
- audioMinDistance?: number;
- audioMaxDistance?: number;
- audioMinLoopGap?: number;
- audioMaxLoopGap?: number;
- audioIs3D?: boolean;
- audioIsLooping?: boolean;
- position?: [number, number, number];
- };
+ entity: AudioEmitterEntity;
}) {
const { debugMode } = useDebug();
const fileName = entity.audioFileName ?? "";
@@ -217,7 +227,8 @@ export const AudioEmitter = memo(function AudioEmitter({
const isLooping = entity.audioIsLooping ?? true;
const [x, y, z] = entity.position ?? [0, 0, 0];
- const { scene, camera } = useThree();
+ const scene = useThree((state) => state.scene);
+ const camera = useThree((state) => state.camera);
const { audioLoader, audioListener } = useAudio();
const { audioEnabled } = useSettings();
@@ -268,8 +279,16 @@ export const AudioEmitter = memo(function AudioEmitter({
return () => {
clearTimers();
- try { sound.stop(); } catch { /* already stopped */ }
- try { sound.disconnect(); } catch { /* already disconnected */ }
+ try {
+ sound.stop();
+ } catch {
+ /* already stopped */
+ }
+ try {
+ sound.disconnect();
+ } catch {
+ /* already disconnected */
+ }
if (is3D) scene.remove(sound);
soundRef.current = null;
isLoadedRef.current = false;
@@ -306,7 +325,9 @@ export const AudioEmitter = memo(function AudioEmitter({
try {
sound.play();
setupLooping(sound, gen);
- } catch { /* expected */ }
+ } catch {
+ /* expected */
+ }
}, gap);
} else {
loopGapIntervalRef.current = setTimeout(checkLoop, 100);
@@ -337,7 +358,9 @@ export const AudioEmitter = memo(function AudioEmitter({
try {
sound.play();
setupLooping(sound, gen);
- } catch { /* expected */ }
+ } catch {
+ /* expected */
+ }
}
});
} else {
@@ -346,7 +369,9 @@ export const AudioEmitter = memo(function AudioEmitter({
sound.play();
setupLooping(sound, gen);
}
- } catch { /* expected */ }
+ } catch {
+ /* expected */
+ }
}
};
@@ -373,7 +398,11 @@ export const AudioEmitter = memo(function AudioEmitter({
} else if (!isNowInRange && wasInRange) {
isInRangeRef.current = false;
clearTimers();
- try { sound.stop(); } catch { /* expected */ }
+ try {
+ sound.stop();
+ } catch {
+ /* expected */
+ }
}
});
@@ -384,7 +413,11 @@ export const AudioEmitter = memo(function AudioEmitter({
if (!audioEnabled) {
clearTimers();
- try { sound.stop(); } catch { /* expected */ }
+ try {
+ sound.stop();
+ } catch {
+ /* expected */
+ }
isInRangeRef.current = false;
}
}, [audioEnabled]);
diff --git a/src/components/AudioEnabled.tsx b/src/components/AudioEnabled.tsx
new file mode 100644
index 00000000..fa516097
--- /dev/null
+++ b/src/components/AudioEnabled.tsx
@@ -0,0 +1,7 @@
+import { ReactNode, Suspense } from "react";
+import { useSettings } from "./SettingsProvider";
+
+export function AudioEnabled({ children }: { children: ReactNode }) {
+ const { audioEnabled } = useSettings();
+ return audioEnabled ? {children} : null;
+}
diff --git a/src/components/Camera.tsx b/src/components/Camera.tsx
index 28e7d936..808ceb6e 100644
--- a/src/components/Camera.tsx
+++ b/src/components/Camera.tsx
@@ -9,17 +9,12 @@ export function Camera({ entity }: { entity: CameraEntity }) {
const dataBlock = entity.cameraDataBlock;
const position = useMemo(
- () =>
- entity.position
- ? new Vector3(...entity.position)
- : new Vector3(),
+ () => (entity.position ? new Vector3(...entity.position) : new Vector3()),
[entity.position],
);
const rotation = useMemo(
() =>
- entity.rotation
- ? new Quaternion(...entity.rotation)
- : new Quaternion(),
+ entity.rotation ? new Quaternion(...entity.rotation) : new Quaternion(),
[entity.rotation],
);
diff --git a/src/components/CamerasProvider.tsx b/src/components/CamerasProvider.tsx
index b35c8fbc..0d4d17f4 100644
--- a/src/components/CamerasProvider.tsx
+++ b/src/components/CamerasProvider.tsx
@@ -35,7 +35,7 @@ export function useCameras() {
}
export function CamerasProvider({ children }: { children: ReactNode }) {
- const { camera } = useThree();
+ const camera = useThree((state) => state.camera);
const [cameraIndex, setCameraIndex] = useState(-1);
const [cameraMap, setCameraMap] = useState>({});
const [initialViewState, setInitialViewState] = useState(() => ({
@@ -53,7 +53,8 @@ export function CamerasProvider({ children }: { children: ReactNode }) {
const unregisterCamera = useCallback((camera: CameraEntry) => {
setCameraMap((prevCameraMap) => {
- const { [camera.id]: _removedCamera, ...remainingCameras } = prevCameraMap;
+ const { [camera.id]: _removedCamera, ...remainingCameras } =
+ prevCameraMap;
return remainingCameras;
});
}, []);
diff --git a/src/components/ChatInput.module.css b/src/components/ChatInput.module.css
new file mode 100644
index 00000000..b08f00d7
--- /dev/null
+++ b/src/components/ChatInput.module.css
@@ -0,0 +1,26 @@
+.InputForm {
+ display: flex;
+}
+
+.Input {
+ width: 100%;
+ background: rgba(0, 50, 60, 0.8);
+ border: 0;
+ border-top: 1px solid rgba(78, 179, 167, 0.2);
+ border-radius: 0;
+ color: rgb(40, 231, 240);
+ font-family: inherit;
+ font-size: 12px;
+ line-height: 1.25;
+ margin: 0;
+ padding: 6px;
+ outline: none;
+}
+
+.Input::placeholder {
+ color: rgba(44, 172, 181, 0.5);
+}
+
+.Input:focus {
+ background: rgba(0, 50, 60, 0.9);
+}
diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx
new file mode 100644
index 00000000..14307b1c
--- /dev/null
+++ b/src/components/ChatInput.tsx
@@ -0,0 +1,33 @@
+import { useCallback, useState } from "react";
+import { liveConnectionStore } from "../state/liveConnectionStore";
+import styles from "./ChatInput.module.css";
+
+export function ChatInput() {
+ const [chatText, setChatText] = useState("");
+
+ const handleSubmit = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+ const text = chatText.trim();
+ if (!text) return;
+ liveConnectionStore.getState().sendCommand("messageSent", text);
+ setChatText("");
+ },
+ [chatText],
+ );
+
+ return (
+
+ );
+}
diff --git a/src/components/ChatSoundPlayer.tsx b/src/components/ChatSoundPlayer.tsx
index c9949853..0541a5b5 100644
--- a/src/components/ChatSoundPlayer.tsx
+++ b/src/components/ChatSoundPlayer.tsx
@@ -2,9 +2,14 @@ import { useEffect, useRef } from "react";
import { Audio } from "three";
import { audioToUrl } from "../loaders";
import { useAudio } from "./AudioContext";
-import { getCachedAudioBuffer, getSoundGeneration, trackSound, untrackSound } from "./AudioEmitter";
+import {
+ getCachedAudioBuffer,
+ getSoundGeneration,
+ trackSound,
+ untrackSound,
+} from "./AudioEmitter";
import { useSettings } from "./SettingsProvider";
-import { engineStore, useEngineSelector } from "../state";
+import { engineStore, useEngineSelector } from "../state/engineStore";
import type { ChatMessage } from "../stream/types";
/**
@@ -13,8 +18,7 @@ import type { ChatMessage } from "../stream/types";
*/
export function ChatSoundPlayer() {
const { audioLoader, audioListener } = useAudio();
- const settings = useSettings();
- const audioEnabled = settings?.audioEnabled ?? false;
+ const { audioEnabled } = useSettings();
const messages = useEngineSelector(
(state) => state.playback.streamSnapshot?.chatMessages,
);
@@ -24,9 +28,7 @@ export function ChatSoundPlayer() {
const playedSetRef = useRef(new WeakSet());
// Track active voice chat sound per sender so a new voice bind from the
// same player stops their previous one (matching Tribes 2 behavior).
- const activeBySenderRef = useRef(
- new Map>(),
- );
+ const activeBySenderRef = useRef(new Map>());
useEffect(() => {
if (
@@ -58,9 +60,17 @@ export function ChatSoundPlayer() {
if (sender) {
const prev = activeBySender.get(sender);
if (prev) {
- try { prev.stop(); } catch { /* already stopped */ }
+ try {
+ prev.stop();
+ } catch {
+ /* already stopped */
+ }
untrackSound(prev);
- try { prev.disconnect(); } catch { /* already disconnected */ }
+ try {
+ prev.disconnect();
+ } catch {
+ /* already disconnected */
+ }
activeBySender.delete(sender);
}
}
@@ -75,7 +85,11 @@ export function ChatSoundPlayer() {
// Clean up the source node once playback finishes.
sound.source!.onended = () => {
untrackSound(sound);
- try { sound.disconnect(); } catch { /* already disconnected */ }
+ try {
+ sound.disconnect();
+ } catch {
+ /* already disconnected */
+ }
if (sender && activeBySender.get(sender) === sound) {
activeBySender.delete(sender);
}
diff --git a/src/components/ChatWindow.module.css b/src/components/ChatWindow.module.css
new file mode 100644
index 00000000..ad55aaa2
--- /dev/null
+++ b/src/components/ChatWindow.module.css
@@ -0,0 +1,63 @@
+.ChatContainer {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ width: 400px;
+ max-width: 50%;
+ display: flex;
+ flex-direction: column;
+ pointer-events: auto;
+ border: 1px solid rgba(44, 172, 181, 0.4);
+}
+
+.ChatWindow {
+ max-height: 12.5em;
+ min-height: 4em;
+ overflow-y: auto;
+ background: rgba(0, 50, 60, 0.65);
+ padding: 6px;
+ user-select: text;
+ font-size: 12px;
+ line-height: 1.25;
+ /* Thin scrollbar that doesn't take much space. */
+ scrollbar-width: thin;
+ scrollbar-color: rgba(44, 172, 181, 0.4) transparent;
+}
+
+.ChatMessage {
+ /* Default to \c0 (GuiChatHudProfile fontColor) for untagged messages. */
+ color: rgb(44, 172, 181);
+ padding: 2px 0;
+}
+
+/* T2 GuiChatHudProfile fontColors palette (\c0–\c9). */
+.ChatColor0 {
+ color: rgb(44, 172, 181);
+}
+.ChatColor1 {
+ color: rgb(4, 235, 105);
+}
+.ChatColor2 {
+ color: rgb(219, 200, 128);
+}
+.ChatColor3 {
+ color: rgb(77, 253, 95);
+}
+.ChatColor4 {
+ color: rgb(40, 231, 240);
+}
+.ChatColor5 {
+ color: rgb(200, 200, 50);
+}
+.ChatColor6 {
+ color: rgb(200, 200, 200);
+}
+.ChatColor7 {
+ color: rgb(220, 220, 20);
+}
+.ChatColor8 {
+ color: rgb(150, 150, 250);
+}
+.ChatColor9 {
+ color: rgb(60, 220, 150);
+}
diff --git a/src/components/ChatWindow.tsx b/src/components/ChatWindow.tsx
new file mode 100644
index 00000000..5969d598
--- /dev/null
+++ b/src/components/ChatWindow.tsx
@@ -0,0 +1,84 @@
+import { lazy, memo, Suspense, useEffect, useRef } from "react";
+import { useEngineSelector } from "../state/engineStore";
+import { ChatMessage, ChatSegment } from "../stream/types";
+import styles from "./ChatWindow.module.css";
+
+const ChatInput = lazy(() =>
+ import("./ChatInput").then((mod) => ({ default: mod.ChatInput })),
+);
+
+const EMPTY_MESSAGES: ChatMessage[] = [];
+
+/** Map a colorCode to a CSS module class name (c0–c9 GuiChatHudProfile). */
+const CHAT_COLOR_CLASSES: Record = {
+ 0: styles.ChatColor0,
+ 1: styles.ChatColor1,
+ 2: styles.ChatColor2,
+ 3: styles.ChatColor3,
+ 4: styles.ChatColor4,
+ 5: styles.ChatColor5,
+ 6: styles.ChatColor6,
+ 7: styles.ChatColor7,
+ 8: styles.ChatColor8,
+ 9: styles.ChatColor9,
+};
+
+function segmentColorClass(colorCode: number): string {
+ return CHAT_COLOR_CLASSES[colorCode] ?? CHAT_COLOR_CLASSES[0];
+}
+
+function chatColorClass(msg: ChatMessage): string {
+ if (msg.colorCode != null && CHAT_COLOR_CLASSES[msg.colorCode]) {
+ return CHAT_COLOR_CLASSES[msg.colorCode];
+ }
+ // Fallback: default to \c0 (teal). Messages with detected codes (like \c2
+ // for flag events) will match above; \c0 kill messages may lose their null
+ // byte color code, so the correct default for server messages is c0.
+ return CHAT_COLOR_CLASSES[0];
+}
+
+export const ChatWindow = memo(function ChatWindow() {
+ const isLive = useEngineSelector(
+ (state) => state.playback.recording?.source === "live",
+ );
+ const messages = useEngineSelector(
+ (state) => state.playback.streamSnapshot?.chatMessages ?? EMPTY_MESSAGES,
+ );
+ const scrollRef = useRef(null);
+
+ const lastMessageId = messages[messages.length - 1]?.id;
+
+ useEffect(() => {
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ }
+ }, [lastMessageId]);
+
+ return (
+
+
+ {messages.map((msg: ChatMessage) => (
+
+ {msg.segments ? (
+ msg.segments.map((seg: ChatSegment, j: number) => (
+
+ {seg.text}
+
+ ))
+ ) : (
+
+ {msg.sender ? `${msg.sender}: ` : ""}
+ {msg.text}
+
+ )}
+
+ ))}
+
+ {isLive && (
+
+
+
+ )}
+
+ );
+});
diff --git a/src/components/CloudLayers.tsx b/src/components/CloudLayers.tsx
index 0d0c29be..85de29be 100644
--- a/src/components/CloudLayers.tsx
+++ b/src/components/CloudLayers.tsx
@@ -471,16 +471,19 @@ export function CloudLayers({ scene }: CloudLayersProps) {
const { data: detailMapList } = useDetailMapList(materialList);
// From Tribes 2 sky.cc line 1170: mRadius = visibleDistance * 0.95
- const visibleDistance = scene.visibleDistance > 0 ? scene.visibleDistance : 500;
+ const visibleDistance =
+ scene.visibleDistance > 0 ? scene.visibleDistance : 500;
const radius = visibleDistance * 0.95;
const cloudSpeeds = useMemo(
- () => scene.cloudLayers.map((l, i) => l.speed || [0.0001, 0.0002, 0.0003][i]),
+ () =>
+ scene.cloudLayers.map((l, i) => l.speed || [0.0001, 0.0002, 0.0003][i]),
[scene.cloudLayers],
);
const cloudHeights = useMemo(
- () => scene.cloudLayers.map((l, i) => l.heightPercent || [0.35, 0.25, 0.2][i]),
+ () =>
+ scene.cloudLayers.map((l, i) => l.heightPercent || [0.35, 0.25, 0.2][i]),
[scene.cloudLayers],
);
@@ -536,7 +539,7 @@ export function CloudLayers({ scene }: CloudLayersProps) {
{layers.map((layer, i) => {
const url = textureToUrl(layer.texture);
return (
-
+
;
missionName: string;
missionType: string;
+ disabled?: boolean;
}) {
const { fogEnabled } = useSettings();
const [showCopied, setShowCopied] = useState(false);
@@ -57,15 +59,16 @@ export function CopyCoordinatesButton({
);
}
diff --git a/src/components/DebugElements.tsx b/src/components/DebugElements.tsx
index c37c3846..55cb1a3e 100644
--- a/src/components/DebugElements.tsx
+++ b/src/components/DebugElements.tsx
@@ -1,11 +1,9 @@
import { Stats, Html } from "@react-three/drei";
-import { useDebug } from "./SettingsProvider";
import { useEffect, useRef } from "react";
import { AxesHelper } from "three";
import styles from "./DebugElements.module.css";
export function DebugElements() {
- const { debugMode } = useDebug();
const axesRef = useRef(null);
useEffect(() => {
@@ -16,7 +14,7 @@ export function DebugElements() {
axes.setColors("rgb(153, 255, 0)", "rgb(0, 153, 255)", "rgb(255, 153, 0)");
});
- return debugMode ? (
+ return (
<>
@@ -43,5 +41,5 @@ export function DebugElements() {
>
- ) : null;
+ );
}
diff --git a/src/components/DebugEnabled.tsx b/src/components/DebugEnabled.tsx
new file mode 100644
index 00000000..f29bfabc
--- /dev/null
+++ b/src/components/DebugEnabled.tsx
@@ -0,0 +1,8 @@
+import { ReactNode, Suspense } from "react";
+import { useDebug } from "./SettingsProvider";
+
+export function DebugEnabled({ children }: { children: ReactNode }) {
+ const { debugMode } = useDebug();
+
+ return debugMode ? {children} : null;
+}
diff --git a/src/components/DebugSuspense.tsx b/src/components/DebugSuspense.tsx
new file mode 100644
index 00000000..9e851498
--- /dev/null
+++ b/src/components/DebugSuspense.tsx
@@ -0,0 +1,50 @@
+import { Suspense, useEffect, type ReactNode } from "react";
+import { createLogger } from "../logger";
+
+const log = createLogger("DebugSuspense");
+
+/**
+ * Suspense wrapper that logs when a component suspends and resolves.
+ * Use in place of `` during debugging to track async loading.
+ */
+export function DebugSuspense({
+ name,
+ fallback = null,
+ children,
+}: {
+ name: string;
+ fallback?: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+ {fallback}
+ }
+ >
+
+ {children}
+
+ );
+}
+
+function DebugSuspenseFallback({
+ name,
+ children,
+}: {
+ name: string;
+ children: ReactNode;
+}) {
+ useEffect(() => {
+ log.debug("🛑 SUSPENDED: %s", name);
+ }, [name]);
+ return children;
+}
+
+function DebugSuspenseResolved({ name }: { name: string }) {
+ useEffect(() => {
+ log.debug("✅ RESOLVED: %s", name);
+ }, [name]);
+ return null;
+}
diff --git a/src/components/DemoPlaybackControls.module.css b/src/components/DemoPlaybackControls.module.css
index 9a3475a1..75e2b571 100644
--- a/src/components/DemoPlaybackControls.module.css
+++ b/src/components/DemoPlaybackControls.module.css
@@ -1,14 +1,8 @@
.Root {
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
- background: rgba(0, 0, 0, 0.7);
- color: #fff;
font-size: 13px;
z-index: 2;
}
diff --git a/src/components/DemoPlaybackControls.tsx b/src/components/DemoPlaybackControls.tsx
index ce9b153c..7e653e33 100644
--- a/src/components/DemoPlaybackControls.tsx
+++ b/src/components/DemoPlaybackControls.tsx
@@ -78,6 +78,7 @@ export function DemoPlaybackControls() {
className={styles.PlayPause}
onClick={isPlaying ? pause : play}
aria-label={isPlaying ? "Pause" : "Play"}
+ autoFocus
>
{isPlaying ? "\u275A\u275A" : "\u25B6"}
diff --git a/src/components/EntityRenderer.tsx b/src/components/EntityRenderer.tsx
index d19c12a4..1ad0d348 100644
--- a/src/components/EntityRenderer.tsx
+++ b/src/components/EntityRenderer.tsx
@@ -1,20 +1,15 @@
-import { lazy, memo, Suspense, useRef } from "react";
+import { lazy, memo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import type { Group } from "three";
import type {
GameEntity,
ShapeEntity as ShapeEntityType,
- ForceFieldBareEntity as ForceFieldBareEntityType,
- PlayerEntity as PlayerEntityType,
- ExplosionEntity as ExplosionEntityType,
- TracerEntity as TracerEntityType,
- SpriteEntity as SpriteEntityType,
- AudioEmitterEntity as AudioEmitterEntityType,
} from "../state/gameEntityTypes";
-import { streamPlaybackStore } from "../state/streamPlaybackStore";
-import { ShapeRenderer } from "./GenericShape";
+import { ShapeRenderer, ShapePlaceholder } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import type { StaticShapeType } from "./ShapeInfoProvider";
+import { DebugSuspense } from "./DebugSuspense";
+import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
import { FloatingLabel } from "./FloatingLabel";
import { useSettings } from "./SettingsProvider";
import { Camera } from "./Camera";
@@ -22,44 +17,51 @@ import { WayPoint } from "./WayPoint";
import { TerrainBlock } from "./TerrainBlock";
import { InteriorInstance } from "./InteriorInstance";
import { Sky } from "./Sky";
+import { AudioEnabled } from "./AudioEnabled";
import type { TorqueObject } from "../torqueScript";
-// Lazy-loaded heavy renderers
-const PlayerModel = lazy(() =>
- import("./PlayerModel").then((mod) => ({ default: mod.PlayerModel })),
-);
+function createLazy(
+ name: K,
+ loader: () => Promise>>,
+): React.ComponentType<{ entity: GameEntity }> {
+ const LazyComponent = lazy(() =>
+ loader().then((mod) => {
+ const NamedComponent = mod[name];
+ return { default: NamedComponent };
+ }),
+ );
+ const LazyComponentWithSuspense = ({ entity }: { entity: GameEntity }) => {
+ return (
+
+
+
+ );
+ };
-const ExplosionShape = lazy(() =>
- import("./ShapeModel").then((mod) => ({
- default: mod.ExplosionShape,
- })),
-);
+ LazyComponentWithSuspense.displayName = `createLazy(${name})`;
+ return LazyComponentWithSuspense;
+}
-const TracerProjectile = lazy(() =>
- import("./Projectiles").then((mod) => ({
- default: mod.TracerProjectile,
- })),
+const PlayerModel = createLazy("PlayerModel", () => import("./PlayerModel"));
+const ExplosionShape = createLazy(
+ "ExplosionShape",
+ () => import("./ShapeModel"),
);
-
-const SpriteProjectile = lazy(() =>
- import("./Projectiles").then((mod) => ({
- default: mod.SpriteProjectile,
- })),
+const TracerProjectile = createLazy(
+ "TracerProjectile",
+ () => import("./Projectiles"),
);
-
-const ForceFieldBareRenderer = lazy(() =>
- import("./ForceFieldBare").then((mod) => ({
- default: mod.ForceFieldBare,
- })),
+const SpriteProjectile = createLazy(
+ "SpriteProjectile",
+ () => import("./Projectiles"),
);
-
-const AudioEmitter = lazy(() =>
- import("./AudioEmitter").then((mod) => ({ default: mod.AudioEmitter })),
-);
-
-const WaterBlock = lazy(() =>
- import("./WaterBlock").then((mod) => ({ default: mod.WaterBlock })),
+const ForceFieldBare = createLazy(
+ "ForceFieldBare",
+ () => import("./ForceFieldBare"),
);
+const AudioEmitter = createLazy("AudioEmitter", () => import("./AudioEmitter"));
+const WaterBlock = createLazy("WaterBlock", () => import("./WaterBlock"));
+const WeaponModel = createLazy("WeaponModel", () => import("./ShapeModel"));
const TEAM_NAMES: Record = {
1: "Storm",
@@ -81,17 +83,21 @@ export const EntityRenderer = memo(function EntityRenderer({
case "Shape":
return ;
case "ForceFieldBare":
- return ;
+ return ;
case "Player":
- return ;
+ return ;
case "Explosion":
- return ;
+ return ;
case "Tracer":
- return ;
+ return ;
case "Sprite":
- return ;
+ return ;
case "AudioEmitter":
- return ;
+ return (
+
+
+
+ );
case "Camera":
return ;
case "WayPoint":
@@ -101,25 +107,21 @@ export const EntityRenderer = memo(function EntityRenderer({
case "InteriorInstance":
return ;
case "Sky":
- return ;
+ return ;
case "Sun":
// Sun lighting is handled by SceneLighting (rendered outside EntityScene)
return null;
case "WaterBlock":
- return (
-
-
-
- );
+ return ;
case "MissionArea":
return null;
case "None":
return null;
+ default:
+ return null;
}
});
-// ── Shape Entity ──
-
function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
const { animationEnabled } = useSettings();
const groupRef = useRef(null);
@@ -131,7 +133,9 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
groupRef.current.rotation.y = (t / 3.0) * Math.PI * 2;
});
- if (!entity.shapeName) return null;
+ if (!entity.shapeName) {
+ throw new Error(`Shape entity missing shapeName: ${entity.id}`);
+ }
const torqueObject = entity.runtimeObject as TorqueObject | undefined;
const shapeType = (entity.shapeType ?? "StaticShape") as StaticShapeType;
@@ -156,7 +160,10 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
type={shapeType}
>
-
+
{flagLabel ? (
{flagLabel}
) : null}
@@ -172,92 +179,26 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
)}
+ {entity.weaponShape && (
+
+ }
+ >
+
+ }
+ >
+
+
+
+ )}
);
}
-
-// ── Force Field Entity ──
-
-function ForceFieldBareEntity({ entity }: { entity: ForceFieldBareEntityType }) {
- if (!entity.forceFieldData) return null;
- return (
-
-
-
- );
-}
-
-// ── Player Entity ──
-
-function PlayerEntity({ entity }: { entity: PlayerEntityType }) {
- if (!entity.shapeName) return null;
-
- return (
-
-
-
- );
-}
-
-// ── Explosion Entity ──
-
-function ExplosionEntity({ entity }: { entity: ExplosionEntityType }) {
- const playback = streamPlaybackStore.getState().playback;
-
- // ExplosionShape still expects a StreamEntity-shaped object.
- // Adapt minimally until that component is also refactored.
- const streamEntity = {
- id: entity.id,
- type: "Explosion" as const,
- dataBlock: entity.shapeName,
- position: entity.position,
- rotation: entity.rotation,
- faceViewer: entity.faceViewer,
- explosionDataBlockId: entity.explosionDataBlockId,
- };
-
- if (!entity.shapeName || !playback) return null;
-
- return (
-
-
-
- );
-}
-
-// ── Tracer Entity ──
-
-function TracerEntity({ entity }: { entity: TracerEntityType }) {
- return (
-
-
-
- );
-}
-
-// ── Sprite Entity ──
-
-function SpriteEntity({ entity }: { entity: SpriteEntityType }) {
- return (
-
-
-
- );
-}
-
-// ── Audio Entity ──
-
-function AudioEntity({ entity }: { entity: AudioEmitterEntityType }) {
- const { audioEnabled } = useSettings();
- if (!entity.audioFileName || !audioEnabled) return null;
-
- return (
-
-
-
- );
-}
diff --git a/src/components/EntityScene.tsx b/src/components/EntityScene.tsx
index 16edefd8..5477c3e6 100644
--- a/src/components/EntityScene.tsx
+++ b/src/components/EntityScene.tsx
@@ -1,25 +1,21 @@
-import { lazy, memo, Suspense, useCallback, useMemo, useRef, useState } from "react";
+import { memo, useCallback, useRef, useState, useMemo } from "react";
import { Quaternion } from "three";
import type { Group } from "three";
import { useFrame } from "@react-three/fiber";
-import { useAllGameEntities } from "../state";
-import type { GameEntity, PositionedEntity, PlayerEntity } from "../state/gameEntityTypes";
+import { useAllGameEntities } from "../state/gameEntityStore";
+import type {
+ GameEntity,
+ PositionedEntity,
+ PlayerEntity,
+} from "../state/gameEntityTypes";
import { isSceneEntity } from "../state/gameEntityTypes";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import { EntityRenderer } from "./EntityRenderer";
+import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
import { PlayerNameplate } from "./PlayerNameplate";
import { FlagMarker } from "./FlagMarker";
-import { FloatingLabel } from "./FloatingLabel";
import { entityTypeColor } from "../stream/playbackUtils";
-import { useDebug } from "./SettingsProvider";
-import { useEngineSelector } from "../state";
-
-
-const WeaponModel = lazy(() =>
- import("./ShapeModel").then((mod) => ({
- default: mod.WeaponModel,
- })),
-);
+import { useEngineSelector } from "../state/engineStore";
/**
* The ONE rendering component tree for all game entities.
@@ -27,39 +23,28 @@ const WeaponModel = lazy(() =>
* Data sources (mission .mis, demo .rec, live server) are controllers that
* populate the store — this component doesn't know or care which is active.
*/
-export function EntityScene({ missionType }: { missionType?: string }) {
- const debug = useDebug();
- const debugMode = debug?.debugMode ?? false;
-
+export function EntityScene() {
const rootRef = useCallback((node: Group | null) => {
streamPlaybackStore.setState({ root: node });
}, []);
return (
-
+
);
}
/** Renders all game entities. Uses an ID-stable selector so the component
* only re-renders when entities are added or removed, not when their
- * fields change. Entity references are cached so that once an entity
- * renders and loads resources via Suspense, it keeps its reference stable. */
-const EntityLayer = memo(function EntityLayer({
- missionType,
- debugMode,
-}: {
- missionType?: string;
- debugMode: boolean;
-}) {
+ * fields change. */
+const EntityLayer = memo(function EntityLayer() {
const entities = useAllGameEntities();
// Cache entity references by ID so that in-place field mutations
- // (threads, colors, weapon shape) don't cause React to see a new
- // object and remount Suspense boundaries. The cache IS updated when
- // the store provides a genuinely new object reference (identity
- // rebuild: armor change, datablock change, etc.).
+ // (threads, colors, weapon shape) don't cause unnecessary remounts.
+ // The cache IS updated when the store provides a genuinely new object
+ // reference (identity rebuild: armor change, datablock change, etc.).
const cacheRef = useRef(new Map());
const cache = cacheRef.current;
@@ -75,29 +60,10 @@ const EntityLayer = memo(function EntityLayer({
}
}
- const filtered = useMemo(() => {
- const result: GameEntity[] = [];
- const lowerType = missionType?.toLowerCase();
- for (const entity of cache.values()) {
- if (lowerType && entity.missionTypesList) {
- const types = new Set(
- entity.missionTypesList
- .toLowerCase()
- .split(/\s+/)
- .filter(Boolean),
- );
- if (types.size > 0 && !types.has(lowerType)) continue;
- }
- result.push(entity);
- }
- return result;
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [entities, missionType]);
-
return (
<>
- {filtered.map((entity) => (
-
+ {[...cache.values()].map((entity) => (
+
))}
>
);
@@ -105,13 +71,11 @@ const EntityLayer = memo(function EntityLayer({
const EntityWrapper = memo(function EntityWrapper({
entity,
- debugMode,
}: {
entity: GameEntity;
- debugMode: boolean;
}) {
- // Scene infrastructure handles its own positioning — render directly.
- // The named group allows the interpolation loop to identify and skip them.
+ // Scene infrastructure handles its own positioning and Suspense — render
+ // directly. The named group allows the interpolation loop to skip them.
if (isSceneEntity(entity)) {
return (
@@ -123,14 +87,13 @@ const EntityWrapper = memo(function EntityWrapper({
if (entity.renderType === "None") return null;
// From here, entity is a PositionedEntity
- return ;
+ return ;
});
/** Renders the player nameplate, subscribing to controlPlayerGhostId
- * internally so that PositionedEntityWrapper doesn't need to. This keeps
+ * internally so that PositionedEntityWrapper doesn't need to. Keeps
* engine store mutations from triggering synchronous selector evaluations
- * on every positioned entity (which was starving Suspense retries for
- * shape GLB loading). */
+ * on every positioned entity. */
function PlayerNameplateIfVisible({ entity }: { entity: PlayerEntity }) {
const controlPlayerGhostId = useEngineSelector(
(state) => state.playback.streamSnapshot?.controlPlayerGhostId,
@@ -146,13 +109,19 @@ function PlayerNameplateIfVisible({ entity }: { entity: PlayerEntity }) {
function FlagMarkerSlot({ entity }: { entity: GameEntity }) {
const flagRef = useRef(false);
const [isFlag, setIsFlag] = useState(() => {
- const flags = "targetRenderFlags" in entity ? (entity.targetRenderFlags as number | undefined) : undefined;
+ const flags =
+ "targetRenderFlags" in entity
+ ? (entity.targetRenderFlags as number | undefined)
+ : undefined;
return ((flags ?? 0) & 0x2) !== 0;
});
flagRef.current = isFlag;
useFrame(() => {
- const flags = "targetRenderFlags" in entity ? (entity.targetRenderFlags as number | undefined) : undefined;
+ const flags =
+ "targetRenderFlags" in entity
+ ? (entity.targetRenderFlags as number | undefined)
+ : undefined;
const nowFlag = ((flags ?? 0) & 0x2) !== 0;
if (nowFlag !== flagRef.current) {
flagRef.current = nowFlag;
@@ -161,19 +130,13 @@ function FlagMarkerSlot({ entity }: { entity: GameEntity }) {
});
if (!isFlag) return null;
- return (
-
-
-
- );
+ return ;
}
function PositionedEntityWrapper({
entity,
- debugMode,
}: {
entity: PositionedEntity;
- debugMode: boolean;
}) {
const position = entity.position;
const scale = entity.scale;
@@ -187,7 +150,12 @@ function PositionedEntityWrapper({
// Entities without a resolved shape get a wireframe placeholder.
if (entity.renderType === "Shape" && !entity.shapeName) {
return (
-
+
- {debugMode && }
);
@@ -212,82 +179,22 @@ function PositionedEntityWrapper({
);
- const shapeName = "shapeName" in entity ? entity.shapeName : undefined;
- const weaponShape = "weaponShape" in entity ? entity.weaponShape : undefined;
-
return (
-
+
-
-
-
+
{isPlayer && (
-
-
-
+
)}
- {debugMode && !shapeName && entity.renderType !== "Shape" && (
-
- )}
- {weaponShape && shapeName && !isPlayer && (
-
-
-
-
-
-
-
- )}
);
}
-
-function MissingShapeLabel({ entity }: { entity: GameEntity }) {
- const bits: string[] = [];
- bits.push(`${entity.id} (${entity.className})`);
- if (typeof entity.ghostIndex === "number") bits.push(`ghost ${entity.ghostIndex}`);
- if (typeof entity.dataBlockId === "number") bits.push(`db ${entity.dataBlockId}`);
- bits.push(
- entity.shapeHint
- ? `shapeHint ${entity.shapeHint}`
- : "shapeHint ",
- );
- return {bits.join(" | ")};
-}
-
-/** Error boundary that renders a fallback when shape loading fails. */
-import { Component } from "react";
-import type { ErrorInfo, ReactNode } from "react";
-
-export class ShapeErrorBoundary extends Component<
- { children: ReactNode; fallback: ReactNode },
- { hasError: boolean }
-> {
- state = { hasError: false };
-
- static getDerivedStateFromError() {
- return { hasError: true };
- }
-
- componentDidCatch(error: Error, info: ErrorInfo) {
- console.warn(
- "[entity] Shape load failed:",
- error.message,
- info.componentStack,
- );
- }
-
- render() {
- if (this.state.hasError) {
- return this.props.fallback;
- }
- return this.props.children;
- }
-}
diff --git a/src/components/FlagMarker.tsx b/src/components/FlagMarker.tsx
index c29a4e08..f2488ac8 100644
--- a/src/components/FlagMarker.tsx
+++ b/src/components/FlagMarker.tsx
@@ -3,6 +3,7 @@ import { useFrame, useThree } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import { Group, Vector3 } from "three";
import { textureToUrl } from "../loaders";
+
interface FlagEntity {
id: string;
iffColor?: { r: number; g: number; b: number };
@@ -10,7 +11,6 @@ interface FlagEntity {
import styles from "./FlagMarker.module.css";
const FLAG_ICON_HEIGHT = 1.5;
-
const FLAG_ICON_URL = textureToUrl("commander/MiniIcons/com_flag_grey");
const _tmpVec = new Vector3();
@@ -24,7 +24,7 @@ export function FlagMarker({ entity }: { entity: FlagEntity }) {
const markerRef = useRef(null);
const iconRef = useRef(null);
const distRef = useRef(null);
- const { camera } = useThree();
+ const camera = useThree((state) => state.camera);
useFrame(() => {
// Tint imperatively — iffColor is mutated in-place by streaming playback.
@@ -52,10 +52,12 @@ export function FlagMarker({ entity }: { entity: FlagEntity }) {