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, torqueQuatHeading, torqueQuatPitch, torqueQuatToThreeJS, } from "./streamHelpers"; import type { Vec3 } from "./streamHelpers"; import type { StreamRecording, StreamSnapshot, TeamScore, PlayerRosterEntry, WeaponsHudSlot, InventoryHudSlot, BackpackHudState, } from "./types"; import { StreamEngine, type MutableEntity } from "./StreamEngine"; interface DemoMissionInfo { /** Mission display name from readplayerinfo row 2 (e.g. "S5-WoodyMyrk"). */ missionDisplayName: string | null; /** Mission type display name from readplayerinfo row 3 (e.g. "Capture the Flag"). */ missionType: string | null; /** Game class name from the SCORE header (e.g. "CTFGame"). */ gameClassName: string | null; /** Server display name from readplayerinfo row 2. */ serverDisplayName: string | null; /** Mod name from readplayerinfo row 3 (e.g. "classic"). */ mod: string | null; /** Name of the player who recorded the demo (from readplayerinfo row 1). */ recorderName: string | null; /** Recording date string from readplayerinfo row 2 (e.g. "May-4-2025 10:37PM"). */ recordingDate: string | null; } function extractMissionInfo(demoValues: string[]): DemoMissionInfo { let missionDisplayName: string | null = null; let missionType: string | null = null; let gameClassName: string | null = null; let serverDisplayName: string | null = null; let mod: string | null = null; let recorderName: string | null = null; let recorderClientId: number = NaN; let recordingDate: string | null = null; for (let i = 0; i < demoValues.length; i++) { // SCORE header: "visible\tgameClassName\tobjCount" const scoreFields = demoValues[i].split("\t"); if (scoreFields.length >= 3 && scoreFields[1]?.endsWith("Game")) { gameClassName = scoreFields[1]; } if (demoValues[i] !== "readplayerinfo") continue; const value = demoValues[i + 1]; if (!value) continue; if (value.startsWith("1\t")) { // Row 1: "1\tclientId\trecorderName\tteamName\tguid" const fields = value.split("\t"); if (fields[1]) recorderClientId = parseInt(fields[1], 10); if (fields[2]) recorderName = stripTaggedStringMarkup(fields[2]).trim(); continue; } if (value.startsWith("2\t")) { // Row 2: "2\tserverName\taddress\tdate\tmissionDisplayName" const fields = value.split("\t"); if (fields[1]) serverDisplayName = fields[1]; if (fields[3]) recordingDate = fields[3]; if (fields[4]) missionDisplayName = fields[4]; continue; } if (value.startsWith("3\t")) { // Row 3: "3\tmod\tmissionTypeDisplayName\t..." const fields = value.split("\t"); if (fields[1]) mod = fields[1]; if (fields[2]) missionType = fields[2]; } } return { missionDisplayName, missionType, gameClassName, serverDisplayName, mod, recorderName, recorderClientId: Number.isFinite(recorderClientId) ? recorderClientId : null, recordingDate, }; } interface ParsedDemoValues { weaponsHud: { slots: Map; activeIndex: number } | null; backpackHud: { packIndex: number; active: boolean; text: string } | null; inventoryHud: { slots: Map; activeSlot: number; } | null; teamScores: TeamScore[]; playerRoster: Map< number, { name: string; teamId: number; score: number; ping: number; packetLoss: number } >; chatMessages: string[]; /** Value from clockHud.getTime() — minutes passed to setTime(). */ clockTimeMin: number | null; 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: [], clockTimeMin: null, gravity: -20, }; if (!demoValues.length) return result; let idx = 0; const next = () => { const v = demoValues[idx++]; return v === "" ? "" : (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(); for (let i = 0; i < playerCount; i++) { const fields = next().split("\t"); const name = stripTaggedStringMarkup(fields[0] ?? "").trim(); const clientId = parseInt(fields[2], 10); const teamId = parseInt(fields[4], 10); const score = parseInt(fields[5], 10) || 0; const ping = parseInt(fields[6], 10) || 0; const packetLoss = parseInt(fields[7], 10) || 0; if (!isNaN(clientId) && !isNaN(teamId)) { result.playerRoster.set(clientId, { name, teamId, score, ping, packetLoss }); } 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(); 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(); 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 — "isVisible\tremainingMinutes" if (idx >= demoValues.length) return result; { const clockFields = next().split("\t"); const timeMin = parseFloat(clockFields[1] ?? ""); if (Number.isFinite(timeMin)) { result.clockTimeMin = timeMin; } } // 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 } >; initialGhosts: Array<{ index: number; type: "create" | "update" | "delete"; classId?: number; parsedData?: Record; }>; controlObjectGhostIndex: number; controlObjectData?: Record; targetEntries: Array<{ targetId: number; name?: string; sensorGroup: number; targetData: number; }>; sensorGroupColors: Array<{ group: number; targetGroup: number; r: number; g: number; b: number; }>; taggedStrings: Map; initialEvents: Array<{ classId: number; parsedData?: Record; }>; demoValues: string[]; firstPerson: boolean; }; // 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[]; playerRoster: PlayerRosterEntry[]; 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, firstPerson: initial.firstPerson, }; this.reset(); } // ── StreamEngine abstract implementations ── getDataBlockData(dataBlockId: number): Record | undefined { const initialBlock = this.initialBlock.dataBlocks.get(dataBlockId); if (initialBlock?.data) { return initialBlock.data; } const packetParser = this.parser.getPacketParser() as unknown as { dataBlockDataMap?: Map>; }; return packetParser.dataBlockDataMap?.get(dataBlockId); } private _shapeConstructorCache: Map | 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 | undefined): { yaw: number; pitch: number; } { // Move-derived angles are valid when the control object is a Player // (including when piloting a vehicle — moves still drive the camera). const hasMoves = 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).trim(), ); } 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.firstPerson = this.initialBlock.firstPerson; this.lastControlType = detectControlObjectType(this.initialBlock.controlObjectData) ?? "player"; this.isPiloting = this.lastControlType === "player" ? !!( this.initialBlock.controlObjectData?.pilot || this.initialBlock.controlObjectData?.controlObjectGhost != null ) : false; this.lastPilotGhostIndex = this.isPiloting && typeof this.initialBlock.controlObjectData?.controlObjectGhost === "number" ? this.initialBlock.controlObjectData.controlObjectGhost : undefined; if (this.isPiloting) { const nested = this.initialBlock.controlObjectData?.controlObjectData as | Record | undefined; const ang = nested?.angPosition as | { x: number; y: number; z: number; w: number } | undefined; if (ang && typeof ang.w === "number") { this.lastVehicleHeading = torqueQuatHeading(ang); this.lastVehiclePitch = torqueQuatPitch(ang); const threeQ = torqueQuatToThreeJS(ang); if (threeQ) { const [qx, qy, qz, qw] = threeQ; const fx = 1 - 2 * (qy * qy + qz * qz); const fy = 2 * (qx * qy + qz * qw); const fz = 2 * (qx * qz - qy * qw); this.lastVehicleOrbitDir = [-fx, -fy, -fz]; } } } 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, ); 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); if (parsed.clockTimeMin != null) { // Reproduce clockHud.setTime(getTime()) at demo start (timeSec=0). this.clockAnchorStreamSec = 0; this.clockDurationMs = parsed.clockTimeMin * 60 * 1000; } // 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.pushChatMessage({ timeSec: 0, sender: fullText.slice(0, colonIdx), text: fullText.slice(colonIdx + 2), kind: "chat", colorCode: primaryColor, segments, }); } else { this.pushChatMessage({ 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(); const collectShapesFromExplosion = (expBlock: Record) => { 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. // During piloting, rotationZ/headX are relative to the vehicle (reset // to 0 on mount). We still accept the reset so move deltas accumulate // from the correct base; the vehicle heading offset is added later in // updateCameraAndHud. 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)) { // InfoBlock: value1 byte 0 = $firstPerson flag, value2 = FOV. // Verified against Tribes2.exe GameConnection::handleRecordedBlock. this.firstPerson = (block.parsed.value1 & 0xff) !== 0; 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[]; let playerRoster: PlayerRosterEntry[]; if ( prev && prev.teamScoresGen === this._teamScoresGen && prev.rosterGen === this._rosterGen ) { teamScores = prev.teamScores; playerRoster = prev.playerRoster; } else { teamScores = this.teamScores.map((ts) => ({ ...ts })); const teamCounts = new Map(); 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; } playerRoster = []; for (const [clientId, entry] of this.playerRoster) { playerRoster.push({ clientId, ...entry }); } } this._snap = { teamScoresGen: this._teamScoresGen, rosterGen: this._rosterGen, teamScores, playerRoster, 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, playerRoster, connectedClientId: this.connectedClientId, matchClockMs: this.computeMatchClockMs(timeSec), }; } // ── Type guards ── private isPacketData(parsed: unknown): parsed is { gameState: { controlObjectGhostIndex?: number; controlObjectData?: Record; compressionPoint?: Vec3; cameraFov?: number; }; events: Array<{ classId: number; parsedData?: Record; }>; ghosts: Array<{ index: number; type: "create" | "update" | "delete"; classId?: number; parsedData?: Record; }>; } { 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 { value1: number; value2: number } { return ( !!parsed && typeof parsed === "object" && "value1" in parsed && typeof (parsed as { value1?: unknown }).value1 === "number" && "value2" in parsed && typeof (parsed as { value2?: unknown }).value2 === "number" ); } } export async function createDemoStreamingRecording( data: ArrayBuffer, ): Promise { const parser = new DemoParser(new Uint8Array(data)); const { header, initialBlock } = await parser.load(); const info = extractMissionInfo(initialBlock.demoValues); const playback = new StreamingPlayback(parser); // Seed StreamEngine's mission info fields from the initial block so they're // available immediately (before any server messages arrive during playback). playback.missionDisplayName = info.missionDisplayName; playback.missionTypeDisplayName = info.missionType; playback.gameClassName = info.gameClassName; playback.serverDisplayName = info.serverDisplayName; playback.connectedPlayerName = info.recorderName; playback.connectedClientId = info.recorderClientId; return { source: "demo", duration: header.demoLengthMs / 1000, missionName: initialBlock.missionName ?? null, gameType: info.missionType, serverDisplayName: info.serverDisplayName, recorderName: info.recorderName, recordingDate: info.recordingDate, streamingPlayback: playback, }; }