vehicle spawning and mounting improvements

This commit is contained in:
Brian Beck 2026-04-05 22:34:39 -07:00
parent bc7d30c5c6
commit 76b2d11e14
114 changed files with 2118 additions and 1538 deletions

View file

@ -2,6 +2,10 @@
import { Suspense } from "react";
import { NuqsAdapter } from "nuqs/adapters/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
skinManifestQueryKey,
fetchSkinManifest,
} from "@/src/components/PlayerModel";
import { FeaturesProvider } from "@/src/components/FeaturesProvider";
import { MapInspector } from "@/src/components/MapInspector";
import { SettingsProvider } from "@/src/components/SettingsProvider";
@ -15,6 +19,16 @@ const queryClient = new QueryClient({
},
});
// Prefetch the custom skins manifest at startup so it's in the cache before
// any PlayerModel renders. This avoids a race where useQuery inside PlayerModel
// can't trigger a re-render during streaming (store mutations starve React's
// concurrent rendering).
queryClient.prefetchQuery({
queryKey: skinManifestQueryKey,
queryFn: fetchSkinManifest,
staleTime: Infinity,
});
export default function HomePage() {
return (
<Suspense>

View file

@ -1,16 +1,20 @@
.Container {
.Container[data-filtered] {
min-height: 100dvh;
}
.Title {
font-size: 13px;
font-weight: bold;
margin: 16px 10px 8px 10px;
margin: 0;
padding: 0;
}
.Header {
font-size: 12px;
opacity: 0.6;
padding: 2px 0;
padding: 18px 4px 4px 2px;
display: flex;
align-items: center;
justify-content: space-between;
}
.Group {
@ -18,10 +22,13 @@
}
.GroupHeader {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
padding: 6px 8px;
padding: 6px 8px 6px 0;
cursor: pointer;
user-select: none;
list-style: none;
@ -31,14 +38,24 @@
display: none;
}
.GroupHeader::before {
content: "[+] ";
font-family: monospace;
color: rgba(255, 255, 255, 0.4);
.OpenedIcon {
display: none;
font-size: 15px;
opacity: 0.6;
}
.Group[open] > .GroupHeader::before {
content: "[-] ";
.ClosedIcon {
display: inline-block;
font-size: 15px;
opacity: 0.6;
}
.Group[open] .OpenedIcon {
display: inline-block;
}
.Group[open] .ClosedIcon {
display: none;
}
.GroupCount {
@ -146,10 +163,20 @@
color: rgba(255, 255, 255, 0.35);
}
.Detail dt::after {
content: ":";
}
.Detail dd {
margin: 0;
}
.FilterInput {
font-family: inherit;
background: rgba(0, 0, 0, 0.2);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0;
margin: 0;
padding: 4px 6px;
}
.FilterInput:focus {
background: rgba(0, 0, 0, 0.5);
}

View file

@ -1,5 +1,6 @@
import { memo, useMemo, useCallback } from "react";
import { memo, useMemo, useCallback, useState, useDeferredValue } from "react";
import { FaLocationArrow } from "react-icons/fa6";
import { matchSorter } from "match-sorter";
import { useGameEntities } from "../state/gameEntityStore";
import { cameraTourStore } from "../state/cameraTourStore";
import type { GameEntity } from "../state/gameEntityTypes";
@ -7,6 +8,7 @@ import { torqueToThree } from "../scene/coordinates";
import { gameEntityStore, useDebugHidden } from "../state/gameEntityStore";
import { useSettings } from "./SettingsProvider";
import styles from "./DebugEntityList.module.css";
import { FaRegMinusSquare, FaRegPlusSquare } from "react-icons/fa";
function getEntityLabel(entity: GameEntity): string {
if (entity.renderType === "Player" && "playerName" in entity) {
@ -116,7 +118,7 @@ function EntityRow({ entity }: { entity: GameEntity }) {
<dl className={styles.Detail}>
{Object.entries(detail).map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dt>{key}:</dt>
<dd>{value}</dd>
</div>
))}
@ -138,15 +140,45 @@ function EntityRow({ entity }: { entity: GameEntity }) {
);
}
const ENTITY_MATCH_KEYS = [
"id",
"className",
"label",
"playerName",
"skinPrefName",
"shapeName",
"dataBlock",
"audioFileName",
"interiorData.interiorFile",
"terrainData.terrFileName",
"waterData.surfaceName",
];
export const DebugEntityList = memo(function DebugEntityList() {
const [filterText, setFilterText] = useState("");
const activeFilterText = useDeferredValue(filterText.trim());
const entities = useGameEntities();
// useGameEntities re-renders on version bump, but the Map reference is
// stable (mutated in place). Use version as a useMemo dep to recompute.
const version = gameEntityStore.getState().version;
const filteredEntities = useMemo(() => {
const allEntities = Array.from(entities.values());
return activeFilterText
? matchSorter(allEntities, activeFilterText, {
keys: ENTITY_MATCH_KEYS,
})
: allEntities;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeFilterText, entities, version]);
const grouped = useMemo(() => {
const groups = new Map<string, GameEntity[]>();
for (const entity of entities.values()) {
for (const entity of filteredEntities) {
if (
entity.renderType === "Sky" ||
entity.renderType === "Sun" ||
@ -168,16 +200,33 @@ export const DebugEntityList = memo(function DebugEntityList() {
list.sort((a, b) => a.id.localeCompare(b.id));
}
return sorted;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entities, version]);
}, [filteredEntities]);
return (
<div className={styles.Container}>
<h4 className={styles.Title}>Entity list</h4>
<section
className={styles.Container}
data-filtered={activeFilterText ? true : undefined}
>
<header className={styles.Header}>
<h4 className={styles.Title}>Entity list</h4>
<input
type="search"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
size={16}
placeholder="Filter"
className={styles.FilterInput}
/>
</header>
{grouped.map(([className, list]) => (
<details key={className} className={styles.Group}>
<details
key={className}
className={styles.Group}
open={activeFilterText ? true : undefined}
>
<summary className={styles.GroupHeader}>
{className}{" "}
<FaRegPlusSquare className={styles.ClosedIcon} />
<FaRegMinusSquare className={styles.OpenedIcon} /> {className}{" "}
<span className={styles.GroupCount}>({list.length})</span>
</summary>
<ul className={styles.List}>
@ -189,6 +238,6 @@ export const DebugEntityList = memo(function DebugEntityList() {
</ul>
</details>
))}
</div>
</section>
);
});

View file

@ -93,11 +93,8 @@ function renderEventDescription(event: TimelineEvent): React.ReactNode {
</>
);
}
if (event.type === "match-start") {
return "Match started";
}
if (event.type === "match-end") {
return "Match ended";
if (event.type === "match-start" || event.type === "match-end") {
return event.description;
}
return event.description;
}

View file

@ -73,12 +73,15 @@ const WaterBlock = createLazy("WaterBlock", () => import("./WaterBlock"));
*/
export const EntityRenderer = memo(function EntityRenderer({
entity,
objectMounts,
}: {
entity: GameEntity;
/** Object-mounted entities (players in vehicles, turrets on vehicles). */
objectMounts?: Record<number, React.ReactNode>;
}) {
switch (entity.renderType) {
case "Shape":
return <ShapeEntity entity={entity} />;
return <ShapeEntity entity={entity} objectMounts={objectMounts} />;
case "ForceFieldBare":
return <ForceFieldBare entity={entity} />;
case "Player":
@ -119,7 +122,13 @@ export const EntityRenderer = memo(function EntityRenderer({
}
});
function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
function ShapeEntity({
entity,
objectMounts,
}: {
entity: ShapeEntityType;
objectMounts?: Record<number, React.ReactNode>;
}) {
const dataSource = useDataSource();
const isStreaming = dataSource === "demo" || dataSource === "live";
const groupRef = useRef<Group>(null);
@ -153,6 +162,30 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
? "#00ff88"
: "yellow";
// Merge image mounts (all 8 slots) with object mounts (players in vehicles).
// Both use the same Mount bones. Each image slot's mount bone comes from
// dataBlock->mountPoint (binary-verified), not from the slot index.
// Object mounts take priority over image mounts at the same bone.
const allMounts = useMemo(() => {
const m: Record<number, React.ReactNode> = { ...objectMounts };
const slots = entity.imageSlots;
if (slots) {
for (let i = 0; i < slots.length; i++) {
const slot = slots[i];
if (!slot?.shapeName || slot.mountPoint in m) continue;
m[slot.mountPoint] = (
<MountedShapeContent
shapeName={slot.shapeName}
imageDataBlockId={slot.dataBlockId}
entityId={entity.id}
skinName={slot.skinName}
/>
);
}
}
return Object.keys(m).length > 0 ? m : undefined;
}, [objectMounts, entity.imageSlots, entity.id]);
return (
<ShapeInfoProvider
object={entity.runtimeObject as TorqueObject | undefined}
@ -166,19 +199,7 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
emap={emap}
entityId={entity.id}
skinName={entity.skinName}
mounted={
entity.weaponShape
? {
0: (
<MountedShapeContent
shapeName={entity.weaponShape}
imageDataBlockId={entity.imageDataBlockIds?.[0]}
entityId={entity.id}
/>
),
}
: undefined
}
mounted={allMounts}
>
{flagLabel ? (
<FloatingLabel opacity={0.6}>{flagLabel}</FloatingLabel>

View file

@ -1,4 +1,11 @@
import { memo, useCallback, useRef, useState, useMemo } from "react";
import React, {
memo,
Suspense,
useCallback,
useRef,
useState,
useMemo,
} from "react";
import { Quaternion } from "three";
import type { Group } from "three";
import { useFrame } from "@react-three/fiber";
@ -55,13 +62,37 @@ const EntityLayer = memo(function EntityLayer() {
}
}
// Build object mount relationships: which entities are mounted on which.
// Mounted entities render inside their target's mount bone (via createPortal
// in ShapeRenderer), NOT as top-level positioned entities.
const mountedIds = new Set<string>();
const mountChildren = new Map<string, Map<number, GameEntity>>();
for (const entity of cache.values()) {
const mountId = entity.mountObjectId;
if (mountId && cache.has(mountId)) {
mountedIds.add(entity.id);
let children = mountChildren.get(mountId);
if (!children) {
children = new Map();
mountChildren.set(mountId, children);
}
children.set(entity.mountNode ?? 0, entity);
}
}
return (
<>
{
// eslint-disable-next-line react-hooks/refs
[...cache.values()].map((entity) => (
<EntityWrapper key={entity.id} entity={entity} />
))
[...cache.values()]
.filter((entity) => !mountedIds.has(entity.id))
.map((entity) => (
<EntityWrapper
key={entity.id}
entity={entity}
mountChildren={mountChildren.get(entity.id)}
/>
))
}
</>
);
@ -69,8 +100,10 @@ const EntityLayer = memo(function EntityLayer() {
const EntityWrapper = memo(function EntityWrapper({
entity,
mountChildren,
}: {
entity: GameEntity;
mountChildren?: Map<number, GameEntity>;
}) {
if (entity.debugHidden) return null;
@ -87,7 +120,9 @@ const EntityWrapper = memo(function EntityWrapper({
if (entity.renderType === "None") return null;
// From here, entity is a PositionedEntity
return <PositionedEntityWrapper entity={entity} />;
return (
<PositionedEntityWrapper entity={entity} mountChildren={mountChildren} />
);
});
/** Imperatively tracks targetRenderFlags bit 0x2 on a game entity and
@ -121,7 +156,13 @@ function FlagMarkerSlot({ entity }: { entity: GameEntity }) {
return <FlagMarker entity={entity} />;
}
function PositionedEntityWrapper({ entity }: { entity: PositionedEntity }) {
function PositionedEntityWrapper({
entity,
mountChildren,
}: {
entity: PositionedEntity;
mountChildren?: Map<number, GameEntity>;
}) {
const position = entity.position;
const scale = entity.scale;
const quaternion = useMemo(() => {
@ -161,6 +202,22 @@ function PositionedEntityWrapper({ entity }: { entity: PositionedEntity }) {
</mesh>
);
// Build object mount content for entities mounted on this one (e.g. players
// sitting in a vehicle). Each mounted entity renders via EntityRenderer
// inside the target's mount bone (portaled by ShapeRenderer).
const objectMounts = useMemo(() => {
if (!mountChildren || mountChildren.size === 0) return undefined;
const mounts: Record<number, React.ReactNode> = {};
for (const [node, child] of mountChildren) {
mounts[node] = (
<Suspense key={child.id}>
<EntityRenderer entity={child} />
</Suspense>
);
}
return mounts;
}, [mountChildren]);
return (
<group
name={entity.id}
@ -170,7 +227,7 @@ function PositionedEntityWrapper({ entity }: { entity: PositionedEntity }) {
>
<group name="model">
<ShapeErrorBoundary fallback={fallback}>
<EntityRenderer entity={entity} />
<EntityRenderer entity={entity} objectMounts={objectMounts} />
</ShapeErrorBoundary>
<FlagMarkerSlot entity={entity} />
</group>

View file

@ -1,11 +1,11 @@
import { Fragment, memo, Suspense, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";
import type { AnimationAction } from "three";
import type { AnimationAction, Object3D } from "three";
import { useGLTF } from "@react-three/drei";
import { createPortal, useFrame } from "@react-three/fiber";
import { createLogger } from "../logger";
import { shapeToUrl } from "../loaders";
import { shapeToUrl, textureToUrl } from "../loaders";
import {
MeshStandardMaterial,
AdditiveAnimationBlendMode,
@ -14,9 +14,12 @@ import {
AnimationUtils,
LoopOnce,
LoopRepeat,
NormalBlending,
Group,
Box3,
Vector3,
RepeatWrapping,
NoColorSpace,
} from "three";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import { useAnisotropy } from "./useAnisotropy";
@ -46,7 +49,49 @@ import {
getPosedNodeTransform,
} from "../stream/playbackUtils";
import { resolveEmapFromImageSlot } from "./resolveEmap";
import { playerEyePositions } from "./PlayerModel";
import type { ThreadState as StreamThreadState } from "../stream/types";
import { loadTexture } from "../textureUtils";
// ── Cloak texture (binary-verified: special/cloakTexture with UV scrolling) ──
// Lazy-loaded on first use since cloaking is rare.
import type { Texture } from "three";
let _cloakTexture: Texture | null = null;
function getCloakTexture(): Texture {
if (!_cloakTexture) {
_cloakTexture = loadTexture(textureToUrl("special/cloakTexture"));
_cloakTexture.wrapS = RepeatWrapping;
_cloakTexture.wrapT = RepeatWrapping;
_cloakTexture.colorSpace = NoColorSpace;
}
return _cloakTexture;
}
// Global UV offset matching engine's static shiftX/shiftY with different moduli
// to create a non-repeating shimmer pattern.
let _cloakShiftX = 0;
let _cloakShiftY = 0;
let _cloakLastFrame = -1;
function advanceCloakUV(frameId: number): void {
if (frameId === _cloakLastFrame) return;
_cloakLastFrame = frameId;
_cloakShiftX = (_cloakShiftX + 1) % 128;
_cloakShiftY = (_cloakShiftY + 1) % 127;
getCloakTexture().offset.set(_cloakShiftX / 127, _cloakShiftY / 126);
}
// Counter-rotate for object-mounted content (players in vehicles).
// The mount bone is inside the vehicle's R90 Y group (DTS Z-up → Three.js
// Y-up). The mounted PlayerModel applies its own R90 Y. We need to undo the
// player's R90 and rotate from GLB Y-up back to DTS Z-up so the player
// stands upright relative to the vehicle's DTS bone coordinate system.
const MOUNT_COUNTER_ROTATION: [x: number, y: number, z: number] = [
Math.PI / 2,
-Math.PI / 2,
0,
];
const STANDARD_90_ROTATION: [x: number, y: number, z: number] = [
0,
@ -68,6 +113,8 @@ interface StreamShapeEntity {
frozen?: boolean;
maxSteeringAngle?: number;
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
fadeVal?: number;
cloakLevel?: number;
dataBlockId?: number;
}
@ -250,6 +297,7 @@ interface ThreadState {
action?: AnimationAction;
visNodes?: VisNode[];
startTime: number;
forward: boolean;
}
/**
@ -556,7 +604,13 @@ export const ShapeModel = memo(function ShapeModel({
}
}
function handlePlayThread(slot: number, sequenceName: string) {
// Match binary's updateThread (FUN_005ebf00): direction is implemented
// via timeScale (+1 forward, -1 backward). State 0=Play, 1=Stop, 2=Pause.
function handlePlayThread(
slot: number,
sequenceName: string,
forward = true,
) {
const seqLower = sequenceName.toLowerCase();
handleStopThread(slot);
@ -565,6 +619,7 @@ export const ShapeModel = memo(function ShapeModel({
const thread: ThreadState = {
sequence: seqLower,
startTime: shapeNowSec(),
forward,
};
if (clip && mixer) {
@ -582,7 +637,13 @@ export const ShapeModel = memo(function ShapeModel({
if (seqBlendByName.has(seqLower)) {
action.blendMode = AdditiveAnimationBlendMode;
}
action.reset().play();
action.timeScale = forward ? 1 : -1;
action.reset();
// For backward playback, start at the end of the clip.
if (!forward) {
action.time = clip.duration;
}
action.play();
thread.action = action;
}
@ -598,11 +659,12 @@ export const ShapeModel = memo(function ShapeModel({
const thread = threads.get(slot);
if (!thread) return;
if (thread.action) thread.action.stop();
// Binary Stop: reset position to 0.0, freeze. Vis nodes go to frame 0.
if (thread.visNodes) {
for (const v of thread.visNodes) {
v.mesh.visible = false;
if (v.mesh.material && !Array.isArray(v.mesh.material)) {
v.mesh.material.opacity = v.keyframes[0];
v.mesh.visible = v.keyframes[0] > 0.01;
}
}
}
@ -795,51 +857,70 @@ export const ShapeModel = memo(function ShapeModel({
!prev ||
prev.sequence !== t.sequence ||
prev.state !== t.state ||
prev.forward !== t.forward ||
prev.atEnd !== t.atEnd;
if (!changed) continue;
// When only atEnd changed (false→true) on a playing thread with
// the same sequence, the animation has finished on the server.
// Don't restart it — snap to the end pose so one-shot animations
// like "deploy" stay clamped instead of collapsing back.
const onlyAtEndChanged =
prev &&
prev.sequence === t.sequence &&
prev.state === t.state &&
t.state === 0 &&
!prev.atEnd &&
t.atEnd;
if (onlyAtEndChanged) {
const thread = threads.get(slot);
if (thread?.action) {
const clip = thread.action.getClip();
thread.action.time = t.forward ? clip.duration : 0;
thread.action.setLoop(LoopOnce, 1);
thread.action.clampWhenFinished = true;
thread.action.paused = true;
}
continue;
}
const seqName = seqIndexToName[t.sequence];
if (!seqName) continue;
if (t.state === 0) {
playThread(slot, seqName);
// If the animation is already finished (atEnd=true on first
// appearance — e.g. a deployed MPB entering scope), snap to
// the end pose immediately instead of replaying it.
// Match binary updateThread (FUN_005ebf00):
// State 0=Play, 1=Stop, 2=Pause
if (t.state === 1) {
// Stop: reset to start, freeze.
stopThread(slot);
} else if (t.state === 2) {
// Pause: freeze at current position.
const thread = threads.get(slot);
if (thread?.action) {
thread.action.paused = true;
}
} else {
// Play (state === 0)
if (t.atEnd) {
const thread = threads.get(slot);
// Already finished: snap to end pose, freeze.
// Check if we need to start the thread first.
let thread = threads.get(slot);
if (!thread || thread.sequence !== seqName) {
playThread(slot, seqName, t.forward);
thread = threads.get(slot);
}
if (thread?.action) {
const clip = thread.action.getClip();
thread.action.time = t.forward ? clip.duration : 0;
thread.action.timeScale = 1;
thread.action.setLoop(LoopOnce, 1);
thread.action.clampWhenFinished = true;
thread.action.paused = true;
}
// Snap vis nodes to end pose.
if (thread?.visNodes) {
for (const v of thread.visNodes) {
const mat = v.mesh.material;
if (!mat || Array.isArray(mat)) continue;
const endIdx = t.forward
? v.keyframes.length - 1
: 0;
mat.opacity = v.keyframes[endIdx];
v.mesh.visible = mat.opacity > 0.01;
}
}
} else {
// Actively playing: (re)start with correct direction.
// Only restart if sequence or direction changed.
const thread = threads.get(slot);
const needRestart =
!thread ||
thread.sequence !== seqName ||
thread.forward !== t.forward;
if (needRestart) {
playThread(slot, seqName, t.forward);
} else if (thread?.action?.paused) {
// Resume from pause with correct direction.
thread.action.paused = false;
thread.action.timeScale = t.forward ? 1 : -1;
}
}
} else {
stopThread(slot);
}
} else if (prev) {
// Thread disappeared — stop it.
@ -854,7 +935,8 @@ export const ShapeModel = memo(function ShapeModel({
}
// Drive vis opacity animations for active threads.
for (const [, thread] of threads) {
// Direction is handled by computing position forward or backward.
for (const [slot, thread] of threads) {
if (!thread.visNodes) continue;
for (const { mesh, keyframes, duration, cyclic } of thread.visNodes) {
@ -863,20 +945,29 @@ export const ShapeModel = memo(function ShapeModel({
if (!animationEnabled) {
mat.opacity = keyframes[0];
mesh.visible = mat.opacity > 0.01;
continue;
}
const elapsed = shapeNowSec() - thread.startTime;
const t = cyclic
? (elapsed % duration) / duration
: Math.min(elapsed / duration, 1);
let t: number;
if (cyclic) {
// Cyclic: wrap position, ignoring direction (cyclic always advances).
t = ((elapsed % duration) + duration) % duration / duration;
} else if (thread.forward) {
t = Math.min(elapsed / duration, 1);
} else {
// Backward: start at 1.0 and move toward 0.0.
t = Math.max(1 - elapsed / duration, 0);
}
const n = keyframes.length;
const pos = t * n;
const lo = Math.floor(pos) % n;
const hi = (lo + 1) % n;
const frac = pos - Math.floor(pos);
const pos = t * (n - 1);
const lo = Math.min(Math.floor(pos), n - 1);
const hi = Math.min(lo + 1, n - 1);
const frac = pos - lo;
mat.opacity = keyframes[lo] + (keyframes[hi] - keyframes[lo]) * frac;
mesh.visible = mat.opacity > 0.01;
}
}
@ -989,6 +1080,82 @@ export const ShapeModel = memo(function ShapeModel({
}
});
// ShapeBase fade opacity and cloak texture effect.
// Fade (mFadeVal): opacity-only, used by startFade().
// Cloak (mCloakLevel): replaces textures with cloakTexture + UV scrolling,
// alpha = 0.125 + (1 - cloakLevel) * 0.875. Binary-verified rendering path.
const lastFadeValRef = useRef(1);
const lastCloakLevelRef = useRef(0);
useFrame((state) => {
const entity = streamEntityRef.current;
const fadeVal = entity?.fadeVal ?? 1;
const cloakLevel = entity?.cloakLevel ?? 0;
const isCloak = cloakLevel > 0;
// Advance global cloak UV offset once per frame (all cloaked shapes share it).
if (isCloak)
advanceCloakUV(
state.frameloop === "never" ? 0 : (state.clock.elapsedTime * 60) | 0,
);
if (
fadeVal === lastFadeValRef.current &&
cloakLevel === lastCloakLevelRef.current
)
return;
lastFadeValRef.current = fadeVal;
lastCloakLevelRef.current = cloakLevel;
// Cloak alpha OVERRIDES fade (binary-verified: TSMesh uses else-if —
// alwaysAlpha takes precedence over overrideFade when both are set).
const combinedAlpha = isCloak ? 0.125 + (1 - cloakLevel) * 0.875 : fadeVal;
const cloakTex = isCloak ? getCloakTexture() : _cloakTexture;
clonedScene.traverse((node: any) => {
if (!node.isMesh || !node.material || Array.isArray(node.material))
return;
const mat = node.material;
const ud = (mat.userData ??= {});
// Save originals on first encounter.
if (ud._baseFadeOpacity == null) {
ud._baseFadeOpacity = mat.opacity ?? 1;
ud._baseFadeTransparent = mat.transparent ?? false;
ud._originalMap = mat.map;
// Originally-translucent materials keep their own texture during cloak.
// Detect via alphaTest (organic/Translucent cutout) or non-normal blending
// (Additive). These match how createMaterialFromFlags sets up materials.
ud._isOriginallyTranslucent =
(ud._baseFadeTransparent as boolean) ||
mat.alphaTest > 0 ||
mat.blending !== NormalBlending;
}
const baseOpacity = ud._baseFadeOpacity as number;
// Cloak texture replacement (non-translucent materials only).
if (isCloak && !ud._isOriginallyTranslucent) {
if (mat.map !== cloakTex) {
mat.map = cloakTex;
mat.needsUpdate = true;
}
} else if (
!isCloak &&
ud._originalMap !== undefined &&
mat.map === cloakTex
) {
mat.map = ud._originalMap;
mat.needsUpdate = true;
}
mat.opacity = combinedAlpha * baseOpacity;
mat.transparent =
combinedAlpha < 1 || (ud._baseFadeTransparent as boolean);
mat.depthWrite =
combinedAlpha >= 1 && !(ud._baseFadeTransparent as boolean);
});
});
// ShapeBase sound slots — managed as PositionalAudio, not entities.
useEntitySoundSlots(streamEntityRef, clonedScene);
@ -1006,19 +1173,53 @@ export const ShapeModel = memo(function ShapeModel({
};
}, [isTarget, gltf.scene]);
// Find mount point bones in the cloned scene for portal rendering.
// Find mount point bones in the cloned scene for portal rendering
// and for vehicle mount position tracking.
const mountBones = useMemo(() => {
if (!mounted) return null;
const bones: Record<number, Group> = {};
for (const slot of Object.keys(mounted)) {
const index = Number(slot);
const nodeName = `Mount${index}`;
clonedScene.traverse((node: any) => {
if (node.name === nodeName) bones[index] = node;
});
}
// Always look for Mount0 (pilot seat for vehicles).
clonedScene.traverse((node: any) => {
const match = node.name.match(/^Mount(\d+)$/);
if (match) bones[Number(match[1])] = node;
});
return Object.keys(bones).length > 0 ? bones : null;
}, [clonedScene, mounted]);
}, [clonedScene]);
// Find Eye node for vehicle camera positioning. Vehicles have an Eye node
// in their DTS shape that defines the cockpit viewpoint.
const eyeBone = useMemo((): Object3D | null => {
let found: Object3D | null = null;
clonedScene.traverse((node: any) => {
if (node.name === "Eye") found = node;
});
return found;
}, [clonedScene]);
// Write animated Eye node position to the shared eye position map so the
// camera system can use it (same map as PlayerModel's eye bone).
useEffect(() => {
if (!eyeBone || !entityId) return;
return () => {
playerEyePositions.delete(entityId);
};
}, [eyeBone, entityId]);
useFrame(() => {
if (!eyeBone || !entityId) return;
let eyePos = playerEyePositions.get(entityId);
if (!eyePos) {
eyePos = new Vector3();
playerEyePositions.set(entityId, eyePos);
}
eyeBone.getWorldPosition(eyePos);
clonedScene.worldToLocal(eyePos);
// Convert GLB (x,y,z) → entity-local Three.js space via R90
// (same swizzle as PlayerModel's eye extraction).
const gx = eyePos.x;
const gy = eyePos.y;
const gz = eyePos.z;
eyePos.set(gz, gy, -gx);
});
return (
<group rotation={noRotation ? undefined : STANDARD_90_ROTATION}>
@ -1040,7 +1241,10 @@ export const ShapeModel = memo(function ShapeModel({
const bone = mountBones[Number(slot)];
return bone ? (
<Fragment key={slot}>
{createPortal(<group>{content}</group>, bone)}
{createPortal(
<group rotation={MOUNT_COUNTER_ROTATION}>{content}</group>,
bone,
)}
</Fragment>
) : null;
})}

View file

@ -1,7 +1,7 @@
import {
Activity,
Fragment,
startTransition,
useDeferredValue,
useEffect,
useMemo,
useRef,
@ -173,7 +173,8 @@ export function MissionSelect({
autoFocus?: boolean;
onCancel: () => void;
}) {
const [searchValue, setSearchValue] = useState("");
const [latestSearchValue, setSearchValue] = useState("");
const searchValue = useDeferredValue(latestSearchValue);
const inputRef = useRef<HTMLInputElement>(null);
const missionTypeRef = useRef<string | null>(missionType);
@ -203,7 +204,7 @@ export function MissionSelect({
}
},
setValue: (value) => {
startTransition(() => setSearchValue(value));
setSearchValue(value);
},
});

View file

@ -102,7 +102,8 @@ function Reticle() {
if (!snap || snap.camera?.mode !== "first-person") return undefined;
const ctrl = snap.controlPlayerGhostId;
if (!ctrl) return undefined;
return snap.entities.find((e: StreamEntity) => e.id === ctrl)?.weaponShape;
return snap.entities.find((e: StreamEntity) => e.id === ctrl)
?.imageSlots?.[0]?.shapeName;
});
if (weaponShape === undefined) return null;
const weapon = normalizeWeaponName(weaponShape);

View file

@ -27,9 +27,9 @@ import {
} from "../stream/playbackUtils";
import { pickMoveAnimation } from "../stream/playerAnimation";
import { WeaponImageStateMachine } from "../stream/weaponStateMachine";
import { useQuery } from "@tanstack/react-query";
import type { WeaponAnimState } from "../stream/weaponStateMachine";
import { getAliasedActions } from "../torqueScript/shapeConstructor";
import { useQuery } from "@tanstack/react-query";
import {
useStaticShape,
ShapePlaceholder,
@ -89,23 +89,25 @@ const SKIN_SUFFIXES: Record<string, string> = {
/** Processed custom skin manifest: suffix → Set of available skin names. */
type SkinLookup = Record<string, Set<string>>;
/** Fetch the remote custom skins manifest (once, cached forever).
* Returns a lookup of suffix Set<skinName> for O(1) has() checks. */
/** Skin manifest query key and fetcher, shared with App.tsx prefetch. */
export const skinManifestQueryKey = ["customSkinManifest"] as const;
export async function fetchSkinManifest(): Promise<SkinLookup> {
const res = await fetch(SKIN_MANIFEST_URL);
if (!res.ok) throw new Error(`${res.status}`);
const raw: { customSkins?: Record<string, string[]> } = await res.json();
const lookup: SkinLookup = {};
if (raw.customSkins) {
for (const [suffix, names] of Object.entries(raw.customSkins)) {
lookup[suffix] = new Set(names);
}
}
return lookup;
}
function useCustomSkinManifest() {
return useQuery<SkinLookup>({
queryKey: ["customSkinManifest"],
queryFn: async () => {
const res = await fetch(SKIN_MANIFEST_URL);
if (!res.ok) throw new Error(`${res.status}`);
const raw: { customSkins?: Record<string, string[]> } = await res.json();
const lookup: SkinLookup = {};
if (raw.customSkins) {
for (const [suffix, names] of Object.entries(raw.customSkins)) {
lookup[suffix] = new Set(names);
}
}
return lookup;
},
queryKey: skinManifestQueryKey,
queryFn: fetchSkinManifest,
staleTime: Infinity,
retry: 1,
});
@ -286,6 +288,9 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
);
// Resolve skin texture URL: local manifest first, then remote manifest.
// The manifest is prefetched at app startup (see App.tsx) so it's
// available synchronously here — no async wait that could be starved
// by store mutations during streaming playback.
const { data: skinManifest } = useCustomSkinManifest();
const skinUrl = useMemo(() => {
@ -335,10 +340,16 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
// Use front-face-only rendering so the camera can see out from inside the
// model in first-person (backface culling hides interior faces).
// Disable frustum culling — when portaled into a vehicle mount bone, the
// bounding sphere is in local space but the world transform comes from the
// bone chain, causing incorrect culling.
scene.traverse((n: any) => {
if (n.isMesh && n.material) {
const mats = Array.isArray(n.material) ? n.material : [n.material];
for (const m of mats) m.side = FrontSide;
if (n.isMesh) {
n.frustumCulled = false;
if (n.material) {
const mats = Array.isArray(n.material) ? n.material : [n.material];
for (const m of mats) m.side = FrontSide;
}
}
});
@ -590,14 +601,14 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
// Track weaponShape changes. The entity is mutated in-place by the
// streaming layer (no React re-render), so we sync it in useFrame.
const weaponShapeRef = useRef(entity.weaponShape);
const [currentWeaponShape, setCurrentWeaponShape] = useState(
entity.weaponShape,
);
const packShapeRef = useRef(entity.packShape);
const [currentPackShape, setCurrentPackShape] = useState(entity.packShape);
const flagShapeRef = useRef(entity.flagShape);
const [currentFlagShape, setCurrentFlagShape] = useState(entity.flagShape);
// Derive weapon/pack/flag from imageSlots.
const getSlotShape = (slot: number) => entity.imageSlots?.[slot]?.shapeName;
const weaponShapeRef = useRef(getSlotShape(0));
const [currentWeaponShape, setCurrentWeaponShape] = useState(getSlotShape(0));
const packShapeRef = useRef(getSlotShape(2));
const [currentPackShape, setCurrentPackShape] = useState(getSlotShape(2));
const flagShapeRef = useRef(getSlotShape(3));
const [currentFlagShape, setCurrentFlagShape] = useState(getSlotShape(3));
// ShapeBase sound slots (weapon switch sounds, etc.) — managed by shared hook.
const entityRef = useRef(entity);
@ -663,17 +674,20 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
// Per-frame animation selection and mixer update.
useFrame((_, delta) => {
if (entity.weaponShape !== weaponShapeRef.current) {
weaponShapeRef.current = entity.weaponShape;
setCurrentWeaponShape(entity.weaponShape);
const curWeapon = getSlotShape(0);
if (curWeapon !== weaponShapeRef.current) {
weaponShapeRef.current = curWeapon;
setCurrentWeaponShape(curWeapon);
}
if (entity.packShape !== packShapeRef.current) {
packShapeRef.current = entity.packShape;
setCurrentPackShape(entity.packShape);
const curPack = getSlotShape(2);
if (curPack !== packShapeRef.current) {
packShapeRef.current = curPack;
setCurrentPackShape(curPack);
}
if (entity.flagShape !== flagShapeRef.current) {
flagShapeRef.current = entity.flagShape;
setCurrentFlagShape(entity.flagShape);
const curFlag = getSlotShape(3);
if (curFlag !== flagShapeRef.current) {
flagShapeRef.current = curFlag;
setCurrentFlagShape(curFlag);
}
const playback = engineStore.getState().playback;
const isPlaying = playback.status === "playing";
@ -929,7 +943,6 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
// from the animated skeleton (rotation is discarded — head rotation
// is constructed from mHead pitch/yaw instead).
if (eyeBone) {
clonedScene.updateWorldMatrix(false, true);
let eyePos = playerEyePositions.get(entity.id);
if (!eyePos) {
eyePos = new Vector3();
@ -981,7 +994,7 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
<Suspense>
<MountedShapeContent
shapeName={currentPackShape}
imageDataBlockId={entity.imageDataBlockIds?.[2]}
imageDataBlockId={entity.imageSlots?.[2]?.dataBlockId}
entityId={entity.id}
/>
</Suspense>,
@ -993,9 +1006,9 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
<Suspense>
<MountedShapeContent
shapeName={currentFlagShape}
imageDataBlockId={entity.imageDataBlockIds?.[3]}
imageDataBlockId={entity.imageSlots?.[3]?.dataBlockId}
entityId={entity.id}
skinName={entity.imageSkinNames?.[3]}
skinName={entity.imageSlots?.[3]?.skinName}
/>
</Suspense>,
mount2,
@ -1075,8 +1088,8 @@ function WeaponModel({
const engineStore = useEngineStoreApi();
const weaponGltf = useStaticShape(weaponShape);
const emap = useMemo(
() => resolveEmapFromImageSlot(entity.imageDataBlockIds?.[0]),
[entity.imageDataBlockIds],
() => resolveEmapFromImageSlot(entity.imageSlots?.[0]?.dataBlockId),
[entity.imageSlots],
);
const anisotropy = useAnisotropy();

View file

@ -42,14 +42,16 @@ function mutateRenderFields(
renderEntity: GameEntity,
stream: StreamEntity,
): void {
// Fields common to all positioned entities.
const e = renderEntity as unknown as Record<string, unknown>;
e.mountObjectId = stream.mountObjectId;
e.mountNode = stream.mountNode;
e.imageSlots = stream.imageSlots;
switch (renderEntity.renderType) {
case "Player": {
const e = renderEntity as unknown as Record<string, unknown>;
e.threads = stream.threads;
e.weaponShape = stream.weaponShape;
e.armAction = stream.armAction;
e.packShape = stream.packShape;
e.flagShape = stream.flagShape;
e.falling = stream.falling;
e.jetting = stream.jetting;
e.weaponImageState = stream.weaponImageState;
@ -63,10 +65,11 @@ function mutateRenderFields(
break;
}
case "Shape": {
const e = renderEntity as unknown as Record<string, unknown>;
e.threads = stream.threads;
e.damageState = stream.damageState;
e.weaponShape = stream.weaponShape;
e.fadeVal = stream.fadeVal;
e.cloakLevel = stream.cloakLevel;
e.armAction = stream.armAction;
e.targetRenderFlags = stream.targetRenderFlags;
e.iffColor = stream.iffColor;
e.soundSlots = stream.soundSlots;
@ -146,6 +149,7 @@ export function StreamingController({
const engineStore = useEngineStoreApi();
const { fov: userFov } = useSettings();
const playbackClockRef = useRef(0);
const lastSeekTimeRef = useRef(0);
const prevTickSnapshotRef = useRef<StreamSnapshot | null>(null);
const currentTickSnapshotRef = useRef<StreamSnapshot | null>(null);
const streamRef = useRef<StreamingPlayback | null>(
@ -187,7 +191,7 @@ export function StreamingController({
getField(renderEntity, "shapeName") !== entity.dataBlock) ||
(renderEntity.renderType !== "Player" &&
hasShapeName &&
getField(renderEntity, "weaponShape") !== entity.weaponShape);
getField(renderEntity, "imageSlots") !== entity.imageSlots);
if (needsNewIdentity) {
const prevHidden = renderEntity?.debugHidden;
@ -196,7 +200,13 @@ export function StreamingController({
map.set(entity.id, renderEntity);
structuralChange = true;
} else {
// Detect mount state changes — EntityScene needs a store re-render
// to re-evaluate mount relationships (portal rendering).
const prevMount = renderEntity!.mountObjectId;
mutateRenderFields(renderEntity!, entity);
if (renderEntity!.mountObjectId !== prevMount) {
structuralChange = true;
}
}
// Keyframe update (mutable — position, rotation, velocity, etc.).
@ -260,6 +270,7 @@ export function StreamingController({
publishedSnapshotRef.current = null;
resetStreamPlayback();
playbackClockRef.current = 0;
lastSeekTimeRef.current = 0;
prevTickSnapshotRef.current = null;
currentTickSnapshotRef.current = null;
@ -345,17 +356,10 @@ export function StreamingController({
const storeState = engineStore.getState();
const playback = storeState.playback;
const isPlaying = playback.status === "playing";
const requestedTimeSec = playback.timeMs / 1000;
const externalSeekWhilePaused =
!isPlaying &&
Math.abs(requestedTimeSec - playbackClockRef.current) > 0.0005;
const externalSeekWhilePlaying =
isPlaying &&
Math.abs(requestedTimeSec - streamPlaybackStore.getState().time) > 0.05;
const isSeeking = externalSeekWhilePaused || externalSeekWhilePlaying;
const isSeeking = playback.seekTime !== lastSeekTimeRef.current;
if (isSeeking) {
// Sync stream cursor to UI/programmatic seek.
playbackClockRef.current = requestedTimeSec;
lastSeekTimeRef.current = playback.seekTime;
playbackClockRef.current = playback.seekTime;
}
// Advance the shared effect clock so all effect timers (particles,
@ -522,7 +526,7 @@ export function StreamingController({
continue;
}
}
if (!entity?.position || entity.hidden) {
if (!entity?.position || (entity.fadeVal === 0 && !entity.cloakLevel)) {
child.visible = false;
continue;
}
@ -591,7 +595,11 @@ export function StreamingController({
_orbitTarget.copy(targetGroup.position);
// Torque orbits the target's render world-box center; player positions
// in our stream are feet-level, so lift to an approximate center.
if (orbitEntity?.type === "Player") {
// For vehicles, use the datablock's cameraOffset (vertical Z offset
// in Torque space = Y in Three.js).
if (currentCamera.orbitOffset) {
_orbitTarget.y += currentCamera.orbitOffset;
} else if (orbitEntity?.type === "Player") {
_orbitTarget.y += 1.0;
}
@ -683,11 +691,6 @@ export function StreamingController({
if (isPlaying && snapshot.exhausted) {
storeState.setPlaybackStatus("paused");
}
const timeMs = playbackClockRef.current * 1000;
if (Math.abs(timeMs - playback.timeMs) > 0.5) {
storeState.setPlaybackTime(timeMs);
}
});
return (

View file

@ -28,7 +28,7 @@ const log = createLogger("TerrainBlock");
import { useSceneSky, useSceneSun } from "../state/gameEntityStore";
import { loadTerrain } from "../loaders";
import { uint16ToFloat32 } from "../arrayUtils";
import { setupMask } from "../textureUtils";
import { packMasksRGB } from "../textureUtils";
import { TerrainTile, TerrainMaterial } from "./TerrainTile";
import {
createTerrainHeightSampler,
@ -565,10 +565,11 @@ export const TerrainBlock = memo(function TerrainBlock({
// Visibility mask for pooled tiles - all visible (no empty squares)
// This is a stable reference shared by all pooled tiles
const pooledVisibilityMask = useMemo(() => createVisibilityMask([]), []);
// Shared alpha textures from terrain alphaMaps - created once for all tiles
const sharedAlphaTextures = useMemo(() => {
// Pack alpha masks into RGB textures (3 masks per texture) to reduce
// sampler count. 6 layers → 2 RGB textures instead of 6 R textures.
const packedAlphaTextures = useMemo(() => {
if (!terrain) return null;
return terrain.alphaMaps.map((data) => setupMask(data));
return packMasksRGB(terrain.alphaMaps);
}, [terrain]);
// Calculate the maximum number of tiles that can be visible at once.
const poolSize = useMemo(() => {
@ -636,14 +637,14 @@ export const TerrainBlock = memo(function TerrainBlock({
!terrain ||
!sharedGeometry ||
!sharedDisplacementMap ||
!sharedAlphaTextures
!packedAlphaTextures
) {
log.debug(
"Not ready: terrain=%s geometry=%s displacement=%s alpha=%s",
!!terrain,
!!sharedGeometry,
!!sharedDisplacementMap,
!!sharedAlphaTextures,
!!packedAlphaTextures,
);
return null;
}
@ -659,7 +660,7 @@ export const TerrainBlock = memo(function TerrainBlock({
geometry={sharedGeometry}
displacementMap={sharedDisplacementMap}
visibilityMask={primaryVisibilityMask}
alphaTextures={sharedAlphaTextures}
alphaTextures={packedAlphaTextures}
detailTextureName={detailTexture}
lightmap={terrainLightmap ?? undefined}
/>
@ -677,7 +678,7 @@ export const TerrainBlock = memo(function TerrainBlock({
displacementMap={sharedDisplacementMap}
visibilityMask={pooledVisibilityMask}
textureNames={terrain.textureNames}
alphaTextures={sharedAlphaTextures}
alphaTextures={packedAlphaTextures}
detailTextureName={detailTexture}
lightmap={terrainLightmap ?? undefined}
/>

View file

@ -147,14 +147,13 @@ const BlendedTerrainTextures = memo(function BlendedTerrainTextures({
// Key for shader structure changes (detail texture, lightmap)
const materialKey = `${detailTextureUrl ? "detail" : "nodetail"}-${lightmap ? "lightmap" : "nolightmap"}`;
// Displacement is done on CPU, so no displacementMap needed
// We keep 'map' to provide UV coordinates for shader (vMapUv)
// Use MeshLambertMaterial for compatibility with shadow maps
// Displacement is done on CPU — no displacementMap or map needed.
// UVs are provided by vTerrainUv (injected in the vertex shader),
// avoiding an unused `map` sampler that would waste a texture unit.
return (
<meshLambertMaterial
ref={materialRef}
key={materialKey}
map={displacementMap}
depthWrite
side={FrontSide}
defines={{ DEBUG_MODE: debugMode ? 1 : 0 }}

View file

@ -134,7 +134,9 @@ function createSwapAtlas(
tex.wrapS = tex.wrapT = RepeatWrapping;
tex.colorSpace = SRGBColorSpace;
tex.flipY = false;
tex.needsUpdate = true;
if (tex.image) {
tex.needsUpdate = true;
}
}
return {
texture: uniqueTextures[0],

View file

@ -12,8 +12,15 @@ export function useIsPlaying(): boolean {
return useEngineSelector((state) => state.playback.status === "playing");
}
/** Playback time for UI display, floored to whole seconds. The selector
* evaluates on every store mutation but only triggers a re-render when
* the displayed second changes (~1/s). */
export function useCurrentTime(): number {
return useEngineSelector((state) => state.playback.timeMs / 1000);
return useEngineSelector((state) =>
Math.floor(
state.playback.streamSnapshot?.timeSec ?? state.playback.seekTime,
),
);
}
export function useDuration(): number {
@ -30,7 +37,7 @@ export function usePlaybackActions() {
const setPlaybackStatus = useEngineSelector(
(state) => state.setPlaybackStatus,
);
const setPlaybackTime = useEngineSelector((state) => state.setPlaybackTime);
const seekPlayback = useEngineSelector((state) => state.seekPlayback);
const setPlaybackRate = useEngineSelector((state) => state.setPlaybackRate);
const setRec = useCallback(
@ -50,10 +57,10 @@ export function usePlaybackActions() {
}, [setPlaybackStatus]);
const seek = useCallback(
(time: number) => {
setPlaybackTime(time * 1000);
(timeSec: number) => {
seekPlayback(timeSec);
},
[setPlaybackTime],
[seekPlayback],
);
const setSpeed = useCallback(

View file

@ -29164,10 +29164,6 @@
"missions/TrueGrit.mis",
["z_mappacks/TWL_T2arenaOfficialMaps.vl2"]
],
"missions/tst_spheremap.mis": [
"missions/TST_SphereMap.mis",
["z_mappacks/zDMP-4.7.3DX.vl2"]
],
"missions/tusklt.mis": [
"missions/TuskLT.mis",
["z_mappacks/z_DMP2-V0.6.vl2"]
@ -30843,7 +30839,6 @@
["z_mappacks/z_DMP2-V0.6.vl2"]
],
"shapes/chaingun_shot.dts": ["shapes/chaingun_shot.dts", ["shapes.vl2"]],
"shapes/chrometest.dts": ["shapes/chromeTest.dts", ["shapes.vl2"]],
"shapes/debris_generic.dts": ["shapes/debris_generic.dts", ["shapes.vl2"]],
"shapes/debris_generic_small.dts": [
"shapes/debris_generic_small.dts",
@ -61282,11 +61277,6 @@
"displayName": "True Grit",
"missionTypes": ["Arena"]
},
"TST_SphereMap": {
"resourcePath": "missions/tst_spheremap.mis",
"displayName": "TST-SphereMap",
"missionTypes": ["CTF"]
},
"TuskLT": {
"resourcePath": "missions/tusklt.mis",
"displayName": "DMP2-Tusk LT",

View file

@ -1,3 +1,15 @@
import type {
TerrainBlockGhostData,
InteriorInstanceGhostData,
TSStaticGhostData,
SkyGhostData,
SunGhostData,
MissionAreaGhostData,
WaterBlockGhostData,
ParsedData,
AffineTransform,
MatrixF as ParserMatrixF,
} from "t2-demo-parser";
import type {
SceneTerrainBlock,
SceneInteriorInstance,
@ -16,147 +28,121 @@ import { createLogger } from "../logger";
const log = createLogger("ghostToScene");
type GhostData = Record<string, unknown>;
const DEFAULT_VEC3: Vec3 = { x: 0, y: 0, z: 0 };
const UNIT_SCALE: Vec3 = { x: 1, y: 1, z: 1 };
function vec3(v: unknown, fallback: Vec3 = { x: 0, y: 0, z: 0 }): Vec3 {
if (v && typeof v === "object" && "x" in v) return v as Vec3;
return fallback;
function color3Or(v: Color3 | undefined, fallback: Color3): Color3 {
return v ?? fallback;
}
function color3(v: unknown, fallback: Color3 = { r: 0, g: 0, b: 0 }): Color3 {
if (v && typeof v === "object" && "r" in v) return v as Color3;
return fallback;
function color4Or(v: Color4 | undefined, fallback: Color4): Color4 {
return v ?? fallback;
}
function color4(
v: unknown,
fallback: Color4 = { r: 0.5, g: 0.5, b: 0.5, a: 1 },
): Color4 {
if (v && typeof v === "object" && "r" in v) return v as Color4;
return fallback;
}
function matrixF(v: unknown): MatrixF {
if (
v &&
typeof v === "object" &&
"elements" in v &&
Array.isArray((v as any).elements)
) {
return v as MatrixF;
}
// readAffineTransform() returns {position, rotation} — convert to MatrixF.
if (v && typeof v === "object" && "position" in v && "rotation" in v) {
const { position: pos, rotation: q } = v as {
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number; w: number };
};
// Quaternion to column-major 4×4 matrix (idx = row + col*4).
const xx = q.x * q.x,
yy = q.y * q.y,
zz = q.z * q.z;
const xy = q.x * q.y,
xz = q.x * q.z,
yz = q.y * q.z;
const wx = q.w * q.x,
wy = q.w * q.y,
wz = q.w * q.z;
/**
* Convert a parser transform (MatrixF or AffineTransform) to the scene's
* MatrixF format. The parser may emit either depending on the ghost class.
*/
function toMatrixF(v: ParserMatrixF | AffineTransform | undefined): MatrixF {
if (!v) {
return {
elements: [
1 - 2 * (yy + zz),
2 * (xy + wz),
2 * (xz - wy),
0,
2 * (xy - wz),
1 - 2 * (xx + zz),
2 * (yz + wx),
0,
2 * (xz + wy),
2 * (yz - wx),
1 - 2 * (xx + yy),
0,
pos.x,
pos.y,
pos.z,
1,
],
position: { x: pos.x, y: pos.y, z: pos.z },
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
position: DEFAULT_VEC3,
};
}
// MatrixF: has elements array
if ("elements" in v) {
return v;
}
// AffineTransform: has position + rotation quaternion
const { position: pos, rotation: q } = v;
const xx = q.x * q.x,
yy = q.y * q.y,
zz = q.z * q.z;
const xy = q.x * q.y,
xz = q.x * q.z,
yz = q.y * q.z;
const wx = q.w * q.x,
wy = q.w * q.y,
wz = q.w * q.z;
return {
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
position: { x: 0, y: 0, z: 0 },
elements: [
1 - 2 * (yy + zz),
2 * (xy + wz),
2 * (xz - wy),
0,
2 * (xy - wz),
1 - 2 * (xx + zz),
2 * (yz + wx),
0,
2 * (xz + wy),
2 * (yz - wx),
1 - 2 * (xx + yy),
0,
pos.x,
pos.y,
pos.z,
1,
],
position: { x: pos.x, y: pos.y, z: pos.z },
};
}
export function terrainFromGhost(
ghostIndex: number,
data: GhostData,
data: TerrainBlockGhostData,
): SceneTerrainBlock {
return {
className: "TerrainBlock",
ghostIndex,
terrFileName: (data.terrFileName as string) ?? "",
detailTextureName: (data.detailTextureName as string) ?? "",
squareSize: (data.squareSize as number) ?? 8,
emptySquareRuns: data.emptySquareRuns as number[] | undefined,
terrFileName: data.terrFileName ?? "",
detailTextureName: data.detailTextureName ?? "",
squareSize: data.squareSize ?? 8,
emptySquareRuns: data.emptySquareRuns,
};
}
export function interiorFromGhost(
ghostIndex: number,
data: GhostData,
data: InteriorInstanceGhostData,
): SceneInteriorInstance {
return {
className: "InteriorInstance",
ghostIndex,
interiorFile: (data.interiorFile as string) ?? "",
transform: matrixF(data.transform),
scale: vec3(data.scale, { x: 1, y: 1, z: 1 }),
showTerrainInside: (data.showTerrainInside as boolean) ?? false,
skinBase: (data.skinBase as string) ?? "",
alarmState: (data.alarmState as boolean) ?? false,
interiorFile: data.interiorFile ?? "",
transform: toMatrixF(data.transform),
scale: data.scale ?? UNIT_SCALE,
showTerrainInside: data.showTerrainInside ?? false,
skinBase: data.skinBase ?? "",
alarmState: data.alarmState ?? false,
};
}
export function tsStaticFromGhost(
ghostIndex: number,
data: GhostData,
data: TSStaticGhostData,
): SceneTSStatic {
return {
className: "TSStatic",
ghostIndex,
shapeName: (data.shapeName as string) ?? "",
transform: matrixF(data.transform),
scale: vec3(data.scale, { x: 1, y: 1, z: 1 }),
shapeName: data.shapeName ?? "",
transform: toMatrixF(data.transform),
scale: data.scale ?? UNIT_SCALE,
};
}
export function skyFromGhost(ghostIndex: number, data: GhostData): SceneSky {
const fogVolumes = Array.isArray(data.fogVolumes)
? (
data.fogVolumes as Array<{
visibleDistance?: number;
minHeight?: number;
maxHeight?: number;
color?: Color3;
}>
).map((v) => ({
export function skyFromGhost(ghostIndex: number, data: SkyGhostData): SceneSky {
const fogVolumes = data.fogVolumes
? data.fogVolumes.map((v) => ({
visibleDistance: v.visibleDistance ?? 0,
minHeight: v.minHeight ?? 0,
maxHeight: v.maxHeight ?? 0,
color: color3(v.color),
color: color3Or(v.color, { r: 0, g: 0, b: 0 }),
}))
: [];
const cloudLayers = Array.isArray(data.cloudLayers)
? (
data.cloudLayers as Array<{
texture?: string;
heightPercent?: number;
speed?: number;
}>
).map((c) => ({
const cloudLayers = data.cloudLayers
? data.cloudLayers.map((c) => ({
texture: c.texture ?? "",
heightPercent: c.heightPercent ?? 0,
speed: c.speed ?? 0,
@ -166,61 +152,56 @@ export function skyFromGhost(ghostIndex: number, data: GhostData): SceneSky {
return {
className: "Sky",
ghostIndex,
materialList: (data.materialList as string) ?? "",
fogColor: color3(data.fogColor),
visibleDistance: (data.visibleDistance as number) ?? 1000,
fogDistance: (data.fogDistance as number) ?? 0,
skySolidColor: color3(data.skySolidColor),
useSkyTextures: (data.useSkyTextures as boolean) ?? true,
materialList: data.materialList ?? "",
fogColor: color3Or(data.fogColor, { r: 0, g: 0, b: 0 }),
visibleDistance: data.visibleDistance ?? 1000,
fogDistance: data.fogDistance ?? 0,
skySolidColor: color3Or(data.skySolidColor, { r: 0, g: 0, b: 0 }),
useSkyTextures: data.useSkyTextures ?? true,
fogVolumes,
cloudLayers,
windVelocity: vec3(data.windVelocity),
windVelocity: data.windVelocity ?? DEFAULT_VEC3,
};
}
export function sunFromGhost(ghostIndex: number, data: GhostData): SceneSun {
export function sunFromGhost(ghostIndex: number, data: SunGhostData): SceneSun {
return {
className: "Sun",
ghostIndex,
direction: vec3(data.direction, { x: 0.57735, y: 0.57735, z: -0.57735 }),
color: color4(data.color, { r: 0.7, g: 0.7, b: 0.7, a: 1 }),
ambient: color4(data.ambient, { r: 0.5, g: 0.5, b: 0.5, a: 1 }),
textures: Array.isArray(data.textures)
? (data.textures as string[])
: undefined,
direction: data.direction ?? { x: 0.57735, y: 0.57735, z: -0.57735 },
color: color4Or(data.color, { r: 0.7, g: 0.7, b: 0.7, a: 1 }),
ambient: color4Or(data.ambient, { r: 0.5, g: 0.5, b: 0.5, a: 1 }),
textures: data.textures,
};
}
export function missionAreaFromGhost(
ghostIndex: number,
data: GhostData,
data: MissionAreaGhostData,
): SceneMissionArea {
const area = data.area as
| { x: number; y: number; w: number; h: number }
| undefined;
return {
className: "MissionArea",
ghostIndex,
area: area ?? { x: -512, y: -512, w: 1024, h: 1024 },
flightCeiling: (data.flightCeiling as number) ?? 2000,
flightCeilingRange: (data.flightCeilingRange as number) ?? 50,
area: data.area ?? { x: -512, y: -512, w: 1024, h: 1024 },
flightCeiling: data.flightCeiling ?? 2000,
flightCeilingRange: data.flightCeilingRange ?? 50,
};
}
export function waterBlockFromGhost(
ghostIndex: number,
data: GhostData,
data: WaterBlockGhostData,
): SceneWaterBlock {
return {
className: "WaterBlock",
ghostIndex,
transform: matrixF(data.transform),
scale: vec3(data.scale, { x: 1, y: 1, z: 1 }),
surfaceName: (data.surfaceName as string) ?? "",
envMapName: (data.envMapName as string) ?? "",
surfaceOpacity: (data.surfaceOpacity as number) ?? 0.75,
waveMagnitude: (data.waveMagnitude as number) ?? 1.0,
envMapIntensity: (data.envMapIntensity as number) ?? 1.0,
transform: toMatrixF(data.transform),
scale: data.scale ?? UNIT_SCALE,
surfaceName: data.surfaceName ?? "",
envMapName: data.envMapName ?? "",
surfaceOpacity: data.surfaceOpacity ?? 0.75,
waveMagnitude: data.waveMagnitude ?? 1.0,
envMapIntensity: data.envMapIntensity ?? 1.0,
};
}
@ -228,66 +209,71 @@ export function waterBlockFromGhost(
export function ghostToSceneObject(
className: string,
ghostIndex: number,
data: GhostData,
data: ParsedData,
): SceneObject | null {
let result: SceneObject | null;
switch (className) {
case "TerrainBlock":
result = terrainFromGhost(ghostIndex, data);
case "TerrainBlock": {
const result = terrainFromGhost(
ghostIndex,
data as TerrainBlockGhostData,
);
log.debug(
"TerrainBlock #%d: terrFileName=%s",
ghostIndex,
(result as SceneTerrainBlock).terrFileName,
result.terrFileName,
);
return result;
case "InteriorInstance":
result = interiorFromGhost(ghostIndex, data);
}
case "InteriorInstance": {
const result = interiorFromGhost(
ghostIndex,
data as InteriorInstanceGhostData,
);
log.debug(
"InteriorInstance #%d: interiorFile=%s",
ghostIndex,
(result as SceneInteriorInstance).interiorFile,
result.interiorFile,
);
return result;
}
case "TSStatic":
return tsStaticFromGhost(ghostIndex, data);
return tsStaticFromGhost(ghostIndex, data as TSStaticGhostData);
case "Sky": {
result = skyFromGhost(ghostIndex, data);
const sky = result as SceneSky;
const result = skyFromGhost(ghostIndex, data as SkyGhostData);
log.debug(
"Sky #%d: materialList=%s fogColor=(%s, %s, %s) visibleDist=%d fogDist=%d useSkyTextures=%s",
ghostIndex,
sky.materialList,
sky.fogColor.r.toFixed(3),
sky.fogColor.g.toFixed(3),
sky.fogColor.b.toFixed(3),
sky.visibleDistance,
sky.fogDistance,
sky.useSkyTextures,
result.materialList,
result.fogColor.r.toFixed(3),
result.fogColor.g.toFixed(3),
result.fogColor.b.toFixed(3),
result.visibleDistance,
result.fogDistance,
result.useSkyTextures,
);
return result;
}
case "Sun": {
result = sunFromGhost(ghostIndex, data);
const sun = result as SceneSun;
const result = sunFromGhost(ghostIndex, data as SunGhostData);
log.debug(
"Sun #%d: dir=(%s, %s, %s) color=(%s, %s, %s) ambient=(%s, %s, %s)",
ghostIndex,
sun.direction.x.toFixed(3),
sun.direction.y.toFixed(3),
sun.direction.z.toFixed(3),
sun.color.r.toFixed(3),
sun.color.g.toFixed(3),
sun.color.b.toFixed(3),
sun.ambient.r.toFixed(3),
sun.ambient.g.toFixed(3),
sun.ambient.b.toFixed(3),
result.direction.x.toFixed(3),
result.direction.y.toFixed(3),
result.direction.z.toFixed(3),
result.color.r.toFixed(3),
result.color.g.toFixed(3),
result.color.b.toFixed(3),
result.ambient.r.toFixed(3),
result.ambient.g.toFixed(3),
result.ambient.b.toFixed(3),
);
return result;
}
case "MissionArea":
return missionAreaFromGhost(ghostIndex, data);
return missionAreaFromGhost(ghostIndex, data as MissionAreaGhostData);
case "WaterBlock":
return waterBlockFromGhost(ghostIndex, data);
return waterBlockFromGhost(ghostIndex, data as WaterBlockGhostData);
default:
return null;
}

View file

@ -28,7 +28,9 @@ export interface RuntimeSliceState {
export interface PlaybackSliceState {
recording: StreamRecording | null;
status: PlaybackStatus;
timeMs: number;
/** Seek target in seconds. Written by UI seek actions, read by
* StreamingController to detect and execute seeks. */
seekTime: number;
rate: number;
durationMs: number;
streamSnapshot: StreamSnapshot | null;
@ -48,7 +50,7 @@ export interface EngineStoreState {
tickInfo?: RuntimeTickInfo,
): void;
setRecording(recording: StreamRecording | null): void;
setPlaybackTime(ms: number): void;
seekPlayback(timeSec: number): void;
setPlaybackStatus(status: PlaybackStatus): void;
setPlaybackRate(rate: number): void;
setPlaybackStreamSnapshot(snapshot: StreamSnapshot | null): void;
@ -111,7 +113,7 @@ const initialState: Omit<
| "clearRuntime"
| "applyRuntimeBatch"
| "setRecording"
| "setPlaybackTime"
| "seekPlayback"
| "setPlaybackStatus"
| "setPlaybackRate"
| "setPlaybackStreamSnapshot"
@ -128,7 +130,7 @@ const initialState: Omit<
playback: {
recording: null,
status: "stopped",
timeMs: 0,
seekTime: 0,
rate: 1,
durationMs: 0,
streamSnapshot: null,
@ -259,7 +261,7 @@ export const engineStore = createStore<EngineStoreState>()(
playback: {
recording,
status: recording ? "stopped" : state.playback.status,
timeMs: recording ? 0 : state.playback.timeMs,
seekTime: recording ? 0 : state.playback.seekTime,
rate: recording ? 1 : state.playback.rate,
durationMs,
// Preserve the last snapshot so HUD/chat persist after unload.
@ -268,14 +270,14 @@ export const engineStore = createStore<EngineStoreState>()(
}));
},
setPlaybackTime(ms: number) {
seekPlayback(timeSec: number) {
set((state) => {
const clamped = clamp(ms, 0, state.playback.durationMs);
const clamped = clamp(timeSec, 0, state.playback.durationMs / 1000);
return {
...state,
playback: {
...state.playback,
timeMs: clamped,
seekTime: clamped,
},
};
});

View file

@ -1,4 +1,5 @@
import type {
ImageSlot,
Keyframe,
ThreadState,
TracerVisual,
@ -53,6 +54,10 @@ interface EntityBase {
missionTypesList?: string;
/** Hidden via the debug entity list. */
debugHidden?: boolean;
/** Entity ID of the object this entity is mounted on (vehicle, etc.). */
mountObjectId?: string;
/** Mount point node index on the mount target (0 = pilot). */
mountNode?: number;
}
// ── Scene infrastructure entities ──
@ -117,6 +122,8 @@ interface PositionedBase extends EntityBase {
scale?: [number, number, number];
velocity?: [number, number, number];
keyframes?: Keyframe[];
/** Mounted image slots (0-7). Mount bone from dataBlock->mountPoint. */
imageSlots?: (ImageSlot | undefined)[];
}
// ── Gameplay entities ──
@ -127,8 +134,6 @@ export interface ShapeEntity extends PositionedBase {
shapeName?: string;
shapeType?: string;
dataBlock?: string;
/** Datablock IDs for each mounted image slot (0-3). */
imageDataBlockIds?: (number | undefined)[];
/** Skin name from TargetManager (e.g. "beagle" for flags, team skins). */
skinName?: string;
threads?: ThreadState[];
@ -138,7 +143,6 @@ export interface ShapeEntity extends PositionedBase {
teamId?: number;
targetRenderFlags?: number;
iffColor?: { r: number; g: number; b: number };
weaponShape?: string;
/** Arm blend animation action index from Player ghost (networked). */
armAction?: number;
/** WheeledVehicle per-wheel state (speed, slip). */
@ -155,22 +159,18 @@ export interface ShapeEntity extends PositionedBase {
maxSteeringAngle?: number;
/** ShapeBase sound slots (from ghost SoundMask). */
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
/** ShapeBase fade value (0=invisible, 1=fully visible). Matches mFadeVal. */
fadeVal?: number;
/** Cloak level (0=visible, 1=fully cloaked). Used for cloak texture effect. */
cloakLevel?: number;
}
export interface PlayerEntity extends PositionedBase {
renderType: "Player";
shapeName?: string;
dataBlock?: string;
/** Datablock IDs for each mounted image slot (0-3). */
imageDataBlockIds?: (number | undefined)[];
/** Skin names for each mounted image slot (e.g. flag team skin on slot 3). */
imageSkinNames?: (string | undefined)[];
weaponShape?: string;
/** Arm blend animation action index from Player ghost (networked). */
armAction?: number;
packShape?: string;
/** DTS shape name for the carried flag (slot 3, Mount2 bone). */
flagShape?: string;
falling?: boolean;
jetting?: boolean;
playerName?: string;

View file

@ -40,6 +40,7 @@ import type {
BackpackHudState,
ChatSegment,
ChatMessage,
ImageSlot,
ThreadState,
StreamVisual,
StreamCamera,
@ -54,6 +55,18 @@ import type {
WeaponImageState,
WeaponImageDataBlockState,
} from "./types";
import type {
ParsedData,
GhostAlwaysObjectEventData,
NetStringEventData,
TargetInfoEventData,
SetSensorGroupEventData,
SensorGroupColorEventData,
RemoteCommandEventData,
Sim3DAudioEventData,
Sim2DAudioEventData,
GhostingMessageEventData,
} from "t2-demo-parser";
import { createLogger } from "../logger";
const log = createLogger("StreamEngine");
@ -74,9 +87,8 @@ export interface MutableEntity {
dataBlock?: string;
visual?: StreamVisual;
direction?: [number, number, number];
weaponShape?: string;
/** Datablock IDs for each mounted image slot (0-3). */
imageDataBlockIds?: (number | undefined)[];
/** Mounted image slots (0-7). Each has shape, mount bone from datablock. */
imageSlots?: (ImageSlot | undefined)[];
playerName?: string;
/** Player skin (team skin like "base", "baseb"). */
skinName?: string;
@ -109,22 +121,29 @@ export interface MutableEntity {
weaponImageState?: WeaponImageState;
weaponImageStates?: WeaponImageDataBlockState[];
weaponImageStatesDbId?: number;
packShape?: string;
flagShape?: string;
/** Skin names for each mounted image slot (from ghost ImageMask skinTag). */
imageSkinNames?: (string | undefined)[];
falling?: boolean;
jetting?: boolean;
headPitch?: number;
headYaw?: number;
targetRenderFlags?: number;
/** Ghost index of the object this entity is mounted on (vehicle, etc.).
* When set, the entity's rendered position is derived from the mount
* target's transform, not from its own ghost position. */
mountObjectGhostIndex?: number;
/** Mount point node on the mount target (0 = pilot, 1+ = passenger/turret). */
mountNode?: number;
/** ShapeBase sound slots (4 max). Raw ghost SoundMask data components
* manage PositionalAudio objects directly, matching Tribes 2's approach. */
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
/** ShapeBase hidden state (binaryCloak from CloakMask). True = invisible.
* Flags are hidden when carried by a player; the flag Item ghost stays
* in scope but shouldn't render. */
hidden?: boolean;
/** ShapeBase fade value (0=invisible, 1=fully visible). Matches mFadeVal. */
fadeVal?: number;
/** Active fade animation state. Set when CloakMask fading=true. */
fadeState?: { fadeOut: boolean; fadeTime: number; elapsed: number };
/** Whether the cloak is active (mCloaked). */
cloaked?: boolean;
/** Cloak level (0=visible, 1=fully cloaked). Interpolated client-side
* at rate dt*2 per tick (0.5s transition). Binary-verified. */
cloakLevel?: number;
/** Item mStatic flag (from InitialUpdateMask). Static items (flags at
* flagstand) skip all physics in Item::processTick. */
isStaticItem?: boolean;
@ -172,7 +191,7 @@ export interface MutableEntity {
export type RuntimeControlObject = {
ghostIndex: number;
data?: Record<string, unknown>;
data?: ParsedData;
position?: Vec3;
};
@ -299,7 +318,7 @@ export abstract class StreamEngine implements StreamingPlayback {
// ── Abstract methods ──
/** Resolve datablock data by numeric ID. */
abstract getDataBlockData(id: number): Record<string, unknown> | undefined;
abstract getDataBlockData(id: number): ParsedData | undefined;
/** Get TSShapeConstructor sequence entries for a shape name. */
abstract getShapeConstructorSequences(
@ -313,9 +332,10 @@ export abstract class StreamEngine implements StreamingPlayback {
* Get camera yaw/pitch for this tick. Demo accumulates from move deltas;
* live reads from server-provided rotation.
*/
protected abstract getCameraYawPitch(
data: Record<string, unknown> | undefined,
): { yaw: number; pitch: number };
protected abstract getCameraYawPitch(data: ParsedData | undefined): {
yaw: number;
pitch: number;
};
/** DTS shape names for weapon effects that should be preloaded. */
abstract getEffectShapes(): string[];
@ -457,7 +477,7 @@ export abstract class StreamEngine implements StreamingPlayback {
protected processControlObject(gameState: {
controlObjectGhostIndex?: number;
controlObjectData?: Record<string, unknown>;
controlObjectData?: ParsedData;
compressionPoint?: Vec3;
cameraFov?: number;
}): void {
@ -468,11 +488,16 @@ export abstract class StreamEngine implements StreamingPlayback {
? gameState.controlObjectGhostIndex
: prevControl.ghostIndex;
const compressionPoint = gameState.compressionPoint;
const controlPosition = isValidPosition(controlData?.position as Vec3)
? (controlData?.position as Vec3)
: isValidPosition(compressionPoint)
? compressionPoint
: prevControl.position;
// When piloting a vehicle, the player's writePacketData skips position
// (binary-verified: mounted check at Player::writePacketData). The
// controlData.position may be (0,0,0) or absent. Use compressionPoint
// which is the vehicle position, updated every packet.
const controlPosition =
!this.isPiloting && isValidPosition(controlData?.position as Vec3)
? (controlData?.position as Vec3)
: isValidPosition(compressionPoint)
? compressionPoint
: prevControl.position;
this.latestControl = {
ghostIndex: nextGhostIndex,
@ -550,7 +575,7 @@ export abstract class StreamEngine implements StreamingPlayback {
// ── Event processing ──
protected processEvent(
event: { classId: number; parsedData?: Record<string, unknown> },
event: { classId: number; parsedData?: ParsedData },
eventName: string | undefined,
): void {
const data = event.parsedData;
@ -561,10 +586,11 @@ export abstract class StreamEngine implements StreamingPlayback {
// These arrive as events, not ghost updates, but contain full ghost data.
// Create entities from them so scene infrastructure renders.
if (type === "GhostAlwaysObjectEvent") {
const ghostIndex = data.ghostIndex as number | undefined;
const classId = data.classId as number | undefined;
const objectData = data.objectData as Record<string, unknown> | undefined;
const hasData = data._hasObjectData as boolean | undefined;
const evt = data as GhostAlwaysObjectEventData;
const ghostIndex = evt.ghostIndex;
const classId = evt.classId;
const objectData = evt.objectData;
const hasData = evt._hasObjectData;
const className =
typeof classId === "number"
? (this.registry.getGhostParser(classId)?.name ??
@ -591,8 +617,9 @@ export abstract class StreamEngine implements StreamingPlayback {
}
if (type === "NetStringEvent" || eventName === "NetStringEvent") {
const id = data.id as number;
const value = data.value as string;
const evt = data as NetStringEventData;
const id = evt.id;
const value = evt.value;
if (id != null && typeof value === "string") {
this.netStrings.set(id, value);
// Resolve any TargetInfoEvents that were waiting for this string.
@ -612,8 +639,9 @@ export abstract class StreamEngine implements StreamingPlayback {
}
if (type === "TargetInfoEvent" || eventName === "TargetInfoEvent") {
const targetId = data.targetId as number | undefined;
const nameTag = data.nameTag as number | undefined;
const evt = data as TargetInfoEventData;
const targetId = evt.targetId;
const nameTag = evt.nameTag;
if (targetId != null && nameTag != null) {
const resolved = this.netStrings.get(nameTag);
if (resolved) {
@ -626,21 +654,21 @@ export abstract class StreamEngine implements StreamingPlayback {
this.pendingNameTags.set(nameTag, targetId);
}
}
const sensorGroup = data.sensorGroup as number | undefined;
const sensorGroup = evt.sensorGroup;
if (targetId != null && sensorGroup != null) {
this.targetTeams.set(targetId, sensorGroup);
}
const renderFlags = data.renderFlags as number | undefined;
const renderFlags = evt.renderFlags;
if (targetId != null && renderFlags != null) {
this.targetRenderFlags.set(targetId, renderFlags);
}
// Skin tags — resolve via net string table.
const skinTag = data.skinTag as number | undefined;
const skinTag = evt.skinTag;
if (targetId != null && skinTag != null && skinTag !== 0x400) {
const resolved = this.netStrings.get(skinTag);
if (resolved) this.targetSkins.set(targetId, resolved);
}
const skinPrefTag = data.skinPrefTag as number | undefined;
const skinPrefTag = evt.skinPrefTag;
if (targetId != null && skinPrefTag != null && skinPrefTag !== 0x400) {
const resolved = this.netStrings.get(skinPrefTag);
if (resolved) this.targetSkinPrefs.set(targetId, resolved);
@ -666,7 +694,8 @@ export abstract class StreamEngine implements StreamingPlayback {
}
if (type === "SetSensorGroupEvent" || eventName === "SetSensorGroupEvent") {
const sg = data.sensorGroup as number | undefined;
const evt = data as SetSensorGroupEventData;
const sg = evt.sensorGroup;
if (sg != null) this.playerSensorGroup = sg;
return;
}
@ -675,16 +704,9 @@ export abstract class StreamEngine implements StreamingPlayback {
type === "SensorGroupColorEvent" ||
eventName === "SensorGroupColorEvent"
) {
const sg = data.sensorGroup as number;
const colors = data.colors as
| Array<{
index: number;
r?: number;
g?: number;
b?: number;
default?: boolean;
}>
| undefined;
const evt = data as SensorGroupColorEventData;
const sg = evt.sensorGroup;
const colors = evt.colors;
if (colors) {
let map = this.sensorGroupColors.get(sg);
if (!map) {
@ -705,15 +727,17 @@ export abstract class StreamEngine implements StreamingPlayback {
// EndGhosting (message=2): the server called resetGhosting — all ghosts
// are invalidated. The parser clears the ghost tracker; we clear entities.
if (type === "GhostingMessageEvent") {
if ((data.message as number) === GhostMessage.EndGhosting) {
const evt = data as GhostingMessageEventData;
if (evt.message === GhostMessage.EndGhosting) {
this.clearAllEntities();
}
return;
}
if (type === "RemoteCommandEvent" || eventName === "RemoteCommandEvent") {
const funcName = this.resolveNetString(data.funcName as string);
const args = data.args as string[];
const evt = data as RemoteCommandEventData;
const funcName = this.resolveNetString(evt.funcName);
const args = evt.args;
const timeSec = this.getTimeSec();
if (funcName === "ChatMessage" && args.length >= 4) {
@ -825,12 +849,17 @@ export abstract class StreamEngine implements StreamingPlayback {
eventName === "Sim3DAudioEvent" ||
eventName === "Sim2DAudioEvent"
) {
const profileId = data.profileId as number;
const is3D =
type === "Sim3DAudioEvent" || eventName === "Sim3DAudioEvent";
const evt = is3D
? (data as Sim3DAudioEventData)
: (data as Sim2DAudioEventData);
const profileId = evt.profileId;
if (typeof profileId === "number") {
const timeSec = this.getTimeSec();
const is3D =
type === "Sim3DAudioEvent" || eventName === "Sim3DAudioEvent";
const position = is3D ? (data.position as Vec3 | undefined) : undefined;
const position = is3D
? (data as Sim3DAudioEventData).position
: undefined;
this.audioEvents.push({ profileId, position, timeSec });
if (this.audioEvents.length > 100) {
this.audioEvents.splice(0, this.audioEvents.length - 100);
@ -845,7 +874,7 @@ export abstract class StreamEngine implements StreamingPlayback {
index: number;
type: "create" | "update" | "delete";
classId?: number;
parsedData?: Record<string, unknown>;
parsedData?: ParsedData;
}): void {
const ghostIndex = ghost.index;
const prevEntityId = this.entityIdByGhostIndex.get(ghostIndex);
@ -926,7 +955,7 @@ export abstract class StreamEngine implements StreamingPlayback {
const sceneObj = ghostToSceneObject(
className,
ghostIndex,
ghost.parsedData as Record<string, unknown>,
ghost.parsedData as ParsedData,
);
if (sceneObj) entity.sceneData = sceneObj;
}
@ -952,11 +981,9 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.targetRenderFlags = undefined;
entity.sensorGroup = undefined;
entity.playerName = undefined;
entity.weaponShape = undefined;
entity.imageDataBlockIds = undefined;
entity.packShape = undefined;
entity.flagShape = undefined;
entity.imageSkinNames = undefined;
entity.imageSlots = undefined;
entity.mountObjectGhostIndex = undefined;
entity.mountNode = undefined;
entity.skinName = undefined;
entity.skinPrefName = undefined;
entity.falling = undefined;
@ -972,7 +999,10 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.energy = undefined;
entity.maxEnergy = undefined;
entity.damageState = undefined;
entity.hidden = undefined;
entity.fadeVal = undefined;
entity.fadeState = undefined;
entity.cloaked = undefined;
entity.cloakLevel = undefined;
entity.actionAnim = undefined;
entity.actionAtEnd = undefined;
entity.armAction = undefined;
@ -986,7 +1016,7 @@ export abstract class StreamEngine implements StreamingPlayback {
protected applyGhostData(
entity: MutableEntity,
rawData: Record<string, unknown> | undefined,
rawData: ParsedData | undefined,
): void {
if (!rawData) return;
const data = rawData;
@ -1123,106 +1153,93 @@ export abstract class StreamEngine implements StreamingPlayback {
}>
| undefined;
if (images && images.length > 0) {
const weaponImage = images.find((img) => img.index === 0);
if (weaponImage?.dataBlockId && weaponImage.dataBlockId > 0) {
const blockData = this.getDataBlockData(weaponImage.dataBlockId);
const weaponShape = resolveShapeName("ShapeBaseImageData", blockData);
if (weaponShape) {
entity.weaponShape = weaponShape;
}
if (!entity.imageDataBlockIds) entity.imageDataBlockIds = [];
entity.imageDataBlockIds[0] = weaponImage.dataBlockId;
const prev = entity.weaponImageState;
entity.weaponImageState = {
dataBlockId: weaponImage.dataBlockId,
triggerDown: weaponImage.triggerDown ?? prev?.triggerDown ?? false,
ammo: weaponImage.ammo ?? prev?.ammo ?? true,
loaded: weaponImage.loaded ?? prev?.loaded ?? true,
target: weaponImage.target ?? prev?.target ?? false,
wet: weaponImage.wet ?? prev?.wet ?? false,
fireCount: weaponImage.fireCount ?? prev?.fireCount ?? 0,
};
if (
blockData &&
entity.weaponImageStatesDbId !== weaponImage.dataBlockId
) {
entity.weaponImageStates = parseWeaponImageStates(blockData);
entity.weaponImageStatesDbId = weaponImage.dataBlockId;
}
} else if (weaponImage && !weaponImage.dataBlockId) {
entity.weaponShape = undefined;
entity.weaponImageState = undefined;
entity.weaponImageStates = undefined;
if (entity.imageDataBlockIds) entity.imageDataBlockIds[0] = undefined;
}
// Pack and flag image slots are Player-only. Other ShapeBase types
// (vehicles, turrets) can have weapons in slots 2-3 but those are NOT
// packs/flags — only slot 0 is universal.
if (entity.type === "Player") {
// Pack image (slot 2 = $BackpackSlot, mountPoint 1 = Mount1)
const packImage = images.find((img) => img.index === 2);
if (packImage?.dataBlockId && packImage.dataBlockId > 0) {
const blockData = this.getDataBlockData(packImage.dataBlockId);
const shape = resolveShapeName("ShapeBaseImageData", blockData);
if (shape) {
entity.packShape = shape;
}
if (!entity.imageDataBlockIds) entity.imageDataBlockIds = [];
entity.imageDataBlockIds[2] = packImage.dataBlockId;
} else if (packImage && !packImage.dataBlockId) {
entity.packShape = undefined;
if (entity.imageDataBlockIds)
entity.imageDataBlockIds[2] = undefined;
}
// Flag image (slot 3 = $FlagSlot, mountPoint 2 = Mount2)
const flagImage = images.find((img) => img.index === 3);
if (flagImage?.dataBlockId && flagImage.dataBlockId > 0) {
if (!entity.imageDataBlockIds) entity.imageDataBlockIds = [];
entity.imageDataBlockIds[3] = flagImage.dataBlockId;
const blockData = this.getDataBlockData(flagImage.dataBlockId);
const shape = resolveShapeName("ShapeBaseImageData", blockData);
if (shape) {
entity.flagShape = shape;
}
if (entity.targetId != null && entity.targetId >= 0) {
const prev = this.targetRenderFlags.get(entity.targetId) ?? 0;
const updated = prev | 0x2;
if (updated !== prev) {
this.targetRenderFlags.set(entity.targetId, updated);
entity.targetRenderFlags = updated;
}
}
} else if (flagImage && !flagImage.dataBlockId) {
entity.flagShape = undefined;
if (entity.imageDataBlockIds)
entity.imageDataBlockIds[3] = undefined;
if (entity.targetId != null && entity.targetId >= 0) {
const prev = this.targetRenderFlags.get(entity.targetId) ?? 0;
const updated = prev & ~0x2;
if (updated !== prev) {
this.targetRenderFlags.set(entity.targetId, updated);
entity.targetRenderFlags = updated;
}
}
}
}
// Per-slot image skin tags (e.g. flag team skin on slot 3).
// Process all 8 image slots uniformly. The mount bone for each
// image comes from dataBlock->mountPoint (binary-verified), NOT
// from the slot index.
for (const img of images) {
if (img.index == null) continue;
let skinName: string | undefined;
if (img.skinTagIndex != null) {
skinName = this.netStrings.get(img.skinTagIndex);
} else if (img.skinName) {
skinName = img.skinName;
}
if (skinName) {
if (!entity.imageSkinNames) entity.imageSkinNames = [];
entity.imageSkinNames[img.index] = skinName;
if (img.index == null || img.index < 0 || img.index >= 8) continue;
if (img.dataBlockId && img.dataBlockId > 0) {
const blockData = this.getDataBlockData(img.dataBlockId);
const shapeName = resolveShapeName("ShapeBaseImageData", blockData);
const mountPoint =
typeof blockData?.mountPoint === "number"
? blockData.mountPoint
: 0;
// Resolve skin from net string tag or inline name.
let skinName: string | undefined;
if (img.skinTagIndex != null) {
skinName = this.netStrings.get(img.skinTagIndex);
} else if (img.skinName) {
skinName = img.skinName;
}
if (shapeName) {
if (!entity.imageSlots) entity.imageSlots = [];
entity.imageSlots[img.index] = {
shapeName,
mountPoint,
dataBlockId: img.dataBlockId,
skinName,
};
}
// Slot 0: weapon state machine (triggerDown, ammo, etc.)
if (img.index === 0) {
const prev = entity.weaponImageState;
entity.weaponImageState = {
dataBlockId: img.dataBlockId,
triggerDown: img.triggerDown ?? prev?.triggerDown ?? false,
ammo: img.ammo ?? prev?.ammo ?? true,
loaded: img.loaded ?? prev?.loaded ?? true,
target: img.target ?? prev?.target ?? false,
wet: img.wet ?? prev?.wet ?? false,
fireCount: img.fireCount ?? prev?.fireCount ?? 0,
};
if (
blockData &&
entity.weaponImageStatesDbId !== img.dataBlockId
) {
entity.weaponImageStates = parseWeaponImageStates(blockData);
entity.weaponImageStatesDbId = img.dataBlockId;
}
}
// Slot 3 on Players: flag — update targetRenderFlags bit 0x2.
if (img.index === 3 && entity.type === "Player") {
if (entity.targetId != null && entity.targetId >= 0) {
const prev = this.targetRenderFlags.get(entity.targetId) ?? 0;
const updated = prev | 0x2;
if (updated !== prev) {
this.targetRenderFlags.set(entity.targetId, updated);
entity.targetRenderFlags = updated;
}
}
}
} else if (!img.dataBlockId) {
// Clear slot.
if (entity.imageSlots) {
entity.imageSlots[img.index] = undefined;
}
// Slot 0: clear weapon state.
if (img.index === 0) {
entity.weaponImageState = undefined;
entity.weaponImageStates = undefined;
}
// Slot 3 on Players: clear flag render flag.
if (img.index === 3 && entity.type === "Player") {
if (entity.targetId != null && entity.targetId >= 0) {
const prev = this.targetRenderFlags.get(entity.targetId) ?? 0;
const updated = prev & ~0x2;
if (updated !== prev) {
this.targetRenderFlags.set(entity.targetId, updated);
entity.targetRenderFlags = updated;
}
}
}
}
}
}
@ -1449,10 +1466,43 @@ export abstract class StreamEngine implements StreamingPlayback {
if (typeof data.damageState === "number") {
entity.damageState = data.damageState;
}
// CloakMask: binaryCloak is mFadeVal == 1.0 (binary-verified at
// FUN_005eead0). true = fully visible, false = invisible (faded out).
if (typeof data.binaryCloak === "boolean") {
entity.hidden = !data.binaryCloak;
// CloakMask (binary-verified, shapeBase.cc:3457-3485):
// cloaked: mCloaked — drives client-side cloakLevel interpolation
// fading: start a fade animation (fadeOut=direction, fadeTime=duration)
// fadeVal: direct mFadeVal == 1.0 when not fading
// CloakMask visibility (binary-verified, shapeBase.cc:3457-3485).
// fadeVal (mFadeVal == 1.0): true = visible, false = invisible.
// fading: fade animation (fadeOut=direction, fadeTime=duration).
// cloaked: mCloaked (stealth/station pad effect — client-side render).
// setCloakedState (FUN_005f0200): on client, does NOT snap mCloakLevel —
// only sets mCloaked. advanceTime interpolates at rate dt*2 (0.5s).
if (typeof data.cloaked === "boolean" && data.cloaked !== entity.cloaked) {
const wasSet = entity.cloaked != null;
entity.cloaked = data.cloaked;
if (!wasSet && data.cloaked) {
// First create with cloaked=true: start fully cloaked. The engine
// technically starts at 0 and animates, but the ghost isn't rendered
// during initial setup so players only ever see the cloaked state.
entity.cloakLevel = 1;
}
// No snap for state changes — client interpolates via advanceFades().
}
if (data.fading === true && typeof data.fadeTime === "number") {
const fadeOut = !!data.fadeOut;
if (data.fadeTime <= 0) {
entity.fadeVal = fadeOut ? 0 : 1;
entity.fadeState = undefined;
} else {
entity.fadeVal = fadeOut ? 1 : 0;
entity.fadeState = {
fadeOut,
fadeTime: data.fadeTime,
elapsed: 0,
};
}
} else if (typeof data.fadeVal === "boolean") {
entity.fadeVal = data.fadeVal ? 1 : 0;
entity.fadeState = undefined;
}
if (typeof data.action === "number") {
entity.actionAnim = data.action;
@ -1462,13 +1512,20 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.armAction = data.armAction;
}
// MountedMask: when a player unmounts from a vehicle, the server resets
// to RootAnim (table action 0) via Player::onUnmount→setActionThread.
// Table actions (0-6) are never sent over the wire, so actionAnim would
// remain at the stale Sitting index. Clear it on unmount.
// MountedMask: track mount state for position derivation and animation.
if (typeof data.mountObject === "number") {
if (data.mountObject === -1) {
// Unmounting — reset action to table anim (not sent by server).
if (data.mountObject >= 0) {
// Mounting on a vehicle/object.
entity.mountObjectGhostIndex = data.mountObject;
entity.mountNode =
typeof data.mountNode === "number" ? data.mountNode : 0;
} else {
// Unmounting — clear mount state and reset action animation.
// Server resets to RootAnim (table action 0) via onUnmount→
// setActionThread, but table actions (0-6) are never sent
// over the wire, so actionAnim would remain stale.
entity.mountObjectGhostIndex = undefined;
entity.mountNode = undefined;
entity.actionAnim = undefined;
entity.actionAtEnd = undefined;
}
@ -1685,6 +1742,67 @@ export abstract class StreamEngine implements StreamingPlayback {
* sparse), we apply basic gravity after a few ticks without a server
* update to prevent items from flying upward indefinitely.
*/
/** Advance fade and cloak animations per tick, matching advanceTime. */
protected advanceFades(): void {
const dt = TICK_DURATION_MS / 1000;
for (const entity of this.entities.values()) {
// mFadeVal animation.
const fs = entity.fadeState;
if (fs) {
fs.elapsed += dt;
if (fs.elapsed >= fs.fadeTime) {
entity.fadeVal = fs.fadeOut ? 0 : 1;
entity.fadeState = undefined;
} else {
const t = fs.elapsed / fs.fadeTime;
entity.fadeVal = fs.fadeOut ? 1 - t : t;
}
}
// mCloakLevel interpolation (binary-verified: rate = dt * 2, 0.5s).
if (entity.cloakLevel != null && entity.cloaked != null) {
if (entity.cloaked) {
entity.cloakLevel = Math.min(entity.cloakLevel + dt * 2, 1);
} else {
entity.cloakLevel = Math.max(entity.cloakLevel - dt * 2, 0);
}
}
}
}
/** Extrapolate the control vehicle's position each tick using velocity.
* controlObjectData arrives sparsely (~10 of ~62 packets/sec). Between
* updates, we integrate position from the last known velocity. */
protected advanceControlVehicle(): void {
if (!this.isPiloting || this.lastPilotGhostIndex == null) return;
if (!this.lastVehiclePos || !this.lastVehicleVelocity) {
if (this.tickCount % 100 === 0) {
console.warn(
"[advanceControlVehicle] piloting but missing data:",
"pos:",
!!this.lastVehiclePos,
"vel:",
!!this.lastVehicleVelocity,
"ghost:",
this.lastPilotGhostIndex,
);
}
return;
}
const vehicleId = this.resolveEntityIdForGhostIndex(
this.lastPilotGhostIndex,
);
const entity = vehicleId ? this.entities.get(vehicleId) : undefined;
if (!entity) return;
const dt = TICK_DURATION_MS / 1000;
const [vx, vy, vz] = this.lastVehicleVelocity;
this.lastVehiclePos[0] += vx * dt;
this.lastVehiclePos[1] += vy * dt;
this.lastVehiclePos[2] += vz * dt;
entity.position = [...this.lastVehiclePos] as [number, number, number];
}
protected advanceItems(): void {
const dt = TICK_DURATION_MS / 1000;
for (const entity of this.entities.values()) {
@ -1695,7 +1813,7 @@ export abstract class StreamEngine implements StreamingPlayback {
!phys ||
phys.atRest ||
entity.isStaticItem ||
entity.hidden ||
entity.fadeVal === 0 ||
!entity.position
)
continue;
@ -1773,9 +1891,7 @@ export abstract class StreamEngine implements StreamingPlayback {
// Verified against tribes2-engine Player::updateMove and Tribes2.exe.
if (this.isPiloting) {
if (data) {
const nested = data.controlObjectData as
| Record<string, unknown>
| undefined;
const nested = data.controlObjectData as ParsedData | undefined;
const ang = nested?.angPosition as
| { x: number; y: number; z: number; w: number }
| undefined;
@ -1804,9 +1920,18 @@ export abstract class StreamEngine implements StreamingPlayback {
pitch = this.lastVehiclePitch;
}
// control.position falls back to compressionPoint, which is the vehicle's
// position when piloting. This updates every packet (~20/s), not just
// when controlObjectData is present (~1/s).
const cameraPos: [number, number, number] = [
control.position.x,
control.position.y,
control.position.z,
];
this.camera = {
time: timeSec,
position: [control.position.x, control.position.y, control.position.z],
position: cameraPos,
rotation: yawPitchToQuaternion(
yaw,
clamp(pitch, -MAX_PITCH, MAX_PITCH),
@ -1849,10 +1974,27 @@ export abstract class StreamEngine implements StreamingPlayback {
// Third-person: orbit the vehicle (if piloting) or the player.
this.camera.mode = "third-person";
if (this.isPiloting && this.lastPilotGhostIndex != null) {
this.camera.orbitTargetId = this.resolveEntityIdForGhostIndex(
const vehicleId = this.resolveEntityIdForGhostIndex(
this.lastPilotGhostIndex,
);
this.camera.orbitDistance = 15;
this.camera.orbitTargetId = vehicleId;
// Use vehicle datablock's cameraMaxDist for orbit distance.
const vEntity = vehicleId
? this.entities.get(vehicleId)
: undefined;
const vDbData =
vEntity?.dataBlockId != null
? this.getDataBlockData(vEntity.dataBlockId)
: undefined;
this.camera.orbitDistance =
typeof vDbData?.cameraMaxDist === "number"
? vDbData.cameraMaxDist
: 15;
// Vertical offset from datablock (Torque Z = Three.js Y).
this.camera.orbitOffset =
typeof vDbData?.cameraOffset === "number"
? vDbData.cameraOffset
: 0;
if (this.lastVehicleOrbitDir) {
this.camera.orbitDirection = this.lastVehicleOrbitDir;
}
@ -1864,12 +2006,21 @@ export abstract class StreamEngine implements StreamingPlayback {
} else {
this.camera.mode = "first-person";
}
if (this.controlPlayerGhostId) {
// When piloting, use the vehicle for camera positioning (its Eye
// node provides the cockpit viewpoint). Otherwise use the player.
if (this.isPiloting && this.lastPilotGhostIndex != null) {
this.camera.controlEntityId = this.resolveEntityIdForGhostIndex(
this.lastPilotGhostIndex,
);
} else if (this.controlPlayerGhostId) {
this.camera.controlEntityId = this.controlPlayerGhostId;
}
}
// Sync control object positions from controlObjectData.
// Sync control object positions. When piloting, control.position is
// the compressionPoint (= vehicle position), updated every packet.
// controlObjectData (with linMomentum, angPosition) arrives more
// sparsely (~1/s); we use it for velocity and rotation.
if (controlType === "player" && control.position) {
if (this.isPiloting && this.lastPilotGhostIndex != null) {
const vehicleId = this.resolveEntityIdForGhostIndex(
@ -1879,31 +2030,25 @@ export abstract class StreamEngine implements StreamingPlayback {
? this.entities.get(vehicleId)
: undefined;
if (vehicleEntity) {
const nested = data?.controlObjectData as
| Record<string, unknown>
| undefined;
if (nested) {
// Fresh position from controlObjectData (linPosition →
// compressionPoint → control.position).
vehicleEntity.position = [
control.position.x,
control.position.y,
control.position.z,
];
this.lastVehiclePos = vehicleEntity.position.slice() as [
number,
number,
number,
];
this.lastVehiclePosTime = timeSec;
// compressionPoint provides position on every packet.
vehicleEntity.position = [
control.position.x,
control.position.y,
control.position.z,
];
this.lastVehiclePos = vehicleEntity.position.slice() as [
number,
number,
number,
];
// Extract velocity from linMomentum for interpolation between
// the sparse position updates (~10 of ~62 packets contain data).
// Sparse controlObjectData provides velocity and rotation.
const nested = data?.controlObjectData as ParsedData | undefined;
if (nested) {
const mom = nested.linMomentum as
| { x: number; y: number; z: number }
| undefined;
if (mom && isValidPosition(mom)) {
// linMomentum = mass * velocity; look up mass from datablock.
const dbId = vehicleEntity.dataBlockId;
const dbData =
dbId != null ? this.getDataBlockData(dbId) : undefined;
@ -1916,8 +2061,6 @@ export abstract class StreamEngine implements StreamingPlayback {
];
vehicleEntity.velocity = this.lastVehicleVelocity;
}
// Sync vehicle rotation from nested angPosition quaternion.
const ang = nested.angPosition as
| { x: number; y: number; z: number; w: number }
| undefined;
@ -1925,22 +2068,6 @@ export abstract class StreamEngine implements StreamingPlayback {
const converted = torqueQuatToThreeJS(ang);
if (converted) vehicleEntity.rotation = converted;
}
} else if (
this.lastVehiclePos &&
this.lastVehicleVelocity &&
this.lastVehiclePosTime > 0
) {
// No nested data this packet — extrapolate from last known
// position + velocity to avoid stutter.
const dt = timeSec - this.lastVehiclePosTime;
if (dt > 0 && dt < 1) {
const [vx, vy, vz] = this.lastVehicleVelocity;
vehicleEntity.position = [
this.lastVehiclePos[0] + vx * dt,
this.lastVehiclePos[1] + vy * dt,
this.lastVehiclePos[2] + vz * dt,
];
}
}
}
} else if (this.controlPlayerGhostId) {
@ -2006,7 +2133,7 @@ export abstract class StreamEngine implements StreamingPlayback {
}
protected getAbsoluteRotation(
data: Record<string, unknown> | undefined,
data: ParsedData | undefined,
): { yaw: number; pitch: number } | null {
if (!data) return null;
if (typeof data.rotationZ === "number" && typeof data.headX === "number") {
@ -2322,7 +2449,7 @@ export abstract class StreamEngine implements StreamingPlayback {
? (this.targetRenderFlags.get(entity.targetId) ??
entity.targetRenderFlags)
: entity.targetRenderFlags;
if (entity.type === "Player" && !entity.flagShape) {
if (entity.type === "Player" && !entity.imageSlots?.[3]) {
renderFlags = renderFlags != null ? renderFlags & ~0x2 : renderFlags;
}
@ -2336,11 +2463,12 @@ export abstract class StreamEngine implements StreamingPlayback {
dataBlockId: entity.dataBlockId,
shapeHint: entity.shapeHint,
dataBlock: entity.dataBlock,
weaponShape: entity.weaponShape,
imageDataBlockIds: entity.imageDataBlockIds,
imageSkinNames: entity.imageSkinNames,
packShape: entity.packShape,
flagShape: entity.flagShape,
imageSlots: entity.imageSlots,
mountObjectId:
entity.mountObjectGhostIndex != null
? this.entityIdByGhostIndex.get(entity.mountObjectGhostIndex)
: undefined,
mountNode: entity.mountNode,
falling: entity.falling,
jetting: entity.jetting,
playerName: entity.playerName,
@ -2366,7 +2494,11 @@ export abstract class StreamEngine implements StreamingPlayback {
actionAtEnd: entity.actionAtEnd,
armAction: entity.armAction,
damageState: entity.damageState,
hidden: entity.hidden,
// Fade and cloak are independent systems, passed separately so the
// renderer can apply the correct visual treatment (texture replacement
// for cloak, opacity-only for fade).
fadeVal: entity.fadeVal ?? 1,
cloakLevel: entity.cloakLevel ?? 0,
faceViewer: entity.faceViewer,
threads: entity.threads,
explosionDataBlockId: entity.explosionDataBlockId,

View file

@ -4,6 +4,7 @@ import {
BlockTypePacket,
DemoParser,
} from "t2-demo-parser";
import type { ParsedData } from "t2-demo-parser";
import { ghostToSceneObject } from "../scene";
import {
toEntityType,
@ -324,18 +325,15 @@ function parseDemoValues(demoValues: string[]): ParsedDemoValues {
class StreamingPlayback extends StreamEngine {
private readonly parser: DemoParser;
private readonly initialBlock: {
dataBlocks: Map<
number,
{ className: string; data: Record<string, unknown> }
>;
dataBlocks: Map<number, { className: string; data: ParsedData }>;
initialGhosts: Array<{
index: number;
type: "create" | "update" | "delete";
classId?: number;
parsedData?: Record<string, unknown>;
parsedData?: ParsedData;
}>;
controlObjectGhostIndex: number;
controlObjectData?: Record<string, unknown>;
controlObjectData?: ParsedData;
targetEntries: Array<{
targetId: number;
name?: string;
@ -354,7 +352,7 @@ class StreamingPlayback extends StreamEngine {
taggedStrings: Map<number, string>;
initialEvents: Array<{
classId: number;
parsedData?: Record<string, unknown>;
parsedData?: ParsedData;
}>;
demoValues: string[];
firstPerson: boolean;
@ -418,13 +416,13 @@ class StreamingPlayback extends StreamEngine {
// ── StreamEngine abstract implementations ──
getDataBlockData(dataBlockId: number): Record<string, unknown> | undefined {
getDataBlockData(dataBlockId: number): ParsedData | undefined {
const initialBlock = this.initialBlock.dataBlocks.get(dataBlockId);
if (initialBlock?.data) {
return initialBlock.data;
}
const packetParser = this.parser.getPacketParser() as unknown as {
dataBlockDataMap?: Map<number, Record<string, unknown>>;
dataBlockDataMap?: Map<number, ParsedData>;
};
return packetParser.dataBlockDataMap?.get(dataBlockId);
}
@ -450,7 +448,7 @@ class StreamingPlayback extends StreamEngine {
return this.moveTicks * (TICK_DURATION_MS / 1000);
}
protected getCameraYawPitch(_data: Record<string, unknown> | undefined): {
protected getCameraYawPitch(_data: ParsedData | undefined): {
yaw: number;
pitch: number;
} {
@ -555,7 +553,7 @@ class StreamingPlayback extends StreamEngine {
: undefined;
if (this.isPiloting) {
const nested = this.initialBlock.controlObjectData?.controlObjectData as
| Record<string, unknown>
| ParsedData
| undefined;
const ang = nested?.angPosition as
| { x: number; y: number; z: number; w: number }
@ -652,7 +650,7 @@ class StreamingPlayback extends StreamEngine {
const sceneObj = ghostToSceneObject(
className,
ghost.index,
ghost.parsedData as Record<string, unknown>,
ghost.parsedData as ParsedData,
);
if (sceneObj) entity.sceneData = sceneObj;
}
@ -777,7 +775,7 @@ class StreamingPlayback extends StreamEngine {
getEffectShapes(): string[] {
const shapes = new Set<string>();
const collectShapesFromExplosion = (expBlock: Record<string, unknown>) => {
const collectShapesFromExplosion = (expBlock: ParsedData) => {
const shape = expBlock.dtsFileName as string | undefined;
if (shape) shapes.add(shape);
const subExplosions = expBlock.subExplosions as
@ -864,6 +862,8 @@ class StreamingPlayback extends StreamEngine {
this.tickCount = this.moveTicks;
this.advanceProjectiles();
this.advanceItems();
this.advanceControlVehicle();
this.advanceFades();
this.removeExpiredExplosions();
this.updateCameraAndHud();
return true;
@ -1047,19 +1047,19 @@ class StreamingPlayback extends StreamEngine {
private isPacketData(parsed: unknown): parsed is {
gameState: {
controlObjectGhostIndex?: number;
controlObjectData?: Record<string, unknown>;
controlObjectData?: ParsedData;
compressionPoint?: Vec3;
cameraFov?: number;
};
events: Array<{
classId: number;
parsedData?: Record<string, unknown>;
parsedData?: ParsedData;
}>;
ghosts: Array<{
index: number;
type: "create" | "update" | "delete";
classId?: number;
parsedData?: Record<string, unknown>;
parsedData?: ParsedData;
}>;
} {
return (

View file

@ -1,4 +1,9 @@
import { BlockTypeMove, BlockTypePacket, DemoParser } from "t2-demo-parser";
import type {
ParsedData,
NetStringEventData,
RemoteCommandEventData,
} from "t2-demo-parser";
import { TICK_DURATION_MS } from "./entityClassification";
import { stripTaggedStringMarkup } from "./streamHelpers";
import type { TimelineEvent } from "../state/demoTimelineStore";
@ -192,6 +197,7 @@ export async function scanDemoTimeline(
const events: TimelineEvent[] = [];
let moveTicks = 0;
let seenMatchStart = false;
let currentMissionName: string | null = null;
let blockCount = 0;
const totalBlocks = parser.blockCount;
@ -224,7 +230,7 @@ export async function scanDemoTimeline(
const packet = block.parsed as {
events?: Array<{
classId: number;
parsedData?: Record<string, unknown>;
parsedData?: ParsedData;
}>;
};
if (!packet.events) continue;
@ -237,8 +243,9 @@ export async function scanDemoTimeline(
const type = evt.parsedData.type as string | undefined;
if (type === "NetStringEvent") {
const id = evt.parsedData.id as number;
const value = evt.parsedData.value as string | undefined;
const nsData = evt.parsedData as NetStringEventData;
const id = nsData.id;
const value = nsData.value;
if (value != null) {
netStrings.set(id, value);
}
@ -254,13 +261,11 @@ export async function scanDemoTimeline(
continue;
}
const funcName = resolveNetString(
evt.parsedData.funcName as string,
netStrings,
);
const rcData = evt.parsedData as RemoteCommandEventData;
const funcName = resolveNetString(rcData.funcName, netStrings);
if (funcName !== "ServerMessage") continue;
const args = evt.parsedData.args as string[];
const args = rcData.args;
if (!args || args.length < 2) continue;
const msgType = resolveNetString(args[0], netStrings);
@ -279,25 +284,45 @@ export async function scanDemoTimeline(
}
}
// Track current mission name from server info messages.
if (msgTypeLower === "msgmissiondropinfo" && args.length >= 3) {
// Wire: args[2]=$MissionDisplayName
const name = stripTaggedStringMarkup(
resolveNetString(args[2], netStrings),
).trim();
if (name) currentMissionName = name;
}
if (msgTypeLower === "msgloadinfo" && args.length >= 4) {
// Wire: args[2]=$CurrentMission, args[3]=$MissionDisplayName
const name = stripTaggedStringMarkup(
resolveNetString(args[3], netStrings),
).trim();
if (name) currentMissionName = name;
}
// Match start: MsgMissionStart is sent when the match actually begins
// (after the countdown). MsgSystemClock is just the countdown timer.
if (msgTypeLower === "msgmissionstart" && !seenMatchStart) {
seenMatchStart = true;
const suffix = currentMissionName ? ` (${currentMissionName})` : "";
events.push({
timeSec,
type: "match-start",
description: "Match started",
description: `Match started${suffix}`,
});
continue;
}
// Match ended.
if (msgTypeLower === "msggameover") {
const suffix = currentMissionName ? ` (${currentMissionName})` : "";
events.push({
timeSec,
type: "match-end",
description: "Match ended",
description: `Match ended${suffix}`,
});
// Reset for the next match in the same demo.
seenMatchStart = false;
continue;
}

View file

@ -26,6 +26,9 @@ function positionedBase(entity: StreamEntity, spawnTime?: number) {
position: entity.position,
rotation: entity.rotation,
velocity: entity.velocity,
mountObjectId: entity.mountObjectId,
mountNode: entity.mountNode,
imageSlots: entity.imageSlots,
keyframes: [
{
time: spawnTime ?? 0,
@ -118,12 +121,7 @@ export function streamEntityToGameEntity(
renderType: "Player",
shapeName: entity.dataBlock,
dataBlock: entity.dataBlock,
imageDataBlockIds: entity.imageDataBlockIds,
imageSkinNames: entity.imageSkinNames,
weaponShape: entity.weaponShape,
armAction: entity.armAction,
packShape: entity.packShape,
flagShape: entity.flagShape,
falling: entity.falling,
jetting: entity.jetting,
playerName: entity.playerName,
@ -234,10 +232,8 @@ export function streamEntityToGameEntity(
? "Item"
: "StaticShape",
dataBlock: entity.dataBlock,
imageDataBlockIds: entity.imageDataBlockIds,
skinName: entity.skinName,
damageState: entity.damageState,
weaponShape: entity.weaponShape,
armAction: entity.armAction,
threads: entity.threads,
targetRenderFlags: entity.targetRenderFlags,
@ -247,6 +243,8 @@ export function streamEntityToGameEntity(
frozen: entity.frozen,
maxSteeringAngle: entity.maxSteeringAngle,
soundSlots: entity.soundSlots,
fadeVal: entity.fadeVal,
cloakLevel: entity.cloakLevel,
} satisfies ShapeEntity;
}
}

View file

@ -1,4 +1,10 @@
import { createLiveParser, type PacketParser } from "t2-demo-parser";
import type {
ParsedData,
RemoteCommandEventData,
CRCChallengeEventData,
GhostingMessageEventData,
} from "t2-demo-parser";
import { createLogger } from "../logger";
import { resolveShapeName, stripTaggedStringMarkup } from "./streamHelpers";
import type { Vec3 } from "./streamHelpers";
@ -74,7 +80,7 @@ export class LiveStreamAdapter extends StreamEngine {
// ── StreamEngine abstract implementations ──
getDataBlockData(id: number): Record<string, unknown> | undefined {
getDataBlockData(id: number): ParsedData | undefined {
return this.packetParser.getDataBlockDataMap()?.get(id);
}
@ -99,7 +105,7 @@ export class LiveStreamAdapter extends StreamEngine {
return this.currentTimeSec;
}
protected getCameraYawPitch(data: Record<string, unknown> | undefined): {
protected getCameraYawPitch(data: ParsedData | undefined): {
yaw: number;
pitch: number;
} {
@ -202,9 +208,8 @@ export class LiveStreamAdapter extends StreamEngine {
* Handle RemoteCommandEvents that require relay-side responses:
* auth events, mission phase acknowledgments, etc.
*/
private handleRelayCommands(parsedData: Record<string, unknown>): void {
if (parsedData.type !== "RemoteCommandEvent") return;
const rawFuncName = parsedData.funcName as string;
private handleRelayCommands(parsedData: RemoteCommandEventData): void {
const rawFuncName = parsedData.funcName;
if (!rawFuncName) return;
const funcName = this.resolveNetString(rawFuncName);
@ -215,7 +220,7 @@ export class LiveStreamAdapter extends StreamEngine {
"t2csri_decryptChallenge",
];
if (authCommands.includes(funcName)) {
const rawArgs = (parsedData.args as string[]) ?? [];
const rawArgs = parsedData.args ?? [];
const args = rawArgs
.map((a) => this.resolveNetString(a))
.filter((a) => a !== "");
@ -226,7 +231,7 @@ export class LiveStreamAdapter extends StreamEngine {
// Mission download phase acknowledgments — the server won't proceed
// to ghosting until the client responds to each phase.
const rawArgs = (parsedData.args as string[]) ?? [];
const rawArgs = parsedData.args ?? [];
const resolvedArgs = rawArgs.map((a) => this.resolveNetString(a));
if (funcName === "MissionStartPhase1") {
const seq = resolvedArgs[0] ?? "";
@ -281,11 +286,10 @@ export class LiveStreamAdapter extends StreamEngine {
}
/** Respond to CRCChallengeEvent — required for Phase 2 to begin. */
private handleCRCChallenge(parsedData: Record<string, unknown>): void {
if (parsedData.type !== "CRCChallengeEvent") return;
const seed = parsedData.crcValue as number;
const field1 = parsedData.field1 as number;
const field2 = parsedData.field2 as number;
private handleCRCChallenge(parsedData: CRCChallengeEventData): void {
const seed = parsedData.crcValue;
const field1 = parsedData.field1;
const field2 = parsedData.field2;
// field1 bit 0 = includeTextures (from $Host::CRCTextures)
const includeTextures = (field1 & 1) !== 0;
log.info(
@ -307,10 +311,7 @@ export class LiveStreamAdapter extends StreamEngine {
for (const [id, block] of dbMap) {
const className = this.dataBlockClassNames.get(id);
if (!className) continue;
const shapeName = resolveShapeName(
className,
block as Record<string, unknown>,
);
const shapeName = resolveShapeName(className, block as ParsedData);
datablocks.push({
objectId: id,
className,
@ -327,11 +328,10 @@ export class LiveStreamAdapter extends StreamEngine {
* The server sends this after activateGhosting(); the client must respond
* with type 1 so the server sets mGhosting=true and begins sending ghosts.
*/
private handleGhostingMessage(parsedData: Record<string, unknown>): void {
if (parsedData.type !== "GhostingMessageEvent") return;
const message = parsedData.message as number;
const sequence = parsedData.sequence as number;
const ghostCount = parsedData.ghostCount as number;
private handleGhostingMessage(parsedData: GhostingMessageEventData): void {
const message = parsedData.message;
const sequence = parsedData.sequence;
const ghostCount = parsedData.ghostCount;
log.info(
"GhostingMessageEvent: message=%d sequence=%d ghostCount=%d",
message,
@ -417,10 +417,18 @@ export class LiveStreamAdapter extends StreamEngine {
// Events
for (const event of parsed.events) {
if (event.parsedData) {
this.handleRelayCommands(event.parsedData);
this.handleCRCChallenge(event.parsedData);
this.handleGhostingMessage(event.parsedData);
const type = event.parsedData.type as string;
if (type === "RemoteCommandEvent") {
this.handleRelayCommands(
event.parsedData as RemoteCommandEventData,
);
} else if (type === "CRCChallengeEvent") {
this.handleCRCChallenge(event.parsedData as CRCChallengeEventData);
} else if (type === "GhostingMessageEvent") {
this.handleGhostingMessage(
event.parsedData as GhostingMessageEventData,
);
}
// Always log RemoteCommandEvents (chat, server messages, HUD).
if (type === "RemoteCommandEvent") {
@ -453,7 +461,7 @@ export class LiveStreamAdapter extends StreamEngine {
}
if (shouldLog) {
const dbData = event.parsedData.dataBlockData as
| Record<string, unknown>
| ParsedData
| undefined;
const shapeName = resolveShapeName(dbClassName ?? "", dbData);
log.debug(
@ -570,6 +578,8 @@ export class LiveStreamAdapter extends StreamEngine {
this.tickCount++;
this.advanceProjectiles();
this.advanceItems();
this.advanceControlVehicle();
this.advanceFades();
// Periodic status at milestones
if (isMilestonePacket && this.tickCount > 1) {

View file

@ -229,7 +229,13 @@ function buildShapeEntity(
const barrelName = getProperty(object, "initialBarrel");
if (barrelName) {
const barrelDb = resolveDatablock(runtime, barrelName);
entity.weaponShape = getProperty(barrelDb, "shapeFile");
const turretShapeName = getProperty(barrelDb, "shapeFile");
if (turretShapeName) {
const mountPoint = Number(getProperty(barrelDb, "mountPoint")) || 0;
entity.imageSlots = [
{ shapeName: turretShapeName, mountPoint, dataBlockId: 0 },
];
}
}
}

View file

@ -76,7 +76,9 @@ export function setupEffectTexture(tex: Texture): void {
tex.magFilter = LinearFilter;
tex.colorSpace = NoColorSpace;
tex.flipY = false;
tex.needsUpdate = true;
if (tex.image) {
tex.needsUpdate = true;
}
}
export function torqueVecToThree(

View file

@ -1,4 +1,5 @@
import { Matrix4, Quaternion } from "three";
import type { ParsedData } from "t2-demo-parser";
import type {
StreamVisual,
WeaponImageDataBlockState,
@ -226,7 +227,7 @@ export function isQuatLike(value: unknown): value is {
*/
export function resolveShapeName(
className: string,
data: Record<string, unknown> | undefined,
data: ParsedData | undefined,
): string | undefined {
if (!data) return undefined;
@ -245,7 +246,7 @@ export function resolveShapeName(
}
export function getNumberField(
data: Record<string, unknown> | undefined,
data: ParsedData | undefined,
keys: readonly string[],
): number | undefined {
if (!data) return undefined;
@ -257,7 +258,7 @@ export function getNumberField(
}
export function getStringField(
data: Record<string, unknown> | undefined,
data: ParsedData | undefined,
keys: readonly string[],
): string | undefined {
if (!data) return undefined;
@ -269,7 +270,7 @@ export function getStringField(
}
export function getBooleanField(
data: Record<string, unknown> | undefined,
data: ParsedData | undefined,
keys: readonly string[],
): boolean | undefined {
if (!data) return undefined;
@ -284,7 +285,7 @@ export function getBooleanField(
export function resolveTracerVisual(
className: string,
data: Record<string, unknown> | undefined,
data: ParsedData | undefined,
): StreamVisual | undefined {
if (!data) return undefined;
@ -334,7 +335,7 @@ export function resolveTracerVisual(
export function resolveSpriteVisual(
className: string,
data: Record<string, unknown> | undefined,
data: ParsedData | undefined,
): StreamVisual | undefined {
if (!data) return undefined;
@ -380,11 +381,9 @@ export function resolveSpriteVisual(
* remap table.
*/
export function parseWeaponImageStates(
blockData: Record<string, unknown>,
blockData: ParsedData,
): WeaponImageDataBlockState[] | undefined {
const rawStates = blockData.states as
| Array<Record<string, unknown>>
| undefined;
const rawStates = blockData.states as Array<ParsedData> | undefined;
if (!Array.isArray(rawStates) || rawStates.length === 0) return undefined;
return rawStates.map((s) => {
@ -533,7 +532,7 @@ export function extractWavTag(text: string): {
export type ControlObjectType = "camera" | "player";
export function detectControlObjectType(
data: Record<string, unknown> | undefined,
data: ParsedData | undefined,
): ControlObjectType | null {
if (!data) return null;
if (typeof data.cameraMode === "number") return "camera";

View file

@ -1,5 +1,15 @@
import type { ParsedData } from "t2-demo-parser";
import type { SceneObject } from "../scene/types";
/** A mounted image in one of 8 ShapeBase image slots. The mount bone
* comes from the image datablock's mountPoint field, not the slot index. */
export interface ImageSlot {
shapeName: string;
mountPoint: number;
dataBlockId: number;
skinName?: string;
}
/** DTS animation thread state from ghost ThreadMask data. */
export interface ThreadState {
index: number;
@ -98,11 +108,8 @@ export interface StreamEntity {
dataBlock?: string;
visual?: StreamVisual;
direction?: [number, number, number];
weaponShape?: string;
/** Datablock IDs for each mounted image slot (0-3). */
imageDataBlockIds?: (number | undefined)[];
/** Skin names for each mounted image slot (0-3), from ghost ImageMask skinTag. */
imageSkinNames?: (string | undefined)[];
/** Mounted image slots (0-7). Mount bone from dataBlock->mountPoint. */
imageSlots?: (ImageSlot | undefined)[];
playerName?: string;
/** IFF color resolved from the sensor group color table (sRGB 0-255). */
iffColor?: { r: number; g: number; b: number };
@ -123,8 +130,11 @@ export interface StreamEntity {
actionAnim?: number;
actionAtEnd?: boolean;
damageState?: number;
/** ShapeBase hidden state (binaryCloak). True = invisible. */
hidden?: boolean;
/** ShapeBase fade value (0=invisible, 1=fully visible). Matches mFadeVal. */
fadeVal?: number;
/** Cloak level (0=visible, 1=fully cloaked). Separate from fadeVal so the
* renderer can apply the cloak texture effect. */
cloakLevel?: number;
faceViewer?: boolean;
/** DTS animation thread states from ghost ThreadMask data. */
threads?: ThreadState[];
@ -136,10 +146,10 @@ export interface StreamEntity {
weaponImageState?: WeaponImageState;
/** Weapon image state machine states from the ShapeBaseImageData datablock. */
weaponImageStates?: WeaponImageDataBlockState[];
/** DTS shape name for the mounted pack (slot 2, Mount1 bone). */
packShape?: string;
/** DTS shape name for the carried flag (slot 3, Mount2 bone). */
flagShape?: string;
/** Entity ID of the object this entity is mounted on (vehicle, etc.). */
mountObjectId?: string;
/** Mount point node index on the mount target (0 = pilot). */
mountNode?: number;
/** Player skin (team skin like "base", "baseb"). */
skinName?: string;
/** Player preferred skin (chosen skin like "RandySavage"). */
@ -207,6 +217,8 @@ export interface StreamCamera {
orbitTargetId?: string;
/** Orbit distance used for third-person camera positioning. */
orbitDistance?: number;
/** Vertical offset for orbit target (from VehicleData.cameraOffset). */
orbitOffset?: number;
/** Absolute control-object yaw in Torque radians (rotZ/rotationZ). */
yaw?: number;
/** Absolute control-object pitch in Torque radians (rotX/headX). */
@ -332,7 +344,7 @@ export interface StreamingPlayback {
/** DTS shape names for weapon effects (explosions) that should be preloaded. */
getEffectShapes(): string[];
/** Resolve a datablock by its numeric ID. */
getDataBlockData(id: number): Record<string, unknown> | undefined;
getDataBlockData(id: number): ParsedData | undefined;
/**
* Get TSShapeConstructor sequence entries for a shape (e.g. "heavy_male.dts").
* Returns the raw sequence strings like `"heavy_male_root.dsq root"`.

View file

@ -78,9 +78,10 @@ export function updateTerrainTextureShader({
shader.uniforms[`albedo${i}`] = { value: tex };
});
// Pass all alpha textures including mask0 for additive blending
// Alpha masks are packed into RGB textures (3 masks per texture).
const packedMaskCount = alphaTextures.length;
alphaTextures.forEach((tex, i) => {
shader.uniforms[`mask${i}`] = { value: tex };
shader.uniforms[`maskPacked${i}`] = { value: tex };
});
// Add visibility mask uniform if we have empty squares
@ -123,27 +124,27 @@ vTerrainWorldPos = (modelMatrix * _terrainPos).xyz;`,
);
}
// Provide terrain UVs without setting MeshLambertMaterial.map (which would
// allocate a texture unit for an unused `map` sampler). The geometry's UV
// attribute maps [0,1] across each terrain tile.
shader.vertexShader = shader.vertexShader.replace(
"#include <common>",
`#include <common>
varying vec2 vTerrainUv;`,
);
shader.vertexShader = shader.vertexShader.replace(
"#include <uv_vertex>",
`#include <uv_vertex>
vTerrainUv = uv;`,
);
// Declare our uniforms and color space functions at the top of the fragment shader
shader.fragmentShader =
`
uniform sampler2D albedo0;
uniform sampler2D albedo1;
uniform sampler2D albedo2;
uniform sampler2D albedo3;
uniform sampler2D albedo4;
uniform sampler2D albedo5;
uniform sampler2D mask0;
uniform sampler2D mask1;
uniform sampler2D mask2;
uniform sampler2D mask3;
uniform sampler2D mask4;
uniform sampler2D mask5;
uniform float tiling0;
uniform float tiling1;
uniform float tiling2;
uniform float tiling3;
uniform float tiling4;
uniform float tiling5;
varying vec2 vTerrainUv;
${Array.from({ length: layerCount }, (_, i) => `uniform sampler2D albedo${i};`).join("\n")}
${Array.from({ length: packedMaskCount }, (_, i) => `uniform sampler2D maskPacked${i};`).join("\n")}
${Array.from({ length: layerCount }, (_, i) => `uniform float tiling${i};`).join("\n")}
${visibilityMask ? "uniform sampler2D visibilityMask;" : ""}
${lightmap ? "uniform sampler2D terrainLightmap;" : ""}
uniform bool sunLightPointsDown;
@ -168,7 +169,7 @@ float terrainShadowFactor = 1.0;
clippingPlaceholder,
`${clippingPlaceholder}
// Early discard for invisible areas (before fog/lighting)
float visibility = texture2D(visibilityMask, vMapUv).r;
float visibility = texture2D(visibilityMask, vTerrainUv).r;
if (visibility < 0.5) {
discard;
}
@ -177,12 +178,12 @@ float terrainShadowFactor = 1.0;
}
// Replace the default map sampling block with our layered blend.
// We rely on vMapUv provided by USE_MAP.
// vTerrainUv is computed from the geometry's UV attribute in the vertex shader.
shader.fragmentShader = shader.fragmentShader.replace(
"#include <map_fragment>",
`
// Sample base albedo layers (sRGB textures auto-decoded to linear by Three.js)
vec2 baseUv = vMapUv;
vec2 baseUv = vTerrainUv;
vec3 c0 = texture2D(albedo0, baseUv * vec2(tiling0)).rgb;
${
layerCount > 1
@ -210,16 +211,22 @@ float terrainShadowFactor = 1.0;
: ""
}
// Sample alpha masks for all layers (use R channel)
// Sample alpha masks from packed RGB textures (3 masks per texture).
// Add +0.5 texel offset: Torque samples alpha at grid corners (integer indices),
// but GPU linear filtering samples at texel centers. This offset aligns them.
vec2 alphaUv = baseUv + vec2(0.5 / ${TERRAIN_SIZE}.0);
float a0 = texture2D(mask0, alphaUv).r;
${layerCount > 1 ? `float a1 = texture2D(mask1, alphaUv).r;` : ""}
${layerCount > 2 ? `float a2 = texture2D(mask2, alphaUv).r;` : ""}
${layerCount > 3 ? `float a3 = texture2D(mask3, alphaUv).r;` : ""}
${layerCount > 4 ? `float a4 = texture2D(mask4, alphaUv).r;` : ""}
${layerCount > 5 ? `float a5 = texture2D(mask5, alphaUv).r;` : ""}
vec3 maskRGB0 = texture2D(maskPacked0, alphaUv).rgb;
float a0 = maskRGB0.r;
${layerCount > 1 ? `float a1 = maskRGB0.g;` : ""}
${layerCount > 2 ? `float a2 = maskRGB0.b;` : ""}
${
layerCount > 3
? `vec3 maskRGB1 = texture2D(maskPacked1, alphaUv).rgb;
float a3 = maskRGB1.r;`
: ""
}
${layerCount > 4 ? `float a4 = maskRGB1.g;` : ""}
${layerCount > 5 ? `float a5 = maskRGB1.b;` : ""}
// Torque-style additive weighted blending (blender.cc):
// result = tex0 * alpha0 + tex1 * alpha1 + tex2 * alpha2 + ...
@ -324,7 +331,7 @@ void RE_Direct_TerrainShadow( const in IncidentLight directLight, const in vec3
lightmap
? `
// Sample terrain lightmap for smooth NdotL
vec2 lightmapUv = vMapUv + vec2(0.5 / ${LIGHTMAP_SIZE}.0);
vec2 lightmapUv = vTerrainUv + vec2(0.5 / ${LIGHTMAP_SIZE}.0);
float lightmapNdotL = texture2D(terrainLightmap, lightmapUv).r;
// Get sun and ambient colors from Three.js lights (these ARE sRGB values from mission file)
@ -362,7 +369,7 @@ void RE_Direct_TerrainShadow( const in IncidentLight directLight, const in vec3
"#include <tonemapping_fragment>",
`#if DEBUG_MODE
// Debug mode: overlay green grid matching terrain grid squares (256x256)
float gridIntensity = terrainDebugGrid(vMapUv, 256.0, 1.5);
float gridIntensity = terrainDebugGrid(vTerrainUv, 256.0, 1.5);
vec3 gridColor = vec3(0.0, 0.8, 0.4); // Green
gl_FragColor.rgb = mix(gl_FragColor.rgb, gridColor, gridIntensity * 0.1);
#endif

View file

@ -7,7 +7,7 @@ import {
LinearFilter,
LinearMipmapLinearFilter,
NoColorSpace,
RedFormat,
RGBAFormat,
RepeatWrapping,
SRGBColorSpace,
Texture,
@ -141,34 +141,42 @@ export function setupTexture<T extends Texture>(
}
tex.magFilter = LinearFilter;
tex.needsUpdate = true;
// Only mark for upload if the texture actually has image data. Textures
// from loadTexture() get needsUpdate set in the load callback instead.
if (tex.image) {
tex.needsUpdate = true;
}
return tex;
}
/**
* Setup a mask texture (single channel, linear color space).
* Used for terrain blend masks and similar data textures.
* Pack single-channel alpha masks into RGB textures (3 masks per texture).
* Reduces sampler count from N to ceil(N/3). Each mask goes into the R, G,
* or B channel. All masks must be 256×256.
*/
export function setupMask(data: Uint8Array): DataTexture {
const tex = new DataTexture(
data,
256,
256,
RedFormat, // 1 channel
UnsignedByteType, // 8-bit
);
// Masks should stay linear
tex.colorSpace = NoColorSpace;
// Set tiling / sampling. For NPOT sizes, disable mips or use power-of-two.
tex.wrapS = tex.wrapT = RepeatWrapping;
tex.generateMipmaps = false; // if width/height are not powers of two
tex.minFilter = LinearFilter; // avoid mips if generateMipmaps=false
tex.magFilter = LinearFilter;
tex.needsUpdate = true;
return tex;
export function packMasksRGB(masks: Uint8Array[], size = 256): DataTexture[] {
const packed: DataTexture[] = [];
for (let i = 0; i < masks.length; i += 3) {
const r = masks[i];
const g = masks[i + 1];
const b = masks[i + 2];
const pixels = size * size;
const rgba = new Uint8Array(pixels * 4);
for (let j = 0; j < pixels; j++) {
rgba[j * 4] = r[j];
rgba[j * 4 + 1] = g ? g[j] : 0;
rgba[j * 4 + 2] = b ? b[j] : 0;
rgba[j * 4 + 3] = 255;
}
const tex = new DataTexture(rgba, size, size, RGBAFormat, UnsignedByteType);
tex.colorSpace = NoColorSpace;
tex.wrapS = tex.wrapT = RepeatWrapping;
tex.generateMipmaps = false;
tex.minFilter = LinearFilter;
tex.magFilter = LinearFilter;
tex.needsUpdate = true;
packed.push(tex);
}
return packed;
}