From 04ea8dac4154d22ba65de15b961d8d3e2bd42003 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:14:30 +1000 Subject: [PATCH 01/13] Add bounded native team-door motion diagnostics --- apps/web/tf2/src/runtime.ts | 8 ++ playwright.team-door.config.ts | 2 + tools/playsrc/profile/team-door.profile.ts | 85 ++++++++++++++++++++++ tools/playsrc/src/profile-runner.ts | 1 + 4 files changed, 96 insertions(+) create mode 100644 playwright.team-door.config.ts create mode 100644 tools/playsrc/profile/team-door.profile.ts diff --git a/apps/web/tf2/src/runtime.ts b/apps/web/tf2/src/runtime.ts index 688adce7b..4ba5da350 100644 --- a/apps/web/tf2/src/runtime.ts +++ b/apps/web/tf2/src/runtime.ts @@ -2540,6 +2540,14 @@ export class Tf2Application { }) const result = await renderer.renderModelPanels(panels) if (generation !== this.#generation || revision !== this.#teamSelectionRenderRevision) return + const doorProfile = (globalThis as any).__playsrcProfile + if (doorProfile?.captureTeamDoors === true) { + const frames = doorProfile.teamDoorFrames ??= [] + if (frames.length < 1024) frames.push({ at: performance.now(), now, milliseconds: result.milliseconds, + panels: authored.map(panel => ({ name: panel.name, animation: panel.animation, sequence: panel.sequence, + timing: this.#teamSelectionAnimations.get(panel.name), + matrices: Array.from(this.#teamSelectionPoses.get(panel.name)?.boneMatrices ?? []) })) }) + } this.#set({ teamSelectionModels: result.panels.map((panel) => `${panel.identity}:${panel.model}:${panel.skin}:${panel.primitives}`).join("|") }) })().catch((error) => { if (generation !== this.#generation || !this.#teamSelection?.state().visible) return diff --git a/playwright.team-door.config.ts b/playwright.team-door.config.ts new file mode 100644 index 000000000..39574b8db --- /dev/null +++ b/playwright.team-door.config.ts @@ -0,0 +1,2 @@ +import { headedProfileConfiguration } from "./tools/playsrc/profile/profile-config" +export default headedProfileConfiguration({ match: "team-door.profile.ts", target: "jump_beef" }) diff --git a/tools/playsrc/profile/team-door.profile.ts b/tools/playsrc/profile/team-door.profile.ts new file mode 100644 index 000000000..541b8e77c --- /dev/null +++ b/tools/playsrc/profile/team-door.profile.ts @@ -0,0 +1,85 @@ +import { readFile, writeFile } from "node:fs/promises" +import { createHash } from "node:crypto" +import path from "node:path" +import { test, expect } from "./application-test" +import { loadLocalConfig } from "../src/config" +import { startupConsoleIdle, startupNativeReader } from "./native-startup" +import { requireStartupNative } from "./static-startup-gate" +import { installBrowserFrameProfiler } from "./browser-frame-profiler" + +test("authored team doors real-time motion and reentry", async ({ page, context }) => { + const { sourceCacheDir } = await loadLocalConfig(), directory = process.env.PLAYSRC_PROFILE_RUN_DIRECTORY! + if (!directory) throw new Error("Use the checked team-door profile runner") + const native = await startupNativeReader(page, sourceCacheDir) + const admissions: unknown[] = [], captures: unknown[] = [], actions: unknown[] = [] + const cdp = await context.newCDPSession(page) + const check = async (name: string) => { + const observation = await native.read(path.join(directory, `${name}.desktop.png`)) + admissions.push(observation) + requireStartupNative(observation) + } + await page.addInitScript(installBrowserFrameProfiler) + await page.addInitScript(() => { (globalThis as any).__playsrcProfile = { captureTeamDoors: false } }) + let sampling = false, captureTask: Promise | undefined, failure: string | null = null + try { + await page.goto("/", { waitUntil: "domcontentloaded" }) + await expect(page.locator("main")).toHaveAttribute("data-phase", "MainMenu") + await check("before-load") + await page.keyboard.press("Backquote") + await page.locator("[aria-label='Console command']").fill("map jump_beef") + await page.keyboard.press("Enter") + if (await page.locator("main").getAttribute("data-console-visible") === "true") await page.keyboard.press("Backquote") + await expect(page.locator("main")).toHaveAttribute("data-team-selection-models", /reddoor:[^|]+:\d+:\d+/, { timeout: 75_000 }) + await page.waitForTimeout(2100) + if (await startupConsoleIdle(sourceCacheDir) < 2000) throw new Error("Team-door motion requires genuine native idle") + await check("before-motion") + const heapBefore = await cdp.send("Runtime.getHeapUsage") + await page.evaluate(() => { (globalThis as any).__playsrcProfile.captureTeamDoors = true; (globalThis as any).__playsrcFrameProfiler.active = true }) + const started = Date.now() + sampling = true + captureTask = (async () => { + while (sampling) { + const file = `motion-${String(captures.length).padStart(3, "0")}.page.png`, before = Date.now() + await page.screenshot({ path: path.join(directory, file) }) + const bytes = await readFile(path.join(directory, file)) + captures.push({ file, before, after: Date.now(), bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), privacy: "client-only-review-required" }) + } + })() + const hover = async (team: "red" | "blue" | "auto" | null, wait: number) => { + actions.push({ at: Date.now(), team }) + if (team) await page.locator(`.team-selection-layer [data-vgui-name='teambutton${team === "blue" ? 0 : team === "red" ? 1 : 2}']`).hover() + else await page.mouse.move(4, 4) + await page.waitForTimeout(wait) + } + for (const team of ["auto", "blue", "red"] as const) { + await hover(team, 550) + await hover(null, 650) + await check(`after-${team}`) + } + await hover("red", 70) + await hover(null, 70) + await hover("red", 650) + await hover(null, 650) + await page.waitForTimeout(Math.max(0, 6000 - (Date.now() - started))) + sampling = false + await captureTask + const activeMilliseconds = Date.now() - started + await check("after-motion") + const observation = await page.evaluate(() => { + const root = globalThis as any + root.__playsrcProfile.captureTeamDoors = false + root.__playsrcFrameProfiler.active = false + return { doors: root.__playsrcProfile.teamDoorFrames, frames: root.__playsrcFrameProfiler } + }) + await writeFile(path.join(directory, "team-door-motion.json"), JSON.stringify({ activeMilliseconds, observation, actions, captures, heapBefore, heapAfter: await cdp.send("Runtime.getHeapUsage") })) + expect(activeMilliseconds).toBeGreaterThanOrEqual(5000) + expect(activeMilliseconds).toBeLessThan(10000) + expect(captures.length).toBeGreaterThan(30) + } catch (error) { failure = String(error); throw error } + finally { + sampling = false + await captureTask?.catch(() => {}) + await writeFile(path.join(directory, "team-door-admission.json"), JSON.stringify({ admissions, records: native.records, actions, captures, failure })) + await native.close() + } +}) diff --git a/tools/playsrc/src/profile-runner.ts b/tools/playsrc/src/profile-runner.ts index c5e5462ff..25cbeaf13 100644 --- a/tools/playsrc/src/profile-runner.ts +++ b/tools/playsrc/src/profile-runner.ts @@ -121,6 +121,7 @@ const PROFILES = Object.freeze({ "class-hud": { config: "playwright.class-hud-profile.config.ts", target: "jump_beef" }, "class-selection": { config: "playwright.class-selection-profile.config.ts", target: "jump_beef" }, "selection-transition": { config: "playwright.selection-transition.config.ts", target: "pl_upward", minimumRemainingMilliseconds: 60_000 }, + "team-door": { config: "playwright.team-door.config.ts", target: "jump_beef", minimumRemainingMilliseconds: 60_000 }, "selection-transition-cpu": { config: "playwright.selection-transition.config.ts", target: "pl_upward", environment: { PLAYSRC_SELECTION_CPU: "1" }, minimumRemainingMilliseconds: 60_000 }, equipment: { config: "playwright.equipment-profile.config.ts", target: "pl_upward" }, "melee-unlocks": { config: "playwright.melee-profile.config.ts", target: "pl_upward" }, From 77ae46b922d5ab39045d4b50bc2ccda403a2722c Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:03:12 +1000 Subject: [PATCH 02/13] Retain door motion analysis after native desktop release --- tools/playsrc/profile/team-door.profile.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tools/playsrc/profile/team-door.profile.ts b/tools/playsrc/profile/team-door.profile.ts index 541b8e77c..3883bfc33 100644 --- a/tools/playsrc/profile/team-door.profile.ts +++ b/tools/playsrc/profile/team-door.profile.ts @@ -6,6 +6,7 @@ import { loadLocalConfig } from "../src/config" import { startupConsoleIdle, startupNativeReader } from "./native-startup" import { requireStartupNative } from "./static-startup-gate" import { installBrowserFrameProfiler } from "./browser-frame-profiler" +import { profileArtifact } from "./profile-artifacts" test("authored team doors real-time motion and reentry", async ({ page, context }) => { const { sourceCacheDir } = await loadLocalConfig(), directory = process.env.PLAYSRC_PROFILE_RUN_DIRECTORY! @@ -41,8 +42,7 @@ test("authored team doors real-time motion and reentry", async ({ page, context while (sampling) { const file = `motion-${String(captures.length).padStart(3, "0")}.page.png`, before = Date.now() await page.screenshot({ path: path.join(directory, file) }) - const bytes = await readFile(path.join(directory, file)) - captures.push({ file, before, after: Date.now(), bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), privacy: "client-only-review-required" }) + captures.push({ file, before, after: Date.now(), privacy: "client-only-review-required" }) } })() const hover = async (team: "red" | "blue" | "auto" | null, wait: number) => { @@ -71,7 +71,15 @@ test("authored team doors real-time motion and reentry", async ({ page, context root.__playsrcFrameProfiler.active = false return { doors: root.__playsrcProfile.teamDoorFrames, frames: root.__playsrcFrameProfiler } }) - await writeFile(path.join(directory, "team-door-motion.json"), JSON.stringify({ activeMilliseconds, observation, actions, captures, heapBefore, heapAfter: await cdp.send("Runtime.getHeapUsage") })) + const heapAfter = await cdp.send("Runtime.getHeapUsage") + await profileArtifact(async () => { + for (const capture of captures as { file: string; bytes?: number; sha256?: string }[]) { + const bytes = await readFile(path.join(directory, capture.file)) + capture.bytes = bytes.length + capture.sha256 = createHash("sha256").update(bytes).digest("hex") + } + await writeFile(path.join(directory, "team-door-motion.json"), JSON.stringify({ activeMilliseconds, observation, actions, captures, heapBefore, heapAfter })) + }) expect(activeMilliseconds).toBeGreaterThanOrEqual(5000) expect(activeMilliseconds).toBeLessThan(10000) expect(captures.length).toBeGreaterThan(30) @@ -79,7 +87,7 @@ test("authored team doors real-time motion and reentry", async ({ page, context finally { sampling = false await captureTask?.catch(() => {}) - await writeFile(path.join(directory, "team-door-admission.json"), JSON.stringify({ admissions, records: native.records, actions, captures, failure })) await native.close() + await profileArtifact(async () => { await writeFile(path.join(directory, "team-door-admission.json"), JSON.stringify({ admissions, records: native.records, actions, captures, failure })) }) } }) From 59d24bde90e6aeaa5dcc341e6c1a83fde76b5ecf Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:11:33 +1000 Subject: [PATCH 03/13] Capture compositor motion frames without serial screenshot stalls --- tools/playsrc/profile/team-door.profile.ts | 29 +++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tools/playsrc/profile/team-door.profile.ts b/tools/playsrc/profile/team-door.profile.ts index 3883bfc33..50c273d1e 100644 --- a/tools/playsrc/profile/team-door.profile.ts +++ b/tools/playsrc/profile/team-door.profile.ts @@ -12,16 +12,21 @@ test("authored team doors real-time motion and reentry", async ({ page, context const { sourceCacheDir } = await loadLocalConfig(), directory = process.env.PLAYSRC_PROFILE_RUN_DIRECTORY! if (!directory) throw new Error("Use the checked team-door profile runner") const native = await startupNativeReader(page, sourceCacheDir) - const admissions: unknown[] = [], captures: unknown[] = [], actions: unknown[] = [] + const admissions: unknown[] = [], captures: { file: string; before: number; after: number; data: string; timestamp: number; privacy: string; bytes?: number; sha256?: string }[] = [], actions: unknown[] = [] const cdp = await context.newCDPSession(page) const check = async (name: string) => { - const observation = await native.read(path.join(directory, `${name}.desktop.png`)) + const observation = await native.read(sampling ? undefined : path.join(directory, `${name}.desktop.png`)) admissions.push(observation) requireStartupNative(observation) } await page.addInitScript(installBrowserFrameProfiler) await page.addInitScript(() => { (globalThis as any).__playsrcProfile = { captureTeamDoors: false } }) - let sampling = false, captureTask: Promise | undefined, failure: string | null = null + let sampling = false, failure: string | null = null + cdp.on("Page.screencastFrame", frame => { + if (sampling && captures.length < 1024) captures.push({ file: `motion-${String(captures.length).padStart(3, "0")}.page.png`, + before: Date.now(), after: Date.now(), timestamp: frame.metadata.timestamp! * 1000, data: frame.data, privacy: "client-only-review-required" }) + void cdp.send("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => {}) + }) try { await page.goto("/", { waitUntil: "domcontentloaded" }) await expect(page.locator("main")).toHaveAttribute("data-phase", "MainMenu") @@ -38,13 +43,7 @@ test("authored team doors real-time motion and reentry", async ({ page, context await page.evaluate(() => { (globalThis as any).__playsrcProfile.captureTeamDoors = true; (globalThis as any).__playsrcFrameProfiler.active = true }) const started = Date.now() sampling = true - captureTask = (async () => { - while (sampling) { - const file = `motion-${String(captures.length).padStart(3, "0")}.page.png`, before = Date.now() - await page.screenshot({ path: path.join(directory, file) }) - captures.push({ file, before, after: Date.now(), privacy: "client-only-review-required" }) - } - })() + await cdp.send("Page.startScreencast", { format: "png", everyNthFrame: 2 }) const hover = async (team: "red" | "blue" | "auto" | null, wait: number) => { actions.push({ at: Date.now(), team }) if (team) await page.locator(`.team-selection-layer [data-vgui-name='teambutton${team === "blue" ? 0 : team === "red" ? 1 : 2}']`).hover() @@ -62,7 +61,7 @@ test("authored team doors real-time motion and reentry", async ({ page, context await hover(null, 650) await page.waitForTimeout(Math.max(0, 6000 - (Date.now() - started))) sampling = false - await captureTask + await cdp.send("Page.stopScreencast") const activeMilliseconds = Date.now() - started await check("after-motion") const observation = await page.evaluate(() => { @@ -73,8 +72,10 @@ test("authored team doors real-time motion and reentry", async ({ page, context }) const heapAfter = await cdp.send("Runtime.getHeapUsage") await profileArtifact(async () => { - for (const capture of captures as { file: string; bytes?: number; sha256?: string }[]) { - const bytes = await readFile(path.join(directory, capture.file)) + for (const capture of captures) { + const bytes = Buffer.from(capture.data, "base64") + await writeFile(path.join(directory, capture.file), bytes) + capture.data = "" capture.bytes = bytes.length capture.sha256 = createHash("sha256").update(bytes).digest("hex") } @@ -86,7 +87,7 @@ test("authored team doors real-time motion and reentry", async ({ page, context } catch (error) { failure = String(error); throw error } finally { sampling = false - await captureTask?.catch(() => {}) + await cdp.send("Page.stopScreencast").catch(() => {}) await native.close() await profileArtifact(async () => { await writeFile(path.join(directory, "team-door-admission.json"), JSON.stringify({ admissions, records: native.records, actions, captures, failure })) }) } From 8e323919b9f39a88d10e8833e99a4a35677d9596 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:21:09 +1000 Subject: [PATCH 04/13] Preserve authored team-door sequence transitions and lifecycle --- apps/web/tf2/src/runtime.ts | 30 +++---- apps/web/tf2/src/team-model-playback.ts | 32 +++++++ .../web/tf2/tests/team-model-playback.test.ts | 26 ++++++ games/tf2/browser/src/presentation.ts | 7 +- games/tf2/browser/src/team-selection/model.ts | 7 +- .../tf2/browser/src/team-selection/runtime.ts | 83 ++++++++++++++++--- .../tests/team-selection/pose-request.test.ts | 18 ++++ .../tests/team-selection/runtime.test.ts | 40 ++++++++- games/tf2/wasm/src/lib.rs | 24 ++++-- packages/formats/studio-model/rust/src/lib.rs | 2 + .../studio-model/rust/src/presentation.rs | 40 +++++++++ .../rust/src/sequence_transition.rs | 83 +++++++++++++++++++ tools/playsrc/profile/team-door.profile.ts | 2 +- 13 files changed, 355 insertions(+), 39 deletions(-) create mode 100644 apps/web/tf2/src/team-model-playback.ts create mode 100644 apps/web/tf2/tests/team-model-playback.test.ts create mode 100644 games/tf2/browser/tests/team-selection/pose-request.test.ts create mode 100644 packages/formats/studio-model/rust/src/sequence_transition.rs diff --git a/apps/web/tf2/src/runtime.ts b/apps/web/tf2/src/runtime.ts index 4ba5da350..0df71f89f 100644 --- a/apps/web/tf2/src/runtime.ts +++ b/apps/web/tf2/src/runtime.ts @@ -12,6 +12,7 @@ import { APPLICATION_BUILD as __PLAYSRC_APPLICATION_BUILD__, WASM_SHA256 as __PL import { TF2_PRESENTATION_SCHEMA, Tf2WorkerClient, Tf2WorkerError, mergePublicationSnapshots, type CoverageSample, type LoadedGame, type ResourceConfiguration, type SimulationPublication, type VisibilityResult } from "@playsrc/game-tf2-browser" import { Tf2EquipmentProfile, Tf2EquipmentPresentation, equippedWeaponSlots, equipmentPipelinePoseRequests, type Tf2EquipmentPreview } from "@playsrc/game-tf2-browser/equipment" import { selectionTransitionMark, selectionTransitionDraw } from "./selection-transition-profile" +import { teamModelPlayback, type TeamModelPlayback } from "./team-model-playback" import { initializeTf2GameUiIntegration, type Tf2GameUiIntegration } from "@playsrc/game-tf2-browser/gameui-integration" import type { Tf2GameUiRequest, Tf2LoadingPhase } from "@playsrc/game-tf2-browser/gameui" import { @@ -478,7 +479,7 @@ export class Tf2Application { #teamSelectionRenderRevision = 0 #pendingClassSelectionTeam?: 2 | 3 readonly #teamSelectionPoses = new Map() - readonly #teamSelectionAnimations = new Map>() + readonly #teamSelectionAnimations = new Map() #teamAdmission?: Readonly<{ generation: number; resolve(): void; reject(error: Error): void }> #hudRootCounts?: Readonly<{ playerStatus: number; ammo: number }> #hudContext?: SessionHudContext @@ -2485,24 +2486,22 @@ export class Tf2Application { const viewport = this.#viewport() this.#teamSelectionRenderTask = (async () => { const requests = authored.flatMap((panel, index) => { - if (panel.sequence === "idle") return [] const artifact = artifacts.models.get(panel.model.toLowerCase()) const timing = artifact?.sequences.find((sequence) => sequence.label.toLowerCase() === panel.sequence.toLowerCase()) if (!artifact || !timing) throw new Error(`TF2 authored team-door sequence is unavailable: ${panel.model}:${panel.sequence}`) - const prior = this.#teamSelectionAnimations.get(panel.name) - const current = prior?.sequence === panel.sequence - ? prior - : Object.freeze({ sequence: panel.sequence, startedSeconds: now, previousSeconds: 0 }) - const elapsed = Math.max(0, now - current.startedSeconds) - if (elapsed > timing.durationSeconds && this.#teamSelectionPoses.has(panel.name)) return [] + const step = teamModelPlayback(this.#teamSelectionAnimations.get(panel.name), panel, now, timing.durationSeconds) + this.#teamSelectionAnimations.set(panel.name, step.state) + if (!step.sample) return [] const request = Object.freeze({ identity: 0x1000 + index, + entityModelPanel: true, + modelPanelReset: step.reset, model: panel.model.toLowerCase(), activity: panel.sequence, - previousElapsedSeconds: Math.min(current.previousSeconds, elapsed), - elapsedSeconds: elapsed, + previousElapsedSeconds: step.previousElapsed, + elapsedSeconds: step.elapsed, currentTimeSeconds: now, - frameTimeSeconds: Math.max(0, elapsed - current.previousSeconds), + frameTimeSeconds: step.frameTime, planarSpeed: 0, screenAspectRatio: viewport.width / viewport.height, worldFarPlane: 1000, @@ -2510,16 +2509,16 @@ export class Tf2Application { lod: 0, bodygroups: Object.freeze(artifact.bodygroupCounts.map(() => 0)), }) - this.#teamSelectionAnimations.set(panel.name, Object.freeze({ ...current, previousSeconds: elapsed })) - return [Object.freeze({ panel: panel.name, request })] + return [Object.freeze({ panel: panel.name, state: step.state, request })] }) if (requests.length > 0) { const posed = await client.models(generation, encodeModelPoseBatch(requests.map((value) => value.request))) - if (generation !== this.#generation || !this.#teamSelection?.state().visible) return + if (generation !== this.#generation || revision !== this.#teamSelectionRenderRevision || !this.#teamSelection?.state().visible) return for (const item of posed) { const selected = requests.find((candidate) => candidate.request.identity === item.identity) if (!selected) throw new Error("TF2 team-door pose identity differs from its authored request") this.#teamSelectionPoses.set(selected.panel, item) + this.#teamSelectionAnimations.set(selected.panel, { ...selected.state, sampledSeconds: selected.request.elapsedSeconds }) } } const panels: readonly ModelPanelPass[] = authored.map((panel, index) => { @@ -2543,9 +2542,10 @@ export class Tf2Application { const doorProfile = (globalThis as any).__playsrcProfile if (doorProfile?.captureTeamDoors === true) { const frames = doorProfile.teamDoorFrames ??= [] - if (frames.length < 1024) frames.push({ at: performance.now(), now, milliseconds: result.milliseconds, + if (frames.length < 4096) frames.push({ at: performance.now(), now, milliseconds: result.milliseconds, panels: authored.map(panel => ({ name: panel.name, animation: panel.animation, sequence: panel.sequence, timing: this.#teamSelectionAnimations.get(panel.name), + cycle: this.#teamSelectionPoses.get(panel.name)?.cycle, matrices: Array.from(this.#teamSelectionPoses.get(panel.name)?.boneMatrices ?? []) })) }) } this.#set({ teamSelectionModels: result.panels.map((panel) => `${panel.identity}:${panel.model}:${panel.skin}:${panel.primitives}`).join("|") }) diff --git a/apps/web/tf2/src/team-model-playback.ts b/apps/web/tf2/src/team-model-playback.ts new file mode 100644 index 000000000..4e99bd1a3 --- /dev/null +++ b/apps/web/tf2/src/team-model-playback.ts @@ -0,0 +1,32 @@ +export type TeamModelPlayback = Readonly<{ + modelRevision: number + animationRevision: number + sequence: string + startedSeconds: number + previousSeconds: number + lastPaintSeconds: number + sampledSeconds: number | null +}> + +/** Presentation scheduling only; the studio owner resolves cycle and pose. */ +export function teamModelPlayback( + prior: TeamModelPlayback | undefined, + panel: Readonly<{ modelRevision: number; animationRevision: number; sequence: string }>, + now: number, + duration: number, +): Readonly<{ state: TeamModelPlayback; elapsed: number; previousElapsed: number; frameTime: number; sample: boolean; reset: boolean }> { + const reset = !prior || prior.modelRevision !== panel.modelRevision + const changed = reset || prior?.animationRevision !== panel.animationRevision + const current: TeamModelPlayback = changed + ? { ...panel, startedSeconds: now, previousSeconds: 0, lastPaintSeconds: now, sampledSeconds: null } + : prior! + const elapsed = Math.min(Math.max(0, now - current.startedSeconds), duration) + return { + state: { ...current, previousSeconds: elapsed, lastPaintSeconds: now }, + elapsed, + previousElapsed: Math.min(current.previousSeconds, elapsed), + frameTime: Math.max(0, now - (prior?.lastPaintSeconds ?? now)), + sample: current.sampledSeconds === null || current.sampledSeconds < duration, + reset, + } +} diff --git a/apps/web/tf2/tests/team-model-playback.test.ts b/apps/web/tf2/tests/team-model-playback.test.ts new file mode 100644 index 000000000..6dcacc041 --- /dev/null +++ b/apps/web/tf2/tests/team-model-playback.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test" +import { teamModelPlayback } from "../src/team-model-playback" + +const panel = { sequence: "hoveropen", modelRevision: 1, animationRevision: 1 } +test("samples exact terminal pose even when a render interval crosses its duration", () => { + const initial = teamModelPlayback(undefined, panel, 1, 30 / 65) + const middle = { ...initial.state, sampledSeconds: 0.1, previousSeconds: 0.1 } + const terminal = teamModelPlayback(middle, panel, 2, 30 / 65) + expect(terminal.sample).toBe(true) + expect(terminal.elapsed).toBe(30 / 65) + expect(teamModelPlayback({ ...terminal.state, sampledSeconds: terminal.elapsed }, panel, 3, 30 / 65).sample).toBe(false) +}) +test("idle, same-sequence commands, interrupted changes and recreated panels cannot reuse an old endpoint", () => { + const done = { ...teamModelPlayback(undefined, panel, 1, 1).state, sampledSeconds: 1 } + for (const next of [ + { ...panel, sequence: "idle", animationRevision: 2 }, + { ...panel, animationRevision: 2 }, + { ...panel, modelRevision: 2 }, + ]) { + const step = teamModelPlayback(done, next, 2, 1) + expect(step.sample).toBe(true) + expect(step.elapsed).toBe(0) + expect(step.previousElapsed).toBe(0) + } + expect(teamModelPlayback(undefined, { ...panel, sequence: "idle" }, 0, 0).sample).toBe(true) +}) diff --git a/games/tf2/browser/src/presentation.ts b/games/tf2/browser/src/presentation.ts index ed6efdda1..989ff328f 100644 --- a/games/tf2/browser/src/presentation.ts +++ b/games/tf2/browser/src/presentation.ts @@ -334,6 +334,7 @@ export type ModelPoseRequest = Readonly<{ classSelection?: boolean controlPoint?: number modelPanel?: boolean + entityModelPanel?: boolean modelPanelReset?: boolean cloak?: ActorCloakState actorIdentity?: number @@ -430,9 +431,11 @@ export function encodeModelPoseBatch(requests: readonly ModelPoseRequest[]): Uin || (request.hudModel && (!request.modelPanel || !request.actorIdentity)) || (request.worldItem !== undefined && typeof request.worldItem !== "boolean") || (request.modelPanel !== undefined && typeof request.modelPanel !== "boolean") + || (request.entityModelPanel !== undefined && typeof request.entityModelPanel !== "boolean") + || (request.entityModelPanel && (request.modelPanel || request.classSelection || request.itemModel || request.phase !== undefined || request.cloak || request.worldItem || request.handsOnlyViewmodel || request.controlPoint !== undefined || request.actorIdentity !== undefined || request.itemDefinition !== undefined || request.packedBody !== undefined)) || (request.modelPanelReset !== undefined && typeof request.modelPanelReset !== "boolean") || ((request.classSelection || request.modelPanel) && ((request.itemModel !== undefined && (request.classSelection || !request.worldItem)) || request.handsOnlyViewmodel || request.phase !== undefined || request.cloak !== undefined)) - || (request.modelPanelReset && !request.classSelection && !request.modelPanel)) { + || (request.modelPanelReset && !request.classSelection && !request.modelPanel && !request.entityModelPanel)) { throw new ProjectilePresentationError("MalformedFact", "model panel pose request") } if (!Number.isSafeInteger(request.identity) || request.identity < 1 || !request.model || !request.activity || @@ -462,7 +465,7 @@ export function encodeModelPoseBatch(requests: readonly ModelPoseRequest[]): Uin throw new ProjectilePresentationError("MalformedFact", "model pose sample") } view.setBigUint64(at, sampleTick, true); at += 8 - bytes[at] = request.controlPoint !== undefined ? 7 : request.classSelection ? 3 : request.modelPanel ? request.worldItem ? 6 : 4 : request.worldItem ? 5 : request.handsOnlyViewmodel ? 2 : request.itemModel === undefined ? 0 : 1 + bytes[at] = request.entityModelPanel ? 8 : request.controlPoint !== undefined ? 7 : request.classSelection ? 3 : request.modelPanel ? request.worldItem ? 6 : 4 : request.worldItem ? 5 : request.handsOnlyViewmodel ? 2 : request.itemModel === undefined ? 0 : 1 bytes[at + 1] = Number(request.attachmentsOnly ?? false) bytes[at + 2] = Number(request.fireView !== undefined) bytes[at + 3] = Number(request.modelPanelReset ?? false) | (Number(cloak !== undefined) << 1) | (Number(request.preparation ?? false) << 2) | (Number(request.hudModel ?? false) << 3) diff --git a/games/tf2/browser/src/team-selection/model.ts b/games/tf2/browser/src/team-selection/model.ts index f9ea9c6f9..89287b5fb 100644 --- a/games/tf2/browser/src/team-selection/model.ts +++ b/games/tf2/browser/src/team-selection/model.ts @@ -124,8 +124,8 @@ export function transitionTf2TeamSelection( if (!state.visible || !state.server) return outcome("ignored", state) if (event.kind === "update") { if (!validServer(event.server)) return outcome("illegal", state) - const focused = state.focused && available(event.server, state.focused) ? state.focused : initialFocus(event.server) - const hovered = state.hovered && available(event.server, state.hovered) ? state.hovered : null + const focused = state.focused && (state.focused === "red" || state.focused === "blue" || available(event.server, state.focused)) ? state.focused : initialFocus(event.server) + const hovered = state.hovered && (state.hovered === "red" || state.hovered === "blue" || available(event.server, state.hovered)) ? state.hovered : null return outcome("applied", Object.freeze({ ...state, server: event.server, focused, hovered })) } if (event.kind === "cancel") { @@ -139,11 +139,12 @@ export function transitionTf2TeamSelection( if (event.team === state.hovered) return outcome("ignored", state) return outcome("applied", Object.freeze({ ...state, hovered: event.team })) } - if (!available(state.server, event.team)) return outcome("ignored", state) if (event.kind === "focus") { + if ((event.team === "auto" || event.team === "spectate") && !available(state.server, event.team)) return outcome("ignored", state) if (event.team === state.focused) return outcome("ignored", state) return outcome("applied", Object.freeze({ ...state, focused: event.team })) } + if (!available(state.server, event.team)) return outcome("ignored", state) const sameTeam = event.team === "red" && state.server.localTeam === 2 || event.team === "blue" && state.server.localTeam === 3 || event.team === "spectate" && state.server.localTeam === 1 diff --git a/games/tf2/browser/src/team-selection/runtime.ts b/games/tf2/browser/src/team-selection/runtime.ts index 76d03bb93..81122d748 100644 --- a/games/tf2/browser/src/team-selection/runtime.ts +++ b/games/tf2/browser/src/team-selection/runtime.ts @@ -65,6 +65,8 @@ export type Tf2TeamSelectionModelPanel = Readonly<{ angles: readonly [number, number, number] animation: string sequence: string + animationRevision: number + modelRevision: number bounds: Readonly<{ x: number; y: number; width: number; height: number }> }> @@ -138,6 +140,14 @@ class Integration implements Tf2TeamSelectionIntegration { readonly #onRequest: Tf2TeamSelectionIntegrationRequest["onRequest"] readonly #onModelPanels: Tf2TeamSelectionIntegrationRequest["onModelPanels"] readonly #animations = new Map() + readonly #animationRevisions = new Map() + readonly #entered = new Set() + readonly #hoverDeadlines = new Map() + readonly #disabled = new Map() + readonly #clock: Tf2TeamSelectionIntegrationRequest["clock"] + #modelRevision = 0 + #animationRevision = 0 + #nextTick = 0 #owner = 0 #state = TF2_TEAM_SELECTION_INITIAL_STATE #destroyed = false @@ -147,6 +157,7 @@ class Integration implements Tf2TeamSelectionIntegration { this.#source = request.resources.document(TEAM_SELECTION_PATH) this.#onRequest = request.onRequest this.#onModelPanels = request.onModelPanels + this.#clock = request.clock const initialized = initializeVguiRuntime({ runtimeIdentity: "tf2-team-selection", root: request.root, @@ -234,8 +245,53 @@ class Integration implements Tf2TeamSelectionIntegration { if (this.#state.visible) this.dispatch({ kind: "hover", team: null }) } - #present(previous: Tf2TeamSelectionState): void { + #setAnimation(name: string, animation: string): void { + const authored = this.#source.root.children.find(node => node.name === name) + const model = authored && object(authored, "model") + if (!model?.children.some(child => child.name.toLowerCase() === "animation" && scalar(child, "name") === animation)) return + this.#animations.set(name, animation) + this.#animationRevisions.set(name, ++this.#animationRevision) + } + + #enter(team: Tf2TeamChoice, entered: boolean): void { + this.#hoverDeadlines.delete(team) + if (entered) { + this.#entered.add(team) + const button = this.#source.root.children.find(node => node.name === BUTTON_NAMES[team]) + const delay = button ? Number(scalar(button, "hover") ?? -1) : -1 + if (delay > 0) this.#hoverDeadlines.set(team, this.#clock.nowSeconds() + delay) + } else this.#entered.delete(team) + this.#setAnimation(this.#modelName(team), `${entered ? "enter" : "exit"}_${this.#disabled.get(team) ? "disabled" : "enabled"}`) + } + + #tickButtons(time: number): void { + const server = this.#state.server + if (!server) return + for (const team of TAB_ORDER) { + const disabled = team === "red" ? server.redDisabled : team === "blue" ? server.blueDisabled : false + if (disabled !== (this.#disabled.get(team) ?? false)) { + this.#disabled.set(team, disabled) + if (this.#entered.has(team)) this.#enter(team, true) + else this.#setAnimation(this.#modelName(team), disabled ? "idle_disabled" : "idle_enabled") + } + const deadline = this.#hoverDeadlines.get(team) + if (deadline !== undefined && deadline < time) { + this.#hoverDeadlines.delete(team) + this.#setAnimation(this.#modelName(team), disabled ? "hover_disabled" : "hover_enabled") + } + } + } + + #present(previous: Tf2TeamSelectionState, event: Tf2TeamSelectionEvent): void { const state = this.#state + if (state.visible && !previous.visible) { + this.#modelRevision += 1 + this.#entered.clear() + this.#hoverDeadlines.clear() + for (const name of MODEL_NAMES) this.#setAnimation(name, "idle_enabled") + this.#tickButtons(this.#clock.nowSeconds()) + this.#nextTick = this.#clock.nowSeconds() + 0.1 + } this.#runtime.deferPresentation(() => { apply(this.#runtime, { kind: "set-panel-state", panel: 1, visible: state.visible }) apply(this.#runtime, { kind: "set-panel-state", panel: this.#owner, visible: state.visible }) @@ -266,22 +322,19 @@ class Integration implements Tf2TeamSelectionIntegration { const button = find(this.#runtime, BUTTON_NAMES[team], this.#owner)! apply(this.#runtime, { kind: "set-panel-state", panel: button, enabled: true }) this.#root.querySelector?.(`[data-vgui-name="${BUTTON_NAMES[team]}"]`)?.setAttribute("aria-disabled", String(disabled)) - const name = team === "red" ? "reddoor" : "bluedoor" - const beforeDisabled = team === "red" ? previous.server?.redDisabled : previous.server?.blueDisabled - if (disabled !== beforeDisabled) this.#animations.set(name, disabled ? "idle_disabled" : "idle_enabled") } if (previous.hovered !== state.hovered) { if (previous.hovered) { - const name = this.#modelName(previous.hovered) - const disabled = previous.hovered === "red" ? server.redDisabled : previous.hovered === "blue" ? server.blueDisabled : false - this.#animations.set(name, disabled ? "exit_disabled" : "exit_enabled") + this.#enter(previous.hovered, false) } if (state.hovered) { - const name = this.#modelName(state.hovered) - const disabled = state.hovered === "red" ? server.redDisabled : state.hovered === "blue" ? server.blueDisabled : false - this.#animations.set(name, disabled ? "enter_disabled" : "enter_enabled") + this.#enter(state.hovered, true) } } + if (event.kind === "focus" && previous.focused !== state.focused) { + if (previous.focused) this.#enter(previous.focused, false) + if (state.focused) this.#enter(state.focused, true) + } }) if (state.focused && previous.focused !== state.focused) { this.#root.querySelector?.(`[data-vgui-name="${BUTTON_NAMES[state.focused]}"]`)?.focus?.() @@ -332,6 +385,8 @@ class Integration implements Tf2TeamSelectionIntegration { angles: Object.freeze([authoredNumber(model, "angles_x"), authoredNumber(model, "angles_y"), authoredNumber(model, "angles_z")]) as readonly [number, number, number], animation, sequence: authoredAnimation ? scalar(authoredAnimation, "sequence") ?? "" : "idle", + animationRevision: this.#animationRevisions.get(name) ?? 0, + modelRevision: this.#modelRevision, bounds: snapshot.bounds, })] })) @@ -343,7 +398,7 @@ class Integration implements Tf2TeamSelectionIntegration { const transition = transitionTf2TeamSelection(previous, event) if (transition.disposition !== "applied") return transition this.#state = transition.state - this.#present(previous) + this.#present(previous, event) if (transition.request) this.#onRequest(transition.request) return transition } @@ -386,6 +441,12 @@ class Integration implements Tf2TeamSelectionIntegration { if (this.#state.visible) { apply(this.#runtime, { kind: "frame", timeSeconds }) this.#syncAccessibility() + if (timeSeconds >= this.#nextTick) { + this.#nextTick = timeSeconds + 0.1 + const before = this.#animationRevision + this.#tickButtons(timeSeconds) + if (before !== this.#animationRevision) this.#onModelPanels(this.modelPanels()) + } } } diff --git a/games/tf2/browser/tests/team-selection/pose-request.test.ts b/games/tf2/browser/tests/team-selection/pose-request.test.ts new file mode 100644 index 000000000..a541e3b4f --- /dev/null +++ b/games/tf2/browser/tests/team-selection/pose-request.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test" +import { encodeModelPoseBatch, type ModelPoseRequest } from "../../src/presentation" + +const request: ModelPoseRequest = { + identity: 4098, model: "models/vgui/ui_team01_red.mdl", activity: "hoveropen", + entityModelPanel: true, modelPanelReset: true, + previousElapsedSeconds: 0, elapsedSeconds: 0, currentTimeSeconds: 1, frameTimeSeconds: 0.015, + planarSpeed: 0, screenAspectRatio: 16 / 9, worldFarPlane: 1000, skin: 0, lod: 0, bodygroups: [0], +} +test("entity panel animation requests have a distinct owner without changing other model panels", () => { + const bytes = encodeModelPoseBatch([request]) + expect(bytes[52]).toBe(8) + expect(bytes[55]).toBe(1) + expect(encodeModelPoseBatch([{ ...request, entityModelPanel: false, modelPanel: true }])[52]).toBe(4) + expect(encodeModelPoseBatch([{ ...request, entityModelPanel: false, modelPanelReset: false }])[52]).toBe(0) + expect(() => encodeModelPoseBatch([{ ...request, modelPanel: true }])).toThrow() + expect(() => encodeModelPoseBatch([{ ...request, actorIdentity: 1 }])).toThrow() +}) diff --git a/games/tf2/browser/tests/team-selection/runtime.test.ts b/games/tf2/browser/tests/team-selection/runtime.test.ts index c8fbbb8bc..f97f8bada 100644 --- a/games/tf2/browser/tests/team-selection/runtime.test.ts +++ b/games/tf2/browser/tests/team-selection/runtime.test.ts @@ -9,6 +9,7 @@ import { } from "../../src/team-selection" function fixture(overrides: Partial = {}) { + let now = 0 const base = createTf2GameUiTransitionFixture() const requests: Tf2TeamSelectionRequest[] = [] const models: (readonly Tf2TeamSelectionModelPanel[])[] = [] @@ -22,16 +23,51 @@ function fixture(overrides: Partial = {}) { resources: base.resources, viewport: { width: 1280, height: 720, devicePixelRatio: 1 }, reducedMotion: true, - clock: { nowSeconds: () => 0 }, + clock: { nowSeconds: () => now }, random: { nextUnit: () => 0 }, onRequest: (request) => requests.push(request), onModelPanels: (panels) => models.push(panels), }) integration.dispatch({ kind: "show", server }) - return Object.freeze({ ...base, server, integration, requests, models }) + return Object.freeze({ ...base, server, integration, requests, models, frame(time: number) { now = time; integration.frame(time) } }) } describe("authored TF2 RED/BLU team-selection VGUI", () => { + test("recreates authored default animations when reopening a hovered door", () => { + const { integration, server } = fixture() + integration.dispatch({ kind: "hover", team: "red" }) + integration.dispatch({ kind: "hide" }) + integration.dispatch({ kind: "show", server }) + expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.sequence).toBe("idle") + }) + + test("disabled doors play the authored delayed fullhover and retain hover across rule updates", () => { + const { integration, server, frame } = fixture() + integration.dispatch({ kind: "hover", team: "red" }) + integration.dispatch({ kind: "update", server: { ...server, redDisabled: true } }) + expect(integration.state().hovered).toBe("red") + frame(0.1) + expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.animation).toBe("enter_disabled") + frame(2.1) + expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.sequence).toBe("fullidle") + frame(2.2) + expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.sequence).toBe("fullhover") + integration.dispatch({ kind: "hover", team: null }) + expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.sequence).toBe("fullidle") + }) + + test("focus navigation enters and exits disabled doors and restarts aliased sequence commands", () => { + const { integration } = fixture({ redDisabled: true }) + integration.dispatch({ kind: "focus", team: "red" }) + const entered = integration.modelPanels().find(panel => panel.name === "reddoor")! + expect(entered.animation).toBe("enter_disabled") + integration.dispatch({ kind: "focus", team: "blue" }) + const exited = integration.modelPanels().find(panel => panel.name === "reddoor")! + expect(exited.animation).toBe("exit_disabled") + expect(exited.sequence).toBe(entered.sequence) + expect(exited.animationRevision).toBeGreaterThan(entered.animationRevision) + expect(integration.dispatch({ kind: "select", team: "red" }).disposition).toBe("ignored") + }) test("consumes owned repeats without autoassigning or moving authored focus", () => { const { integration, requests } = fixture() const focused = integration.state().focused diff --git a/games/tf2/wasm/src/lib.rs b/games/tf2/wasm/src/lib.rs index 5ab5e61d0..db3a87171 100644 --- a/games/tf2/wasm/src/lib.rs +++ b/games/tf2/wasm/src/lib.rs @@ -663,6 +663,7 @@ struct ClassPreview { model: String, scene: playsrc_tf2::class_selection::ScenePlayer, flex: playsrc_tf2::class_selection::ModelPanelFlexState, + transitions: playsrc_studio_model::SequenceTransitioner, } struct Slot { @@ -2485,6 +2486,7 @@ struct ModelPoseRequest { control_point: Option, class_selection: bool, model_panel: bool, + entity_model_panel: bool, model_panel_reset: bool, flex_controllers: Option>, actor_identity: u32, @@ -2746,14 +2748,15 @@ fn decode_model_requests(bytes: &[u8]) -> Result, ()> { let item_definition = (definition != u32::MAX).then_some(definition); let start_tick = reader.u64()?; let activity_start_tick = (start_tick != u64::MAX).then_some(start_tick); - if kind > 7 + if kind > 8 || (kind == 7 && (actor_identity == 0 || has_cloak || [local_factor, world_factor, raw_factor].into_iter().chain(player_tint).any(|v| v != 0.0))) || (matches!(kind, 3 | 4 | 6) && actor_identity != 0 && !hud_model) || attachments_only > 1 || has_fire_view > 1 || (attachments_only == 1 && (kind != 1 || has_fire_view != 1)) || (has_fire_view == 1 && kind != 1) - || model_panel_reset > 1 || (model_panel_reset != 0 && !matches!(kind, 3 | 4 | 6)) + || model_panel_reset > 1 || (model_panel_reset != 0 && !matches!(kind, 3 | 4 | 6 | 8)) + || (kind == 8 && (actor_identity != 0 || has_cloak || hud_model || item_definition.is_some())) || item_definition.is_some_and(|definition| playsrc_tf2::equipment::supported_item(definition).is_none()) || item_definition.is_some() && !matches!(kind, 3 | 4 | 5 | 6) && activity_start_tick.is_none() { @@ -2776,7 +2779,7 @@ fn decode_model_requests(bytes: &[u8]) -> Result, ()> { let model = reader.text()?.to_ascii_lowercase(); let item_text = reader.text()?.to_ascii_lowercase(); let item = match kind { - 0 | 2 | 3 | 4 | 7 if item_text.is_empty() => None, + 0 | 2 | 3 | 4 | 7 | 8 if item_text.is_empty() => None, 1 | 5 | 6 if !item_text.is_empty() => Some(item_text), _ => return Err(()), }; @@ -2804,7 +2807,7 @@ fn decode_model_requests(bytes: &[u8]) -> Result, ()> { 3 => Some(playsrc_studio_model::ViewModelPhase::ReloadInsertOrLoop), 4 => Some(playsrc_studio_model::ViewModelPhase::ReloadFinish), 5 => Some(playsrc_studio_model::ViewModelPhase::Idle), - u8::MAX if matches!(kind, 0 | 3 | 4 | 5 | 6 | 7) => None, + u8::MAX if matches!(kind, 0 | 3 | 4 | 5 | 6 | 7 | 8) => None, _ => return Err(()), }; let packed_body_value = i32::from_le_bytes(reader.take(4)?.try_into().map_err(|_| ())?); @@ -2899,6 +2902,7 @@ fn decode_model_requests(bytes: &[u8]) -> Result, ()> { control_point: (kind == 7).then_some(actor_identity), class_selection: kind == 3, model_panel: matches!(kind, 3 | 4 | 6), + entity_model_panel: kind == 8, model_panel_reset: model_panel_reset != 0, flex_controllers: None, actor_identity: if kind == 7 { 0 } else { actor_identity }, @@ -3075,7 +3079,7 @@ fn encode_model_poses( out.extend_from_slice(&0_u32.to_le_bytes()); let mut output_count = 0_u32; let mut sampled_poses = - BTreeMap::<(String, usize, u32, u32, Vec, Option), playsrc_studio_model::SampledPose>::new(); + BTreeMap::<(String, usize, u32, u32, Vec, Option, Option), playsrc_studio_model::SampledPose>::new(); for (request_index, original) in requests.iter().enumerate() { let mut stage = "class-scene"; let result = (|| -> Result<(), ()> { @@ -3336,6 +3340,7 @@ fn encode_model_poses( request.elapsed.to_bits(), pose_parameters.iter().map(|p| p.0).collect(), request.barrel_angle.map(f32::to_bits), + request.entity_model_panel.then_some(request.identity), ); if let std::collections::btree_map::Entry::Vacant(entry) = sampled_poses.entry(pose_key.clone()) @@ -3348,6 +3353,15 @@ fn encode_model_poses( (base, base_cycle - base_cycle.trunc(), vec![playsrc_studio_model::AnimationLayer { sequence, cycle: playsrc_studio_model::Float32(cycle.to_bits()), weight: playsrc_studio_model::Float32(1.0f32.to_bits()), }]) + } else if request.entity_model_panel { + let retained = class_scenes.entry(request.identity).or_default(); + if retained.model != request.model || request.model_panel_reset { + retained.transitions.clear(); + retained.model = request.model.clone(); + } + let layers = retained.transitions.update(model, sequence, cycle, request.current_time, + (request.current_time - request.frame_time).max(0.0), &pose_parameters).map_err(|_| ())?; + (sequence, cycle, layers) } else { (sequence, cycle, Vec::new()) }; let pose = playsrc_studio_model::sample_pose_at_time( model, diff --git a/packages/formats/studio-model/rust/src/lib.rs b/packages/formats/studio-model/rust/src/lib.rs index 0109d6389..0d278c5eb 100644 --- a/packages/formats/studio-model/rust/src/lib.rs +++ b/packages/formats/studio-model/rust/src/lib.rs @@ -9,11 +9,13 @@ use std::{ mod eye; mod lighting; mod presentation; +mod sequence_transition; mod static_lighting; mod viewmodel; pub use eye::*; pub use lighting::*; pub use presentation::*; +pub use sequence_transition::*; pub use static_lighting::*; pub use viewmodel::*; diff --git a/packages/formats/studio-model/rust/src/presentation.rs b/packages/formats/studio-model/rust/src/presentation.rs index bce707eee..8860f746a 100644 --- a/packages/formats/studio-model/rust/src/presentation.rs +++ b/packages/formats/studio-model/rust/src/presentation.rs @@ -5637,6 +5637,46 @@ mod tests { ); } + #[test] + fn entity_panel_transitions_preserve_authored_rotation_sign_hierarchy_and_interruption_order() { + let mut model = build().model; + let root = [float(0.5); 4]; + let handle = [float(0.0), float(0.0), float((std::f32::consts::FRAC_PI_4 / 2.0).sin()), float((std::f32::consts::FRAC_PI_4 / 2.0).cos())]; + model.animations.clear(); + for index in 0..2 { + model.animations.push(animation(index, b"panel", 0.0, 0)); + model.sequences[index].animation_indices = vec![index as i16]; + for frame in &mut model.animations[index].frames { + frame.rotations = vec![root, if index == 0 { identity_quaternion() } else { handle }]; + } + } + let parameters = vec![float(0.0)]; + let mut transitions = crate::SequenceTransitioner::default(); + assert!(transitions.update(&model, 0, 1.0, 1.0, 0.99, ¶meters).unwrap().is_empty()); + let layers = transitions.update(&model, 1, 0.0, 1.02, 1.0, ¶meters).unwrap(); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].sequence, 0); + assert!((f32::from_bits(layers[0].weight.0) - 0.972).abs() < 1e-5); + let state = AnimationState { base_sequence: 1, cycle: float(0.0), pose_parameters: parameters.clone(), layers, bone_rotations: Vec::new() }; + let pose = sample_pose(&model, &state).unwrap(); + assert!(values4(pose.local_rotations[1])[2] > 0.0); + assert!(values4(pose.local_rotations[1])[2] < values4(handle)[2]); + let world = apply_entity_transform(&model, &pose, source_entity_transform(vector([290.0, 0.0, -34.0]), vector([0.0, 180.0, 0.0])).unwrap()).unwrap(); + let matrix = world.bone_matrices[1].0.map(|v| f32::from_bits(v.0)); + assert!(matrix[8] > 0.0 && -matrix[4] > 0.0, "positive local rotation raises the screen-right axis; do not negate it"); + let interrupted = transitions.update(&model, 0, 0.0, 1.05, 1.04, ¶meters).unwrap(); + assert_eq!(interrupted.iter().map(|v| v.sequence).collect::>(), [1, 0]); + assert!(transitions.update(&model, 0, 1.0, 1.3, 1.29, ¶meters).unwrap().is_empty()); + // A terminal pose may have been reused for many paints; its next fade + // starts at the previous paint, not at its last expensive pose sample. + let resumed = transitions.update(&model, 1, 0.0, 5.02, 5.0, ¶meters).unwrap(); + assert!((f32::from_bits(resumed[0].weight.0) - 0.972).abs() < 1e-5); + transitions.clear(); + assert!(transitions.update(&model, 1, 0.0, 6.0, 5.99, ¶meters).unwrap().is_empty()); + model.sequences[0].flags |= 2; + assert!(transitions.update(&model, 0, 0.0, 6.01, 6.0, ¶meters).unwrap().is_empty()); + } + #[test] fn standard_blending_rotations_feed_descendants_palettes_and_attachments() { let model = build().model; diff --git a/packages/formats/studio-model/rust/src/sequence_transition.rs b/packages/formats/studio-model/rust/src/sequence_transition.rs new file mode 100644 index 000000000..7e934eabe --- /dev/null +++ b/packages/formats/studio-model/rust/src/sequence_transition.rs @@ -0,0 +1,83 @@ +use crate::{AnimationLayer, Float32, PresentationError, PresentationErrorCode, PresentationModel, sequence_timing}; + +#[derive(Clone, Debug)] +struct Entry { + sequence: usize, + cycle: f32, + time: f32, + fade_out: f32, +} + +/// C_BaseAnimating sequence transitions, distinct from studio frame interpolation. +#[derive(Clone, Debug, Default)] +pub struct SequenceTransitioner { + queue: Vec, +} + +impl SequenceTransitioner { + pub fn clear(&mut self) { self.queue.clear(); } + + /// `previous_paint_time` also accounts for paints that reused a terminal pose. + /// Finished sequences still participate in the next authored cross-fade. + pub fn update( + &mut self, + model: &PresentationModel, + sequence: usize, + cycle: f32, + time: f32, + previous_paint_time: f32, + parameters: &[Float32], + ) -> Result, PresentationError> { + let invalid = || PresentationError { code: PresentationErrorCode::InvalidState, identity: model.identity.clone() }; + if ![cycle, time, previous_paint_time].into_iter().all(f32::is_finite) || previous_paint_time > time { + return Err(invalid()); + } + let incoming = model.sequences.get(sequence).ok_or_else(invalid)?; + if let Some(current) = self.queue.last_mut() { + if current.time < previous_paint_time { + let timing = sequence_timing(model, current.sequence, parameters)?; + current.cycle = advance(current.cycle, previous_paint_time - current.time, timing); + current.time = previous_paint_time; + } + if current.time != 0.0 && current.sequence != sequence { + if incoming.flags & 2 != 0 { + self.queue.clear(); + } else { + current.fade_out = f32::from_bits(model.sequences[current.sequence].fade_out.0) + .min(f32::from_bits(incoming.fade_in.0)); + } + self.queue.push(Entry { sequence, cycle, time, fade_out: 0.0 }); + } else { + *current = Entry { sequence, cycle, time, fade_out: 0.0 }; + } + } else { + self.queue.push(Entry { sequence, cycle, time, fade_out: 0.0 }); + } + let last = self.queue.len() - 1; + let mut index = 0; + self.queue.retain(|entry| { + let keep = index == last || fade(entry, time) > 0.0; + index += 1; + keep + }); + self.queue[..self.queue.len() - 1].iter().rev().map(|entry| { + let timing = sequence_timing(model, entry.sequence, parameters)?; + Ok(AnimationLayer { + sequence: entry.sequence, + cycle: Float32(advance(entry.cycle, time - entry.time, timing).to_bits()), + weight: Float32(fade(entry, time).to_bits()), + }) + }).collect() + } +} + +fn advance(cycle: f32, elapsed: f32, timing: crate::SequenceTiming) -> f32 { + let value = cycle + elapsed * f32::from_bits(timing.cycles_per_second.0); + if timing.looping { value - value.floor() } else { value.clamp(0.0, 1.0) } +} + +fn fade(entry: &Entry, time: f32) -> f32 { + if entry.fade_out <= 0.0 { return 0.0; } + let s = 1.0 - (time - entry.time) / entry.fade_out; + if s > 0.0 && s <= 1.0 { 3.0 * s * s - 2.0 * s * s * s } else { s.min(1.0) } +} diff --git a/tools/playsrc/profile/team-door.profile.ts b/tools/playsrc/profile/team-door.profile.ts index 50c273d1e..7dff34f1c 100644 --- a/tools/playsrc/profile/team-door.profile.ts +++ b/tools/playsrc/profile/team-door.profile.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile } from "node:fs/promises" +import { writeFile } from "node:fs/promises" import { createHash } from "node:crypto" import path from "node:path" import { test, expect } from "./application-test" From 3c7c8ca953af0674e763e15a2bb7fec4ecff0aa6 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:24:37 +1000 Subject: [PATCH 05/13] Exercise disabled door hover focus and reopen with local bots --- playwright.team-door-lifecycle.config.ts | 2 + tools/playsrc/profile/team-door.profile.ts | 48 +++++++++++++++++++++- tools/playsrc/src/profile-runner.ts | 2 + 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 playwright.team-door-lifecycle.config.ts diff --git a/playwright.team-door-lifecycle.config.ts b/playwright.team-door-lifecycle.config.ts new file mode 100644 index 000000000..e527e67d1 --- /dev/null +++ b/playwright.team-door-lifecycle.config.ts @@ -0,0 +1,2 @@ +import { headedProfileConfiguration } from "./tools/playsrc/profile/profile-config" +export default headedProfileConfiguration({ match: "team-door.profile.ts", target: "pl_upward" }) diff --git a/tools/playsrc/profile/team-door.profile.ts b/tools/playsrc/profile/team-door.profile.ts index 7dff34f1c..899546fb1 100644 --- a/tools/playsrc/profile/team-door.profile.ts +++ b/tools/playsrc/profile/team-door.profile.ts @@ -7,6 +7,9 @@ import { startupConsoleIdle, startupNativeReader } from "./native-startup" import { requireStartupNative } from "./static-startup-gate" import { installBrowserFrameProfiler } from "./browser-frame-profiler" import { profileArtifact } from "./profile-artifacts" +import { chooseTf2Team } from "./team-selection-evidence" + +const disabledTeam = process.env.PLAYSRC_TEAM_DOOR_DISABLED as "red" | "blue" | undefined test("authored team doors real-time motion and reentry", async ({ page, context }) => { const { sourceCacheDir } = await loadLocalConfig(), directory = process.env.PLAYSRC_PROFILE_RUN_DIRECTORY! @@ -32,10 +35,21 @@ test("authored team doors real-time motion and reentry", async ({ page, context await expect(page.locator("main")).toHaveAttribute("data-phase", "MainMenu") await check("before-load") await page.keyboard.press("Backquote") - await page.locator("[aria-label='Console command']").fill("map jump_beef") + await page.locator("[aria-label='Console command']").fill(`map ${disabledTeam ? "pl_upward" : "jump_beef"}`) await page.keyboard.press("Enter") if (await page.locator("main").getAttribute("data-console-visible") === "true") await page.keyboard.press("Backquote") await expect(page.locator("main")).toHaveAttribute("data-team-selection-models", /reddoor:[^|]+:\d+:\d+/, { timeout: 75_000 }) + if (disabledTeam) { + await chooseTf2Team(page, "red", () => check("before-team-choice")) + await expect(page.locator("main")).toHaveAttribute("data-phase", "Ready", { timeout: 60_000 }) + await page.keyboard.press("Backquote") + await page.locator("[aria-label='Console command']").fill(`tf_bot_add 3 ${disabledTeam} soldier easy`) + await page.keyboard.press("Enter") + await expect(page.locator("main")).toHaveAttribute("data-bot-count", "3") + await page.keyboard.press("Backquote") + await page.keyboard.press("Period") + await expect(page.locator(`.team-selection-layer [data-vgui-name='teambutton${disabledTeam === "red" ? 1 : 0}']`)).toHaveAttribute("aria-disabled", "true") + } await page.waitForTimeout(2100) if (await startupConsoleIdle(sourceCacheDir) < 2000) throw new Error("Team-door motion requires genuine native idle") await check("before-motion") @@ -50,6 +64,30 @@ test("authored team doors real-time motion and reentry", async ({ page, context else await page.mouse.move(4, 4) await page.waitForTimeout(wait) } + if (disabledTeam) { + await hover(null, 200) + await hover(disabledTeam, 2350) + await hover(null, 350) + await check("before-focus") + // Explicit navigation invokes enter/exit; an initial desktop focus alone + // is not a cursor-enter event in the authored PC menu. + await page.keyboard.press("ArrowRight") + for (let index = 0; index < (disabledTeam === "red" ? 3 : 2); index++) await page.keyboard.press("ArrowRight") + actions.push({ at: Date.now(), focus: disabledTeam }) + await page.waitForTimeout(2350) + await page.keyboard.press("ArrowRight") + await page.waitForTimeout(350) + await check("before-reopen") + await page.keyboard.press("Escape") + await expect(page.locator("main")).toHaveAttribute("data-team-selection-visible", "false") + await page.keyboard.press("Period") + await expect(page.locator("main")).toHaveAttribute("data-team-selection-visible", "true") + actions.push({ at: Date.now(), reopen: true }) + await hover(disabledTeam, 100) + await hover(null, 100) + await hover(disabledTeam, 350) + await hover(null, 350) + } else { for (const team of ["auto", "blue", "red"] as const) { await hover(team, 550) await hover(null, 650) @@ -59,6 +97,7 @@ test("authored team doors real-time motion and reentry", async ({ page, context await hover(null, 70) await hover("red", 650) await hover(null, 650) + } await page.waitForTimeout(Math.max(0, 6000 - (Date.now() - started))) sampling = false await cdp.send("Page.stopScreencast") @@ -79,11 +118,16 @@ test("authored team doors real-time motion and reentry", async ({ page, context capture.bytes = bytes.length capture.sha256 = createHash("sha256").update(bytes).digest("hex") } - await writeFile(path.join(directory, "team-door-motion.json"), JSON.stringify({ activeMilliseconds, observation, actions, captures, heapBefore, heapAfter })) + await writeFile(path.join(directory, "team-door-motion.json"), JSON.stringify({ activeMilliseconds, disabledTeam, observation, actions, captures, heapBefore, heapAfter })) }) expect(activeMilliseconds).toBeGreaterThanOrEqual(5000) expect(activeMilliseconds).toBeLessThan(10000) expect(captures.length).toBeGreaterThan(30) + if (disabledTeam) { + const name = disabledTeam === "red" ? "reddoor" : "bluedoor" + expect(observation.doors.some((frame: any) => frame.panels.some((panel: any) => panel.name === name && panel.animation === "hover_disabled"))).toBe(true) + expect(observation.doors.at(-1).panels.find((panel: any) => panel.name === name).sequence).toBe("fullidle") + } } catch (error) { failure = String(error); throw error } finally { sampling = false diff --git a/tools/playsrc/src/profile-runner.ts b/tools/playsrc/src/profile-runner.ts index 25cbeaf13..467e436fa 100644 --- a/tools/playsrc/src/profile-runner.ts +++ b/tools/playsrc/src/profile-runner.ts @@ -122,6 +122,8 @@ const PROFILES = Object.freeze({ "class-selection": { config: "playwright.class-selection-profile.config.ts", target: "jump_beef" }, "selection-transition": { config: "playwright.selection-transition.config.ts", target: "pl_upward", minimumRemainingMilliseconds: 60_000 }, "team-door": { config: "playwright.team-door.config.ts", target: "jump_beef", minimumRemainingMilliseconds: 60_000 }, + "team-door-disabled-red": { config: "playwright.team-door-lifecycle.config.ts", target: "pl_upward", environment: { PLAYSRC_TEAM_DOOR_DISABLED: "red" }, minimumRemainingMilliseconds: 90_000 }, + "team-door-disabled-blue": { config: "playwright.team-door-lifecycle.config.ts", target: "pl_upward", environment: { PLAYSRC_TEAM_DOOR_DISABLED: "blue" }, minimumRemainingMilliseconds: 90_000 }, "selection-transition-cpu": { config: "playwright.selection-transition.config.ts", target: "pl_upward", environment: { PLAYSRC_SELECTION_CPU: "1" }, minimumRemainingMilliseconds: 60_000 }, equipment: { config: "playwright.equipment-profile.config.ts", target: "pl_upward" }, "melee-unlocks": { config: "playwright.melee-profile.config.ts", target: "pl_upward" }, From 175cffe1ceea51ae7834547d2053d5879443cbcf Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:27:52 +1000 Subject: [PATCH 06/13] Apply disabled-door changes on the authored button tick --- games/tf2/browser/src/team-selection/runtime.ts | 11 +++++------ .../tf2/browser/tests/team-selection/runtime.test.ts | 8 +++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/games/tf2/browser/src/team-selection/runtime.ts b/games/tf2/browser/src/team-selection/runtime.ts index 81122d748..8a53533ce 100644 --- a/games/tf2/browser/src/team-selection/runtime.ts +++ b/games/tf2/browser/src/team-selection/runtime.ts @@ -158,6 +158,7 @@ class Integration implements Tf2TeamSelectionIntegration { this.#onRequest = request.onRequest this.#onModelPanels = request.onModelPanels this.#clock = request.clock + this.#nextTick = Math.floor(request.clock.nowSeconds() * 1000) + 100 const initialized = initializeVguiRuntime({ runtimeIdentity: "tf2-team-selection", root: request.root, @@ -259,7 +260,7 @@ class Integration implements Tf2TeamSelectionIntegration { this.#entered.add(team) const button = this.#source.root.children.find(node => node.name === BUTTON_NAMES[team]) const delay = button ? Number(scalar(button, "hover") ?? -1) : -1 - if (delay > 0) this.#hoverDeadlines.set(team, this.#clock.nowSeconds() + delay) + if (delay > 0) this.#hoverDeadlines.set(team, Math.fround(Math.fround(this.#clock.nowSeconds()) + delay)) } else this.#entered.delete(team) this.#setAnimation(this.#modelName(team), `${entered ? "enter" : "exit"}_${this.#disabled.get(team) ? "disabled" : "enabled"}`) } @@ -275,7 +276,7 @@ class Integration implements Tf2TeamSelectionIntegration { else this.#setAnimation(this.#modelName(team), disabled ? "idle_disabled" : "idle_enabled") } const deadline = this.#hoverDeadlines.get(team) - if (deadline !== undefined && deadline < time) { + if (deadline !== undefined && deadline < Math.fround(time)) { this.#hoverDeadlines.delete(team) this.#setAnimation(this.#modelName(team), disabled ? "hover_disabled" : "hover_enabled") } @@ -289,8 +290,6 @@ class Integration implements Tf2TeamSelectionIntegration { this.#entered.clear() this.#hoverDeadlines.clear() for (const name of MODEL_NAMES) this.#setAnimation(name, "idle_enabled") - this.#tickButtons(this.#clock.nowSeconds()) - this.#nextTick = this.#clock.nowSeconds() + 0.1 } this.#runtime.deferPresentation(() => { apply(this.#runtime, { kind: "set-panel-state", panel: 1, visible: state.visible }) @@ -441,8 +440,8 @@ class Integration implements Tf2TeamSelectionIntegration { if (this.#state.visible) { apply(this.#runtime, { kind: "frame", timeSeconds }) this.#syncAccessibility() - if (timeSeconds >= this.#nextTick) { - this.#nextTick = timeSeconds + 0.1 + if (Math.floor(timeSeconds * 1000) >= this.#nextTick) { + this.#nextTick = Math.floor(timeSeconds * 1000) + 100 const before = this.#animationRevision this.#tickButtons(timeSeconds) if (before !== this.#animationRevision) this.#onModelPanels(this.modelPanels()) diff --git a/games/tf2/browser/tests/team-selection/runtime.test.ts b/games/tf2/browser/tests/team-selection/runtime.test.ts index f97f8bada..267c22bbe 100644 --- a/games/tf2/browser/tests/team-selection/runtime.test.ts +++ b/games/tf2/browser/tests/team-selection/runtime.test.ts @@ -29,6 +29,8 @@ function fixture(overrides: Partial = {}) { onModelPanels: (panels) => models.push(panels), }) integration.dispatch({ kind: "show", server }) + now = 0.1 + integration.frame(now) return Object.freeze({ ...base, server, integration, requests, models, frame(time: number) { now = time; integration.frame(time) } }) } @@ -46,11 +48,11 @@ describe("authored TF2 RED/BLU team-selection VGUI", () => { integration.dispatch({ kind: "hover", team: "red" }) integration.dispatch({ kind: "update", server: { ...server, redDisabled: true } }) expect(integration.state().hovered).toBe("red") - frame(0.1) + frame(0.2) expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.animation).toBe("enter_disabled") - frame(2.1) - expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.sequence).toBe("fullidle") frame(2.2) + expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.sequence).toBe("fullidle") + frame(2.3) expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.sequence).toBe("fullhover") integration.dispatch({ kind: "hover", team: null }) expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.sequence).toBe("fullidle") From 916f0440810236e361dc6f680c38e7ebedc0567e Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:36:16 +1000 Subject: [PATCH 07/13] Refresh visible team rules without recreating unchanged animations --- apps/web/tf2/src/runtime.ts | 22 +++++++++++++++++++++- tools/playsrc/profile/team-door.profile.ts | 13 +++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/web/tf2/src/runtime.ts b/apps/web/tf2/src/runtime.ts index 0df71f89f..95e64e051 100644 --- a/apps/web/tf2/src/runtime.ts +++ b/apps/web/tf2/src/runtime.ts @@ -480,6 +480,8 @@ export class Tf2Application { #pendingClassSelectionTeam?: 2 | 3 readonly #teamSelectionPoses = new Map() readonly #teamSelectionAnimations = new Map() + #teamSelectionUpdateTask: Promise | undefined + #teamSelectionUpdateTime = 0 #teamAdmission?: Readonly<{ generation: number; resolve(): void; reject(error: Error): void }> #hudRootCounts?: Readonly<{ playerStatus: number; ammo: number }> #hudContext?: SessionHudContext @@ -2469,6 +2471,7 @@ export class Tf2Application { this.#classSelection?.dispatch({ kind: "hide" }) this.#neutral() if (document.pointerLockElement === this.#canvas) await document.exitPointerLock() + this.#teamSelectionUpdateTime = 0 this.#teamSelection.dispatch({ kind: "show", server }) } @@ -2555,6 +2558,20 @@ export class Tf2Application { }).finally(() => { this.#teamSelectionRenderTask = undefined }) } + #updateTeamSelection(timeSeconds: number): void { + if (!this.#teamSelection?.state().visible || !this.#client || this.#teamSelectionUpdateTask + || timeSeconds < this.#teamSelectionUpdateTime) return + this.#teamSelectionUpdateTime = timeSeconds + 0.1 + const generation = this.#generation, epoch = this.#selectionViewportEpoch + this.#teamSelectionUpdateTask = this.#client.teamSelection(generation).then(server => { + if (generation !== this.#generation || epoch !== this.#selectionViewportEpoch || !this.#teamSelection?.state().visible) return + if (JSON.stringify(server) !== JSON.stringify(this.#teamSelection.state().server)) this.#teamSelection.dispatch({ kind: "update", server }) + }).catch(error => { + if (generation === this.#generation && epoch === this.#selectionViewportEpoch && this.#teamSelection?.state().visible) + this.#set({ phase: "Failed", gameUi: "failure", detail: this.#failureDetail(error, "TF2 team state update failed") }) + }).finally(() => { this.#teamSelectionUpdateTask = undefined }) + } + #hudPresentationObservation( observation?: ReturnType, currentBinding?: Tf2HudBinding, @@ -4637,7 +4654,10 @@ export class Tf2Application { if (this.#view.localMatchVisible) this.#localMatch?.frame(timeSeconds) if (this.#equipment?.visible()) { this.#equipment.frame(timeSeconds); this.#renderEquipment() } if (this.#classSelection?.state().visible) this.#classSelection.frame(timeSeconds) - if (this.#teamSelection?.state().visible) this.#teamSelection.frame(timeSeconds) + if (this.#teamSelection?.state().visible) { + this.#updateTeamSelection(timeSeconds) + this.#teamSelection.frame(timeSeconds) + } } catch (error) { this.#paused = true this.#set({ phase: "Failed", gameUi: "failure", detail: this.#failureDetail(error, "VGUI frame failed") }) diff --git a/tools/playsrc/profile/team-door.profile.ts b/tools/playsrc/profile/team-door.profile.ts index 899546fb1..5d8cd69f3 100644 --- a/tools/playsrc/profile/team-door.profile.ts +++ b/tools/playsrc/profile/team-door.profile.ts @@ -74,7 +74,7 @@ test("authored team doors real-time motion and reentry", async ({ page, context await page.keyboard.press("ArrowRight") for (let index = 0; index < (disabledTeam === "red" ? 3 : 2); index++) await page.keyboard.press("ArrowRight") actions.push({ at: Date.now(), focus: disabledTeam }) - await page.waitForTimeout(2350) + await page.waitForTimeout(350) await page.keyboard.press("ArrowRight") await page.waitForTimeout(350) await check("before-reopen") @@ -87,6 +87,15 @@ test("authored team doors real-time motion and reentry", async ({ page, context await hover(null, 100) await hover(disabledTeam, 350) await hover(null, 350) + await page.keyboard.press("Backquote") + await page.locator("[aria-label='Console command']").fill("tf_bot_kick all") + await page.keyboard.press("Enter") + await expect(page.locator("main")).toHaveAttribute("data-bot-count", "0") + await page.keyboard.press("Backquote") + await expect(page.locator(`.team-selection-layer [data-vgui-name='teambutton${disabledTeam === "red" ? 1 : 0}']`)).toHaveAttribute("aria-disabled", "false") + actions.push({ at: Date.now(), enabled: disabledTeam }) + await hover(disabledTeam, 550) + await hover(null, 650) } else { for (const team of ["auto", "blue", "red"] as const) { await hover(team, 550) @@ -126,7 +135,7 @@ test("authored team doors real-time motion and reentry", async ({ page, context if (disabledTeam) { const name = disabledTeam === "red" ? "reddoor" : "bluedoor" expect(observation.doors.some((frame: any) => frame.panels.some((panel: any) => panel.name === name && panel.animation === "hover_disabled"))).toBe(true) - expect(observation.doors.at(-1).panels.find((panel: any) => panel.name === name).sequence).toBe("fullidle") + expect(observation.doors.at(-1).panels.find((panel: any) => panel.name === name).sequence).toBe("hoverclose") } } catch (error) { failure = String(error); throw error } finally { From 2c5ca6238bed411d33949b627e064be04a4e36f7 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:41:39 +1000 Subject: [PATCH 08/13] Keep short panel sequences alive until authored cross-fades finish --- apps/web/tf2/src/runtime.ts | 2 +- apps/web/tf2/src/team-model-playback.ts | 5 +++-- apps/web/tf2/tests/team-model-playback.test.ts | 8 ++++++++ games/tf2/browser/src/presentation.ts | 7 ++++--- games/tf2/wasm/src/lib.rs | 10 +++++++++- packages/formats/studio-model/rust/src/presentation.rs | 2 ++ .../studio-model/rust/src/sequence_transition.rs | 1 + 7 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/web/tf2/src/runtime.ts b/apps/web/tf2/src/runtime.ts index 95e64e051..80656242b 100644 --- a/apps/web/tf2/src/runtime.ts +++ b/apps/web/tf2/src/runtime.ts @@ -2521,7 +2521,7 @@ export class Tf2Application { const selected = requests.find((candidate) => candidate.request.identity === item.identity) if (!selected) throw new Error("TF2 team-door pose identity differs from its authored request") this.#teamSelectionPoses.set(selected.panel, item) - this.#teamSelectionAnimations.set(selected.panel, { ...selected.state, sampledSeconds: selected.request.elapsedSeconds }) + this.#teamSelectionAnimations.set(selected.panel, { ...selected.state, sampledSeconds: selected.request.elapsedSeconds, transitioning: item.transitioning }) } } const panels: readonly ModelPanelPass[] = authored.map((panel, index) => { diff --git a/apps/web/tf2/src/team-model-playback.ts b/apps/web/tf2/src/team-model-playback.ts index 4e99bd1a3..5509311c8 100644 --- a/apps/web/tf2/src/team-model-playback.ts +++ b/apps/web/tf2/src/team-model-playback.ts @@ -6,6 +6,7 @@ export type TeamModelPlayback = Readonly<{ previousSeconds: number lastPaintSeconds: number sampledSeconds: number | null + transitioning: boolean }> /** Presentation scheduling only; the studio owner resolves cycle and pose. */ @@ -18,7 +19,7 @@ export function teamModelPlayback( const reset = !prior || prior.modelRevision !== panel.modelRevision const changed = reset || prior?.animationRevision !== panel.animationRevision const current: TeamModelPlayback = changed - ? { ...panel, startedSeconds: now, previousSeconds: 0, lastPaintSeconds: now, sampledSeconds: null } + ? { ...panel, startedSeconds: now, previousSeconds: 0, lastPaintSeconds: now, sampledSeconds: null, transitioning: false } : prior! const elapsed = Math.min(Math.max(0, now - current.startedSeconds), duration) return { @@ -26,7 +27,7 @@ export function teamModelPlayback( elapsed, previousElapsed: Math.min(current.previousSeconds, elapsed), frameTime: Math.max(0, now - (prior?.lastPaintSeconds ?? now)), - sample: current.sampledSeconds === null || current.sampledSeconds < duration, + sample: current.sampledSeconds === null || current.sampledSeconds < duration || current.transitioning, reset, } } diff --git a/apps/web/tf2/tests/team-model-playback.test.ts b/apps/web/tf2/tests/team-model-playback.test.ts index 6dcacc041..2f35a143d 100644 --- a/apps/web/tf2/tests/team-model-playback.test.ts +++ b/apps/web/tf2/tests/team-model-playback.test.ts @@ -24,3 +24,11 @@ test("idle, same-sequence commands, interrupted changes and recreated panels can } expect(teamModelPlayback(undefined, { ...panel, sequence: "idle" }, 0, 0).sample).toBe(true) }) + +test("short sequences keep sampling until the studio transition owner has finished fading", () => { + const hover = { ...panel, sequence: "hover" } + const initial = teamModelPlayback(undefined, hover, 1, 5 / 30) + const ended = { ...initial.state, sampledSeconds: 5 / 30, transitioning: true } + expect(teamModelPlayback(ended, hover, 1.18, 5 / 30).sample).toBe(true) + expect(teamModelPlayback({ ...ended, transitioning: false }, hover, 1.21, 5 / 30).sample).toBe(false) +}) diff --git a/games/tf2/browser/src/presentation.ts b/games/tf2/browser/src/presentation.ts index 989ff328f..258b39020 100644 --- a/games/tf2/browser/src/presentation.ts +++ b/games/tf2/browser/src/presentation.ts @@ -391,6 +391,7 @@ export type PosedModel = Readonly<{ cyclesPerSecond: number durationSeconds: number looping: boolean + transitioning: boolean previousCycle: number cycle: number boneMatrices: Float32Array @@ -591,8 +592,8 @@ export function decodeModelPoseOutput(bytes: Uint8Array): readonly PosedModel[] wearable = Object.freeze({ itemId, effect, controlPoint, particleBytes }) } const model = text(), activity = text(), sequence = u32(), framesPerSecond = f32(), weightedFrameCount = f32(), - cyclesPerSecond = f32(), durationSeconds = f32(), looping = u8() - if (looping > 1 || u8() || u8() || u8()) throw new ProjectilePresentationError("MalformedFact", "model pose timing") + cyclesPerSecond = f32(), durationSeconds = f32(), looping = u8(), transitioning = u8() + if (looping > 1 || transitioning > 1 || u8() || u8()) throw new ProjectilePresentationError("MalformedFact", "model pose timing") const previousCycle = f32(), cycle = f32() const present=u8();if(present>1||u8()||u8()||u8())throw new ProjectilePresentationError("MalformedFact","viewmodel state");const values=present===1?new Array(15):null;for(let index=0;index<15;index+=1){const value=f32();if(values)values[index]=value}const passRestored=u8(),depthRestored=u8(),itemTranslucent=u8();if(u8())throw new ProjectilePresentationError("MalformedFact","viewmodel flags") const phase=u8(),drawDisposition=u8(),suppression=u8(),reflected=u8(),frontFace=u8(),cullFace=u8(),restoredCull=u8(),reserved=u8() @@ -691,7 +692,7 @@ export function decodeModelPoseOutput(bytes: Uint8Array): readonly PosedModel[] return Object.freeze({ primitive, indices, positions, normals }) })) if (attachmentMode === 1 && (primitives.length !== 0 || eyes.length !== 0 || flex.length !== 0)) throw new ProjectilePresentationError("MalformedFact", "attachment-only pose contains geometry") - output.push(Object.freeze({ identity, cloak, sampleTick, attachmentsOnly: attachmentMode === 1, attachmentsWorld: attachmentsWorld === 1, role: (["single", "hand", "item", "wearable"] as const)[roleCode]!, wearable, model, activity, sequence, framesPerSecond, weightedFrameCount, cyclesPerSecond, durationSeconds, looping: looping === 1, previousCycle, cycle, boneMatrices, events, primitives, attachments, lighting, eyes, flex, viewmodel })) + output.push(Object.freeze({ identity, cloak, sampleTick, attachmentsOnly: attachmentMode === 1, attachmentsWorld: attachmentsWorld === 1, role: (["single", "hand", "item", "wearable"] as const)[roleCode]!, wearable, model, activity, sequence, framesPerSecond, weightedFrameCount, cyclesPerSecond, durationSeconds, looping: looping === 1, transitioning: transitioning === 1, previousCycle, cycle, boneMatrices, events, primitives, attachments, lighting, eyes, flex, viewmodel })) } if (at !== bytes.length) throw new ProjectilePresentationError("MalformedFact", "model pose output trailing bytes") return Object.freeze(output) diff --git a/games/tf2/wasm/src/lib.rs b/games/tf2/wasm/src/lib.rs index db3a87171..7ef32ee33 100644 --- a/games/tf2/wasm/src/lib.rs +++ b/games/tf2/wasm/src/lib.rs @@ -2487,6 +2487,7 @@ struct ModelPoseRequest { class_selection: bool, model_panel: bool, entity_model_panel: bool, + pose_transitioning: bool, model_panel_reset: bool, flex_controllers: Option>, actor_identity: u32, @@ -2903,6 +2904,7 @@ fn decode_model_requests(bytes: &[u8]) -> Result, ()> { class_selection: kind == 3, model_panel: matches!(kind, 3 | 4 | 6), entity_model_panel: kind == 8, + pose_transitioning: false, model_panel_reset: model_panel_reset != 0, flex_controllers: None, actor_identity: if kind == 7 { 0 } else { actor_identity }, @@ -3378,6 +3380,12 @@ fn encode_model_poses( entry.insert(pose); } let pose = sampled_poses.get(&pose_key).ok_or(())?; + let mut entity_request; + let request = if request.entity_model_panel { + entity_request = request.clone(); + entity_request.pose_transitioning = class_scenes.get(&request.identity).ok_or(())?.transitions.is_transitioning(); + &entity_request + } else { request }; stage = "world-model-primitives"; let selected = playsrc_studio_model::select_primitives( model, @@ -3691,7 +3699,7 @@ fn encode_model_pose_part( ] { out.extend_from_slice(&value.0.to_le_bytes()); } - out.extend_from_slice(&[u8::from(timing.looping), 0, 0, 0]); + out.extend_from_slice(&[u8::from(timing.looping), u8::from(request.pose_transitioning), 0, 0]); out.extend_from_slice(&previous_cycle.to_le_bytes()); out.extend_from_slice(&cycle.to_le_bytes()); if let Some(view) = view { diff --git a/packages/formats/studio-model/rust/src/presentation.rs b/packages/formats/studio-model/rust/src/presentation.rs index 8860f746a..11962c0b6 100644 --- a/packages/formats/studio-model/rust/src/presentation.rs +++ b/packages/formats/studio-model/rust/src/presentation.rs @@ -5654,6 +5654,7 @@ mod tests { let mut transitions = crate::SequenceTransitioner::default(); assert!(transitions.update(&model, 0, 1.0, 1.0, 0.99, ¶meters).unwrap().is_empty()); let layers = transitions.update(&model, 1, 0.0, 1.02, 1.0, ¶meters).unwrap(); + assert!(transitions.is_transitioning()); assert_eq!(layers.len(), 1); assert_eq!(layers[0].sequence, 0); assert!((f32::from_bits(layers[0].weight.0) - 0.972).abs() < 1e-5); @@ -5667,6 +5668,7 @@ mod tests { let interrupted = transitions.update(&model, 0, 0.0, 1.05, 1.04, ¶meters).unwrap(); assert_eq!(interrupted.iter().map(|v| v.sequence).collect::>(), [1, 0]); assert!(transitions.update(&model, 0, 1.0, 1.3, 1.29, ¶meters).unwrap().is_empty()); + assert!(!transitions.is_transitioning()); // A terminal pose may have been reused for many paints; its next fade // starts at the previous paint, not at its last expensive pose sample. let resumed = transitions.update(&model, 1, 0.0, 5.02, 5.0, ¶meters).unwrap(); diff --git a/packages/formats/studio-model/rust/src/sequence_transition.rs b/packages/formats/studio-model/rust/src/sequence_transition.rs index 7e934eabe..ee94f5a4a 100644 --- a/packages/formats/studio-model/rust/src/sequence_transition.rs +++ b/packages/formats/studio-model/rust/src/sequence_transition.rs @@ -16,6 +16,7 @@ pub struct SequenceTransitioner { impl SequenceTransitioner { pub fn clear(&mut self) { self.queue.clear(); } + pub fn is_transitioning(&self) -> bool { self.queue.len() > 1 } /// `previous_paint_time` also accounts for paints that reused a terminal pose. /// Finished sequences still participate in the next authored cross-fade. From 8f8a1c986e978ef7c97b7ae2e5313803c6aa0805 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:47:21 +1000 Subject: [PATCH 09/13] Carry per-panel sequence reset parity into studio transitions --- apps/web/tf2/src/runtime.ts | 1 + games/tf2/browser/src/presentation.ts | 4 +++- games/tf2/browser/src/team-selection/runtime.ts | 3 ++- .../tests/team-selection/pose-request.test.ts | 7 ++++--- games/tf2/wasm/src/lib.rs | 8 +++++--- .../studio-model/rust/src/presentation.rs | 17 ++++++++++------- .../rust/src/sequence_transition.rs | 11 +++++++---- 7 files changed, 32 insertions(+), 19 deletions(-) diff --git a/apps/web/tf2/src/runtime.ts b/apps/web/tf2/src/runtime.ts index 80656242b..d1213d861 100644 --- a/apps/web/tf2/src/runtime.ts +++ b/apps/web/tf2/src/runtime.ts @@ -2498,6 +2498,7 @@ export class Tf2Application { const request = Object.freeze({ identity: 0x1000 + index, entityModelPanel: true, + entityAnimationRevision: panel.animationRevision, modelPanelReset: step.reset, model: panel.model.toLowerCase(), activity: panel.sequence, diff --git a/games/tf2/browser/src/presentation.ts b/games/tf2/browser/src/presentation.ts index 258b39020..52d75e9ee 100644 --- a/games/tf2/browser/src/presentation.ts +++ b/games/tf2/browser/src/presentation.ts @@ -335,6 +335,7 @@ export type ModelPoseRequest = Readonly<{ controlPoint?: number modelPanel?: boolean entityModelPanel?: boolean + entityAnimationRevision?: number modelPanelReset?: boolean cloak?: ActorCloakState actorIdentity?: number @@ -433,6 +434,7 @@ export function encodeModelPoseBatch(requests: readonly ModelPoseRequest[]): Uin || (request.worldItem !== undefined && typeof request.worldItem !== "boolean") || (request.modelPanel !== undefined && typeof request.modelPanel !== "boolean") || (request.entityModelPanel !== undefined && typeof request.entityModelPanel !== "boolean") + || (Boolean(request.entityModelPanel) !== (request.entityAnimationRevision !== undefined)) || (request.entityModelPanel && (request.modelPanel || request.classSelection || request.itemModel || request.phase !== undefined || request.cloak || request.worldItem || request.handsOnlyViewmodel || request.controlPoint !== undefined || request.actorIdentity !== undefined || request.itemDefinition !== undefined || request.packedBody !== undefined)) || (request.modelPanelReset !== undefined && typeof request.modelPanelReset !== "boolean") || ((request.classSelection || request.modelPanel) && ((request.itemModel !== undefined && (request.classSelection || !request.worldItem)) || request.handsOnlyViewmodel || request.phase !== undefined || request.cloak !== undefined)) @@ -449,7 +451,7 @@ export function encodeModelPoseBatch(requests: readonly ModelPoseRequest[]): Uin const cloak = request.cloak if (cloak && (!Number.isSafeInteger(cloak.identity) || cloak.identity < 1 || cloak.identity > 0xffff_ffff)) throw new ProjectilePresentationError("MalformedFact", "model actor identity") if (request.controlPoint !== undefined && (!Number.isSafeInteger(request.controlPoint) || request.controlPoint <= 0 || request.controlPoint > 0xffff_ffff || request.actorIdentity !== undefined || cloak || request.itemModel || request.classSelection || request.modelPanel)) throw new ProjectilePresentationError("MalformedFact", "control point pose identity") - const actorIdentity = request.actorIdentity ?? cloak?.identity ?? 0 + const actorIdentity = request.entityAnimationRevision ?? request.actorIdentity ?? cloak?.identity ?? 0 if (!Number.isSafeInteger(actorIdentity) || actorIdentity < 0 || actorIdentity > UINT32_MAX || (cloak && cloak.identity !== actorIdentity)) throw new ProjectilePresentationError("MalformedFact", "model actor identity") view.setUint32(at, request.controlPoint ?? actorIdentity, true); at += 4 for (const value of cloak ? [cloak.localFactor, cloak.worldFactor, cloak.rawFactor, ...cloak.playerTint] : [0, 0, 0, 0, 0, 0]) { diff --git a/games/tf2/browser/src/team-selection/runtime.ts b/games/tf2/browser/src/team-selection/runtime.ts index 8a53533ce..0f34c2853 100644 --- a/games/tf2/browser/src/team-selection/runtime.ts +++ b/games/tf2/browser/src/team-selection/runtime.ts @@ -251,7 +251,8 @@ class Integration implements Tf2TeamSelectionIntegration { const model = authored && object(authored, "model") if (!model?.children.some(child => child.name.toLowerCase() === "animation" && scalar(child, "name") === animation)) return this.#animations.set(name, animation) - this.#animationRevisions.set(name, ++this.#animationRevision) + this.#animationRevision += 1 + this.#animationRevisions.set(name, ((this.#animationRevisions.get(name) ?? 0) + 1) >>> 0) } #enter(team: Tf2TeamChoice, entered: boolean): void { diff --git a/games/tf2/browser/tests/team-selection/pose-request.test.ts b/games/tf2/browser/tests/team-selection/pose-request.test.ts index a541e3b4f..c8cd6799e 100644 --- a/games/tf2/browser/tests/team-selection/pose-request.test.ts +++ b/games/tf2/browser/tests/team-selection/pose-request.test.ts @@ -3,7 +3,7 @@ import { encodeModelPoseBatch, type ModelPoseRequest } from "../../src/presentat const request: ModelPoseRequest = { identity: 4098, model: "models/vgui/ui_team01_red.mdl", activity: "hoveropen", - entityModelPanel: true, modelPanelReset: true, + entityModelPanel: true, entityAnimationRevision: 1, modelPanelReset: true, previousElapsedSeconds: 0, elapsedSeconds: 0, currentTimeSeconds: 1, frameTimeSeconds: 0.015, planarSpeed: 0, screenAspectRatio: 16 / 9, worldFarPlane: 1000, skin: 0, lod: 0, bodygroups: [0], } @@ -11,8 +11,9 @@ test("entity panel animation requests have a distinct owner without changing oth const bytes = encodeModelPoseBatch([request]) expect(bytes[52]).toBe(8) expect(bytes[55]).toBe(1) - expect(encodeModelPoseBatch([{ ...request, entityModelPanel: false, modelPanel: true }])[52]).toBe(4) - expect(encodeModelPoseBatch([{ ...request, entityModelPanel: false, modelPanelReset: false }])[52]).toBe(0) + expect(encodeModelPoseBatch([{ ...request, entityModelPanel: false, entityAnimationRevision: undefined, modelPanel: true }])[52]).toBe(4) + expect(encodeModelPoseBatch([{ ...request, entityModelPanel: false, entityAnimationRevision: undefined, modelPanelReset: false }])[52]).toBe(0) + expect(new DataView(bytes.buffer).getUint32(16, true)).toBe(1) expect(() => encodeModelPoseBatch([{ ...request, modelPanel: true }])).toThrow() expect(() => encodeModelPoseBatch([{ ...request, actorIdentity: 1 }])).toThrow() }) diff --git a/games/tf2/wasm/src/lib.rs b/games/tf2/wasm/src/lib.rs index 7ef32ee33..7c49a84a4 100644 --- a/games/tf2/wasm/src/lib.rs +++ b/games/tf2/wasm/src/lib.rs @@ -2487,6 +2487,7 @@ struct ModelPoseRequest { class_selection: bool, model_panel: bool, entity_model_panel: bool, + entity_animation_revision: u32, pose_transitioning: bool, model_panel_reset: bool, flex_controllers: Option>, @@ -2757,7 +2758,7 @@ fn decode_model_requests(bytes: &[u8]) -> Result, ()> { || (attachments_only == 1 && (kind != 1 || has_fire_view != 1)) || (has_fire_view == 1 && kind != 1) || model_panel_reset > 1 || (model_panel_reset != 0 && !matches!(kind, 3 | 4 | 6 | 8)) - || (kind == 8 && (actor_identity != 0 || has_cloak || hud_model || item_definition.is_some())) + || (kind == 8 && (has_cloak || hud_model || item_definition.is_some())) || item_definition.is_some_and(|definition| playsrc_tf2::equipment::supported_item(definition).is_none()) || item_definition.is_some() && !matches!(kind, 3 | 4 | 5 | 6) && activity_start_tick.is_none() { @@ -2904,10 +2905,11 @@ fn decode_model_requests(bytes: &[u8]) -> Result, ()> { class_selection: kind == 3, model_panel: matches!(kind, 3 | 4 | 6), entity_model_panel: kind == 8, + entity_animation_revision: if kind == 8 { actor_identity } else { 0 }, pose_transitioning: false, model_panel_reset: model_panel_reset != 0, flex_controllers: None, - actor_identity: if kind == 7 { 0 } else { actor_identity }, + actor_identity: if matches!(kind, 7 | 8) { 0 } else { actor_identity }, cloak: if kind == 7 { None } else { cloak }, preparation, hud_model, @@ -3362,7 +3364,7 @@ fn encode_model_poses( retained.model = request.model.clone(); } let layers = retained.transitions.update(model, sequence, cycle, request.current_time, - (request.current_time - request.frame_time).max(0.0), &pose_parameters).map_err(|_| ())?; + (request.current_time - request.frame_time).max(0.0), &pose_parameters, request.entity_animation_revision).map_err(|_| ())?; (sequence, cycle, layers) } else { (sequence, cycle, Vec::new()) }; let pose = playsrc_studio_model::sample_pose_at_time( diff --git a/packages/formats/studio-model/rust/src/presentation.rs b/packages/formats/studio-model/rust/src/presentation.rs index 11962c0b6..41dc245d4 100644 --- a/packages/formats/studio-model/rust/src/presentation.rs +++ b/packages/formats/studio-model/rust/src/presentation.rs @@ -5652,8 +5652,8 @@ mod tests { } let parameters = vec![float(0.0)]; let mut transitions = crate::SequenceTransitioner::default(); - assert!(transitions.update(&model, 0, 1.0, 1.0, 0.99, ¶meters).unwrap().is_empty()); - let layers = transitions.update(&model, 1, 0.0, 1.02, 1.0, ¶meters).unwrap(); + assert!(transitions.update(&model, 0, 1.0, 1.0, 0.99, ¶meters, 0).unwrap().is_empty()); + let layers = transitions.update(&model, 1, 0.0, 1.02, 1.0, ¶meters, 0).unwrap(); assert!(transitions.is_transitioning()); assert_eq!(layers.len(), 1); assert_eq!(layers[0].sequence, 0); @@ -5665,18 +5665,21 @@ mod tests { let world = apply_entity_transform(&model, &pose, source_entity_transform(vector([290.0, 0.0, -34.0]), vector([0.0, 180.0, 0.0])).unwrap()).unwrap(); let matrix = world.bone_matrices[1].0.map(|v| f32::from_bits(v.0)); assert!(matrix[8] > 0.0 && -matrix[4] > 0.0, "positive local rotation raises the screen-right axis; do not negate it"); - let interrupted = transitions.update(&model, 0, 0.0, 1.05, 1.04, ¶meters).unwrap(); + let interrupted = transitions.update(&model, 0, 0.0, 1.05, 1.04, ¶meters, 0).unwrap(); assert_eq!(interrupted.iter().map(|v| v.sequence).collect::>(), [1, 0]); - assert!(transitions.update(&model, 0, 1.0, 1.3, 1.29, ¶meters).unwrap().is_empty()); + assert!(transitions.update(&model, 0, 1.0, 1.3, 1.29, ¶meters, 0).unwrap().is_empty()); assert!(!transitions.is_transitioning()); // A terminal pose may have been reused for many paints; its next fade // starts at the previous paint, not at its last expensive pose sample. - let resumed = transitions.update(&model, 1, 0.0, 5.02, 5.0, ¶meters).unwrap(); + let resumed = transitions.update(&model, 1, 0.0, 5.02, 5.0, ¶meters, 0).unwrap(); assert!((f32::from_bits(resumed[0].weight.0) - 0.972).abs() < 1e-5); transitions.clear(); - assert!(transitions.update(&model, 1, 0.0, 6.0, 5.99, ¶meters).unwrap().is_empty()); + assert!(transitions.update(&model, 1, 0.0, 6.0, 5.99, ¶meters, 0).unwrap().is_empty()); + assert_eq!(transitions.update(&model, 1, 0.0, 6.01, 6.0, ¶meters, 1).unwrap().len(), 1); + assert!(transitions.update(&model, 1, 1.0, 6.3, 6.29, ¶meters, 1).unwrap().is_empty()); + assert!(transitions.update(&model, 1, 0.0, 6.4, 6.39, ¶meters, 9).unwrap().is_empty(), "three-bit parity wraps without a forced transition"); model.sequences[0].flags |= 2; - assert!(transitions.update(&model, 0, 0.0, 6.01, 6.0, ¶meters).unwrap().is_empty()); + assert!(transitions.update(&model, 0, 0.0, 6.5, 6.49, ¶meters, 10).unwrap().is_empty()); } #[test] diff --git a/packages/formats/studio-model/rust/src/sequence_transition.rs b/packages/formats/studio-model/rust/src/sequence_transition.rs index ee94f5a4a..1d74b5bf1 100644 --- a/packages/formats/studio-model/rust/src/sequence_transition.rs +++ b/packages/formats/studio-model/rust/src/sequence_transition.rs @@ -6,6 +6,7 @@ struct Entry { cycle: f32, time: f32, fade_out: f32, + parity: u8, } /// C_BaseAnimating sequence transitions, distinct from studio frame interpolation. @@ -28,31 +29,33 @@ impl SequenceTransitioner { time: f32, previous_paint_time: f32, parameters: &[Float32], + revision: u32, ) -> Result, PresentationError> { let invalid = || PresentationError { code: PresentationErrorCode::InvalidState, identity: model.identity.clone() }; if ![cycle, time, previous_paint_time].into_iter().all(f32::is_finite) || previous_paint_time > time { return Err(invalid()); } let incoming = model.sequences.get(sequence).ok_or_else(invalid)?; + let parity = (revision & 7) as u8; if let Some(current) = self.queue.last_mut() { if current.time < previous_paint_time { let timing = sequence_timing(model, current.sequence, parameters)?; current.cycle = advance(current.cycle, previous_paint_time - current.time, timing); current.time = previous_paint_time; } - if current.time != 0.0 && current.sequence != sequence { + if current.time != 0.0 && (current.sequence != sequence || current.parity != parity) { if incoming.flags & 2 != 0 { self.queue.clear(); } else { current.fade_out = f32::from_bits(model.sequences[current.sequence].fade_out.0) .min(f32::from_bits(incoming.fade_in.0)); } - self.queue.push(Entry { sequence, cycle, time, fade_out: 0.0 }); + self.queue.push(Entry { sequence, cycle, time, fade_out: 0.0, parity }); } else { - *current = Entry { sequence, cycle, time, fade_out: 0.0 }; + *current = Entry { sequence, cycle, time, fade_out: 0.0, parity }; } } else { - self.queue.push(Entry { sequence, cycle, time, fade_out: 0.0 }); + self.queue.push(Entry { sequence, cycle, time, fade_out: 0.0, parity }); } let last = self.queue.len() - 1; let mut index = 0; From a99a3f036fb465509a5a4d40bad2032d32ec1db0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:49:31 +1000 Subject: [PATCH 10/13] Advance restarted panel sequences through their first paint interval --- apps/web/tf2/src/team-model-playback.ts | 2 +- apps/web/tf2/tests/team-model-playback.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/tf2/src/team-model-playback.ts b/apps/web/tf2/src/team-model-playback.ts index 5509311c8..9ceb8ab28 100644 --- a/apps/web/tf2/src/team-model-playback.ts +++ b/apps/web/tf2/src/team-model-playback.ts @@ -19,7 +19,7 @@ export function teamModelPlayback( const reset = !prior || prior.modelRevision !== panel.modelRevision const changed = reset || prior?.animationRevision !== panel.animationRevision const current: TeamModelPlayback = changed - ? { ...panel, startedSeconds: now, previousSeconds: 0, lastPaintSeconds: now, sampledSeconds: null, transitioning: false } + ? { ...panel, startedSeconds: reset ? now : prior!.lastPaintSeconds, previousSeconds: 0, lastPaintSeconds: now, sampledSeconds: null, transitioning: false } : prior! const elapsed = Math.min(Math.max(0, now - current.startedSeconds), duration) return { diff --git a/apps/web/tf2/tests/team-model-playback.test.ts b/apps/web/tf2/tests/team-model-playback.test.ts index 2f35a143d..d47d41884 100644 --- a/apps/web/tf2/tests/team-model-playback.test.ts +++ b/apps/web/tf2/tests/team-model-playback.test.ts @@ -17,9 +17,9 @@ test("idle, same-sequence commands, interrupted changes and recreated panels can { ...panel, animationRevision: 2 }, { ...panel, modelRevision: 2 }, ]) { - const step = teamModelPlayback(done, next, 2, 1) + const step = teamModelPlayback(done, next, 1.02, 1) expect(step.sample).toBe(true) - expect(step.elapsed).toBe(0) + expect(step.elapsed).toBeCloseTo(next.modelRevision === panel.modelRevision ? 0.02 : 0, 8) expect(step.previousElapsed).toBe(0) } expect(teamModelPlayback(undefined, { ...panel, sequence: "idle" }, 0, 0).sample).toBe(true) From bbd5918a857462e5307c90b3e56fab237429b51f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:51:53 +1000 Subject: [PATCH 11/13] Require settled terminal door poses in native motion acceptance --- tools/playsrc/profile/team-door.profile.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/playsrc/profile/team-door.profile.ts b/tools/playsrc/profile/team-door.profile.ts index 5d8cd69f3..a72656f9f 100644 --- a/tools/playsrc/profile/team-door.profile.ts +++ b/tools/playsrc/profile/team-door.profile.ts @@ -137,6 +137,11 @@ test("authored team doors real-time motion and reentry", async ({ page, context expect(observation.doors.some((frame: any) => frame.panels.some((panel: any) => panel.name === name && panel.animation === "hover_disabled"))).toBe(true) expect(observation.doors.at(-1).panels.find((panel: any) => panel.name === name).sequence).toBe("hoverclose") } + for (const name of disabledTeam ? [disabledTeam === "red" ? "reddoor" : "bluedoor"] : ["autodoor", "bluedoor", "reddoor"]) { + const final = observation.doors.at(-1).panels.find((panel: any) => panel.name === name) + expect(final.cycle).toBe(1) + expect(final.timing.transitioning).toBe(false) + } } catch (error) { failure = String(error); throw error } finally { sampling = false From 6fb84fec64b8ccd8308e40cc5e70634e6ffe92fe Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:04:43 +1000 Subject: [PATCH 12/13] Keep panel animation owners stable across optional panel visibility --- apps/web/tf2/src/runtime.ts | 4 ++-- games/tf2/browser/src/team-selection/runtime.ts | 2 ++ games/tf2/browser/tests/team-selection/runtime.test.ts | 8 ++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/tf2/src/runtime.ts b/apps/web/tf2/src/runtime.ts index d1213d861..727367154 100644 --- a/apps/web/tf2/src/runtime.ts +++ b/apps/web/tf2/src/runtime.ts @@ -2488,7 +2488,7 @@ export class Tf2Application { const authored = this.#teamSelectionModelPanels const viewport = this.#viewport() this.#teamSelectionRenderTask = (async () => { - const requests = authored.flatMap((panel, index) => { + const requests = authored.flatMap((panel) => { const artifact = artifacts.models.get(panel.model.toLowerCase()) const timing = artifact?.sequences.find((sequence) => sequence.label.toLowerCase() === panel.sequence.toLowerCase()) if (!artifact || !timing) throw new Error(`TF2 authored team-door sequence is unavailable: ${panel.model}:${panel.sequence}`) @@ -2496,7 +2496,7 @@ export class Tf2Application { this.#teamSelectionAnimations.set(panel.name, step.state) if (!step.sample) return [] const request = Object.freeze({ - identity: 0x1000 + index, + identity: 0x1000 + panel.panelId, entityModelPanel: true, entityAnimationRevision: panel.animationRevision, modelPanelReset: step.reset, diff --git a/games/tf2/browser/src/team-selection/runtime.ts b/games/tf2/browser/src/team-selection/runtime.ts index 0f34c2853..83f86bc5b 100644 --- a/games/tf2/browser/src/team-selection/runtime.ts +++ b/games/tf2/browser/src/team-selection/runtime.ts @@ -58,6 +58,7 @@ const LIMITS: VguiRuntimeLimits = Object.freeze({ export type Tf2TeamSelectionModelPanel = Readonly<{ name: typeof MODEL_NAMES[number] + panelId: VguiPanelId model: string skin: number fov: number @@ -378,6 +379,7 @@ class Integration implements Tf2TeamSelectionIntegration { && child.value === null && scalar(child, "name") === animation) return [Object.freeze({ name, + panelId: snapshot.id, model: scalar(model, "modelname") ?? "", skin: authoredNumber(model, "skin"), fov: authoredNumber(authored, "fov"), diff --git a/games/tf2/browser/tests/team-selection/runtime.test.ts b/games/tf2/browser/tests/team-selection/runtime.test.ts index 267c22bbe..a46aaccf4 100644 --- a/games/tf2/browser/tests/team-selection/runtime.test.ts +++ b/games/tf2/browser/tests/team-selection/runtime.test.ts @@ -70,6 +70,14 @@ describe("authored TF2 RED/BLU team-selection VGUI", () => { expect(exited.animationRevision).toBeGreaterThan(entered.animationRevision) expect(integration.dispatch({ kind: "select", team: "red" }).disposition).toBe("ignored") }) + + test("retains model entity identities when optional neighbouring panels disappear", () => { + const { integration, server } = fixture() + const before = integration.modelPanels().find(panel => panel.name === "spectate")!.panelId + integration.dispatch({ kind: "update", server: { ...server, autoAssignVisible: false } }) + expect(integration.modelPanels().find(panel => panel.name === "spectate")!.panelId).toBe(before) + expect(new Set(integration.modelPanels().map(panel => panel.panelId)).size).toBe(integration.modelPanels().length) + }) test("consumes owned repeats without autoassigning or moving authored focus", () => { const { integration, requests } = fixture() const focused = integration.state().focused From 95e86b33de901617cc6b45c7686cea95a65570fe Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:09:04 +1000 Subject: [PATCH 13/13] Keep unchanged template poses on their existing static GPU pipeline --- apps/web/tf2/src/runtime.ts | 7 ++++-- apps/web/tf2/src/team-model-playback.ts | 22 +++++++++++++++++++ .../web/tf2/tests/team-model-playback.test.ts | 20 ++++++++++++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/web/tf2/src/runtime.ts b/apps/web/tf2/src/runtime.ts index 727367154..794c3ad8e 100644 --- a/apps/web/tf2/src/runtime.ts +++ b/apps/web/tf2/src/runtime.ts @@ -12,7 +12,7 @@ import { APPLICATION_BUILD as __PLAYSRC_APPLICATION_BUILD__, WASM_SHA256 as __PL import { TF2_PRESENTATION_SCHEMA, Tf2WorkerClient, Tf2WorkerError, mergePublicationSnapshots, type CoverageSample, type LoadedGame, type ResourceConfiguration, type SimulationPublication, type VisibilityResult } from "@playsrc/game-tf2-browser" import { Tf2EquipmentProfile, Tf2EquipmentPresentation, equippedWeaponSlots, equipmentPipelinePoseRequests, type Tf2EquipmentPreview } from "@playsrc/game-tf2-browser/equipment" import { selectionTransitionMark, selectionTransitionDraw } from "./selection-transition-profile" -import { teamModelPlayback, type TeamModelPlayback } from "./team-model-playback" +import { teamModelPlayback, TeamModelSkinning, type TeamModelPlayback } from "./team-model-playback" import { initializeTf2GameUiIntegration, type Tf2GameUiIntegration } from "@playsrc/game-tf2-browser/gameui-integration" import type { Tf2GameUiRequest, Tf2LoadingPhase } from "@playsrc/game-tf2-browser/gameui" import { @@ -480,6 +480,7 @@ export class Tf2Application { #pendingClassSelectionTeam?: 2 | 3 readonly #teamSelectionPoses = new Map() readonly #teamSelectionAnimations = new Map() + readonly #teamSelectionSkinning = new TeamModelSkinning() #teamSelectionUpdateTask: Promise | undefined #teamSelectionUpdateTime = 0 #teamAdmission?: Readonly<{ generation: number; resolve(): void; reject(error: Error): void }> @@ -1972,6 +1973,7 @@ export class Tf2Application { this.#pendingProjectileTimeline = [] this.#teamSelectionPoses.clear() this.#teamSelectionAnimations.clear() + this.#teamSelectionSkinning.clear() this.#pendingClassSelectionTeam = undefined this.#pendingPresentation = undefined this.#preparedPresentation = undefined @@ -2522,11 +2524,12 @@ export class Tf2Application { const selected = requests.find((candidate) => candidate.request.identity === item.identity) if (!selected) throw new Error("TF2 team-door pose identity differs from its authored request") this.#teamSelectionPoses.set(selected.panel, item) + this.#teamSelectionSkinning.observe(selected.panel, item) this.#teamSelectionAnimations.set(selected.panel, { ...selected.state, sampledSeconds: selected.request.elapsedSeconds, transitioning: item.transitioning }) } } const panels: readonly ModelPanelPass[] = authored.map((panel, index) => { - const pose = this.#teamSelectionPoses.get(panel.name) + const pose = this.#teamSelectionSkinning.needsPose(panel.name) ? this.#teamSelectionPoses.get(panel.name) : undefined return Object.freeze({ identity: panel.name, model: panel.model, diff --git a/apps/web/tf2/src/team-model-playback.ts b/apps/web/tf2/src/team-model-playback.ts index 9ceb8ab28..f3fa4e058 100644 --- a/apps/web/tf2/src/team-model-playback.ts +++ b/apps/web/tf2/src/team-model-playback.ts @@ -9,6 +9,28 @@ export type TeamModelPlayback = Readonly<{ transitioning: boolean }> +/** Templates already contain sequence zero at cycle zero. Keep those pixels + * and their static GPU pipeline until an actual pose differs; once deformed, + * idle still has to restore the retained skinned instance. */ +export class TeamModelSkinning { + readonly #templates = new Map() + readonly #deformed = new Set() + + observe(name: string, pose: Readonly<{ sequence: number; cycle: number; boneMatrices: Float32Array; flex: readonly unknown[] }>): void { + if (this.#deformed.has(name)) return + const template = this.#templates.get(name) + if (!template && pose.sequence === 0 && pose.cycle === 0 && pose.flex.length === 0) { + this.#templates.set(name, pose.boneMatrices) + return + } + if (!template || pose.flex.length !== 0 || template.length !== pose.boneMatrices.length + || template.some((value, index) => value !== pose.boneMatrices[index])) this.#deformed.add(name) + } + + needsPose(name: string): boolean { return this.#deformed.has(name) } + clear(): void { this.#templates.clear(); this.#deformed.clear() } +} + /** Presentation scheduling only; the studio owner resolves cycle and pose. */ export function teamModelPlayback( prior: TeamModelPlayback | undefined, diff --git a/apps/web/tf2/tests/team-model-playback.test.ts b/apps/web/tf2/tests/team-model-playback.test.ts index d47d41884..e1fdc759c 100644 --- a/apps/web/tf2/tests/team-model-playback.test.ts +++ b/apps/web/tf2/tests/team-model-playback.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { teamModelPlayback } from "../src/team-model-playback" +import { teamModelPlayback, TeamModelSkinning } from "../src/team-model-playback" const panel = { sequence: "hoveropen", modelRevision: 1, animationRevision: 1 } test("samples exact terminal pose even when a render interval crosses its duration", () => { @@ -32,3 +32,21 @@ test("short sequences keep sampling until the studio transition owner has finish expect(teamModelPlayback(ended, hover, 1.18, 5 / 30).sample).toBe(true) expect(teamModelPlayback({ ...ended, transitioning: false }, hover, 1.21, 5 / 30).sample).toBe(false) }) + +test("retains exact template pixels until deformation and restores idle through the same skinned owner", () => { + const state = new TeamModelSkinning() + const idle = { sequence: 0, cycle: 0, boneMatrices: Float32Array.of(1, 0, 0, 2), flex: [] } + state.observe("door", idle) + expect(state.needsPose("door")).toBe(false) + state.observe("door", { ...idle, cycle: 1, boneMatrices: idle.boneMatrices.slice() }) + expect(state.needsPose("door")).toBe(false) + state.observe("door", { ...idle, sequence: 1, boneMatrices: Float32Array.of(0.5, 0, 0, 2) }) + expect(state.needsPose("door")).toBe(true) + state.observe("door", idle) + expect(state.needsPose("door")).toBe(true) + state.observe("disabled-first", { ...idle, sequence: 1 }) + expect(state.needsPose("disabled-first")).toBe(true) + state.clear() + state.observe("door", idle) + expect(state.needsPose("door")).toBe(false) +})