begin live server support

This commit is contained in:
Brian Beck 2026-03-09 12:38:40 -07:00
parent 0c9ddb476a
commit e4ae265184
368 changed files with 17756 additions and 7738 deletions

1868
src/stream/StreamEngine.ts Normal file

File diff suppressed because it is too large Load diff

948
src/stream/demoStreaming.ts Normal file
View file

@ -0,0 +1,948 @@
import {
BlockTypeInfo,
BlockTypeMove,
BlockTypePacket,
DemoParser,
} from "t2-demo-parser";
import { ghostToSceneObject } from "../scene";
import {
toEntityType,
toEntityId,
TICK_DURATION_MS,
} from "./entityClassification";
import {
clamp,
MAX_PITCH,
isValidPosition,
stripTaggedStringMarkup,
detectControlObjectType,
parseColorSegments,
backpackBitmapToIndex,
} from "./streamHelpers";
import type { Vec3 } from "./streamHelpers";
import type {
StreamRecording,
StreamSnapshot,
TeamScore,
WeaponsHudSlot,
InventoryHudSlot,
BackpackHudState,
} from "./types";
import { StreamEngine, type MutableEntity } from "./StreamEngine";
function extractMissionInfo(demoValues: string[]): {
missionName: string | null;
gameType: string | null;
} {
let missionName: string | null = null;
let gameType: string | null = null;
for (let i = 0; i < demoValues.length; i++) {
if (demoValues[i] !== "readplayerinfo") continue;
const value = demoValues[i + 1];
if (!value) continue;
if (value.startsWith("2\t")) {
const fields = value.split("\t");
if (fields[4]) {
missionName = fields[4];
}
continue;
}
if (value.startsWith("3\t")) {
const fields = value.split("\t");
if (fields[2]) {
gameType = fields[2];
}
}
}
return { missionName, gameType };
}
interface ParsedDemoValues {
weaponsHud: { slots: Map<number, number>; activeIndex: number } | null;
backpackHud: { packIndex: number; active: boolean; text: string } | null;
inventoryHud: {
slots: Map<number, number>;
activeSlot: number;
} | null;
teamScores: TeamScore[];
playerRoster: Map<number, { name: string; teamId: number }>;
chatMessages: string[];
gravity: number;
}
/**
* Parse the $DemoValue[] array to extract initial HUD state.
*
* Sections are written sequentially by saveDemoSettings/getState in
* recordings.cs: MISC, PLAYERLIST, RETICLE, BACKPACK, WEAPON, INVENTORY,
* SCORE, CLOCK, CHAT, GRAVITY.
*/
function parseDemoValues(demoValues: string[]): ParsedDemoValues {
const result: ParsedDemoValues = {
weaponsHud: null,
backpackHud: null,
inventoryHud: null,
teamScores: [],
playerRoster: new Map(),
chatMessages: [],
gravity: -20,
};
if (!demoValues.length) return result;
let idx = 0;
const next = () => {
const v = demoValues[idx++];
return v === "<BLANK>" ? "" : (v ?? "");
};
// MISC: 1 value
next();
// PLAYERLIST: count + count entries
if (idx >= demoValues.length) return result;
const playerCount = parseInt(next(), 10) || 0;
const playerCountByTeam = new Map<number, number>();
for (let i = 0; i < playerCount; i++) {
const fields = next().split("\t");
const name = fields[0] ?? "";
const clientId = parseInt(fields[2], 10);
const teamId = parseInt(fields[4], 10);
if (!isNaN(clientId) && !isNaN(teamId)) {
result.playerRoster.set(clientId, { name, teamId });
}
if (!isNaN(teamId) && teamId > 0) {
playerCountByTeam.set(teamId, (playerCountByTeam.get(teamId) ?? 0) + 1);
}
}
// RETICLE: 1 value
if (idx >= demoValues.length) return result;
next();
// BACKPACK: 1 value (bitmap TAB visible TAB text TAB textVisible TAB pack)
if (idx >= demoValues.length) return result;
{
const backpackVal = next();
const fields = backpackVal.split("\t");
const bitmap = fields[0] ?? "";
const visible = fields[1] === "1" || fields[1] === "true";
const text = fields[2] ?? "";
const pack = fields[4] === "1" || fields[4] === "true";
if (visible && bitmap) {
const packIndex = backpackBitmapToIndex(bitmap);
result.backpackHud = { packIndex, active: pack, text };
}
}
// WEAPON: header + count bitmap entries + slotCount slot entries
if (idx >= demoValues.length) return result;
const weaponHeader = next().split("\t");
const weaponCount = parseInt(weaponHeader[4], 10) || 0;
const weaponSlotCount = parseInt(weaponHeader[5], 10) || 0;
const weaponActive = parseInt(weaponHeader[6], 10);
for (let i = 0; i < weaponCount; i++) next();
const slots = new Map<number, number>();
for (let i = 0; i < weaponSlotCount; i++) {
const fields = next().split("\t");
const slotId = parseInt(fields[0], 10);
const ammo = parseInt(fields[1], 10);
if (!isNaN(slotId)) {
slots.set(slotId, isNaN(ammo) ? -1 : ammo);
}
}
result.weaponsHud = {
slots,
activeIndex: isNaN(weaponActive) ? -1 : weaponActive,
};
// INVENTORY: header + count bitmap entries + slotCount slot entries
if (idx >= demoValues.length) return result;
const invHeader = next().split("\t");
const invCount = parseInt(invHeader[4], 10) || 0;
const invSlotCount = parseInt(invHeader[5], 10) || 0;
const invActive = parseInt(invHeader[6], 10);
for (let i = 0; i < invCount; i++) next();
{
const invSlots = new Map<number, number>();
for (let i = 0; i < invSlotCount; i++) {
const fields = next().split("\t");
const slotId = parseInt(fields[0], 10);
const count = parseInt(fields[1], 10);
if (!isNaN(slotId) && !isNaN(count) && count > 0) {
invSlots.set(slotId, count);
}
}
if (invSlots.size > 0) {
result.inventoryHud = {
slots: invSlots,
activeSlot: isNaN(invActive) ? -1 : invActive,
};
}
}
// SCORE: header (visible TAB gameType TAB objCount) + objCount entries.
if (idx >= demoValues.length) return result;
const scoreHeader = next().split("\t");
const gameType = scoreHeader[1] ?? "";
const objCount = parseInt(scoreHeader[2], 10) || 0;
const scoreObjs: string[] = [];
for (let i = 0; i < objCount; i++) scoreObjs.push(next());
if (gameType === "CTFGame" && objCount >= 8) {
for (let t = 0; t < 2; t++) {
const base = t * 4;
const teamId = t + 1;
result.teamScores.push({
teamId,
name: scoreObjs[base] ?? "",
score: parseInt(scoreObjs[base + 1], 10) || 0,
playerCount: playerCountByTeam.get(teamId) ?? 0,
});
}
} else if (gameType === "TR2Game" && objCount >= 4) {
for (let t = 0; t < 2; t++) {
const base = t * 2;
const teamId = t + 1;
result.teamScores.push({
teamId,
name: scoreObjs[base + 1] ?? "",
score: parseInt(scoreObjs[base], 10) || 0,
playerCount: playerCountByTeam.get(teamId) ?? 0,
});
}
}
// CLOCK: 1 value
if (idx >= demoValues.length) return result;
next();
// CHAT: always 10 entries
for (let i = 0; i < 10; i++) {
if (idx >= demoValues.length) break;
const line = next();
if (line) {
result.chatMessages.push(line);
}
}
// GRAVITY: 1 value
if (idx < demoValues.length) {
const g = parseFloat(next());
if (Number.isFinite(g)) {
result.gravity = g;
}
}
return result;
}
class StreamingPlayback extends StreamEngine {
private readonly parser: DemoParser;
private readonly initialBlock: {
dataBlocks: Map<
number,
{ className: string; data: Record<string, unknown> }
>;
initialGhosts: Array<{
index: number;
type: "create" | "update" | "delete";
classId?: number;
parsedData?: Record<string, unknown>;
}>;
controlObjectGhostIndex: number;
controlObjectData?: Record<string, unknown>;
targetEntries: Array<{
targetId: number;
name?: string;
sensorGroup: number;
targetData: number;
}>;
sensorGroupColors: Array<{
group: number;
targetGroup: number;
r: number;
g: number;
b: number;
}>;
taggedStrings: Map<number, string>;
initialEvents: Array<{
classId: number;
parsedData?: Record<string, unknown>;
}>;
demoValues: string[];
};
// Demo-specific: move delta tracking for V12-style camera rotation
private moveTicks = 0;
private absoluteYaw = 0;
private absolutePitch = 0;
private lastAbsYaw = 0;
private lastAbsPitch = 0;
private exhausted = false;
// Generation counters for derived-array caching in buildSnapshot().
private _teamScoresGen = 0;
private _rosterGen = 0;
private _weaponsHudGen = 0;
private _inventoryHudGen = 0;
// Cached snapshot
private _cachedSnapshot: StreamSnapshot | null = null;
private _cachedSnapshotTick = -1;
// Cached derived arrays
private _snap: {
teamScoresGen: number;
rosterGen: number;
teamScores: TeamScore[];
weaponsHudGen: number;
weaponsHud: { slots: WeaponsHudSlot[]; activeIndex: number };
inventoryHudGen: number;
inventoryHud: { slots: InventoryHudSlot[]; activeSlot: number };
backpackPackIndex: number;
backpackActive: boolean;
backpackText: string;
backpackHud: BackpackHudState | null;
} | null = null;
constructor(parser: DemoParser) {
super();
this.parser = parser;
this.registry = parser.getRegistry();
this.ghostTracker = parser.getGhostTracker();
const initial = parser.initialBlock;
this.initialBlock = {
dataBlocks: initial.dataBlocks,
initialGhosts: initial.initialGhosts,
controlObjectGhostIndex: initial.controlObjectGhostIndex,
controlObjectData: initial.controlObjectData,
targetEntries: initial.targetEntries,
sensorGroupColors: initial.sensorGroupColors,
taggedStrings: initial.taggedStrings,
initialEvents: initial.initialEvents,
demoValues: initial.demoValues,
};
this.reset();
}
// ── StreamEngine abstract implementations ──
getDataBlockData(dataBlockId: number): Record<string, unknown> | 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>>;
};
return packetParser.dataBlockDataMap?.get(dataBlockId);
}
private _shapeConstructorCache: Map<string, string[]> | null = null;
getShapeConstructorSequences(shapeName: string): string[] | undefined {
if (!this._shapeConstructorCache) {
this._shapeConstructorCache = new Map();
for (const [, db] of this.initialBlock.dataBlocks) {
if (db.className !== "TSShapeConstructor" || !db.data) continue;
const shape = db.data.shape as string | undefined;
const seqs = db.data.sequences as string[] | undefined;
if (shape && seqs) {
this._shapeConstructorCache.set(shape.toLowerCase(), seqs);
}
}
}
return this._shapeConstructorCache.get(shapeName.toLowerCase());
}
protected getTimeSec(): number {
return this.moveTicks * (TICK_DURATION_MS / 1000);
}
protected getCameraYawPitch(
_data: Record<string, unknown> | undefined,
): { yaw: number; pitch: number } {
const hasMoves = !this.isPiloting && this.lastControlType === "player";
const yaw = hasMoves ? this.absoluteYaw : this.lastAbsYaw;
const pitch = hasMoves ? this.absolutePitch : this.lastAbsPitch;
if (hasMoves) {
this.lastAbsYaw = yaw;
this.lastAbsPitch = pitch;
}
return { yaw, pitch };
}
protected getControlPlayerHeadPitch(_pitch: number): number {
return clamp(this.absolutePitch / MAX_PITCH, -1, 1);
}
// ── Generation counter hooks ──
protected onTeamScoresChanged(): void {
this._teamScoresGen++;
}
protected onRosterChanged(): void {
this._rosterGen++;
}
protected onWeaponsHudChanged(): void {
this._weaponsHudGen++;
}
protected onInventoryHudChanged(): void {
this._inventoryHudGen++;
}
// ── StreamingPlayback interface ──
reset(): void {
this.parser.reset();
// parser.reset() creates a fresh GhostTracker internally — refresh our
// reference so resolveGhostClassName doesn't use the stale one.
this.ghostTracker = this.parser.getGhostTracker();
this._cachedSnapshot = null;
this._cachedSnapshotTick = -1;
this._snap = null;
this.resetSharedState();
// Seed net strings from initial block
for (const [id, value] of this.initialBlock.taggedStrings) {
this.netStrings.set(id, value);
}
for (const entry of this.initialBlock.targetEntries) {
if (entry.name) {
this.targetNames.set(
entry.targetId,
stripTaggedStringMarkup(entry.name),
);
}
this.targetTeams.set(entry.targetId, entry.sensorGroup);
this.targetRenderFlags.set(entry.targetId, entry.targetData);
}
// Seed IFF color table from the initial block.
for (const c of this.initialBlock.sensorGroupColors) {
let map = this.sensorGroupColors.get(c.group);
if (!map) {
map = new Map();
this.sensorGroupColors.set(c.group, map);
}
map.set(c.targetGroup, { r: c.r, g: c.g, b: c.b });
}
// Demo-specific state
this.moveTicks = 0;
this.absoluteYaw = 0;
this.absolutePitch = 0;
this.lastAbsYaw = 0;
this.lastAbsPitch = 0;
this.lastControlType =
detectControlObjectType(this.initialBlock.controlObjectData) ?? "player";
this.isPiloting =
this.lastControlType === "player"
? !!(
this.initialBlock.controlObjectData?.pilot ||
this.initialBlock.controlObjectData?.controlObjectGhost != null
)
: false;
this.lastCameraMode =
this.lastControlType === "camera" &&
typeof this.initialBlock.controlObjectData?.cameraMode === "number"
? this.initialBlock.controlObjectData.cameraMode
: undefined;
this.lastOrbitGhostIndex =
this.lastControlType === "camera" &&
typeof this.initialBlock.controlObjectData?.orbitObjectGhostIndex ===
"number"
? this.initialBlock.controlObjectData.orbitObjectGhostIndex
: undefined;
if (this.lastControlType === "camera") {
const minOrbit = this.initialBlock.controlObjectData?.minOrbitDist as
| number
| undefined;
const maxOrbit = this.initialBlock.controlObjectData?.maxOrbitDist as
| number
| undefined;
const curOrbit = this.initialBlock.controlObjectData?.curOrbitDist as
| number
| undefined;
if (
typeof minOrbit === "number" &&
typeof maxOrbit === "number" &&
Number.isFinite(minOrbit) &&
Number.isFinite(maxOrbit)
) {
this.lastOrbitDistance = Math.max(0, maxOrbit - minOrbit);
} else if (typeof curOrbit === "number" && Number.isFinite(curOrbit)) {
this.lastOrbitDistance = Math.max(0, curOrbit);
} else {
this.lastOrbitDistance = undefined;
}
} else {
this.lastOrbitDistance = undefined;
}
const initialAbsRot = this.getAbsoluteRotation(
this.initialBlock.controlObjectData,
);
if (initialAbsRot) {
this.absoluteYaw = initialAbsRot.yaw;
this.absolutePitch = initialAbsRot.pitch;
this.lastAbsYaw = initialAbsRot.yaw;
this.lastAbsPitch = initialAbsRot.pitch;
}
this.exhausted = false;
this.latestFov = 100;
this.latestControl = {
ghostIndex: this.initialBlock.controlObjectGhostIndex,
data: this.initialBlock.controlObjectData,
position: isValidPosition(
this.initialBlock.controlObjectData?.position as Vec3,
)
? (this.initialBlock.controlObjectData?.position as Vec3)
: undefined,
};
this.controlPlayerGhostId =
this.lastControlType === "player" &&
this.initialBlock.controlObjectGhostIndex >= 0
? toEntityId("Player", this.initialBlock.controlObjectGhostIndex)
: undefined;
for (const ghost of this.initialBlock.initialGhosts) {
if (ghost.type !== "create" || ghost.classId == null) continue;
const className = this.registry.getGhostParser(ghost.classId)?.name;
if (!className) {
throw new Error(
`No ghost parser for classId ${ghost.classId} (ghost index ${ghost.index})`,
);
}
const id = toEntityId(className, ghost.index);
const entity: MutableEntity = {
id,
ghostIndex: ghost.index,
className,
spawnTick: 0,
type: toEntityType(className),
rotation: [0, 0, 0, 1],
};
this.applyGhostData(entity, ghost.parsedData);
if (ghost.parsedData) {
const sceneObj = ghostToSceneObject(
className,
ghost.index,
ghost.parsedData as Record<string, unknown>,
);
if (sceneObj) entity.sceneData = sceneObj;
}
this.entities.set(id, entity);
this.entityIdByGhostIndex.set(ghost.index, id);
}
// Derive playerSensorGroup from the control player entity
if (
this.playerSensorGroup === 0 &&
this.lastControlType === "player" &&
this.latestControl.ghostIndex >= 0
) {
const ctrlId = this.entityIdByGhostIndex.get(
this.latestControl.ghostIndex,
);
const ctrlEntity = ctrlId ? this.entities.get(ctrlId) : undefined;
if (ctrlEntity?.sensorGroup != null && ctrlEntity.sensorGroup > 0) {
this.playerSensorGroup = ctrlEntity.sensorGroup;
}
}
// Process initial events
for (const evt of this.initialBlock.initialEvents) {
const eventName = this.registry.getEventParser(evt.classId)?.name;
if (eventName === "SetSensorGroupEvent" && evt.parsedData) {
const sg = evt.parsedData.sensorGroup as number | undefined;
if (sg != null) this.playerSensorGroup = sg;
} else if (eventName === "RemoteCommandEvent" && evt.parsedData) {
const funcName = this.resolveNetString(
evt.parsedData.funcName as string,
);
const args = evt.parsedData.args as string[];
if (funcName === "ServerMessage") {
this.handleServerMessage(args);
}
this.handleHudRemoteCommand(funcName, args);
}
}
// Seed HUD state from demoValues
const parsed = parseDemoValues(this.initialBlock.demoValues);
if (parsed.weaponsHud) {
this.weaponsHud.slots = parsed.weaponsHud.slots;
this.weaponsHud.activeIndex = parsed.weaponsHud.activeIndex;
}
if (parsed.backpackHud) {
this.backpackHud.packIndex = parsed.backpackHud.packIndex;
this.backpackHud.active = parsed.backpackHud.active;
this.backpackHud.text = parsed.backpackHud.text;
}
if (parsed.inventoryHud) {
this.inventoryHud.slots = parsed.inventoryHud.slots;
this.inventoryHud.activeSlot = parsed.inventoryHud.activeSlot;
}
this.teamScores = parsed.teamScores;
this.playerRoster = new Map(parsed.playerRoster);
// Seed chat messages from demoValues
for (const rawLine of parsed.chatMessages) {
const segments = parseColorSegments(rawLine);
if (!segments.length) continue;
const fullText = segments.map((s) => s.text).join("");
if (!fullText.trim()) continue;
const primaryColor = segments[0].colorCode;
const hasChatColor = segments.some(
(s) => s.colorCode === 3 || s.colorCode === 4,
);
const isPlayerChat = hasChatColor && fullText.includes(": ");
if (isPlayerChat) {
const colonIdx = fullText.indexOf(": ");
this.chatMessages.push({
timeSec: 0,
sender: fullText.slice(0, colonIdx),
text: fullText.slice(colonIdx + 2),
kind: "chat",
colorCode: primaryColor,
segments,
});
} else {
this.chatMessages.push({
timeSec: 0,
sender: "",
text: fullText,
kind: "server",
colorCode: primaryColor,
segments,
});
}
}
this.updateCameraAndHud();
}
getSnapshot(): StreamSnapshot {
if (
this._cachedSnapshot &&
this._cachedSnapshotTick === this.moveTicks
) {
return this._cachedSnapshot;
}
const snapshot = this.buildSnapshot();
this._cachedSnapshot = snapshot;
this._cachedSnapshotTick = this.moveTicks;
return snapshot;
}
getEffectShapes(): string[] {
const shapes = new Set<string>();
const collectShapesFromExplosion = (expBlock: Record<string, unknown>) => {
const shape = expBlock.dtsFileName as string | undefined;
if (shape) shapes.add(shape);
const subExplosions = expBlock.subExplosions as
| (number | null)[]
| undefined;
if (Array.isArray(subExplosions)) {
for (const subId of subExplosions) {
if (subId == null) continue;
const subBlock = this.getDataBlockData(subId);
if (subBlock?.dtsFileName) {
shapes.add(subBlock.dtsFileName as string);
}
}
}
};
for (const [, block] of this.initialBlock.dataBlocks) {
const explosionId = block.data?.explosion as number | undefined;
if (explosionId == null) continue;
const expBlock = this.getDataBlockData(explosionId);
if (expBlock) collectShapesFromExplosion(expBlock);
}
return [...shapes];
}
stepToTime(
targetTimeSec: number,
maxMoveTicks = Number.POSITIVE_INFINITY,
): StreamSnapshot {
const safeTargetSec = Number.isFinite(targetTimeSec)
? Math.max(0, targetTimeSec)
: 0;
const targetTicks = Math.floor((safeTargetSec * 1000) / TICK_DURATION_MS);
let didReset = false;
if (targetTicks < this.moveTicks) {
this.reset();
didReset = true;
}
const wasExhausted = this.exhausted;
let movesProcessed = 0;
while (
!this.exhausted &&
this.moveTicks < targetTicks &&
movesProcessed < maxMoveTicks
) {
if (!this.stepOneMoveTick()) {
break;
}
movesProcessed += 1;
}
if (
movesProcessed === 0 &&
!didReset &&
wasExhausted === this.exhausted &&
this._cachedSnapshot &&
this._cachedSnapshotTick === this.moveTicks
) {
return this._cachedSnapshot;
}
const snapshot = this.buildSnapshot();
this._cachedSnapshot = snapshot;
this._cachedSnapshotTick = this.moveTicks;
return snapshot;
}
// ── Demo block processing ──
private stepOneMoveTick(): boolean {
while (true) {
const block = this.parser.nextBlock();
if (!block) {
this.exhausted = true;
return false;
}
this.handleBlock(block);
if (block.type === BlockTypeMove) {
this.moveTicks += 1;
this.tickCount = this.moveTicks;
this.advanceProjectiles();
this.advanceItems();
this.removeExpiredExplosions();
this.updateCameraAndHud();
return true;
}
}
}
private handleBlock(block: { type: number; parsed?: unknown }): void {
if (block.type === BlockTypePacket && this.isPacketData(block.parsed)) {
const packet = block.parsed;
// Process control object
this.processControlObject(packet.gameState);
// Apply ghost rotation to absolute tracking. This must happen before
// the next move delta so that our tracking stays calibrated to V12.
const controlData = packet.gameState.controlObjectData;
if (controlData) {
const absRot = this.getAbsoluteRotation(controlData);
if (absRot) {
this.absoluteYaw = absRot.yaw;
this.absolutePitch = absRot.pitch;
this.lastAbsYaw = absRot.yaw;
this.lastAbsPitch = absRot.pitch;
}
}
for (const evt of packet.events) {
const eventName = this.registry.getEventParser(evt.classId)?.name;
this.processEvent(evt, eventName);
}
for (const ghost of packet.ghosts) {
this.processGhostUpdate(ghost);
}
return;
}
if (block.type === BlockTypeInfo && this.isInfoData(block.parsed)) {
if (Number.isFinite(block.parsed.value2)) {
this.latestFov = block.parsed.value2;
}
return;
}
if (block.type === BlockTypeMove && this.isMoveData(block.parsed)) {
// Replicate V12 Player::updateMove(): apply delta then wrap/clamp.
this.absoluteYaw += block.parsed.yaw ?? 0;
const TWO_PI = Math.PI * 2;
this.absoluteYaw =
((this.absoluteYaw % TWO_PI) + TWO_PI) % TWO_PI;
this.absolutePitch = clamp(
this.absolutePitch + (block.parsed.pitch ?? 0),
-MAX_PITCH,
MAX_PITCH,
);
}
}
// ── Build snapshot (with generation-counter caching) ──
private buildSnapshot(): StreamSnapshot {
const entities = this.buildEntityList();
const timeSec = this.getTimeSec();
const prev = this._snap;
const { chatMessages, audioEvents } =
this.buildTimeFilteredEvents(timeSec);
const weaponsHud =
prev && prev.weaponsHudGen === this._weaponsHudGen
? prev.weaponsHud
: {
slots: Array.from(this.weaponsHud.slots.entries()).map(
([index, ammo]): WeaponsHudSlot => ({ index, ammo }),
),
activeIndex: this.weaponsHud.activeIndex,
};
const inventoryHud =
prev && prev.inventoryHudGen === this._inventoryHudGen
? prev.inventoryHud
: {
slots: Array.from(this.inventoryHud.slots.entries()).map(
([slot, count]): InventoryHudSlot => ({ slot, count }),
),
activeSlot: this.inventoryHud.activeSlot,
};
const backpackHud =
prev &&
prev.backpackPackIndex === this.backpackHud.packIndex &&
prev.backpackActive === this.backpackHud.active &&
prev.backpackText === this.backpackHud.text
? prev.backpackHud
: this.backpackHud.packIndex >= 0
? { ...this.backpackHud }
: null;
let teamScores: TeamScore[];
if (
prev &&
prev.teamScoresGen === this._teamScoresGen &&
prev.rosterGen === this._rosterGen
) {
teamScores = prev.teamScores;
} else {
teamScores = this.teamScores.map((ts) => ({ ...ts }));
const teamCounts = new Map<number, number>();
for (const { teamId } of this.playerRoster.values()) {
if (teamId > 0) {
teamCounts.set(teamId, (teamCounts.get(teamId) ?? 0) + 1);
}
}
for (const ts of teamScores) {
ts.playerCount = teamCounts.get(ts.teamId) ?? 0;
}
}
this._snap = {
teamScoresGen: this._teamScoresGen,
rosterGen: this._rosterGen,
teamScores,
weaponsHudGen: this._weaponsHudGen,
weaponsHud,
inventoryHudGen: this._inventoryHudGen,
inventoryHud,
backpackPackIndex: this.backpackHud.packIndex,
backpackActive: this.backpackHud.active,
backpackText: this.backpackHud.text,
backpackHud,
};
return {
timeSec,
exhausted: this.exhausted,
camera: this.camera,
entities,
controlPlayerGhostId: this.controlPlayerGhostId,
playerSensorGroup: this.playerSensorGroup,
status: this.lastStatus,
chatMessages,
audioEvents,
weaponsHud,
backpackHud,
inventoryHud,
teamScores,
};
}
// ── Type guards ──
private isPacketData(parsed: unknown): parsed is {
gameState: {
controlObjectGhostIndex?: number;
controlObjectData?: Record<string, unknown>;
compressionPoint?: Vec3;
cameraFov?: number;
};
events: Array<{
classId: number;
parsedData?: Record<string, unknown>;
}>;
ghosts: Array<{
index: number;
type: "create" | "update" | "delete";
classId?: number;
parsedData?: Record<string, unknown>;
}>;
} {
return (
!!parsed &&
typeof parsed === "object" &&
"gameState" in parsed &&
"events" in parsed &&
"ghosts" in parsed
);
}
private isMoveData(
parsed: unknown,
): parsed is { yaw?: number; pitch?: number } {
return !!parsed && typeof parsed === "object" && "yaw" in parsed;
}
private isInfoData(parsed: unknown): parsed is { value2: number } {
return (
!!parsed &&
typeof parsed === "object" &&
"value2" in parsed &&
typeof (parsed as { value2?: unknown }).value2 === "number"
);
}
}
export async function createDemoStreamingRecording(
data: ArrayBuffer,
): Promise<StreamRecording> {
const parser = new DemoParser(new Uint8Array(data));
const { header, initialBlock } = await parser.load();
const { missionName: infoMissionName, gameType } = extractMissionInfo(
initialBlock.demoValues,
);
return {
source: "demo",
duration: header.demoLengthMs / 1000,
missionName: infoMissionName ?? initialBlock.missionName ?? null,
gameType,
streamingPlayback: new StreamingPlayback(parser),
};
}

185
src/stream/entityBridge.ts Normal file
View file

@ -0,0 +1,185 @@
import type { StreamEntity } from "./types";
import type {
GameEntity,
ShapeEntity,
PlayerEntity,
ForceFieldBareEntity,
ExplosionEntity,
TracerEntity,
SpriteEntity,
AudioEmitterEntity,
CameraEntity,
WayPointEntity,
} from "../state/gameEntityTypes";
import type { SceneTSStatic } from "../scene/types";
/** Common fields extracted from a StreamEntity for positioned game entities. */
function positionedBase(entity: StreamEntity, spawnTime?: number) {
return {
id: entity.id,
className: entity.className ?? entity.type,
ghostIndex: entity.ghostIndex,
dataBlockId: entity.dataBlockId,
shapeHint: entity.shapeHint,
spawnTime,
position: entity.position,
rotation: entity.rotation,
velocity: entity.velocity,
keyframes: [
{
time: spawnTime ?? 0,
position: entity.position ?? [0, 0, 0] as [number, number, number],
rotation: entity.rotation ?? [0, 0, 0, 1] as [number, number, number, number],
},
],
};
}
/** Convert a StreamEntity to a GameEntity for the entity store. */
export function streamEntityToGameEntity(
entity: StreamEntity,
spawnTime?: number,
): GameEntity {
// Scene infrastructure — routed from sceneData
if (entity.sceneData) {
const base = {
id: entity.id,
className: entity.className ?? entity.type,
ghostIndex: entity.ghostIndex,
dataBlockId: entity.dataBlockId,
shapeHint: entity.shapeHint,
spawnTime,
};
switch (entity.sceneData.className) {
case "TerrainBlock":
return { ...base, renderType: "TerrainBlock", terrainData: entity.sceneData };
case "InteriorInstance":
return { ...base, renderType: "InteriorInstance", interiorData: entity.sceneData };
case "Sky":
return { ...base, renderType: "Sky", skyData: entity.sceneData };
case "Sun":
return { ...base, renderType: "Sun", sunData: entity.sceneData };
case "WaterBlock":
return { ...base, renderType: "WaterBlock", waterData: entity.sceneData };
case "MissionArea":
return { ...base, renderType: "MissionArea", missionAreaData: entity.sceneData };
case "TSStatic":
// TSStatic is rendered as a shape — extract shapeName from scene data.
return {
...positionedBase(entity, spawnTime),
renderType: "Shape",
shapeName: (entity.sceneData as SceneTSStatic).shapeName,
shapeType: "TSStatic",
dataBlock: entity.dataBlock,
} satisfies ShapeEntity;
}
}
// Projectile visuals
if (entity.visual?.kind === "tracer") {
return {
...positionedBase(entity, spawnTime),
renderType: "Tracer",
visual: entity.visual,
dataBlock: entity.dataBlock,
direction: entity.direction,
} satisfies TracerEntity;
}
if (entity.visual?.kind === "sprite") {
return {
...positionedBase(entity, spawnTime),
renderType: "Sprite",
visual: entity.visual,
} satisfies SpriteEntity;
}
// Player
if (entity.type === "Player") {
return {
...positionedBase(entity, spawnTime),
renderType: "Player",
shapeName: entity.dataBlock,
dataBlock: entity.dataBlock,
weaponShape: entity.weaponShape,
playerName: entity.playerName,
iffColor: entity.iffColor,
threads: entity.threads,
weaponImageState: entity.weaponImageState,
weaponImageStates: entity.weaponImageStates,
headPitch: entity.headPitch,
headYaw: entity.headYaw,
targetRenderFlags: entity.targetRenderFlags,
} satisfies PlayerEntity;
}
// Explosion
if (entity.type === "Explosion") {
return {
...positionedBase(entity, spawnTime),
renderType: "Explosion",
shapeName: entity.dataBlock,
dataBlock: entity.dataBlock,
explosionDataBlockId: entity.explosionDataBlockId,
faceViewer: entity.faceViewer,
} satisfies ExplosionEntity;
}
// Force field
if (entity.className === "ForceFieldBare") {
return {
...positionedBase(entity, spawnTime),
renderType: "ForceFieldBare",
} satisfies ForceFieldBareEntity;
}
// Audio emitter
if (entity.className === "AudioEmitter") {
return {
...positionedBase(entity, spawnTime),
renderType: "AudioEmitter",
audioFileName: entity.audioFileName,
audioVolume: entity.audioVolume,
audioIs3D: entity.audioIs3D,
audioIsLooping: entity.audioIsLooping ?? true,
audioMinDistance: entity.audioMinDistance,
audioMaxDistance: entity.audioMaxDistance,
audioMinLoopGap: entity.audioMinLoopGap,
audioMaxLoopGap: entity.audioMaxLoopGap,
} satisfies AudioEmitterEntity;
}
// WayPoint
if (entity.className === "WayPoint") {
return {
...positionedBase(entity, spawnTime),
renderType: "WayPoint",
label: entity.label,
} satisfies WayPointEntity;
}
// Camera
if (entity.className === "Camera") {
return {
...positionedBase(entity, spawnTime),
renderType: "Camera",
} satisfies CameraEntity;
}
// Default: generic DTS shape
return {
...positionedBase(entity, spawnTime),
renderType: "Shape",
shapeName: entity.dataBlock,
shapeType:
entity.className === "Turret"
? "Turret"
: entity.className === "Item"
? "Item"
: "StaticShape",
dataBlock: entity.dataBlock,
weaponShape: entity.weaponShape,
threads: entity.threads,
targetRenderFlags: entity.targetRenderFlags,
iffColor: entity.iffColor,
} satisfies ShapeEntity;
}

View file

@ -0,0 +1,69 @@
/** Class names for vehicle ghosts. */
export const vehicleClassNames = new Set([
"FlyingVehicle",
"HoverVehicle",
"WheeledVehicle",
]);
/** All projectile class names. */
export const projectileClassNames = new Set([
"BombProjectile",
"EnergyProjectile",
"FlareProjectile",
"GrenadeProjectile",
"LinearFlareProjectile",
"LinearProjectile",
"Projectile",
"SeekerProjectile",
"TracerProjectile",
]);
/** Projectile classes with linear (constant-velocity) physics. */
export const linearProjectileClassNames = new Set([
"LinearProjectile",
"TracerProjectile",
"LinearFlareProjectile",
"Projectile",
]);
/** Projectile classes with ballistic (gravity-affected) physics. */
export const ballisticProjectileClassNames = new Set([
"GrenadeProjectile",
"EnergyProjectile",
"FlareProjectile",
"BombProjectile",
]);
/** Projectile classes that use seeking (homing) physics. */
export const seekerProjectileClassNames = new Set(["SeekerProjectile"]);
/** Deployable/placed object class names. */
export const deployableClassNames = new Set([
"StaticShape",
"ScopeAlwaysShape",
"Turret",
"BeaconObject",
"ForceFieldBare",
]);
/** Map a ghost class name to a high-level entity type string. */
export function toEntityType(className: string): string {
if (className === "Player") return "Player";
if (vehicleClassNames.has(className)) return "Vehicle";
if (className === "Item") return "Item";
if (projectileClassNames.has(className)) return "Projectile";
if (deployableClassNames.has(className)) return "Deployable";
return "Ghost";
}
/** Generate a stable entity ID from ghost class name and index. */
export function toEntityId(className: string, ghostIndex: number): string {
return `${className}_${ghostIndex}`;
}
/** Tribes 2 default IFF colors (sRGB 0-255). */
export const IFF_GREEN = Object.freeze({ r: 0, g: 255, b: 0 });
export const IFF_RED = Object.freeze({ r: 255, g: 0, b: 0 });
/** Torque engine tick duration in milliseconds. */
export const TICK_DURATION_MS = 32;

555
src/stream/liveStreaming.ts Normal file
View file

@ -0,0 +1,555 @@
import {
createLiveParser,
type PacketParser,
} from "t2-demo-parser";
import { resolveShapeName, stripTaggedStringMarkup } from "./streamHelpers";
import type { Vec3 } from "./streamHelpers";
import type { StreamSnapshot } from "./types";
import { StreamEngine } from "./StreamEngine";
import type { RelayClient } from "./relayClient";
// ── Player list entry ──
export interface PlayerListEntry {
targetId: number;
name: string;
sensorGroup: number;
}
/**
* Adapts live game packets from a relay connection into the
* StreamingPlayback interface used by the existing rendering pipeline.
*/
export class LiveStreamAdapter extends StreamEngine {
private packetParser: PacketParser;
relay: RelayClient;
private currentTimeSec = 0;
private connectSynced = false;
private _snapshot: StreamSnapshot | null = null;
private _snapshotTick = -1;
private _ready = false;
/** Class names for datablocks, tracked from SimDataBlockEvents. */
private dataBlockClassNames = new Map<number, string>();
/** Called once when the first ghost entity is created. */
onReady?: () => void;
constructor(relay: RelayClient) {
super();
this.relay = relay;
const { registry, ghostTracker, packetParser } = createLiveParser();
this.packetParser = packetParser;
this.ghostTracker = ghostTracker;
this.registry = registry;
}
// ── StreamEngine abstract implementations ──
getDataBlockData(id: number): Record<string, unknown> | undefined {
return this.packetParser.getDataBlockDataMap()?.get(id);
}
private _shapeConstructorCache: Map<string, string[]> | null = null;
getShapeConstructorSequences(shapeName: string): string[] | undefined {
// Rebuild cache each call since datablocks arrive incrementally.
this._shapeConstructorCache = new Map();
const dbMap = this.packetParser.getDataBlockDataMap();
if (!dbMap) return undefined;
for (const [, block] of dbMap) {
const shape = block.shape as string | undefined;
const seqs = block.sequences as string[] | undefined;
if (shape && seqs) {
this._shapeConstructorCache.set(shape.toLowerCase(), seqs);
}
}
return this._shapeConstructorCache.get(shapeName.toLowerCase());
}
protected getTimeSec(): number {
return this.currentTimeSec;
}
protected getCameraYawPitch(
data: Record<string, unknown> | undefined,
): { yaw: number; pitch: number } {
const absRot = this.getAbsoluteRotation(data);
return absRot ?? { yaw: 0, pitch: 0 };
}
getEffectShapes(): string[] {
const shapes = new Set<string>();
const dbMap = this.packetParser.getDataBlockDataMap();
if (!dbMap) return [];
for (const [, block] of dbMap) {
const explosionId = block.explosion as number | undefined;
if (explosionId == null) continue;
const expBlock = dbMap.get(explosionId);
if (expBlock?.dtsFileName) {
shapes.add(expBlock.dtsFileName as string);
}
}
return [...shapes];
}
// ── StreamingPlayback interface ──
reset(): void {
this.resetSharedState();
this.ghostTracker.clear?.();
this.currentTimeSec = 0;
this._snapshot = null;
this._snapshotTick = -1;
this.dataBlockClassNames.clear();
this.observerMode = "fly";
}
getSnapshot(): StreamSnapshot {
if (this._snapshot && this._snapshotTick === this.tickCount) {
return this._snapshot;
}
return this.buildSnapshot();
}
stepToTime(
targetTimeSec: number,
_maxMoveTicks?: number,
): StreamSnapshot {
this.currentTimeSec = targetTimeSec;
return this.getSnapshot();
}
// ── Live-specific: connect sequence sync ──
private syncConnectSequence(data: Uint8Array): void {
if (this.connectSynced || data.length < 1) return;
this.connectSynced = true;
const connectSeqBit = (data[0] >> 1) & 1;
// The browser parser is a passive observer — it never sends packets
// (the relay handles all outgoing UDP traffic). Set lastSendSeq very
// high so the parser's ack validation (lastSendSeq < highestAck →
// reject) never fires. Without this, the parser rejects any packet
// where the server acks relay-sent sequences (e.g. auth events).
this.packetParser.setConnectionProtocolState({
lastSeqRecvdAtSend: new Array(32).fill(0),
lastSeqRecvd: 0,
highestAckedSeq: 0,
lastSendSeq: 0x1fffffff,
ackMask: 0,
connectSequence: connectSeqBit,
lastRecvAckAck: 0,
connectionEstablished: true,
});
}
// ── Live-specific: feed raw packet ──
feedPacket(data: Uint8Array): void {
this.syncConnectSequence(data);
this.processPacket(data);
}
// ── Live-specific: auth event detection ──
/**
* 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;
if (!rawFuncName) return;
const funcName = this.resolveNetString(rawFuncName);
// T2csri auth events → forward to relay for crypto processing.
const authCommands = [
"t2csri_pokeClient",
"t2csri_getChallengeChunk",
"t2csri_decryptChallenge",
];
if (authCommands.includes(funcName)) {
const rawArgs = (parsedData.args as string[]) ?? [];
const args = rawArgs
.map((a) => this.resolveNetString(a))
.filter((a) => a !== "");
console.log(`[live] auth event: ${funcName}`, args);
this.relay.sendAuthEvent(funcName, args);
return;
}
// 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 resolvedArgs = rawArgs.map((a) => this.resolveNetString(a));
if (funcName === "MissionStartPhase1") {
const seq = resolvedArgs[0] ?? "";
console.log(`[live] mission phase 1, seq=${seq}`);
this.relay.sendCommand("MissionStartPhase1Done", [seq]);
} else if (funcName === "MissionStartPhase2") {
const seq = resolvedArgs[0] ?? "";
console.log(`[live] mission phase 2 (datablocks), seq=${seq}`);
this.relay.sendCommand("MissionStartPhase2Done", [seq]);
} else if (funcName === "MissionStartPhase3") {
const seq = resolvedArgs[0] ?? "";
console.log(`[live] mission phase 3 (ghosting), seq=${seq}`);
// Send an empty favorites list then acknowledge phase 3.
this.relay.sendCommand("setClientFav", [""]);
this.relay.sendCommand("MissionStartPhase3Done", [seq]);
}
}
/** 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;
// field1 bit 0 = includeTextures (from $Host::CRCTextures)
const includeTextures = (field1 & 1) !== 0;
console.log(
`[live] CRC challenge: seed=0x${(seed >>> 0).toString(16)} ` +
`f1=0x${(field1 >>> 0).toString(16)} f2=0x${(field2 >>> 0).toString(16)} ` +
`includeTextures=${includeTextures}`,
);
// Collect datablocks for relay-side CRC computation over game files.
const dbMap = this.packetParser.getDataBlockDataMap();
const datablocks: { objectId: number; className: string; shapeName: string }[] = [];
if (dbMap) {
for (const [id, block] of dbMap) {
const className = this.dataBlockClassNames.get(id);
if (!className) continue;
const shapeName = resolveShapeName(className, block as Record<string, unknown>);
datablocks.push({
objectId: id,
className,
shapeName: shapeName ?? "",
});
}
}
console.log(`[live] CRC: sending ${datablocks.length} datablocks for computation`);
this.relay.sendCRCCompute(seed, field2, datablocks, includeTextures);
}
/**
* Respond to GhostingMessageEvent type 0 (GhostAlwaysDone).
* 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;
console.log(
`[live] GhostingMessageEvent: message=${message} sequence=${sequence} ghostCount=${ghostCount}`,
);
if (message === 0) {
// GhostAlwaysDone → send type 1 acknowledgment
console.log(`[live] Sending ghost ack (type 1) for sequence ${sequence}`);
this.relay.sendGhostAck(sequence, ghostCount);
}
}
/**
* Server-side observer camera mode. In "fly" mode, trigger 0 (fire) would
* make the server assign a team so we must NEVER send fire in fly mode.
* Jump (trigger 2) transitions between modes.
*/
observerMode: "fly" | "follow" = "fly";
/** Enter follow mode (from fly) or cycle to next player (in follow). */
cycleObserveNext(): void {
if (this.observerMode === "fly") {
// Jump trigger enters observerFollow from observerFly
console.log("[live] observer: fly → follow (jump trigger)");
this.sendTrigger(2);
this.observerMode = "follow";
} else {
// Fire trigger cycles to next player in observerFollow
console.log("[live] observer: cycle next (fire trigger)");
this.sendTrigger(0);
}
}
/** Toggle between follow and free-fly observer modes. */
toggleObserverMode(): void {
if (this.observerMode === "fly") {
// Jump trigger enters observerFollow from observerFly
console.log("[live] observer: fly → follow (jump trigger)");
this.sendTrigger(2);
this.observerMode = "follow";
} else {
// Jump trigger returns to observerFly from observerFollow
console.log("[live] observer: follow → fly (jump trigger)");
this.sendTrigger(2);
this.observerMode = "fly";
}
}
private sendTrigger(index: number): void {
const trigger: [boolean, boolean, boolean, boolean, boolean, boolean] =
[false, false, false, false, false, false];
trigger[index] = true;
this.relay.sendMove({
x: 0, y: 0, z: 0,
yaw: 0, pitch: 0, roll: 0,
trigger,
freeLook: false,
});
}
/** Get the player list (for observer cycling UI). */
getPlayerList(): PlayerListEntry[] {
const entries: PlayerListEntry[] = [];
for (const [targetId, name] of this.targetNames) {
const sg = this.targetTeams.get(targetId) ?? 0;
entries.push({ targetId, name, sensorGroup: sg });
}
return entries;
}
// ── Packet processing ──
private processPacket(data: Uint8Array): void {
try {
const rejectedBefore = this.packetParser.protocolRejected;
const noDispatchBefore = this.packetParser.protocolNoDispatch;
const parsed = this.packetParser.parsePacket(data);
const wasRejected = this.packetParser.protocolRejected > rejectedBefore;
const wasNoDispatch = this.packetParser.protocolNoDispatch > noDispatchBefore;
if (wasRejected || wasNoDispatch) {
console.warn(
`[live] packet #${this.tickCount} ${wasRejected ? "REJECTED" : "no-dispatch"}: ${data.length} bytes` +
` (total rejected=${this.packetParser.protocolRejected}, noDispatch=${this.packetParser.protocolNoDispatch})`,
);
}
const isEarlyPacket = this.tickCount < 20;
const isMilestonePacket = this.tickCount % 100 === 0;
const shouldLog = isEarlyPacket || isMilestonePacket;
if (shouldLog) {
console.log(
`[live] packet #${this.tickCount}: ${parsed.events.length} events, ${parsed.ghosts.length} ghosts, ${data.length} bytes` +
(parsed.gameState.controlObjectGhostIndex !== undefined
? `, control=${parsed.gameState.controlObjectGhostIndex}`
: "") +
(parsed.gameState.cameraFov !== undefined
? `, fov=${parsed.gameState.cameraFov}`
: ""),
);
}
// Control object state
this.processControlObject(parsed.gameState);
// 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;
// Log events in early packets
if (isEarlyPacket) {
if (type !== "NetStringEvent") {
console.log(
`[live] event: ${type}`,
type === "RemoteCommandEvent"
? { funcName: this.resolveNetString(event.parsedData.funcName as string ?? "") }
: type === "SimDataBlockEvent"
? { id: event.parsedData.objectId, className: event.parsedData.dataBlockClassName }
: undefined,
);
}
}
// Track SimDataBlockEvent class names for CRC computation.
if (type === "SimDataBlockEvent") {
const dbId = event.parsedData.objectId as number | undefined;
const dbClassName = event.parsedData.dataBlockClassName as string | undefined;
if (dbId != null && dbClassName) {
this.dataBlockClassNames.set(dbId, dbClassName);
}
if (shouldLog) {
const dbData = event.parsedData.dataBlockData as Record<string, unknown> | undefined;
const shapeName = resolveShapeName(dbClassName ?? "", dbData);
console.log(
`[live] datablock: id=${dbId} class=${dbClassName ?? "?"}` +
(shapeName ? ` shape=${shapeName}` : ""),
);
}
}
const eventName = this.registry.getEventParser(event.classId)?.name;
this.processEvent(event, eventName);
// Log net strings in early packets
if (isEarlyPacket && type === "NetStringEvent") {
const id = event.parsedData.id as number;
const value = event.parsedData.value as string;
if (id != null && typeof value === "string") {
console.log(`[live] netString #${id} = "${value.length > 60 ? value.slice(0, 60) + "…" : value}"`);
}
}
// Log target info
if (type === "TargetInfoEvent") {
const targetId = event.parsedData.targetId as number | undefined;
const nameTag = event.parsedData.nameTag as number | undefined;
if (targetId != null && nameTag != null) {
const resolved = this.netStrings.get(nameTag);
if (resolved) {
const name = stripTaggedStringMarkup(resolved);
console.log(`[live] target #${targetId}: "${name}" team=${event.parsedData.sensorGroup ?? "?"}`);
}
}
}
// Log sensor group changes
if (type === "SetSensorGroupEvent") {
const sg = event.parsedData.sensorGroup as number | undefined;
if (sg != null) {
console.log(`[live] sensor group changed: → ${sg}`);
}
}
// Log sensor group colors
if (type === "SensorGroupColorEvent") {
const sg = event.parsedData.sensorGroup as number;
const colors = event.parsedData.colors as Array<unknown> | undefined;
if (colors) {
console.log(
`[live] sensor group colors: group=${sg}, ${colors.length} entries`,
);
}
}
}
}
// Ghosts
for (const ghost of parsed.ghosts) {
if (ghost.type === "create") {
const pos = ghost.parsedData?.position as Vec3 | undefined;
const hasPos = pos && typeof pos.x === "number" && typeof pos.y === "number" && typeof pos.z === "number";
const className = this.resolveGhostClassName(ghost.index, ghost.classId);
console.log(
`[live] ghost create: #${ghost.index} ${className ?? "?"}` +
(hasPos ? ` at (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})` : "") +
` (${this.entities.size + 1} entities total)`,
);
if (!this._ready) {
this._ready = true;
this.onReady?.();
}
} else if (ghost.type === "delete") {
const prevEntityId = this.entityIdByGhostIndex.get(ghost.index);
const prevEntity = prevEntityId ? this.entities.get(prevEntityId) : undefined;
if (this.tickCount < 50 || this.tickCount % 200 === 0) {
console.log(
`[live] ghost delete: #${ghost.index} ${prevEntity?.className ?? "?"}` +
` (${this.entities.size - 1} entities remaining)`,
);
}
}
this.processGhostUpdate(ghost);
}
this.tickCount++;
this.advanceProjectiles();
this.advanceItems();
// Periodic status at milestones
if (isMilestonePacket && this.tickCount > 1) {
const dbMap = this.packetParser.getDataBlockDataMap();
console.log(
`[live] status @ tick ${this.tickCount}: ${this.entities.size} entities, ` +
`${dbMap?.size ?? 0} datablocks, ` +
`rejected=${this.packetParser.protocolRejected}, noDispatch=${this.packetParser.protocolNoDispatch}`,
);
}
// Entity count milestones
const entityCount = this.entities.size;
if (
this.tickCount === 1 ||
(entityCount > 0 && entityCount % 25 === 0 && this.tickCount < 100)
) {
const types = new Map<string, number>();
for (const e of this.entities.values()) {
types.set(e.type, (types.get(e.type) ?? 0) + 1);
}
const summary = [...types.entries()]
.map(([t, c]) => `${t}=${c}`)
.join(" ");
console.log(
`[live] entity count: ${entityCount} (${summary})`,
);
}
this.updateCameraAndHud();
// Log camera position for early packets
if (this.tickCount <= 5 && this.camera) {
const [cx, cy, cz] = this.camera.position;
console.log(
`[live] camera: mode=${this.camera.mode} pos=(${cx.toFixed(1)}, ${cy.toFixed(1)}, ${cz.toFixed(1)}) fov=${this.camera.fov}`,
);
}
} catch (e) {
const errorContext = {
tickCount: this.tickCount,
entityCount: this.entities.size,
dataLength: data.length,
controlGhost: this.latestControl.ghostIndex,
connectSynced: this.connectSynced,
};
console.error("Failed to process live packet:", e, errorContext);
}
}
// ── Build snapshot ──
private buildSnapshot(): StreamSnapshot {
const entities = this.buildEntityList();
const timeSec = this.currentTimeSec;
const { chatMessages, audioEvents } = this.buildTimeFilteredEvents(timeSec);
const { weaponsHud, inventoryHud, backpackHud, teamScores } =
this.buildHudState();
// Default observer camera if none exists
if (!this.camera) {
this.camera = {
time: timeSec,
position: [0, 0, 200],
rotation: [0, 0, 0, 1],
fov: 90,
mode: "observer",
};
}
const snapshot: StreamSnapshot = {
timeSec,
exhausted: false,
camera: this.camera,
entities,
controlPlayerGhostId: this.controlPlayerGhostId,
playerSensorGroup: this.playerSensorGroup,
status: this.lastStatus,
chatMessages,
audioEvents,
weaponsHud,
backpackHud,
inventoryHud,
teamScores,
};
this._snapshot = snapshot;
this._snapshotTick = this.tickCount;
return snapshot;
}
}

View file

@ -0,0 +1,297 @@
import type { TorqueObject, TorqueRuntime } from "../torqueScript";
import type {
GameEntity,
ShapeEntity,
ForceFieldBareEntity,
AudioEmitterEntity,
CameraEntity,
WayPointEntity,
} from "../state/gameEntityTypes";
import { getPosition, getProperty, getScale } from "../mission";
import {
terrainFromMis,
interiorFromMis,
skyFromMis,
sunFromMis,
missionAreaFromMis,
waterBlockFromMis,
} from "../scene/misToScene";
/** Resolve a named datablock from the runtime. */
function resolveDatablock(
runtime: TorqueRuntime,
name: string | undefined,
): TorqueObject | undefined {
if (!name) return undefined;
return runtime.state.datablocks.get(name);
}
/** Handles TorqueScript's various truthy representations. */
function isTruthy(value: unknown): boolean {
if (typeof value === "string") {
const lower = value.toLowerCase();
return lower !== "0" && lower !== "false" && lower !== "";
}
return !!value;
}
function parseColor3(colorStr: string): [number, number, number] {
const parts = colorStr.split(" ").map((s) => parseFloat(s));
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
function parseRotationToQuat(
rotationStr: string,
): [number, number, number, number] {
const [ax, ay, az, angleDeg] = rotationStr.split(" ").map(parseFloat);
// Convert Torque axis-angle to Three.js quaternion (with coordinate swap
// and angle negation matching getRotation() in mission.ts).
const halfRad = (-(angleDeg || 0) * Math.PI) / 360;
const s = Math.sin(halfRad);
const c = Math.cos(halfRad);
const len = Math.sqrt(
(ay || 0) * (ay || 0) + (az || 0) * (az || 0) + (ax || 0) * (ax || 0),
);
if (len < 1e-8) return [0, 0, 0, 1];
// Three.js quaternion [x, y, z, w] with Torque→Three axis swap (x→y, y→z, z→x)
return [
((ay || 0) / len) * s,
((az || 0) / len) * s,
((ax || 0) / len) * s,
c,
];
}
/**
* Build a GameEntity from a mission TorqueObject. Returns null if the
* object's className is not a renderable entity type.
*/
export function buildGameEntityFromMission(
object: TorqueObject,
runtime: TorqueRuntime,
teamId?: number,
): GameEntity | null {
const className = object._className;
const id = `mission_${object._id}`;
const position = getPosition(object);
const scale = getScale(object);
const rotStr = object.rotation ?? "1 0 0 0";
const rotation = parseRotationToQuat(rotStr);
const datablockName = getProperty(object, "dataBlock") ?? "";
const datablock = resolveDatablock(runtime, datablockName);
const missionTypesList = getProperty(object, "missionTypesList");
const base = {
id,
className,
runtimeObject: object,
missionTypesList,
};
const posBase = { ...base, position, rotation, scale };
switch (className) {
// Scene infrastructure
case "TerrainBlock":
return { ...base, renderType: "TerrainBlock", terrainData: terrainFromMis(object) };
case "InteriorInstance":
return { ...base, renderType: "InteriorInstance", interiorData: interiorFromMis(object) };
case "Sky":
return { ...base, renderType: "Sky", skyData: skyFromMis(object) };
case "Sun":
return { ...base, renderType: "Sun", sunData: sunFromMis(object) };
case "WaterBlock":
return { ...base, renderType: "WaterBlock", waterData: waterBlockFromMis(object) };
case "MissionArea":
return { ...base, renderType: "MissionArea", missionAreaData: missionAreaFromMis(object) };
// Shapes
case "StaticShape":
case "Item":
case "Turret":
case "TSStatic":
return buildShapeEntity(posBase, object, datablock, runtime, className, teamId, datablockName);
// Force field
case "ForceFieldBare":
return buildForceFieldEntity(posBase, object, datablock, scale);
// Audio
case "AudioEmitter":
return {
...posBase,
renderType: "AudioEmitter",
audioFileName: getProperty(object, "fileName") ?? undefined,
audioVolume: parseFloat(getProperty(object, "volume")) || 1,
audioIs3D: (getProperty(object, "is3D") ?? "0") !== "0",
audioIsLooping: (getProperty(object, "isLooping") ?? "0") !== "0",
audioMinDistance: parseFloat(getProperty(object, "minDistance")) || 1,
audioMaxDistance: parseFloat(getProperty(object, "maxDistance")) || 1,
audioMinLoopGap: parseFloat(getProperty(object, "minLoopGap")) || 0,
audioMaxLoopGap: parseFloat(getProperty(object, "maxLoopGap")) || 0,
} satisfies AudioEmitterEntity;
case "Camera":
return {
...posBase,
renderType: "Camera",
cameraDataBlock: datablockName || undefined,
} satisfies CameraEntity;
case "WayPoint":
return {
...posBase,
renderType: "WayPoint",
label: getProperty(object, "name") || undefined,
} satisfies WayPointEntity;
default:
return null;
}
}
function buildShapeEntity(
posBase: {
id: string;
className: string;
runtimeObject: unknown;
missionTypesList?: string;
position?: [number, number, number];
rotation?: [number, number, number, number];
scale?: [number, number, number];
},
object: TorqueObject,
datablock: TorqueObject | undefined,
runtime: TorqueRuntime,
className: string,
teamId: number | undefined,
datablockName: string,
): ShapeEntity {
const shapeName = className === "TSStatic"
? getProperty(object, "shapeName")
: getProperty(datablock, "shapeFile");
const shapeType =
className === "Turret" ? "Turret"
: className === "Item" ? "Item"
: className === "TSStatic" ? "TSStatic"
: "StaticShape";
const entity: ShapeEntity = {
...posBase,
renderType: "Shape",
shapeName,
shapeType,
dataBlock: datablockName || undefined,
teamId,
};
if (className === "Item") {
entity.rotate = isTruthy(
getProperty(object, "rotate") ?? getProperty(datablock, "rotate"),
);
}
if (className === "Turret") {
const barrelName = getProperty(object, "initialBarrel");
if (barrelName) {
const barrelDb = resolveDatablock(runtime, barrelName);
entity.barrelShapeName = getProperty(barrelDb, "shapeFile");
}
}
return entity;
}
function buildForceFieldEntity(
posBase: {
id: string;
className: string;
runtimeObject: unknown;
missionTypesList?: string;
position?: [number, number, number];
rotation?: [number, number, number, number];
scale?: [number, number, number];
},
object: TorqueObject,
datablock: TorqueObject | undefined,
rawScale: [number, number, number] | undefined,
): ForceFieldBareEntity {
const colorStr = getProperty(datablock, "color");
const color = colorStr
? parseColor3(colorStr)
: ([1, 1, 1] as [number, number, number]);
const baseTranslucency =
parseFloat(getProperty(datablock, "baseTranslucency")) || 1;
const numFrames = parseInt(getProperty(datablock, "numFrames"), 10) || 1;
const framesPerSec = parseFloat(getProperty(datablock, "framesPerSec")) || 1;
const scrollSpeed = parseFloat(getProperty(datablock, "scrollSpeed")) || 0;
const umapping = parseFloat(getProperty(datablock, "umapping")) || 1;
const vmapping = parseFloat(getProperty(datablock, "vmapping")) || 1;
const textures: string[] = [];
for (let i = 0; i < numFrames; i++) {
const texturePath = getProperty(datablock, `texture${i}`);
if (texturePath) {
textures.push(texturePath);
}
}
// ForceFieldBare uses "scale" as box dimensions, not as a transform scale.
const dimensions = rawScale ?? [1, 1, 1];
return {
...posBase,
scale: undefined, // Don't apply scale as a group transform
renderType: "ForceFieldBare",
forceFieldData: {
textures,
color,
baseTranslucency,
numFrames,
framesPerSec,
scrollSpeed,
umapping,
vmapping,
dimensions,
},
};
}
/**
* Walk a TorqueObject tree and extract all GameEntities.
* Respects team assignment from SimGroup hierarchy.
*/
export function walkMissionTree(
root: TorqueObject,
runtime: TorqueRuntime,
teamId?: number,
): GameEntity[] {
const entities: GameEntity[] = [];
// Determine team from SimGroup hierarchy
let currentTeam = teamId;
if (root._className === "SimGroup") {
if (root._name?.toLowerCase() === "teams") {
currentTeam = undefined;
} else if (currentTeam === undefined && root._name) {
const match = root._name.match(/^team(\d+)$/i);
if (match) {
currentTeam = parseInt(match[1], 10);
}
}
}
// Try to build entity for this object
const entity = buildGameEntityFromMission(root, runtime, currentTeam);
if (entity) {
entities.push(entity);
}
// Recurse into children
if (root._children) {
for (const child of root._children) {
entities.push(...walkMissionTree(child, runtime, currentTeam));
}
}
return entities;
}

458
src/stream/playbackUtils.ts Normal file
View file

@ -0,0 +1,458 @@
import {
AnimationClip,
AnimationMixer,
ClampToEdgeWrapping,
Group,
LinearFilter,
Matrix4,
MeshLambertMaterial,
NoColorSpace,
Object3D,
Quaternion,
Vector3,
} from "three";
import type {
BufferGeometry,
Material,
MeshStandardMaterial,
Texture,
} from "three";
import {
createMaterialFromFlags,
applyShapeShaderModifications,
} from "../components/GenericShape";
import { isOrganicShape } from "../components/ShapeInfoProvider";
import {
loadIflAtlas,
getFrameIndexForTime,
updateAtlasFrame,
} from "../components/useIflTexture";
import { getHullBoneIndices, filterGeometryByVertexGroups } from "../meshUtils";
import { loadTexture, setupTexture } from "../textureUtils";
import { textureToUrl } from "../loaders";
import type { Keyframe } from "./types";
/** Fallback eye height when the player model isn't loaded or has no Cam node. */
export const DEFAULT_EYE_HEIGHT = 2.1;
/** Torque's animation crossfade duration (seconds). */
export const ANIM_TRANSITION_TIME = 0.25;
export const STREAM_TICK_MS = 32;
export const STREAM_TICK_SEC = STREAM_TICK_MS / 1000;
// ── Temp vectors / quaternions (module-level to avoid per-frame alloc) ──
const _tracerOrientI = new Vector3();
const _tracerOrientK = new Vector3();
const _tracerOrientMat = new Matrix4();
const _upY = new Vector3(0, 1, 0);
/** ShapeRenderer's 90° Y rotation and its inverse, used for mount transforms. */
export const _r90 = new Quaternion().setFromAxisAngle(
new Vector3(0, 1, 0),
Math.PI / 2,
);
export const _r90inv = _r90.clone().invert();
// ── Pure functions ──
/**
* Torque/Tribes stores camera FOV as horizontal degrees, while Three.js
* PerspectiveCamera.fov expects vertical degrees.
*/
export function torqueHorizontalFovToThreeVerticalFov(
torqueFovDeg: number,
aspect: number,
): number {
const safeAspect = Number.isFinite(aspect) && aspect > 0.000001 ? aspect : 4 / 3;
const clampedFov = Math.max(0.01, Math.min(179.99, torqueFovDeg));
const hRad = (clampedFov * Math.PI) / 180;
const vRad = 2 * Math.atan(Math.tan(hRad / 2) / safeAspect);
return (vRad * 180) / Math.PI;
}
export function setupEffectTexture(tex: Texture): void {
tex.wrapS = ClampToEdgeWrapping;
tex.wrapT = ClampToEdgeWrapping;
tex.minFilter = LinearFilter;
tex.magFilter = LinearFilter;
tex.colorSpace = NoColorSpace;
tex.flipY = false;
tex.needsUpdate = true;
}
export function torqueVecToThree(
v: [number, number, number],
out: Vector3,
): Vector3 {
return out.set(v[1], v[2], v[0]);
}
export function setQuaternionFromDir(dir: Vector3, out: Quaternion): void {
// Equivalent to MathUtils::createOrientFromDir in Torque:
// column1 = direction, with Torque up-vector converted to Three up-vector.
_tracerOrientI.crossVectors(dir, _upY);
if (_tracerOrientI.lengthSq() < 1e-8) {
_tracerOrientI.set(-1, 0, 0);
}
_tracerOrientI.normalize();
_tracerOrientK.crossVectors(_tracerOrientI, dir).normalize();
_tracerOrientMat.set(
_tracerOrientI.x,
dir.x,
_tracerOrientK.x,
0,
_tracerOrientI.y,
dir.y,
_tracerOrientK.y,
0,
_tracerOrientI.z,
dir.z,
_tracerOrientK.z,
0,
0,
0,
0,
1,
);
out.setFromRotationMatrix(_tracerOrientMat);
}
/** Binary search for the keyframe at or before the given time. */
export function getKeyframeAtTime(
keyframes: Keyframe[],
time: number,
): Keyframe | null {
if (keyframes.length === 0) return null;
if (time <= keyframes[0].time) return keyframes[0];
if (time >= keyframes[keyframes.length - 1].time)
return keyframes[keyframes.length - 1];
let lo = 0;
let hi = keyframes.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (keyframes[mid].time <= time) lo = mid;
else hi = mid;
}
return keyframes[lo];
}
/**
* Clone a shape scene, apply the "Root" idle animation at t=0, and return the
* world-space transform of the named node. This evaluates the skeleton at its
* idle pose rather than using the collapsed bind pose.
*/
export function getPosedNodeTransform(
scene: Group,
animations: AnimationClip[],
nodeName: string,
overrideClipNames?: string[],
): { position: Vector3; quaternion: Quaternion } | null {
const clone = scene.clone(true);
const rootClip = animations.find((a) => a.name === "Root");
if (rootClip) {
const mixer = new AnimationMixer(clone);
mixer.clipAction(rootClip).play();
// Play override clips (e.g. arm pose) which replace bone transforms
// on the bones they animate, at clip midpoint (neutral pose).
if (overrideClipNames) {
for (const name of overrideClipNames) {
const clip = animations.find(
(a) => a.name.toLowerCase() === name.toLowerCase(),
);
if (clip) {
const action = mixer.clipAction(clip);
action.time = clip.duration / 2;
action.setEffectiveTimeScale(0);
action.play();
}
}
}
mixer.setTime(0);
}
clone.updateMatrixWorld(true);
let position: Vector3 | null = null;
let quaternion: Quaternion | null = null;
clone.traverse((n) => {
if (!position && n.name === nodeName) {
position = new Vector3();
quaternion = new Quaternion();
n.getWorldPosition(position);
n.getWorldQuaternion(quaternion);
}
});
if (!position || !quaternion) return null;
return { position, quaternion };
}
/**
* Smooth vertex normals across co-located split vertices (same position, different
* UVs). Matches the technique used by ShapeModel for consistent lighting.
*/
export function smoothVertexNormals(geometry: BufferGeometry): void {
geometry.computeVertexNormals();
const posAttr = geometry.attributes.position;
const normAttr = geometry.attributes.normal;
if (!posAttr || !normAttr) return;
const positions = posAttr.array as Float32Array;
const normals = normAttr.array as Float32Array;
// Build map of position -> vertex indices at that position.
const positionMap = new Map<string, number[]>();
for (let i = 0; i < posAttr.count; i++) {
const key = `${positions[i * 3].toFixed(4)},${positions[i * 3 + 1].toFixed(4)},${positions[i * 3 + 2].toFixed(4)}`;
if (!positionMap.has(key)) {
positionMap.set(key, []);
}
positionMap.get(key)!.push(i);
}
// Average normals for vertices at the same position.
for (const indices of positionMap.values()) {
if (indices.length > 1) {
let nx = 0,
ny = 0,
nz = 0;
for (const idx of indices) {
nx += normals[idx * 3];
ny += normals[idx * 3 + 1];
nz += normals[idx * 3 + 2];
}
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (len > 0) {
nx /= len;
ny /= len;
nz /= len;
}
for (const idx of indices) {
normals[idx * 3] = nx;
normals[idx * 3 + 1] = ny;
normals[idx * 3 + 2] = nz;
}
}
}
normAttr.needsUpdate = true;
}
export interface ShapeMaterialResult {
material: Material;
/** Back-face material for organic/translucent two-pass rendering. */
backMaterial?: Material;
/** For IFL materials: loads atlas, configures texture, sets up animation. */
initialize?: (mesh: Object3D, getTime: () => number) => Promise<() => void>;
}
/**
* Replace a PBR MeshStandardMaterial with a diffuse-only Lambert/Basic material
* matching the Tribes 2 material pipeline. Textures are loaded asynchronously
* from URLs (GLB files don't embed texture data; they store a resource_path in
* material userData instead).
*/
export function replaceWithShapeMaterial(
mat: MeshStandardMaterial,
vis: number,
isOrganic = false,
): ShapeMaterialResult {
const resourcePath: string | undefined = mat.userData?.resource_path;
const flagNames = new Set<string>(mat.userData?.flag_names ?? []);
if (!resourcePath) {
// No texture path — plain Lambert fallback with fog/lighting shaders.
const fallback = new MeshLambertMaterial({
color: mat.color,
side: 2, // DoubleSide
reflectivity: 0,
});
applyShapeShaderModifications(fallback);
return { material: fallback };
}
// IFL materials need async atlas loading — create with null map to avoid
// "Resource not found" warnings from textureToUrl, and return an initializer
// that loads the atlas and sets up per-frame animation.
if (flagNames.has("IflMaterial")) {
const result = createMaterialFromFlags(
mat, null, flagNames, isOrganic, vis,
);
if (Array.isArray(result)) {
const material = result[1];
return {
material,
backMaterial: result[0],
initialize: (mesh, getTime) =>
initializeIflMaterial(material, resourcePath, mesh, getTime),
};
}
return {
material: result,
initialize: (mesh, getTime) =>
initializeIflMaterial(result, resourcePath, mesh, getTime),
};
}
// Load texture via ImageBitmapLoader (decodes off main thread). The returned
// Texture is empty initially and gets populated when the image arrives;
// Three.js re-renders automatically once loaded.
const url = textureToUrl(resourcePath);
const texture = loadTexture(url);
const isTranslucent = flagNames.has("Translucent");
if (isOrganic || isTranslucent) {
setupTexture(texture, { disableMipmaps: true });
} else {
setupTexture(texture);
}
const result = createMaterialFromFlags(
mat, texture, flagNames, isOrganic, vis,
);
if (Array.isArray(result)) {
return { material: result[1], backMaterial: result[0] };
}
return { material: result };
}
export interface IflInitializer {
mesh: Object3D;
initialize: (mesh: Object3D, getTime: () => number) => Promise<() => void>;
}
async function initializeIflMaterial(
material: Material,
resourcePath: string,
mesh: Object3D,
getTime: () => number,
): Promise<() => void> {
const iflPath = `textures/${resourcePath}.ifl`;
const atlas = await loadIflAtlas(iflPath);
(material as any).map = atlas.texture;
material.needsUpdate = true;
let disposed = false;
const prevOnBeforeRender = mesh.onBeforeRender;
mesh.onBeforeRender = function (this: any, ...args: any[]) {
prevOnBeforeRender?.apply(this, args);
if (disposed) return;
updateAtlasFrame(atlas, getFrameIndexForTime(atlas, getTime()));
};
return () => {
disposed = true;
mesh.onBeforeRender = prevOnBeforeRender ?? (() => {});
};
}
/**
* Post-process a cloned shape scene: hide collision/hull geometry, smooth
* normals, and replace PBR materials with diffuse-only Lambert materials.
* Returns IFL initializers for any IFL materials found.
*/
export function processShapeScene(
scene: Object3D,
shapeName?: string,
): IflInitializer[] {
const iflInitializers: IflInitializer[] = [];
const isOrganic = shapeName ? isOrganicShape(shapeName) : false;
// Find skeleton for hull bone filtering.
let skeleton: any = null;
scene.traverse((n: any) => {
if (!skeleton && n.skeleton) skeleton = n.skeleton;
});
const hullBoneIndices = skeleton
? getHullBoneIndices(skeleton)
: new Set<number>();
// Collect back-face meshes to add after traversal (can't modify during traverse).
const backFaceMeshes: Array<{ parent: Object3D; mesh: any }> = [];
scene.traverse((node: any) => {
if (!node.isMesh) return;
// Hide unwanted nodes: hull geometry, unassigned materials.
if (node.name.match(/^Hulk/i) || node.material?.name === "Unassigned") {
node.visible = false;
return;
}
// Hide vis-animated meshes (default vis < 0.01) but DON'T skip material
// replacement — they need correct textures for when they become visible
// (e.g. disc launcher's Disc mesh toggles visibility via state machine).
const hasVisSequence = !!node.userData?.vis_sequence;
if ((node.userData?.vis ?? 1) < 0.01) {
node.visible = false;
}
// Filter hull-influenced triangles and smooth normals.
if (node.geometry) {
let geometry = filterGeometryByVertexGroups(
node.geometry,
hullBoneIndices,
);
geometry = geometry.clone();
smoothVertexNormals(geometry);
node.geometry = geometry;
}
// Replace PBR materials with diffuse-only Lambert materials.
// For vis-animated meshes, use vis=1 so the material is fully opaque —
// their visibility is toggled via node.visible, not material opacity.
const vis: number = hasVisSequence ? 1 : (node.userData?.vis ?? 1);
if (Array.isArray(node.material)) {
node.material = node.material.map((m: MeshStandardMaterial) => {
const result = replaceWithShapeMaterial(m, vis, isOrganic);
if (result.initialize) {
iflInitializers.push({ mesh: node, initialize: result.initialize });
}
if (result.backMaterial && node.parent) {
const backMesh = node.clone();
backMesh.material = result.backMaterial;
backFaceMeshes.push({ parent: node.parent, mesh: backMesh });
}
return result.material;
});
} else if (node.material) {
const result = replaceWithShapeMaterial(node.material, vis, isOrganic);
if (result.initialize) {
iflInitializers.push({ mesh: node, initialize: result.initialize });
}
node.material = result.material;
if (result.backMaterial && node.parent) {
const backMesh = node.clone();
backMesh.material = result.backMaterial;
backFaceMeshes.push({ parent: node.parent, mesh: backMesh });
}
}
});
// Add back-face meshes for two-pass organic/translucent rendering.
for (const { parent, mesh } of backFaceMeshes) {
parent.add(mesh);
}
return iflInitializers;
}
export function entityTypeColor(type: string): string {
switch (type.toLowerCase()) {
case "player":
return "#00ff88";
case "vehicle":
return "#ff8800";
case "projectile":
return "#ff0044";
case "deployable":
return "#ffcc00";
default:
return "#8888ff";
}
}

View file

@ -0,0 +1,70 @@
/**
* Movement animation selection logic replicating Torque's
* Player::pickActionAnimation() (player.cc:2280).
*/
/** Torque falling threshold: Z velocity below this = falling. */
const FALLING_THRESHOLD = -10;
/** Minimum velocity dot product to count as intentional movement. */
const MOVE_THRESHOLD = 0.1;
export interface MoveAnimationResult {
/** Engine alias name (e.g. "root", "run", "back", "side", "fall"). */
animation: string;
/** 1 for forward playback, -1 for reversed (right strafe). */
timeScale: number;
}
/**
* Extract body yaw (Torque rotationZ) from a Three.js quaternion produced by
* `playerYawToQuaternion()`. That function builds a Y-axis rotation:
* qy = sin(-rotZ / 2), qw = cos(-rotZ / 2)
* So: rotZ = -2 * atan2(qy, qw)
*/
function quaternionToBodyYaw(q: [number, number, number, number]): number {
return -2 * Math.atan2(q[1], q[3]);
}
/**
* Pick the movement animation for a player based on their velocity and
* body orientation, matching Torque's pickActionAnimation().
*
* @param velocity Torque world-space velocity [x, y, z], or undefined for idle.
* @param rotation Three.js quaternion from playerYawToQuaternion().
*/
export function pickMoveAnimation(
velocity: [number, number, number] | undefined,
rotation: [number, number, number, number],
): MoveAnimationResult {
if (!velocity) {
return { animation: "root", timeScale: 1 };
}
const [vx, vy, vz] = velocity;
// Falling: Torque Z velocity below threshold.
if (vz < FALLING_THRESHOLD) {
return { animation: "fall", timeScale: 1 };
}
// Convert world velocity to player object space using body yaw.
const yaw = quaternionToBodyYaw(rotation);
const cosY = Math.cos(yaw);
const sinY = Math.sin(yaw);
// Torque object space: localY = forward, localX = right.
const localX = vx * cosY + vy * sinY;
const localY = -vx * sinY + vy * cosY;
// Pick direction with largest dot product.
const forwardDot = localY;
const backDot = -localY;
const leftDot = -localX;
const rightDot = localX;
const maxDot = Math.max(forwardDot, backDot, leftDot, rightDot);
if (maxDot < MOVE_THRESHOLD) {
return { animation: "root", timeScale: 1 };
}
if (maxDot === forwardDot) {
return { animation: "run", timeScale: 1 };
}
if (maxDot === backDot) {
return { animation: "back", timeScale: 1 };
}
if (maxDot === leftDot) {
return { animation: "side", timeScale: 1 };
}
// Right strafe: same Side animation, reversed.
return { animation: "side", timeScale: -1 };
}

197
src/stream/relayClient.ts Normal file
View file

@ -0,0 +1,197 @@
import type {
ClientMessage,
ClientMove,
ServerMessage,
ServerInfo,
ConnectionStatus,
} from "../../relay/types";
export type RelayEventHandler = {
onOpen?: () => void;
onStatus?: (status: ConnectionStatus, message?: string, connectSequence?: number, mapName?: string) => void;
onServerList?: (servers: ServerInfo[]) => void;
onGamePacket?: (data: Uint8Array) => void;
/** Relay↔T2 server RTT. */
onPing?: (ms: number) => void;
/** Browser↔relay WebSocket RTT. */
onWsPing?: (ms: number) => void;
onError?: (message: string) => void;
onClose?: () => void;
};
/**
* WebSocket client that connects to the relay server.
* Handles JSON control messages and binary game packet forwarding.
*/
export class RelayClient {
private ws: WebSocket | null = null;
private handlers: RelayEventHandler;
private url: string;
private _connected = false;
private wsPingInterval: ReturnType<typeof setInterval> | null = null;
private smoothedWsPing = 0;
constructor(url: string, handlers: RelayEventHandler) {
this.url = url;
this.handlers = handlers;
}
get connected(): boolean {
return this._connected;
}
connect(): void {
this.ws = new WebSocket(this.url);
this.ws.binaryType = "arraybuffer";
this.ws.onopen = () => {
console.log("[relay] WebSocket connected to", this.url);
this._connected = true;
this.startWsPing();
this.handlers.onOpen?.();
};
this.ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
// Binary message — game packet from server
this.handlers.onGamePacket?.(new Uint8Array(event.data));
} else {
// JSON control message
try {
const message: ServerMessage = JSON.parse(event.data as string);
this.handleMessage(message);
} catch (e) {
console.error("Failed to parse relay message:", e);
}
}
};
this.ws.onclose = () => {
console.log("[relay] WebSocket disconnected");
this._connected = false;
this.stopWsPing();
this.handlers.onClose?.();
};
this.ws.onerror = () => {
console.error("[relay] WebSocket error");
this.handlers.onError?.("WebSocket connection error");
};
}
private handleMessage(message: ServerMessage): void {
switch (message.type) {
case "serverList":
this.handlers.onServerList?.(message.servers);
break;
case "status":
this.handlers.onStatus?.(message.status, message.message, message.connectSequence, message.mapName);
break;
case "ping":
this.handlers.onPing?.(message.ms);
break;
case "wsPong": {
const rtt = Date.now() - message.ts;
this.smoothedWsPing =
this.smoothedWsPing === 0
? rtt
: this.smoothedWsPing * 0.5 + rtt * 0.5;
this.handlers.onWsPing?.(Math.round(this.smoothedWsPing));
break;
}
case "error":
this.handlers.onError?.(message.message);
break;
}
}
/** Request the server list from the master server. */
listServers(): void {
this.send({ type: "listServers" });
}
/** Send a WebSocket ping to measure browser↔relay RTT. */
sendWsPing(): void {
this.send({ type: "wsPing", ts: Date.now() });
}
/** Join a specific game server. */
joinServer(address: string): void {
console.log("[relay] Joining server:", address);
this.send({ type: "joinServer", address });
}
/** Disconnect from the current game server. */
disconnectServer(): void {
this.send({ type: "disconnect" });
}
/** Forward a T2csri auth event to the relay. */
sendAuthEvent(command: string, args: string[]): void {
this.send({ type: "sendCommand", command, args });
}
/** Send a commandToServer through the relay. */
sendCommand(command: string, args: string[]): void {
this.send({ type: "sendCommand", command, args });
}
/** Send a CRC challenge response through the relay (legacy echo). */
sendCRCResponse(crcValue: number, field1: number, field2: number): void {
this.send({ type: "sendCRCResponse", crcValue, field1, field2 });
}
/** Send datablock info for relay-side CRC computation over game files. */
sendCRCCompute(
seed: number,
field2: number,
datablocks: { objectId: number; className: string; shapeName: string }[],
includeTextures: boolean,
): void {
this.send({ type: "sendCRCCompute", seed, field2, includeTextures, datablocks });
}
/** Send a GhostAlwaysDone acknowledgment through the relay. */
sendGhostAck(sequence: number, ghostCount: number): void {
this.send({ type: "sendGhostAck", sequence, ghostCount });
}
/** Send a move struct to the relay for forwarding to the game server. */
sendMove(move: ClientMove): void {
this.send({ type: "sendMove", move });
}
/** Close the WebSocket connection entirely. */
close(): void {
this.stopWsPing();
if (this.ws) {
this.ws.close();
this.ws = null;
}
this._connected = false;
}
private startWsPing(): void {
this.smoothedWsPing = 0;
// Send immediately so we have a measurement before the server list arrives.
this.send({ type: "wsPing", ts: Date.now() });
this.wsPingInterval = setInterval(() => {
this.send({ type: "wsPing", ts: Date.now() });
}, 7000);
}
private stopWsPing(): void {
if (this.wsPingInterval != null) {
clearInterval(this.wsPingInterval);
this.wsPingInterval = null;
}
}
private send(message: ClientMessage): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
console.warn("[relay] send dropped (ws not open):", message.type);
}
}
}

468
src/stream/streamHelpers.ts Normal file
View file

@ -0,0 +1,468 @@
import { Matrix4, Quaternion } from "three";
import type {
StreamVisual,
WeaponImageDataBlockState,
ChatSegment,
} from "./types";
import { projectileClassNames } from "./entityClassification";
export type Vec3 = { x: number; y: number; z: number };
// ── Math helpers ──
const _rotMat = new Matrix4();
const _rotQuat = new Quaternion();
export function clamp(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
export const MAX_PITCH = Math.PI * 0.494;
export const CameraMode_OrbitObject = 3;
/**
* Build a Three.js quaternion from Torque observer yaw/pitch angles.
* Uses a shared Matrix4/Quaternion to avoid per-frame allocations.
*/
export function yawPitchToQuaternion(
yaw: number,
pitch: number,
): [number, number, number, number] {
const sx = Math.sin(pitch);
const cx = Math.cos(pitch);
const sz = Math.sin(yaw);
const cz = Math.cos(yaw);
_rotMat.set(
-sz, cz * sx, -cz * cx, 0,
0, cx, sx, 0,
cz, sz * sx, -sz * cx, 0,
0, 0, 0, 1,
);
_rotQuat.setFromRotationMatrix(_rotMat);
return [_rotQuat.x, _rotQuat.y, _rotQuat.z, _rotQuat.w];
}
/** Player body rotation: yaw only, around Three.js Y axis. */
export function playerYawToQuaternion(
rotZ: number,
): [number, number, number, number] {
const halfAngle = -rotZ / 2;
return [0, Math.sin(halfAngle), 0, Math.cos(halfAngle)];
}
/** Convert a Torque quaternion (x-right, y-forward, z-up) to Three.js. */
export function torqueQuatToThreeJS(q: {
x: number;
y: number;
z: number;
w: number;
}): [number, number, number, number] | null {
if (
!Number.isFinite(q.x) ||
!Number.isFinite(q.y) ||
!Number.isFinite(q.z) ||
!Number.isFinite(q.w)
) {
return null;
}
// Axis swizzle (x,y,z)->(y,z,x) and inverted rotation direction.
const x = -q.y;
const y = -q.z;
const z = -q.x;
const w = q.w;
const lenSq = x * x + y * y + z * z + w * w;
if (lenSq <= 1e-12) return null;
const invLen = 1 / Math.sqrt(lenSq);
return [x * invLen, y * invLen, z * invLen, w * invLen];
}
// ── Position / type guards ──
export function isValidPosition(
pos: { x: number; y: number; z: number } | undefined | null,
): pos is { x: number; y: number; z: number } {
return (
pos != null &&
Number.isFinite(pos.x) &&
Number.isFinite(pos.y) &&
Number.isFinite(pos.z)
);
}
export function isVec3Like(
value: unknown,
): value is { x: number; y: number; z: number } {
return (
!!value &&
typeof value === "object" &&
typeof (value as { x?: unknown }).x === "number" &&
typeof (value as { y?: unknown }).y === "number" &&
typeof (value as { z?: unknown }).z === "number"
);
}
export function isQuatLike(value: unknown): value is {
x: number;
y: number;
z: number;
w: number;
} {
return (
!!value &&
typeof value === "object" &&
typeof (value as { x?: unknown }).x === "number" &&
typeof (value as { y?: unknown }).y === "number" &&
typeof (value as { z?: unknown }).z === "number" &&
typeof (value as { w?: unknown }).w === "number"
);
}
// ── DataBlock field accessors ──
/**
* Resolve the DTS shape path from a datablock's parsed data.
* Accepts either a ghost className (e.g. "LinearProjectile") or a datablock
* className (e.g. "LinearProjectileData") to determine which field holds the
* shape path.
*/
export function resolveShapeName(
className: string,
data: Record<string, unknown> | undefined,
): string | undefined {
if (!data) return undefined;
let value: unknown;
if (
projectileClassNames.has(className) ||
className.endsWith("ProjectileData")
) {
value = data.projectileShapeName;
} else if (className === "DebrisData") {
value = data.shapeFileName;
} else {
value = data.shapeName;
}
return typeof value === "string" && value.length > 0 ? value : undefined;
}
export function getNumberField(
data: Record<string, unknown> | undefined,
keys: readonly string[],
): number | undefined {
if (!data) return undefined;
for (const key of keys) {
const value = data[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return undefined;
}
export function getStringField(
data: Record<string, unknown> | undefined,
keys: readonly string[],
): string | undefined {
if (!data) return undefined;
for (const key of keys) {
const value = data[key];
if (typeof value === "string" && value.length > 0) return value;
}
return undefined;
}
export function getBooleanField(
data: Record<string, unknown> | undefined,
keys: readonly string[],
): boolean | undefined {
if (!data) return undefined;
for (const key of keys) {
const value = data[key];
if (typeof value === "boolean") return value;
}
return undefined;
}
// ── Visual resolution ──
export function resolveTracerVisual(
className: string,
data: Record<string, unknown> | undefined,
): StreamVisual | undefined {
if (!data) return undefined;
const texture =
getStringField(data, ["tracerTex0", "textureName0", "texture0"]) ?? "";
const hasTracerHints =
className === "TracerProjectile" ||
(texture.length > 0 && getNumberField(data, ["tracerLength"]) != null);
if (!hasTracerHints || !texture) return undefined;
const crossTexture = getStringField(data, [
"tracerTex1",
"textureName1",
"texture1",
]);
const tracerLength = getNumberField(data, ["tracerLength"]) ?? 10;
const canonicalTracerWidth = getNumberField(data, ["tracerWidth"]);
const aliasTracerWidth = getNumberField(data, ["tracerAlpha"]);
const tracerWidth =
canonicalTracerWidth != null &&
(getNumberField(data, ["crossViewAng"]) != null ||
canonicalTracerWidth <= 0.7)
? canonicalTracerWidth
: (aliasTracerWidth ?? canonicalTracerWidth ?? 0.5);
const crossViewAng =
getNumberField(data, ["crossViewAng", "crossViewFraction"]) ??
(typeof data.tracerWidth === "number" && data.tracerWidth > 0.7
? data.tracerWidth
: 0.98);
const crossSize =
getNumberField(data, ["crossSize", "muzzleVelocity"]) ?? 0.45;
const renderCross =
getBooleanField(data, ["renderCross", "proximityRadius"]) ?? true;
return {
kind: "tracer",
texture,
crossTexture,
tracerLength,
tracerWidth,
crossViewAng,
crossSize,
renderCross,
};
}
export function resolveSpriteVisual(
className: string,
data: Record<string, unknown> | undefined,
): StreamVisual | undefined {
if (!data) return undefined;
if (className === "LinearFlareProjectile") {
const texture = getStringField(data, ["smokeTexture", "flareTexture"]);
if (!texture) return undefined;
const color = data.flareColor as
| { r: number; g: number; b: number }
| undefined;
const size = getNumberField(data, ["size"]) ?? 0.5;
return {
kind: "sprite",
texture,
color: color
? { r: color.r, g: color.g, b: color.b }
: { r: 1, g: 1, b: 1 },
size,
};
}
if (className === "FlareProjectile") {
const texture = getStringField(data, ["flareTexture"]);
if (!texture) return undefined;
const size = getNumberField(data, ["size"]) ?? 4.0;
return {
kind: "sprite",
texture,
color: { r: 1, g: 0.9, b: 0.5 },
size,
};
}
return undefined;
}
// ── Weapon image state parsing ──
/**
* Parse weapon image state machine from a ShapeBaseImageData datablock.
*
* CRITICAL: The parser's field names for transitions are MISALIGNED with
* the actual engine packing order. See demoStreaming.ts for details on the
* remap table.
*/
export function parseWeaponImageStates(
blockData: Record<string, unknown>,
): WeaponImageDataBlockState[] | undefined {
const rawStates = blockData.states as
| Array<Record<string, unknown>>
| undefined;
if (!Array.isArray(rawStates) || rawStates.length === 0) return undefined;
return rawStates.map((s) => {
const remap = (v: unknown): number => {
const n = v as number;
if (n == null) return -1;
return n - 1;
};
return {
name: (s.name as string) ?? "",
transitionOnNotLoaded: remap(s.transitionOnAmmo),
transitionOnLoaded: remap(s.transitionOnNoAmmo),
transitionOnNoAmmo: remap(s.transitionOnTarget),
transitionOnAmmo: remap(s.transitionOnNoTarget),
transitionOnNoTarget: remap(s.transitionOnWet),
transitionOnTarget: remap(s.transitionOnNotWet),
transitionOnNotWet: remap(s.transitionOnTriggerUp),
transitionOnWet: remap(s.transitionOnTriggerDown),
transitionOnTriggerUp: remap(s.transitionOnTimeout),
transitionOnTriggerDown: remap(s.transitionGeneric0In),
transitionOnTimeout: remap(s.transitionGeneric0Out),
timeoutValue: s.timeoutValue as number | undefined,
waitForTimeout: (s.waitForTimeout as boolean) ?? false,
fire: (s.fire as boolean) ?? false,
sequence: s.sequence as number | undefined,
spin: (s.spin as number) ?? 0,
direction: (s.direction as boolean) ?? true,
scaleAnimation: (s.scaleAnimation as boolean) ?? false,
loaded: (s.loaded as number) ?? 0,
soundDataBlockId: (s.sound as number) ?? -1,
};
});
}
// ── Chat / text helpers ──
/** Strip non-printable Torque tagged string markup from a string. */
export function stripTaggedStringMarkup(s: string): string {
let stripped = "";
for (let i = 0; i < s.length; i++) {
if (s.charCodeAt(i) >= 0x20) stripped += s[i];
}
return stripped;
}
/**
* Byte-to-fontColors-index remap table from the Torque V12 renderer (dgl.cc).
*
* TorqueScript `\cN` escapes are encoded via `collapseRemap` in scan.l,
* producing byte values that skip \t (0x9), \n (0xa), and \r (0xd).
*/
const BYTE_TO_COLOR_INDEX: Record<number, number> = {
0x2: 0, 0x3: 1, 0x4: 2, 0x5: 3, 0x6: 4,
0x7: 5, 0x8: 6, 0xb: 7, 0xc: 8, 0xe: 9,
};
const BYTE_COLOR_RESET = 0x0f;
const BYTE_COLOR_PUSH = 0x10;
const BYTE_COLOR_POP = 0x11;
/**
* Extract the leading Torque \c color index (09) from a tagged string.
*/
export function detectColorCode(s: string): number | undefined {
for (let i = 0; i < s.length; i++) {
const code = s.charCodeAt(i);
const colorIndex = BYTE_TO_COLOR_INDEX[code];
if (colorIndex !== undefined) return colorIndex;
if (code >= 0x20) return undefined;
}
return undefined;
}
/** Parse a raw Torque HudMessageVector line into colored segments. */
export function parseColorSegments(raw: string): ChatSegment[] {
const segments: ChatSegment[] = [];
let currentColor = 0;
let currentText = "";
let inTaggedString = false;
for (let i = 0; i < raw.length; i++) {
const code = raw.charCodeAt(i);
if (code === BYTE_COLOR_PUSH) {
inTaggedString = true;
continue;
}
if (code === BYTE_COLOR_POP) {
inTaggedString = false;
continue;
}
if (inTaggedString) {
if (code >= 0x20) currentText += raw[i];
continue;
}
const colorIndex = BYTE_TO_COLOR_INDEX[code];
if (colorIndex !== undefined) {
if (currentText) {
segments.push({ text: currentText, colorCode: currentColor });
currentText = "";
}
currentColor = colorIndex;
} else if (code === BYTE_COLOR_RESET) {
if (currentText) {
segments.push({ text: currentText, colorCode: currentColor });
currentText = "";
}
currentColor = 0;
} else if (code >= 0x20) {
currentText += raw[i];
}
}
if (currentText) {
segments.push({ text: currentText, colorCode: currentColor });
}
return segments;
}
/** Extract an embedded `~w<path>` sound tag from a message string. */
export function extractWavTag(
text: string,
): { text: string; wavPath: string | null } {
const idx = text.indexOf("~w");
if (idx === -1) return { text, wavPath: null };
return {
text: text.substring(0, idx),
wavPath: text.substring(idx + 2),
};
}
// ── Control object detection ──
export type ControlObjectType = "camera" | "player";
export function detectControlObjectType(
data: Record<string, unknown> | undefined,
): ControlObjectType | null {
if (!data) return null;
if (typeof data.cameraMode === "number") return "camera";
if (typeof data.rotationZ === "number") return "player";
return null;
}
// ── Backpack HUD ──
const BACKPACK_BITMAP_TO_INDEX = new Map<string, number>([
["gui/hud_new_packammo", 0],
["gui/hud_new_packcloak", 1],
["gui/hud_new_packenergy", 2],
["gui/hud_new_packrepair", 3],
["gui/hud_new_packsatchel", 4],
["gui/hud_new_packshield", 5],
["gui/hud_new_packinventory", 6],
["gui/hud_new_packmotionsens", 7],
["gui/hud_new_packradar", 8],
["gui/hud_new_packturretout", 9],
["gui/hud_new_packturretin", 10],
["gui/hud_new_packsensjam", 11],
["gui/hud_new_packturret", 12],
["gui/hud_satchel_unarmed", 18],
]);
export function backpackBitmapToIndex(bitmap: string): number {
const lower = bitmap.toLowerCase();
for (const [key, val] of BACKPACK_BITMAP_TO_INDEX) {
if (key === lower) return val;
}
return -1;
}

288
src/stream/types.ts Normal file
View file

@ -0,0 +1,288 @@
import type { SceneObject } from "../scene/types";
/** DTS animation thread state from ghost ThreadMask data. */
export interface ThreadState {
index: number;
sequence: number;
state: number;
forward: boolean;
atEnd: boolean;
}
export interface WeaponImageState {
dataBlockId: number;
triggerDown: boolean;
ammo: boolean;
loaded: boolean;
target: boolean;
wet: boolean;
fireCount: number;
}
export interface WeaponImageDataBlockState {
name: string;
transitionOnLoaded: number;
transitionOnNotLoaded: number;
transitionOnAmmo: number;
transitionOnNoAmmo: number;
transitionOnTarget: number;
transitionOnNoTarget: number;
transitionOnWet: number;
transitionOnNotWet: number;
transitionOnTriggerUp: number;
transitionOnTriggerDown: number;
transitionOnTimeout: number;
timeoutValue?: number;
waitForTimeout: boolean;
fire: boolean;
sequence?: number;
spin: number;
direction: boolean;
scaleAnimation: boolean;
loaded: number;
/** AudioProfile datablock ID for the state's entry sound, or -1 if none. */
soundDataBlockId: number;
}
export interface Keyframe {
time: number;
/** Position in Torque space [x, y, z]. */
position: [number, number, number];
/** Quaternion in Three.js space [x, y, z, w]. */
rotation: [number, number, number, number];
/** Camera FOV in degrees (camera entity keyframes only). */
fov?: number;
/** Velocity in Torque world space [x, y, z]. */
velocity?: [number, number, number];
/** Normalized health (0 = dead, 1 = full). Derived from ghost damageLevel. */
health?: number;
/** Normalized energy (0 = empty, 1 = full). Derived from ghost energyPercent. */
energy?: number;
/** Torque DamageState: 0 = Enabled, 1 = Disabled (dead), 2 = Destroyed. */
damageState?: number;
/** Action animation index from ghost ActionMask (indices >= 7 are non-table
* actions like death animations). */
actionAnim?: number;
/** True when the action animation has reached its final frame. */
actionAtEnd?: boolean;
}
export interface TracerVisual {
kind: "tracer";
/** Main tracer streak texture (e.g. "special/tracer00"). */
texture: string;
/** Edge-on cross section texture (e.g. "special/tracercross"). */
crossTexture?: string;
tracerLength: number;
tracerWidth: number;
crossViewAng: number;
crossSize: number;
renderCross: boolean;
}
export interface SpriteVisual {
kind: "sprite";
/** Sprite texture (e.g. "flarebase"). */
texture: string;
/** sRGB tint color from the datablock (e.g. flareColor). */
color: { r: number; g: number; b: number };
/** Billboard size in world units. */
size: number;
}
export type StreamVisual = TracerVisual | SpriteVisual;
export interface StreamEntity {
id: string;
type: string;
dataBlock?: string;
visual?: StreamVisual;
direction?: [number, number, number];
weaponShape?: string;
playerName?: string;
/** IFF color resolved from the sensor group color table (sRGB 0-255). */
iffColor?: { r: number; g: number; b: number };
/** Target render flags bitmask from the Target Manager. */
targetRenderFlags?: number;
ghostIndex?: number;
className?: string;
dataBlockId?: number;
shapeHint?: string;
/** Position in Torque space [x, y, z]. */
position?: [number, number, number];
/** Quaternion in Three.js space [x, y, z, w]. */
rotation?: [number, number, number, number];
/** Velocity in Torque world space [x, y, z]. */
velocity?: [number, number, number];
health?: number;
energy?: number;
actionAnim?: number;
actionAtEnd?: boolean;
damageState?: number;
faceViewer?: boolean;
/** DTS animation thread states from ghost ThreadMask data. */
threads?: ThreadState[];
/** Numeric ID of the ExplosionData datablock (for particle effect resolution). */
explosionDataBlockId?: number;
/** Numeric ID of the ParticleEmitterData for in-flight trail particles. */
maintainEmitterId?: number;
/** Weapon image condition flags from ghost ImageMask data. */
weaponImageState?: WeaponImageState;
/** Weapon image state machine states from the ShapeBaseImageData datablock. */
weaponImageStates?: WeaponImageDataBlockState[];
/** Head pitch for blend animations, normalized [-1,1]. -1 = max down, 1 = max up. */
headPitch?: number;
/** Head yaw for blend animations (freelook), normalized [-1,1]. -1 = max right, 1 = max left. */
headYaw?: number;
/** WayPoint display label. */
label?: string;
// AudioEmitter ghost fields
audioFileName?: string;
audioVolume?: number;
audioIs3D?: boolean;
audioIsLooping?: boolean;
audioMinDistance?: number;
audioMaxDistance?: number;
audioMinLoopGap?: number;
audioMaxLoopGap?: number;
/** Scene infrastructure data (terrain, interior, sky, etc.). */
sceneData?: SceneObject;
}
export interface StreamCamera {
/** Timestamp in seconds for the current camera state. */
time: number;
/** Position in Torque space [x, y, z]. */
position: [number, number, number];
/** Quaternion in Three.js space [x, y, z, w]. */
rotation: [number, number, number, number];
fov: number;
mode: "first-person" | "third-person" | "observer";
controlEntityId?: string;
orbitTargetId?: string;
/** Orbit distance used for third-person camera positioning. */
orbitDistance?: number;
/** Absolute control-object yaw in Torque radians (rotZ/rotationZ). */
yaw?: number;
/** Absolute control-object pitch in Torque radians (rotX/headX). */
pitch?: number;
}
/** A colored text segment from inline \c color switching. */
export interface ChatSegment {
text: string;
/** Torque \c color index (09) from the GuiChatHudProfile fontColors palette. */
colorCode: number;
}
export interface ChatMessage {
timeSec: number;
sender: string;
text: string;
kind: "chat" | "server";
/**
* Torque \c color index (09) from the GuiChatHudProfile fontColors palette.
* 0=default/death, 1=join/drop, 2=gameplay/flags, 3=team chat, 4=global chat,
* 6=player name, 7=tribe tag, 8=smurf name, 9=bot name.
*/
colorCode?: number;
/** Colored text segments for inline color switching in rendered text. */
segments?: ChatSegment[];
/** Audio file path from ~w tag (e.g. "fx/misc/flag_taken.wav"). */
soundPath?: string;
/** Pitch multiplier for voice chat (default 1.0). */
soundPitch?: number;
}
export interface WeaponsHudSlot {
/** HUD slot index (017), matching the $WeaponsHudData table. */
index: number;
/** Ammo count, or -1 for infinite (energy weapons). */
ammo: number;
}
export interface TeamScore {
teamId: number;
name: string;
score: number;
playerCount: number;
}
export interface BackpackHudState {
/** Index into the $BackpackHudData table, or -1 if no pack. */
packIndex: number;
/** Whether the pack is currently activated/armed. */
active: boolean;
/** Optional text overlay (e.g. sensor pack counts). */
text: string;
}
export interface InventoryHudSlot {
/** Display slot (0=grenade, 1=mine, 2=beacon, 3=repairkit). */
slot: number;
/** Item count. */
count: number;
}
export interface PendingAudioEvent {
profileId: number;
position?: { x: number; y: number; z: number };
timeSec: number;
}
export interface StreamSnapshot {
timeSec: number;
exhausted: boolean;
camera: StreamCamera | null;
entities: StreamEntity[];
controlPlayerGhostId?: string;
/** Recording player's sensor group (team number). */
playerSensorGroup: number;
status: { health: number; energy: number };
chatMessages: ChatMessage[];
/** One-shot audio events from Sim3DAudioEvent / Sim2DAudioEvent. */
audioEvents: PendingAudioEvent[];
/** Weapons HUD state from inventory RemoteCommandEvents. */
weaponsHud: {
/** Weapon slots present in the player's inventory, in HUD index order. */
slots: WeaponsHudSlot[];
/** Currently active (selected) HUD slot index, or -1 if none. */
activeIndex: number;
};
/** Backpack/pack HUD state from RemoteCommandEvents. */
backpackHud: BackpackHudState | null;
/** Inventory HUD state (grenades, mines, beacons, repair kits). */
inventoryHud: {
slots: InventoryHudSlot[];
activeSlot: number;
};
/** Team scores aggregated from the PLAYERLIST demoValues section. */
teamScores: TeamScore[];
}
export interface StreamingPlayback {
reset(): void;
getSnapshot(): StreamSnapshot;
stepToTime(targetTimeSec: number, maxMoveTicks?: number): StreamSnapshot;
/** 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;
/**
* Get TSShapeConstructor sequence entries for a shape (e.g. "heavy_male.dts").
* Returns the raw sequence strings like `"heavy_male_root.dsq root"`.
*/
getShapeConstructorSequences(shapeName: string): string[] | undefined;
}
export interface StreamRecording {
/** "demo" for .rec file playback, "live" for live server observation. */
source: "demo" | "live";
duration: number;
/** Mission name (e.g. "S5-WoodyMyrk"). */
missionName: string | null;
/** Game type display name (e.g. "Capture the Flag"). */
gameType: string | null;
/** Streaming parser session for tick-driven playback. */
streamingPlayback: StreamingPlayback;
}

View file

@ -0,0 +1,322 @@
import type {
WeaponImageDataBlockState,
WeaponImageState,
} from "./types";
/** Transition index sentinel: -1 means "no transition defined". */
const NO_TRANSITION = -1;
/** Max transitions per tick to prevent infinite loops from misconfigured datablocks. */
const MAX_TRANSITIONS_PER_TICK = 32;
/** Torque SpinState enum values from ShapeBaseImageData (shapeBase.h). */
const SPIN_STOP = 1; // NoSpin
const SPIN_UP = 2; // SpinUp
const SPIN_DOWN = 3; // SpinDown
const SPIN_FULL = 4; // FullSpin
export interface WeaponAnimState {
/** Name of the current animation sequence to play (lowercase), or null. */
sequenceName: string | null;
/** Whether the current state is a fire state. */
isFiring: boolean;
/** Spin thread timeScale (0 = stopped, 1 = full speed). */
spinTimeScale: number;
/** Whether the animation should play in reverse. */
reverse: boolean;
/** Whether the animation timeScale should be scaled to the timeout. */
scaleAnimation: boolean;
/** The timeout value of the current state (for timeScale calculation). */
timeoutValue: number;
/** True when a state transition occurred this tick. */
transitioned: boolean;
/** AudioProfile datablock IDs for sounds that should play this tick.
* In the engine, every state entry triggers its stateSound; a single tick
* can chain through multiple states, so multiple sounds may fire. */
soundDataBlockIds: number[];
/** Index of the current state in the state machine. */
stateIndex: number;
}
/**
* Client-side weapon image state machine replicating the Torque C++ logic from
* `ShapeBase::updateImageState` / `ShapeBase::setImageState`. The server sends
* only condition flags (trigger, ammo, loaded, wet, target) and a fireCount;
* the client runs its own copy of the state machine to determine which
* animation to play.
*/
export class WeaponImageStateMachine {
private states: WeaponImageDataBlockState[];
private seqIndexToName: string[];
private currentStateIndex = 0;
private delayTime = 0;
private lastFireCount = -1;
private spinTimeScale = 0;
constructor(
states: WeaponImageDataBlockState[],
seqIndexToName: string[],
) {
this.states = states;
this.seqIndexToName = seqIndexToName;
if (states.length > 0) {
this.delayTime = states[0].timeoutValue ?? 0;
}
}
get stateIndex(): number {
return this.currentStateIndex;
}
reset(): void {
this.currentStateIndex = 0;
this.delayTime = this.states.length > 0
? (this.states[0].timeoutValue ?? 0)
: 0;
this.lastFireCount = -1;
}
/**
* Advance the state machine by `dt` seconds using the given condition flags.
* Returns the animation state to apply this frame.
*/
tick(dt: number, flags: WeaponImageState): WeaponAnimState {
if (this.states.length === 0) {
return {
sequenceName: null,
isFiring: false,
spinTimeScale: 0,
reverse: false,
scaleAnimation: false,
timeoutValue: 0,
transitioned: false,
soundDataBlockIds: [],
stateIndex: -1,
};
}
// Detect fire count changes — forces a resync to the Fire state.
// The server increments fireCount each time it fires; if our state machine
// has diverged, this brings us back in sync.
const fireCountChanged =
this.lastFireCount >= 0 && flags.fireCount !== this.lastFireCount;
this.lastFireCount = flags.fireCount;
const soundDataBlockIds: number[] = [];
if (fireCountChanged) {
const fireIdx = this.states.findIndex((s) => s.fire);
if (fireIdx >= 0 && fireIdx !== this.currentStateIndex) {
this.currentStateIndex = fireIdx;
this.delayTime = this.states[fireIdx].timeoutValue ?? 0;
// Fire count resync is a state entry — play its sound.
const fireSound = this.states[fireIdx].soundDataBlockId;
if (fireSound >= 0) soundDataBlockIds.push(fireSound);
}
}
this.delayTime -= dt;
let transitioned = fireCountChanged;
// Per-tick transition evaluation (C++ updateImageState): check conditions
// and timeout when delayTime <= 0 or waitForTimeout is false.
let nextState = this.evaluateTickTransitions(flags);
// Process transitions. Self-transitions just reset delayTime (matching
// the C++ setImageState early return path). Different-state transitions
// run full entry logic including recursive entry transitions.
let transitionsThisTick = 0;
while (nextState >= 0 && transitionsThisTick < MAX_TRANSITIONS_PER_TICK) {
transitionsThisTick++;
transitioned = true;
if (nextState === this.currentStateIndex) {
// Self-transition (C++ setImageState self-transition path):
// reset delayTime only; skip entry transitions and spin handling.
this.delayTime = this.states[nextState].timeoutValue ?? 0;
break;
}
// Transition to a different state (C++ setImageState normal path).
const lastSpin = this.states[this.currentStateIndex].spin;
const lastDelay = this.delayTime;
this.currentStateIndex = nextState;
const newTimeout = this.states[nextState].timeoutValue ?? 0;
this.delayTime = newTimeout;
// Every state entry plays its sound (C++ setImageState).
const entrySound = this.states[nextState].soundDataBlockId;
if (entrySound >= 0) soundDataBlockIds.push(entrySound);
// Spin handling on state entry (C++ setImageState spin switch).
const newSpin = this.states[nextState].spin;
switch (newSpin) {
case SPIN_STOP:
this.spinTimeScale = 0;
break;
case SPIN_FULL:
this.spinTimeScale = 1;
break;
case SPIN_UP:
// Partial ramp reversal from SpinDown: adjust delayTime so the ramp
// starts from the current barrel speed.
if (lastSpin === SPIN_DOWN && newTimeout > 0) {
this.delayTime *= 1 - lastDelay / newTimeout;
}
break;
case SPIN_DOWN:
// Partial ramp reversal from SpinUp.
if (lastSpin === SPIN_UP && newTimeout > 0) {
this.delayTime *= 1 - lastDelay / newTimeout;
}
break;
// SPIN_IGNORE (0): preserve spinTimeScale.
}
// Entry transitions: check conditions immediately (no waitForTimeout,
// no timeout). Matches C++ setImageState's recursive condition checks.
nextState = this.evaluateEntryTransitions(flags);
}
// Per-tick spin update (C++ updateImageState spin switch).
// In C++, FullSpin/NoSpin/IgnoreSpin are no-ops here (set on state entry
// in setImageState). But our fireCount resync path bypasses the transition
// loop, so we must handle FullSpin and NoSpin per-tick as a fallback.
const state = this.states[this.currentStateIndex];
const timeout = state.timeoutValue ?? 0;
switch (state.spin) {
case SPIN_STOP:
this.spinTimeScale = 0;
break;
case SPIN_UP:
this.spinTimeScale = timeout > 0
? Math.max(0, 1 - this.delayTime / timeout)
: 1;
break;
case SPIN_FULL:
this.spinTimeScale = 1;
break;
case SPIN_DOWN:
this.spinTimeScale = timeout > 0
? Math.max(0, this.delayTime / timeout)
: 0;
break;
// SPIN_IGNORE (0): leave spinTimeScale unchanged.
}
return {
sequenceName: this.resolveSequenceName(state),
isFiring: state.fire,
spinTimeScale: this.spinTimeScale,
reverse: !state.direction,
scaleAnimation: state.scaleAnimation,
timeoutValue: state.timeoutValue ?? 0,
transitioned,
soundDataBlockIds,
stateIndex: this.currentStateIndex,
};
}
/**
* Per-tick transition evaluation (C++ updateImageState).
* Respects waitForTimeout: only evaluates when delayTime has elapsed
* or the state doesn't require waiting. Includes timeout transition.
*
* V12 engine priority order: loaded, ammo, target, wet, trigger, timeout.
*/
private evaluateTickTransitions(flags: WeaponImageState): number {
const state = this.states[this.currentStateIndex];
const timedOut = this.delayTime <= 0;
const canTransition = timedOut || !state.waitForTimeout;
if (!canTransition) return -1;
const cond = this.evaluateConditions(state, flags);
if (cond !== -1) return cond;
// timeout (only when delayTime has elapsed)
if (timedOut) {
const timeoutTarget = state.transitionOnTimeout;
if (timeoutTarget !== NO_TRANSITION) {
return timeoutTarget;
}
}
return -1;
}
/**
* Entry transition evaluation (C++ setImageState).
* Fires immediately on state entry ignores waitForTimeout and does NOT
* check timeout transition.
*/
private evaluateEntryTransitions(flags: WeaponImageState): number {
const state = this.states[this.currentStateIndex];
return this.evaluateConditions(state, flags);
}
/**
* Evaluate condition-based transitions in V12 priority order:
* loaded, ammo, target, wet, trigger.
*
* Matches C++ updateImageState: no self-transition guard. If a condition
* resolves to the current state, setImageState handles it as a
* self-transition (just resets delayTime).
*/
private evaluateConditions(
state: WeaponImageDataBlockState,
flags: WeaponImageState,
): number {
// loaded
const loadedTarget = flags.loaded
? state.transitionOnLoaded
: state.transitionOnNotLoaded;
if (loadedTarget !== NO_TRANSITION) {
return loadedTarget;
}
// ammo
const ammoTarget = flags.ammo
? state.transitionOnAmmo
: state.transitionOnNoAmmo;
if (ammoTarget !== NO_TRANSITION) {
return ammoTarget;
}
// target
const targetTarget = flags.target
? state.transitionOnTarget
: state.transitionOnNoTarget;
if (targetTarget !== NO_TRANSITION) {
return targetTarget;
}
// wet
const wetTarget = flags.wet
? state.transitionOnWet
: state.transitionOnNotWet;
if (wetTarget !== NO_TRANSITION) {
return wetTarget;
}
// trigger
const triggerTarget = flags.triggerDown
? state.transitionOnTriggerDown
: state.transitionOnTriggerUp;
if (triggerTarget !== NO_TRANSITION) {
return triggerTarget;
}
return -1;
}
/** Resolve a state's sequence index to a clip name via the GLB metadata. */
private resolveSequenceName(
state: WeaponImageDataBlockState,
): string | null {
if (state.sequence == null || state.sequence < 0) return null;
const name = this.seqIndexToName[state.sequence];
return name ?? null;
}
}