diff --git a/apps/web/tf2/README.md b/apps/web/tf2/README.md index 293246bb9..414b89dfb 100644 --- a/apps/web/tf2/README.md +++ b/apps/web/tf2/README.md @@ -8,6 +8,7 @@ Deliver faithful TF2 gameplay and presentation at `https://playsrc.online/tf2`. - Assemble the TF2 game, selected TF2 rulesets, simulation, networking, presentation, assets, and product UI. - Own browser lifecycle, input, settings, navigation, and application state. +- Measure one positive integer CSS-pixel presentation viewport from the shared application content box; coalesce content-box, visual-viewport, orientation, fullscreen, pointer-lock, and DPR notifications into one animation-frame publication shared by Rendering and every VGUI owner. Zero size suspends publication, duplicate records are inert, and teardown removes every observer, listener, media query, and pending frame. - Play the configured Valve WebM once per page process while Main Menu VGUI initializes hidden, inert, unfocusable, and accessibility-hidden; admit exact audio/video through a browser gesture, accept Escape skip, and reveal the ready menu atomically. Drive Console map loading through the configured `stamp_background_map` layer and 380×112 VGUI loading dialog using only owner-reported status/progress, then preserve pause/resume/disconnect, TF2 HUD resources, and persistent Options through the game-owned adapters. HUD/GameUI/Options resources share the selected desktop condition, and HUD geometry reapplies exact proportional coordinates on integer viewport changes. Standard and Advanced Options construct independently on first request, and hidden Options perform no frame work. - Adapt typed VGUI console submissions to owner-defined command and convar operations. `map ` selects one declared map; `map https:////.bsp` is an explicit bounded playsrc acquisition operation and is not a TF2 parity capability. - Own typed `cl_showfps 0|1|2` and `cl_showpos 0|1|2` values, immutable diagnostic inputs, exact catalog revisions/current-value output, and explicit unavailable distinctions; VGUI owns the bounded diagnostic panel. diff --git a/apps/web/tf2/ROADMAP.md b/apps/web/tf2/ROADMAP.md index e6ab13675..12e8ec187 100644 --- a/apps/web/tf2/ROADMAP.md +++ b/apps/web/tf2/ROADMAP.md @@ -77,6 +77,7 @@ This integration refines `TF2-WEB-004` through `TF2-WEB-008` and `TF2-WEB-012` t | Video, Audio, Mouse, Keyboard, Multiplayer, and Advanced Options stage one current settings profile, apply or cancel atomically, reset defaults, capture/unbind conflicting bindings, and report restart requirements. | Configured Options resources present one `TF2_SELECTED_OPTIONS` state. VGUI applies exact alignment, scheme state colors/borders, SectionedList flags, slider geometry and ComboBox popup state; untouched clamped controls cannot become pending changes. | Deterministic codec/state vectors plus resource-derived control inventory and headed 1,280×720/390×844 default/armed/selected/disabled/dropdown/edit/cancel/apply/default/binding/conflict/unbind/reload schedules pass. | Ready | | Startup media, GameUI, loading VGUI, HUD, Options, Console, Rendering, Audio, input, Simulation, immutable objects, and derived caching retain sole ownership and release every browser resource in reverse lifecycle order. | Preact owns only application mounts and orchestration; startup owns its media URL/listeners and hidden-menu handle; VGUI owns Main Menu/loading/HUD descendants, modal state, input, animation clock, and cleanup. Map/disconnect transitions reset one mounted HUD runtime; application release destroys it once. | DOM-owner snapshots, exact one-HUD/one-GameUI panel counts, hidden/inert accessibility checks, listener/font/image/object-URL/worker/audio/cache cleanup, strict builds, privacy scans, and repeated destroy evidence pass. | Ready | | Application frame admission advances visible Options and active GameUI/HUD owners; hidden Options retain immutable state without layout, animation, DOM, raster, input, or request work until shown. Standard and Advanced Options initialize independently from complete shared settings state. | The browser lazily constructs the selected Options family, advances only that visible runtime, rebases its post-construction frame clock, and tracks deferred GameUI owner requests for cleanup. MainMenu no longer creates or frames either Options runtime; standard Options does not create Advanced's 88 rows. | **Owner suspension trace:** checked MainMenu/standard-Options profiles plus GameUI/HUD/Options unit and browser schedules compare owner frame work, resumed clocks, snapshots, requests, DOM mutations, pixels, and teardown. | Ready | +| The client surface and proportional HUD share one current physical presentation viewport through size, visual-viewport, orientation, fullscreen, pointer-lock, and device-pixel-ratio transitions. | One application owner measures the shared mount content box, admits only positive integer CSS dimensions plus DPR, coalesces notifications into one animation-frame commit, and publishes one immutable record to Rendering and every mounted VGUI owner. Zero size suspends publication; an identical record is inert. | **Composed viewport schedule:** headed fixed-size, height-only, width-only, portrait, landscape, ultrawide, DPR, restore, duplicate and teardown vectors compare owner revisions, complete DOM rectangles, configured bottom-panel offsets, layer alpha/color occupancy, and final composed rows. | Ready | ### Active visual-output audit diff --git a/apps/web/tf2/src/presentation-viewport.ts b/apps/web/tf2/src/presentation-viewport.ts new file mode 100644 index 000000000..368294004 --- /dev/null +++ b/apps/web/tf2/src/presentation-viewport.ts @@ -0,0 +1,196 @@ +export type ApplicationPresentationViewport = Readonly<{ width: number; height: number; devicePixelRatio: number; revision: number }> + +type ListenerTarget = Readonly<{ + addEventListener(type: string, listener: () => void): void + removeEventListener(type: string, listener: () => void): void +}> + +export type PresentationViewportPlatform = Readonly<{ + measure(): Readonly<{ width: number; height: number; devicePixelRatio: number }> + requestFrame(callback: () => void): number + cancelFrame(handle: number): void + observeResize(callback: () => void): Readonly<{ disconnect(): void }> + visualViewport: ListenerTarget | null + orientation: ListenerTarget | null + document: ListenerTarget + resolutionQuery(devicePixelRatio: number): ListenerTarget +}> + +export type PresentationViewportOwnerSnapshot = Readonly<{ + lifecycle: "live" | "destroyed" + viewport: ApplicationPresentationViewport | null + notifications: number + measurements: number + publications: number + suspensions: number + pendingFrames: number + listeners: number + observers: number +}> + +export type PresentationViewportOwner = Readonly<{ + first(): Promise + notify(): void + snapshot(): PresentationViewportOwnerSnapshot + destroy(): void +}> + +export function initializePresentationViewportOwner(request: Readonly<{ + platform: PresentationViewportPlatform + onViewport(viewport: ApplicationPresentationViewport): void + onSuspended(): void +}>): PresentationViewportOwner { + let lifecycle: "live" | "destroyed" = "live" + let viewport: ApplicationPresentationViewport | null = null + let revision = 0 + let notifications = 0 + let measurements = 0 + let publications = 0 + let suspensions = 0 + let suspended = false + let frame: number | null = null + let resolutionQuery: ListenerTarget | null = null + let resolutionDevicePixelRatio: number | null = null + let firstSettled = false + let resolveFirst!: (value: ApplicationPresentationViewport) => void + let rejectFirst!: (reason: Error) => void + const first = new Promise((resolve, reject) => { + resolveFirst = resolve + rejectFirst = reject + }) + + const same = (left: ApplicationPresentationViewport | null, right: Readonly<{ width: number; height: number; devicePixelRatio: number }>): boolean => + left !== null + && left.width === right.width + && left.height === right.height + && left.devicePixelRatio === right.devicePixelRatio + + const schedule = (): void => { + if (lifecycle === "destroyed") return + notifications += 1 + if (frame !== null) return + frame = request.platform.requestFrame(commit) + } + + const resolutionChanged = (): void => schedule() + + const armResolutionQuery = (devicePixelRatio: number): void => { + if (resolutionDevicePixelRatio === devicePixelRatio) return + resolutionQuery?.removeEventListener("change", resolutionChanged) + resolutionQuery = request.platform.resolutionQuery(devicePixelRatio) + resolutionDevicePixelRatio = devicePixelRatio + resolutionQuery.addEventListener("change", resolutionChanged) + } + + function commit(): void { + frame = null + if (lifecycle === "destroyed") return + measurements += 1 + const measured = request.platform.measure() + const width = Math.trunc(measured.width) + const height = Math.trunc(measured.height) + const devicePixelRatio = measured.devicePixelRatio + const valid = Number.isFinite(width) && Number.isFinite(height) + && width > 0 && width <= 32767 && height > 0 && height <= 32767 + && Number.isFinite(devicePixelRatio) && devicePixelRatio >= 0.5 && devicePixelRatio <= 8 + if (!valid) { + if (!suspended) { + viewport = null + suspended = true + suspensions += 1 + request.onSuspended() + } + return + } + suspended = false + armResolutionQuery(devicePixelRatio) + if (same(viewport, { width, height, devicePixelRatio })) return + revision += 1 + viewport = Object.freeze({ width, height, devicePixelRatio, revision }) + publications += 1 + request.onViewport(viewport) + if (!firstSettled) { + firstSettled = true + resolveFirst(viewport) + } + } + + const resizeObserver = request.platform.observeResize(schedule) + request.platform.visualViewport?.addEventListener("resize", schedule) + request.platform.orientation?.addEventListener("change", schedule) + request.platform.document.addEventListener("fullscreenchange", schedule) + request.platform.document.addEventListener("pointerlockchange", schedule) + schedule() + + return Object.freeze({ + first: () => first, + notify: schedule, + snapshot: () => Object.freeze({ + lifecycle, + viewport, + notifications, + measurements, + publications, + suspensions, + pendingFrames: frame === null ? 0 : 1, + listeners: lifecycle === "destroyed" ? 0 : 2 + Number(request.platform.visualViewport !== null) + Number(request.platform.orientation !== null) + Number(resolutionQuery !== null), + observers: lifecycle === "destroyed" ? 0 : 1, + }), + destroy: () => { + if (lifecycle === "destroyed") return + lifecycle = "destroyed" + if (frame !== null) request.platform.cancelFrame(frame) + frame = null + resizeObserver.disconnect() + request.platform.visualViewport?.removeEventListener("resize", schedule) + request.platform.orientation?.removeEventListener("change", schedule) + request.platform.document.removeEventListener("fullscreenchange", schedule) + request.platform.document.removeEventListener("pointerlockchange", schedule) + resolutionQuery?.removeEventListener("change", resolutionChanged) + resolutionQuery = null + if (!firstSettled) { + firstSettled = true + rejectFirst(new Error("Presentation viewport owner was destroyed before a positive content box was admitted")) + } + }, + }) +} + +export function initializeBrowserPresentationViewportOwner(request: Readonly<{ + root: HTMLElement + onViewport(viewport: ApplicationPresentationViewport): void + onSuspended(): void +}>): PresentationViewportOwner { + const owner = initializePresentationViewportOwner({ + platform: { + measure: () => Object.freeze({ + width: request.root.clientWidth, + height: request.root.clientHeight, + devicePixelRatio: window.devicePixelRatio, + }), + requestFrame: (callback) => requestAnimationFrame(callback), + cancelFrame: (handle) => cancelAnimationFrame(handle), + observeResize: (callback) => { + const observer = new ResizeObserver(callback) + observer.observe(request.root, { box: "content-box" }) + return Object.freeze({ disconnect: () => observer.disconnect() }) + }, + visualViewport: window.visualViewport, + orientation: screen.orientation ?? null, + document, + resolutionQuery: (devicePixelRatio) => matchMedia(`(resolution: ${devicePixelRatio}dppx)`), + }, + onViewport: (viewport) => { + request.root.dataset.presentationViewportState = "active" + request.root.dataset.presentationViewport = `${viewport.width}x${viewport.height}@${viewport.devicePixelRatio}` + request.root.dataset.presentationViewportRevision = String(viewport.revision) + request.onViewport(viewport) + }, + onSuspended: () => { + request.root.dataset.presentationViewportState = "suspended" + delete request.root.dataset.presentationViewport + request.onSuspended() + }, + }) + return owner +} diff --git a/apps/web/tf2/src/runtime.ts b/apps/web/tf2/src/runtime.ts index 16c3e5cb6..553e60b38 100644 --- a/apps/web/tf2/src/runtime.ts +++ b/apps/web/tf2/src/runtime.ts @@ -66,6 +66,11 @@ import { loadBrowserConfiguration, type BrowserConfiguration } from "./config" import { PhysicalButtonState, applyPointerDelta, rawPointerMovementUnsupported, rebasePointerYaw, resolvePhysicalBinding } from "./input" import { TF2_SELECTED_OPTIONS, type AdapterRequestResult, type SettingsAdapterRequest } from "@playsrc/settings" import { SimulationClockQueue } from "./simulation-clock" +import { + initializeBrowserPresentationViewportOwner, + type ApplicationPresentationViewport, + type PresentationViewportOwner, +} from "./presentation-viewport" const MAX_EXTERNAL_BYTES = 536_870_912 const SIMULATION_SAMPLE_INTERVAL_SECONDS = 0.015 @@ -213,6 +218,7 @@ type PreparedPresentation=Readonly<{ export class Tf2Application { #canvas: HTMLCanvasElement + readonly #presentationRoot: HTMLElement readonly #vguiRoot: HTMLElement readonly #gameUiRoot: HTMLElement readonly #hudRoot: HTMLElement @@ -221,6 +227,8 @@ export class Tf2Application { readonly #startupRoot: HTMLElement readonly #startupVideo: HTMLVideoElement readonly #publish: (view: ApplicationView) => void + readonly #viewportOwner: PresentationViewportOwner + #presentationViewport?: ApplicationPresentationViewport #configuration?: BrowserConfiguration #dependencies: Uint8Array = new Uint8Array() #dependencyEntries = new Map() @@ -343,6 +351,11 @@ export class Tf2Application { publish: (view: ApplicationView) => void, ) { this.#canvas = canvas + const presentationRoot = canvas.parentElement + if (!presentationRoot || [roots.vgui, roots.gameUi, roots.hud, roots.options, roots.loading, roots.startup].some((root) => root.parentElement !== presentationRoot)) { + throw new Error("TF2 presentation owners do not share one application mount") + } + this.#presentationRoot = presentationRoot this.#vguiRoot = roots.vgui this.#gameUiRoot = roots.gameUi this.#hudRoot = roots.hud @@ -351,6 +364,11 @@ export class Tf2Application { this.#startupRoot = roots.startup this.#startupVideo = roots.startupVideo this.#publish = publish + this.#viewportOwner = initializeBrowserPresentationViewportOwner({ + root: this.#presentationRoot, + onViewport: (viewport) => this.#commitPresentationViewport(viewport), + onSuspended: () => this.#suspendPresentationViewport(), + }) this.#gameUiRoot.hidden = true this.#gameUiRoot.inert = true this.#gameUiRoot.setAttribute("aria-hidden", "true") @@ -405,12 +423,12 @@ export class Tf2Application { this.#syncLoadingPresentation() } - #syncLoadingPresentation(): void { + #syncLoadingPresentation(viewport = this.#viewport()): void { if (!this.#loadingPresentation || !this.#loadingVgui || !this.#gameUi || this.#loadingPresentationGeneration < 1) return const snapshot = this.#loadingPresentation.update( this.#loadingPresentationGeneration, this.#gameUi.state(), - this.#viewport(), + viewport, this.#loadingBackground, ) if (snapshot) this.#loadingVgui.apply(snapshot) @@ -644,7 +662,7 @@ export class Tf2Application { async #prepareMainMenu(): Promise { if (!this.#configuration || !this.#presentationRandom) throw new Error("TF2 Main Menu inputs are unavailable") this.#set({ menuPreparation: "console-resources" }) - this.#consoleResources = await resolveConfiguredConsoleResources(this.#dependencyEntries, Math.max(1, this.#vguiRoot.getBoundingClientRect().height)) + this.#consoleResources = await resolveConfiguredConsoleResources(this.#dependencyEntries, this.#viewport().height) this.#blockers.add(this.#consoleResources.blocker) this.#set({ menuPreparation: "vgui-resources" }) this.#uiResources = await initializeTf2VguiResources({ @@ -819,6 +837,7 @@ export class Tf2Application { async start(): Promise { try { + await this.#viewportOwner.first() this.#configuration = await loadBrowserConfiguration() this.#presentationRandom = createTf2PresentationRandom(this.#configuration.presentation.randomSeed) this.#renderLevel = this.#configuration.renderLevel @@ -919,7 +938,7 @@ export class Tf2Application { powerPreference: "high-performance", }) finishLoadPhase("rendererCreate") - this.resize() + this.#resizeRenderer() this.#advanceLoading("creating-client-world") const scene = await this.#renderer.loadMap({ payload: this.#loaded.payload, @@ -1172,12 +1191,48 @@ export class Tf2Application { }) } - #viewport() { - const bounds = this.#vguiRoot.getBoundingClientRect() - return { - width: Math.max(1, Math.trunc(bounds.width)), - height: Math.max(1, Math.trunc(bounds.height)), - devicePixelRatio: window.devicePixelRatio, + #viewport(): ApplicationPresentationViewport { + if (!this.#presentationViewport) throw new Error("TF2 presentation viewport is suspended") + return this.#presentationViewport + } + + #resizeRenderer(viewport = this.#viewport()): void { + this.#renderer?.resize(viewport.width, viewport.height, viewport.devicePixelRatio) + } + + #commitPresentationViewport(viewport: ApplicationPresentationViewport): void { + this.#presentationViewport = viewport + const identity = `${viewport.revision}:${viewport.width}x${viewport.height}@${viewport.devicePixelRatio}` + for (const owner of [this.#canvas, this.#startupRoot, this.#loadingRoot, this.#gameUiRoot, this.#hudRoot, this.#optionsRoot, this.#vguiRoot]) { + owner.dataset.presentationViewport = identity + owner.dataset.presentationViewportState = "active" + } + this.#resizeRenderer(viewport) + this.#console?.apply({ kind: "set-viewport", viewport }) + this.#diagnostics?.apply({ kind: "set-viewport", viewport }) + this.#gameUi?.setViewport(viewport) + this.#hudIntegration?.setViewport(viewport) + this.#options?.setViewport(viewport) + this.#loadingVgui?.setViewport(viewport) + if (this.#loadingPresentationGeneration > 0 && this.#configuration) { + const result = resolveTf2LoadingBackground({ + generation: this.#loadingPresentationGeneration, + mapIdentity: "jump_beef", + viewport, + mapPhotoLookups: this.#configuration.loading.mapPhotoLocations.map((location) => Object.freeze({ location, outcome: "missing" as const })), + backingMaterial: this.#configuration.loading.stampBackground.material, + backingTexture: this.#configuration.loading.stampBackground.texture, + }) + if (result.ok) this.#loadingBackground = result + this.#syncLoadingPresentation(viewport) + } + } + + #suspendPresentationViewport(): void { + this.#presentationViewport = undefined + for (const owner of [this.#canvas, this.#startupRoot, this.#loadingRoot, this.#gameUiRoot, this.#hudRoot, this.#optionsRoot, this.#vguiRoot]) { + delete owner.dataset.presentationViewport + owner.dataset.presentationViewportState = "suspended" } } @@ -1462,7 +1517,7 @@ export class Tf2Application { configuration: this.#renderLevel === 2 ? SOURCE_PC_INTEGER_HDR : SOURCE_LDR, powerPreference: "high-performance", }) - this.resize() + this.#resizeRenderer() } const scene = await this.#renderer.loadMap({ payload: staged.payload, @@ -1492,7 +1547,7 @@ export class Tf2Application { configuration: priorConfiguration, powerPreference: "high-performance", }) - this.resize() + this.#resizeRenderer() } await this.#renderer.loadMap({ payload: prior.payload, @@ -1975,7 +2030,7 @@ export class Tf2Application { if(!prepared||!client||!renderer||prepared.generation!==generation)return const viewRevision=this.#viewRevision,yaw=this.#yaw,pitch=this.#pitch const camera=tf2Camera(prepared.snapshot,yaw,pitch) - const viewport=this.#canvas.getBoundingClientRect() + const viewport=this.#viewport() const phaseStart=performance.now(),visibilityStart=phaseStart let visibility=prepared.visibility if(visibility.water.visibleWater===null&&visibility.water.passes.every(pass=>pass.kind==="main")){ @@ -2162,7 +2217,7 @@ export class Tf2Application { if (l) add(p.launcherIdentity, new Set(l.attachments.keys())) } const camera = tf2Camera(snapshot, this.#yaw, this.#pitch) - const viewport=this.#canvas.getBoundingClientRect(),viewmodel=this.#viewmodels.map(snapshot,{aspectRatio:Math.max(1,viewport.width)/Math.max(1,viewport.height),farPlane:camera.far}) + const viewport=this.#viewport(),viewmodel=this.#viewmodels.map(snapshot,{aspectRatio:Math.max(1,viewport.width)/Math.max(1,viewport.height),farPlane:camera.far}) const lockerRequests=[...this.#lockerAnimations].flatMap(([identity,state])=>{const occurrence=this.#artifacts!.modelOccurrences.find(value=>value.entity===identity),artifact=occurrence&&this.#artifacts!.models.get(occurrence.model);if(!occurrence||!artifact){this.#blockers.add(`TF2 regenerate model presentation unavailable: ${identity}`);return []}const closed=snapshot.tick>=state.closeTick,animation=closed?state.closeAnimation:state.openAnimation,start=closed?state.closeTick:state.openTick,elapsed=Math.max(0,Number(snapshot.tick-start)*0.015),previousTick=snapshot.tick>BigInt(publication.selectedTicks)?snapshot.tick-BigInt(publication.selectedTicks):0n,previousElapsed=Math.max(0,Number(previousTick-start)*0.015);return [Object.freeze({identity,model:occurrence.model,activity:animation,previousElapsedSeconds:Math.min(previousElapsed,elapsed),elapsedSeconds:elapsed,currentTimeSeconds:Number(snapshot.tick)*0.015,frameTimeSeconds:publication.selectedTicks*0.015,planarSpeed:0,screenAspectRatio:Math.max(1,viewport.width)/Math.max(1,viewport.height),worldFarPlane:camera.far,skin:occurrence.skin,lod:0,bodygroups:Object.freeze([]),packedBody:state.body})]}) const modelStart=performance.now();this.#wasmCalls.models++;const modelRequest=client.models(generation, encodeModelPoseBatch([viewmodel.request,...lockerRequests]));this.#wasmCalls.visibility++;const visibilityRequest=client.visibility(generation,{ position:camera.position, @@ -2317,7 +2372,6 @@ export class Tf2Application { window.addEventListener("mousedown", this.#mouseDown) window.addEventListener("mouseup", this.#mouseUp, true) window.addEventListener("mousemove", this.#mouseMove) - window.addEventListener("resize", this.#resize) window.addEventListener("blur", this.#blur) document.addEventListener("visibilitychange", this.#visibility) document.addEventListener("pointerlockchange", this.#pointerLock) @@ -2332,7 +2386,6 @@ export class Tf2Application { window.removeEventListener("mousedown", this.#mouseDown) window.removeEventListener("mouseup", this.#mouseUp, true) window.removeEventListener("mousemove", this.#mouseMove) - window.removeEventListener("resize", this.#resize) window.removeEventListener("blur", this.#blur) document.removeEventListener("visibilitychange", this.#visibility) document.removeEventListener("pointerlockchange", this.#pointerLock) @@ -2457,7 +2510,6 @@ export class Tf2Application { this.#mouseViewRevision+=1 } - readonly #resize = (): void => this.resize() readonly #blur = (): void => this.#neutral() readonly #visibility = (): void => { this.#paused = document.hidden @@ -2560,29 +2612,6 @@ export class Tf2Application { this.#set({ consoleVisible: true }) } - resize(): void { - const bounds = this.#canvas.getBoundingClientRect() - this.#renderer?.resize(bounds.width, bounds.height, window.devicePixelRatio) - this.#console?.apply({ kind: "set-viewport", viewport: this.#viewport() }) - this.#diagnostics?.apply({ kind: "set-viewport", viewport: this.#viewport() }) - this.#gameUi?.setViewport(this.#viewport()) - this.#hudIntegration?.setViewport(this.#viewport()) - this.#options?.setViewport(this.#viewport()) - this.#loadingVgui?.setViewport(this.#viewport()) - if (this.#loadingPresentationGeneration > 0 && this.#configuration) { - const result = resolveTf2LoadingBackground({ - generation: this.#loadingPresentationGeneration, - mapIdentity: "jump_beef", - viewport: this.#viewport(), - mapPhotoLookups: this.#configuration.loading.mapPhotoLocations.map((location) => Object.freeze({ location, outcome: "missing" as const })), - backingMaterial: this.#configuration.loading.stampBackground.material, - backingTexture: this.#configuration.loading.stampBackground.texture, - }) - if (result.ok) this.#loadingBackground = result - this.#syncLoadingPresentation() - } - } - async close(): Promise { if (this.#closed) return await this.#release() @@ -2629,6 +2658,7 @@ export class Tf2Application { async #release(): Promise { if (this.#closed) return this.#closed = true + this.#viewportOwner.destroy() this.#removeStartupListeners() this.#startup?.destroy() this.#startup = undefined diff --git a/apps/web/tf2/tests/presentation-viewport.test.ts b/apps/web/tf2/tests/presentation-viewport.test.ts new file mode 100644 index 000000000..c5c0647dd --- /dev/null +++ b/apps/web/tf2/tests/presentation-viewport.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test" +import { + initializePresentationViewportOwner, + type ApplicationPresentationViewport, + type PresentationViewportPlatform, +} from "../src/presentation-viewport" + +class Target { + readonly listeners = new Map void>>() + addEventListener(type: string, listener: () => void): void { + const listeners = this.listeners.get(type) ?? new Set() + listeners.add(listener) + this.listeners.set(type, listeners) + } + removeEventListener(type: string, listener: () => void): void { this.listeners.get(type)?.delete(listener) } + dispatch(type: string): void { for (const listener of [...(this.listeners.get(type) ?? [])]) listener() } + count(): number { return [...this.listeners.values()].reduce((total, listeners) => total + listeners.size, 0) } +} + +function fixture(initial = { width: 1280, height: 720, devicePixelRatio: 1 }) { + let measurement = initial + let nextFrame = 1 + const frames = new Map void>() + const visualViewport = new Target() + const orientation = new Target() + const document = new Target() + const resolutionQueries: Target[] = [] + let resizeCallback: (() => void) | null = null + let observers = 0 + const platform: PresentationViewportPlatform = { + measure: () => measurement, + requestFrame(callback) { const handle = nextFrame++; frames.set(handle, callback); return handle }, + cancelFrame(handle) { frames.delete(handle) }, + observeResize(callback) { resizeCallback = callback; observers += 1; return { disconnect() { resizeCallback = null; observers -= 1 } } }, + visualViewport, + orientation, + document, + resolutionQuery() { const target = new Target(); resolutionQueries.push(target); return target }, + } + const publications: ApplicationPresentationViewport[] = [] + let suspensions = 0 + const owner = initializePresentationViewportOwner({ platform, onViewport: (viewport) => publications.push(viewport), onSuspended: () => { suspensions += 1 } }) + const flush = () => { const pending = [...frames.values()]; frames.clear(); for (const callback of pending) callback() } + return { + owner, + publications, + visualViewport, + orientation, + document, + resolutionQueries, + flush, + resize: () => resizeCallback?.(), + set: (next: typeof measurement) => { measurement = next }, + resources: () => ({ frames: frames.size, observers, listeners: visualViewport.count() + orientation.count() + document.count() + resolutionQueries.reduce((sum, query) => sum + query.count(), 0) }), + } +} + +describe("TF2 application presentation viewport owner", () => { + test("coalesces every browser notification into one immutable positive viewport", async () => { + const value = fixture() + value.resize() + value.visualViewport.dispatch("resize") + value.orientation.dispatch("change") + value.document.dispatch("fullscreenchange") + value.document.dispatch("pointerlockchange") + expect(value.owner.snapshot().pendingFrames).toBe(1) + value.flush() + expect(await value.owner.first()).toEqual({ width: 1280, height: 720, devicePixelRatio: 1, revision: 1 }) + expect(value.publications).toHaveLength(1) + expect(Object.isFrozen(value.publications[0])).toBeTrue() + expect(value.owner.snapshot()).toMatchObject({ measurements: 1, publications: 1, suspensions: 0 }) + }) + + test("makes duplicates inert, suspends zero size, rearms DPR and restores exactly", () => { + const value = fixture() + value.flush() + const initial = value.owner.snapshot() + value.resize() + value.flush() + expect(value.owner.snapshot()).toMatchObject({ viewport: initial.viewport, publications: 1 }) + + value.set({ width: 0, height: 0, devicePixelRatio: 1 }) + value.resize() + value.flush() + expect(value.owner.snapshot()).toMatchObject({ viewport: null, publications: 1, suspensions: 1 }) + + value.set({ width: 1024, height: 768, devicePixelRatio: 2 }) + value.visualViewport.dispatch("resize") + value.flush() + expect(value.publications.at(-1)).toEqual({ width: 1024, height: 768, devicePixelRatio: 2, revision: 2 }) + expect(value.resolutionQueries).toHaveLength(2) + expect(value.resolutionQueries[0]!.count()).toBe(0) + expect(value.resolutionQueries[1]!.count()).toBe(1) + }) + + test("destroys the observer, listeners, DPR query and pending frame exactly once", () => { + const value = fixture() + value.flush() + value.resize() + expect(value.resources().frames).toBe(1) + value.owner.destroy() + value.owner.destroy() + expect(value.owner.snapshot()).toMatchObject({ lifecycle: "destroyed", pendingFrames: 0, listeners: 0, observers: 0 }) + expect(value.resources()).toEqual({ frames: 0, observers: 0, listeners: 0 }) + }) + + test("reports initial zero-size suspension and waits for the first positive box", async () => { + const value = fixture({ width: 0, height: 0, devicePixelRatio: 1 }) + value.flush() + expect(value.owner.snapshot()).toMatchObject({ viewport: null, publications: 0, suspensions: 1 }) + value.resize() + value.flush() + expect(value.owner.snapshot().suspensions).toBe(1) + value.set({ width: 390, height: 844, devicePixelRatio: 1 }) + value.resize() + value.flush() + expect(await value.owner.first()).toEqual({ width: 390, height: 844, devicePixelRatio: 1, revision: 1 }) + }) +}) diff --git a/games/tf2/browser/src/hud-integration/runtime.ts b/games/tf2/browser/src/hud-integration/runtime.ts index 048513c9f..2400c6820 100644 --- a/games/tf2/browser/src/hud-integration/runtime.ts +++ b/games/tf2/browser/src/hud-integration/runtime.ts @@ -100,7 +100,6 @@ const HUD_CLASS = "resource/ui/hudplayerclass.res" const HUD_HEALTH = "resource/ui/hudplayerhealth.res" const HUD_AMMO = "resource/ui/hudammoweapons.res" const HUD_WEAPONS = "resource/ui/hudweaponselection.res" - const scalar = (node: VguiResourceNode, name: string): string | null => node.children.find((child) => child.name.toLowerCase() === name.toLowerCase() && child.value !== null)?.value ?? null const node = (name: string, children: readonly VguiResourceNode[]): VguiResourceNode => Object.freeze({ name, value: null, condition: null, children: Object.freeze(children) }) @@ -168,10 +167,12 @@ class Integration implements Tf2HudIntegration { readonly #publishedValues = new Map() #previous: Tf2HudAvailability = tf2HudUnavailable("initial") #binding: Tf2HudBinding | null = null + #viewport: VguiViewport #destroyed = false constructor(request: Tf2HudIntegrationRequest) { this.#onCommand = request.onCommand + this.#viewport = Object.freeze({ ...request.viewport }) const availableImages = new Set(request.resources.clientScheme.images.map((image) => image.name.toLowerCase())) const missingImages = TF2_HUD_DYNAMIC_IMAGES.filter((image) => !availableImages.has(image.toLowerCase())) if (missingImages.length > 0) throw new Error(`TF2 HUD dynamic images are unavailable: ${missingImages.join(",")}`) @@ -330,12 +331,18 @@ class Integration implements Tf2HudIntegration { frame(timeSeconds: number): void { apply(this.#runtime, { kind: "frame", timeSeconds }) } setViewport(viewport: VguiViewport): void { - apply(this.#runtime, { kind: "set-viewport", viewport }) - this.#captureBaseBounds() - if (this.#binding) { - this.#publishedValues.clear() - this.#applyValues(this.#binding) - } + if (viewport.width === this.#viewport.width + && viewport.height === this.#viewport.height + && viewport.devicePixelRatio === this.#viewport.devicePixelRatio) return + this.#runtime.deferPresentation(() => { + apply(this.#runtime, { kind: "set-viewport", viewport }) + this.#viewport = Object.freeze({ ...viewport }) + this.#captureBaseBounds() + if (this.#binding) { + this.#publishedValues.clear() + this.#applyValues(this.#binding) + } + }) } probe(): Tf2HudIntegrationProbe { const panels = [ diff --git a/games/tf2/browser/src/hud/README.md b/games/tf2/browser/src/hud/README.md index b0adc7def..1316e9b85 100644 --- a/games/tf2/browser/src/hud/README.md +++ b/games/tf2/browser/src/hud/README.md @@ -3,3 +3,5 @@ This module purely maps immutable TF2 gameplay/replay snapshots, the immutable player-class presentation setting, and ordered events to one configured class/model/health/ammo presentation, named HUD animations, presentation notifications, and typed weapon-selection/respawn/scoreboard requests. It contains no DOM, clock, randomness, settings persistence, gameplay mutation, replay advancement, resource parsing or animation execution. HUD integration rejects a resource closure missing any dynamically selected class, team background, ammo background or health image; it never preserves the configured Scout image as a fallback. + +HUD viewport replacement is one atomic VGUI commit. It recomputes configured proportional and bottom/right-relative geometry, reapplies dynamic bounds adjustments, and ignores duplicate viewport records. diff --git a/games/tf2/browser/src/hud/ROADMAP.md b/games/tf2/browser/src/hud/ROADMAP.md index a5bee6762..30d145566 100644 --- a/games/tf2/browser/src/hud/ROADMAP.md +++ b/games/tf2/browser/src/hud/ROADMAP.md @@ -25,6 +25,7 @@ Authority is Valve Source SDK 2013 commit `88fa198fba3fb85d46d4c95018254693fdc3a | Every health condition panel starts hidden, then exact condition words activate only the matching independent panels and the first active member of each SDK buff class; inactive Halloween status remains hidden. | One complete condition inventory publishes a false baseline before exact grouped/independent visibility and image values. | All-zero, one-at-a-time, overlapping grouped and complete removal/reset vectors pass. | Ready | | Active-weapon identity, display name, optional item definition, slot/position, clip/reserve/maxima, reload and display mode remain distinct facts on one ammo/weapon presentation. | Known identity/name never become `unknown` when item definition is unavailable; one active ammo root and selection model bind all supplied facts. | Three compact weapons, reload phases, switch/regenerate and unavailable-item integration snapshots pass. | Ready | | Class/team/weapon/lifecycle resets and ordered HUD animations update the sole active panel instances once. | Map/disconnect reset clears stale presentation caches; each ordered event starts each named sequence once. | Switch, death/respawn, regenerate and map/disconnect reset transcripts pass; shared app reset routing is pending. | Game-owned ready; shared lifecycle routing pending | +| The proportional HUD viewport and configured bottom/right-relative player health and weapon-ammo roots resolve from every current physical viewport size; a duplicate viewport notification performs no reload, layout or publication. | HUD integration applies one atomic VGUI viewport commit, refreshes dynamic bounds from the new configured geometry, and suppresses an identical width/height/device-pixel-ratio notification. | Fixed configured-resource transitions cover 1,280×720, 1,024×768, 1,600×900, 2,560×1,080, 390×844, 844×390, DPR 2, restoration and duplicate notification. | Ready | ## Exclusions diff --git a/games/tf2/browser/tests/hud-vertical-layout/symptom-loop.test.ts b/games/tf2/browser/tests/hud-vertical-layout/symptom-loop.test.ts new file mode 100644 index 000000000..c1c7eb495 --- /dev/null +++ b/games/tf2/browser/tests/hud-vertical-layout/symptom-loop.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test" +import type { + VguiControlRegistration, + VguiImagePresentation, + VguiResourceDocument, + VguiResourceNode, + VguiScheme, +} from "@playsrc/vgui" +import { FakeDocument, createRoot } from "../../../../../packages/presentation/vgui/tests/fake-dom" +import { initializeTf2HudIntegration } from "../../src/hud-integration" +import { TF2_HUD_DYNAMIC_IMAGES } from "../../src/hud" +import type { Tf2VguiResources } from "../../src/ui-integration" +import { tf2UiResources } from "../../src/ui-resources" + +const scalar = (name: string, value: string): VguiResourceNode => Object.freeze({ name, value, condition: null, children: Object.freeze([]) }) +const object = (name: string, children: readonly VguiResourceNode[]): VguiResourceNode => Object.freeze({ name, value: null, condition: null, children }) +const panel = (name: string, geometry: Readonly<{ xpos: string; ypos: string; wide: string; tall: string }>): VguiResourceNode => + object(name, [ + scalar("ControlName", "CTFHudElement"), + scalar("fieldName", name), + scalar("xpos", geometry.xpos), + scalar("ypos", geometry.ypos), + scalar("wide", geometry.wide), + scalar("tall", geometry.tall), + ]) + +const documents = new Map([ + ["scripts/hudlayout.res", Object.freeze({ + logicalIdentity: "scripts/hudlayout.res", + revision: "1f18cb73d9ef54ff79ea208c9996db0655ac731b2ee8e9a82ff63a4b697f400f", + root: object("HudLayout", [ + panel("HudPlayerStatus", { xpos: "0", ypos: "0", wide: "f0", tall: "480" }), + object("HudWeaponAmmo", [ + scalar("ControlName", "CTFHudElement"), + scalar("fieldName", "HudWeaponAmmo"), + scalar("xpos", "r95"), + scalar("ypos", "r55"), + scalar("wide", "94"), + scalar("tall", "45"), + ]), + panel("HudWeaponSelection", { xpos: "0", ypos: "0", wide: "f0", tall: "480" }), + panel("HudCrosshair", { xpos: "0", ypos: "0", wide: "640", tall: "480" }), + ]), + })], + ["resource/ui/hudplayerclass.res", Object.freeze({ + logicalIdentity: "resource/ui/hudplayerclass.res", + revision: "10181165d10a81821672fd8e104d798e18cf896ca1156cf92df8ce0a07f8c89d", + root: object("Resource", [panel("HudPlayerClass", { xpos: "0", ypos: "0", wide: "f0", tall: "480" })]), + })], + ["resource/ui/hudplayerhealth.res", Object.freeze({ + logicalIdentity: "resource/ui/hudplayerhealth.res", + revision: "31fabca97c196eb2cff565b18ff8b3a17aa8806c8e1a980c9376782be7fa4774", + root: object("Resource", [panel("HudPlayerHealth", { xpos: "0", ypos: "r120", wide: "250", tall: "120" })]), + })], + ["resource/ui/hudammoweapons.res", Object.freeze({ + logicalIdentity: "resource/ui/hudammoweapons.res", + revision: "a23a98f009dd34ac8c94e7149b1ded56eb9ed66e03d583fcd9c2ab68c3cb7734", + root: object("Resource", []), + })], + ["resource/ui/hudweaponselection.res", Object.freeze({ + logicalIdentity: "resource/ui/hudweaponselection.res", + revision: "7a6f02c7eab4f0befdac5c69082c9334b0a03975738e1fc6d598ba6c91967138", + root: object("Resource", []), + })], +]) + +const customControls: readonly VguiControlRegistration[] = Object.freeze(["CTFHudElement", "CTFHealthPanel"].map((name) => Object.freeze({ + name, + baseControl: "EditablePanel" as const, + element: "div" as const, + role: null, + focusable: false, + animationVariables: Object.freeze([]), + acceptedProperties: Object.freeze([]), +}))) + +const images: readonly VguiImagePresentation[] = Object.freeze(TF2_HUD_DYNAMIC_IMAGES.map((name, index) => Object.freeze({ + name, + logicalIdentity: `materials/vgui/hud-vertical-layout/${index}.vtf`, + revision: `hud-vertical-layout-${index}`, + browserUrl: "data:image/png;base64,AA==", + width: 1, + height: 1, + frames: 1, + hardwareFiltered: false, +}))) + +const scheme: VguiScheme = Object.freeze({ + identity: "resource/clientscheme.res", + revision: "2701e270ea1da7e03b21dc780f12b7ea743868c612e3fbe4b8ab69e7dd8879de", + tag: "ClientScheme", + colors: Object.freeze([]), + settings: Object.freeze([]), + fonts: Object.freeze([]), + borders: Object.freeze([]), + images, +}) + +const resources: Tf2VguiResources = Object.freeze({ + identity: "tf2-hud-vertical-layout-loop", + descriptor: tf2UiResources, + clientScheme: scheme, + sourceScheme: scheme, + localization: Object.freeze({ identity: "resource/tf_english.txt", revision: "hud-vertical-layout", language: "english", tokens: Object.freeze([]) }), + animations: Object.freeze({ identity: "scripts/hudanimations-manifest.txt", revision: "hud-vertical-layout", scripts: Object.freeze([]), activeConditions: Object.freeze([]) }), + activeConditions: Object.freeze(["WIN32"]), + customControls, + diagnostics: Object.freeze([]), + document(logicalPath) { + const result = documents.get(logicalPath.toLowerCase()) + if (!result) throw new Error(`missing ${logicalPath}`) + return result + }, + panelDocument(logicalPath) { + const result = tf2UiResources.panels.find((value) => value.source.logicalPath === logicalPath.toLowerCase()) + if (!result) throw new Error(`missing ${logicalPath}`) + return result + }, + destroy() {}, +}) + +const viewports = Object.freeze([ + Object.freeze({ width: 1280, height: 720, devicePixelRatio: 1 }), + Object.freeze({ width: 1024, height: 768, devicePixelRatio: 1 }), + Object.freeze({ width: 1600, height: 900, devicePixelRatio: 1 }), + Object.freeze({ width: 2560, height: 1080, devicePixelRatio: 1 }), + Object.freeze({ width: 390, height: 844, devicePixelRatio: 1 }), + Object.freeze({ width: 844, height: 390, devicePixelRatio: 1 }), + Object.freeze({ width: 1280, height: 720, devicePixelRatio: 2 }), + Object.freeze({ width: 1280, height: 720, devicePixelRatio: 1 }), +]) + +const scaled = (value: number, height: number) => Math.trunc(value * height / 480) + +describe("configured TF2 HUD vertical viewport symptom loop", () => { + test("keeps the HUD viewport and bottom panels on every admitted viewport transition", () => { + const hud = initializeTf2HudIntegration({ + root: createRoot(new FakeDocument()) as unknown as HTMLElement, + resources, + viewport: viewports[0]!, + reducedMotion: true, + clock: { nowSeconds: () => 0 }, + random: { nextUnit: () => 0.5 }, + onCommand() {}, + }) + + for (const viewport of viewports) { + hud.setViewport(viewport) + const snapshot = hud.snapshot().vgui + const named = (name: string) => snapshot.panels.find((candidate) => candidate.name === name)! + expect(snapshot.viewport).toEqual(viewport) + expect(named("HudViewport").bounds).toEqual({ x: 0, y: 0, width: viewport.width, height: viewport.height }) + expect(named("HudPlayerStatus").bounds).toEqual({ x: 0, y: 0, width: viewport.width, height: viewport.height }) + expect(named("HudPlayerHealth").bounds).toEqual({ + x: 0, + y: viewport.height - scaled(120, viewport.height), + width: scaled(250, viewport.height), + height: scaled(120, viewport.height), + }) + expect(named("HudWeaponAmmo").bounds).toEqual({ + x: viewport.width - scaled(95, viewport.height), + y: viewport.height - scaled(55, viewport.height), + width: scaled(94, viewport.height), + height: scaled(45, viewport.height), + }) + } + + const beforeRepeat = hud.snapshot().vgui + hud.setViewport(viewports.at(-1)!) + expect(hud.snapshot().vgui).toEqual(beforeRepeat) + }) +}) diff --git a/packages/presentation/vgui/README.md b/packages/presentation/vgui/README.md index 74a97a276..2b4656e60 100644 --- a/packages/presentation/vgui/README.md +++ b/packages/presentation/vgui/README.md @@ -65,6 +65,7 @@ Present generic Source 1 panel trees, controls, resources, schemes, localized te - Own the generic developer-console frame, pointer-captured title movement and eight-direction resize, bounded output and command history, catalog-driven completion presentation, text-entry interaction, and typed submission/completion/system requests without executing commands or owning convar state. - Own one bounded client diagnostic panel for immutable FPS and position inputs without owning `cl_showfps`, `cl_showpos`, map, camera, player, or Simulation state. - Own keyboard, pointer, cursor, capture, focus, navigation, IME, clipboard-seam, accessibility, reduced-motion, browser-resize, and device-pixel-ratio behavior. +- Recompute screen-relative and explicit `proportionalToParent` resource geometry from each admitted integer CSS-pixel viewport; device-pixel ratio alone never changes panel bounds. - Compose lossless scheme documents, select desktop conditions and ordered font candidates, request exact content/external/bitmap/local sources, mount range faces atomically, consume supplied metrics/raster profiles, and suppress only unavailable glyph paint without disabling panel state or input. - Present VGUI through direct DOM and CSS without importing Preact. - Retain stable DOM parents and exact material rasters, skip geometry/DOM work for static frames, batch integration construction into one layout/publication commit, and publish only panels whose complete presentation signature changed. diff --git a/packages/presentation/vgui/ROADMAP.md b/packages/presentation/vgui/ROADMAP.md index df16bc22d..332f18ea8 100644 --- a/packages/presentation/vgui/ROADMAP.md +++ b/packages/presentation/vgui/ROADMAP.md @@ -73,7 +73,7 @@ Ownership is resolved. Individual rows remain `Not started` or `Blocked` accordi | Panel allocation, reparenting, child order, safe handles, auto-deletion, deferred deletion, and teardown maintain one acyclic live hierarchy. | Monotonic identities are never reused; creation and reparenting enforce depth, child and cycle bounds; deferred deletion commits at frame end and clears every dead-target queue and input reference. | **Model-based hierarchy comparison:** [`tests/runtime.test.ts`](tests/runtime.test.ts) compares create, reparent, cycle rejection, deferred delete, dead messages, stale identity rejection and zero-resource teardown. | Ready | | Signed z-order, stable equal-z order, front/back movement, popup order, and topmost-popup state determine paint and hit-test precedence. | Children retain ascending signed z plus stable tie order; front/back movement remains inside the equal-z range; popups retain an independent normal/topmost order used by DOM publication and reverse hit testing. | **Order trace and DOM-stack comparison:** fixed overlap and popup vectors in [`tests/runtime.test.ts`](tests/runtime.test.ts) compare hit targets and movement; browser evidence retains DOM order. | Ready | | Local and ancestor visibility plus enabled, mouse-input-enabled, and keyboard-input-enabled state independently govern layout, paint, focus, capture, input, and accessibility. | Panel state keeps every flag independent, derives effective ancestor visibility, releases ineligible focus/capture, gates hit testing and publishes exact hidden/disabled/pointer/accessibility state. | **State truth-table comparison:** deterministic model vectors plus retained computed styles/accessibility compare visible, disabled, pointer, focus and modal transitions. | Ready | -| Integer local, absolute, minimum, inset, and clipped bounds plus 640×480 height-based proportional conversion produce deterministic CSS-pixel geometry. A child inherits its parent's proportional mode when parented, and changing a panel's mode applies recursively through its current descendants. | Integer model geometry clamps minimum size, adds parent left/top inset, intersects half-open clips, clamps popups to the workspace and applies viewport-height/480 proportional truncation independently from parent size and device-pixel ratio. Parenting inherits proportional state; explicit state changes recurse through current descendants. | **Geometry differential vectors:** nested, popup, inset, minimum, proportional inheritance/mutation, nested viewport-scale, viewport and DPR vectors compare snapshots and browser rectangles. | Ready | +| Integer local, absolute, minimum, inset, and clipped bounds plus 640×480 height-based proportional conversion produce deterministic CSS-pixel geometry. A child inherits its parent's proportional mode when parented, and changing a panel's mode applies recursively through its current descendants. | Integer model geometry clamps minimum size, adds parent left/top inset, intersects half-open clips, clamps popups to the workspace and applies viewport-height/480 proportional truncation independently from parent size and device-pixel ratio. Parenting inherits proportional state; explicit state changes recurse through current descendants. | **Geometry differential vectors:** nested, popup, inset, minimum, proportional inheritance/mutation, nested viewport-scale, viewport and DPR vectors compare snapshots and browser rectangles; the vertical-viewport loop adds fixed screen-relative and explicit `proportionalToParent` resize vectors. | Ready | | Parent anchors, fill and alignment forms, auto-resize, aspect-relative dimensions, title-safe edges, and sibling corner pins recompute in dependency order. | Resource geometry implements `r`, `c`, `s`, `p`, `f`, `o`, ordered `+`/`-`, explicit parent-relative selection, four pin corners, all four auto-resize modes including the retained resize bug, and eight sibling pin points. Viewport resize applies parent auto-resize first, then reapplies authored geometry and refreshes pin offsets so stale pre-resize pins cannot overwrite screen-relative coordinates. | **Resize schedule comparison:** fixed parent, viewport, proportional, sibling and auto-resize schedules in [`tests/runtime.test.ts`](tests/runtime.test.ts) plus the checked TF2 HUD profile compare solved integer bounds. | Ready | | Scheme application and layout invalidation propagate once through affected descendants, then control-specific layout completes before paint. | Registry, resource, text, dialog-variable, viewport and control operations synchronously compute one model revision, one geometry closure and one DOM publication; failed validation publishes neither model nor DOM changes. | **Invalidation trace comparison:** phase traces and revision snapshots compare each operation's single solve/publication closure. | Ready | | Solved clipping and front-to-back hit testing honor ancestor clips, insets, visibility, input interest, popups, and modal restrictions. | The model publishes every solved clip as CSS `clip-path`; hit testing visits topmost popups then reverse-z descendants, uses half-open rectangles and applies application/inclusive/exclusive modal scope before dispatch. | **Analytic rectangle and pointer vectors:** overlap, edge, popup, application-modal and outside-subtree vectors compare clips, targets and no-hit results. | Ready | @@ -105,7 +105,7 @@ Ownership is resolved. Individual rows remain `Not started` or `Blocked` accordi | Popups, topmost popups, application-modal surfaces, inclusive and excluded modal subtrees, outside-click notification, and dismissal preserve one deterministic input and paint scope. | Popups retain model parent but publish at workspace scope; independent order/topmost state, app modal and inclusive/exclusive subtree scope govern focus/hit testing/capture; outside clicks emit typed messages. | **Modal/popup schedule comparison:** fixed popup, app-modal, subtree and outside-click schedules compare clips, targets, focus and DOM placement. | Ready | | Message maps, queued and delayed messages, typed commands, action signals, info requests, and child messages preserve ownership, declared parameter types, target order, and dead-target safety. | Owned typed messages retain source/target/order/due time; action targets dispatch in reverse insertion order; dead targets/sources are discarded; generic handlers emit typed commands/messages. Declarative derived-to-base message-map registration remains absent. | **Message-log comparison:** delayed/dead-target, nested action, control command and dialog vectors compare order/ownership/disposal. | In progress | | Injected clock, clipboard, user-config, cursor-lock, external-open, HTML, and audio seams return typed browser results without granting routing, persistence, network, or audio authority to VGUI. | Clock, deterministic random, clipboard request/result, external-open, command and sound requests are typed and injected. User-config, cursor-lock and HTML result operations remain absent from the selected runtime. | **In-memory adapter conformance:** fixed clipboard, URL, command, audio and sink-failure schedules compare requests/results and direct-global absence. | In progress | -| Browser resize and device-pixel-ratio changes atomically update viewport, proportional values, font and image inputs, layout, scissor, and repaint without changing CSS geometry from ratio alone. | One viewport operation finishes cancelable animation work on size change, reloads typed sequences, recomputes proportional/resource/auto-resize geometry and clips, and preserves CSS geometry for ratio-only changes. The TF2 application truncates browser mount bounds to Source's integer screen coordinates before every owner update. | **Resize/DPR capture schedule:** model vectors and the checked repeated-resize TF2 HUD profile compare viewport, geometry, resources, stable DOM identities and restored initial bounds. | Ready | +| Browser resize and device-pixel-ratio changes atomically update viewport, proportional values, font and image inputs, layout, scissor, and repaint without changing CSS geometry from ratio alone. | One viewport operation finishes cancelable animation work on size change, reloads typed sequences, recomputes proportional/resource/auto-resize geometry and clips, and preserves CSS geometry for ratio-only changes. The TF2 application truncates browser mount bounds to Source's integer screen coordinates before every owner update. | **Resize/DPR capture schedule:** model vectors and the configured TF2 HUD loop compare height-only, width-only, portrait, landscape, ultrawide, DPR-only and restored geometry. Shared application admission of visual-viewport, orientation, fullscreen and pointer-lock transitions remains outside this package. | Ready | | Every accepted control exposes deterministic role, name, description, state, value, relationships, focus order, hidden state, and disabled state in the browser accessibility tree. | Every selected control and auxiliary item/frame/dialog node publishes explicit role, name, description, disabled/hidden/checked/pressed/value/orientation/modal/relationship/tab state. | **Accessibility snapshot comparison:** retained headed-Chrome modal snapshot and control-type gate cover all 22 selected identities. | Ready | | Explicit reduced-motion mode publishes animation endpoints at start while preserving sequence command timing, event order, state changes, and completion time. | Reduced mode samples each active interpolation at its endpoint from start through normal completion while delayed queues and removal times remain unchanged. | **Dual-mode virtual-clock comparison:** fixed normal/reduced schedules compare endpoints, active lifetime, delayed commands and terminal state. | Ready | | Malformed resources, missing logical inputs, unknown controls or properties, unsupported adapters, dead handles, invalid references, and invalid input transitions return exact classifications without partial publication. | Stable diagnostics classify configuration, panel, hierarchy, resource, property, reference, registry, text, animation, message, DOM and sink failures. Span-preserving parser diagnostics and a complete mutation corpus remain external/absent. | **Mutation and fault-injection corpus:** fixed unknown factory/property, malformed color/cycle, hierarchy, clipboard and resource atomicity vectors compare code/subject and pre-state. | In progress | diff --git a/packages/presentation/vgui/tests/hud-vertical-layout/symptom-loop.test.ts b/packages/presentation/vgui/tests/hud-vertical-layout/symptom-loop.test.ts new file mode 100644 index 000000000..43b6e8290 --- /dev/null +++ b/packages/presentation/vgui/tests/hud-vertical-layout/symptom-loop.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test" +import { initializeVguiRuntime, type VguiResourceNode, type VguiRuntimeLimits, type VguiScheme } from "../../src" +import { FakeDocument, createRoot } from "../fake-dom" + +const limits: VguiRuntimeLimits = Object.freeze({ + maxPanels: 32, + maxHierarchyDepth: 8, + maxChildrenPerPanel: 16, + maxResourceNodes: 64, + maxResourceDepth: 8, + maxPropertiesPerPanel: 16, + maxStringCodeUnits: 255, + maxTextCodeUnits: 255, + maxDialogVariables: 8, + maxLocalizationTokens: 8, + maxSchemeColors: 8, + maxSchemeSettings: 8, + maxSchemeBorders: 8, + maxSchemeImages: 8, + maxAnimationScripts: 1, + maxAnimationSequences: 1, + maxAnimationCommands: 1, + maxActiveAnimations: 1, + maxDelayedCommands: 1, + maxQueuedMessages: 8, + maxDiagnostics: 16, + maxDomNodes: 64, + maxListeners: 16, +}) + +const scheme: VguiScheme = Object.freeze({ + identity: "resource/client-scheme.res", + revision: "vertical-layout-1", + tag: "ClientScheme", + colors: Object.freeze([]), + settings: Object.freeze([]), + fonts: Object.freeze([]), + borders: Object.freeze([]), + images: Object.freeze([]), +}) + +const scalar = (name: string, value: string): VguiResourceNode => Object.freeze({ name, value, condition: null, children: Object.freeze([]) }) +const object = (name: string, children: readonly VguiResourceNode[]): VguiResourceNode => Object.freeze({ name, value: null, condition: null, children }) + +describe("VGUI vertical viewport symptom loop", () => { + test("distinguishes screen-relative and explicit parent-relative resource geometry", () => { + const root = createRoot(new FakeDocument()) + const initialized = initializeVguiRuntime({ + runtimeIdentity: "vertical-layout-loop", + root: root as unknown as HTMLElement, + rootControl: { control: "EditablePanel", name: "HudViewport" }, + viewport: { width: 1280, height: 720, devicePixelRatio: 1 }, + limits, + clock: { nowSeconds: () => 0 }, + random: { nextUnit: () => 0.5 }, + scheme, + localization: { identity: "resource/tf_english.txt", revision: "vertical-layout-1", language: "english", tokens: Object.freeze([]) }, + animationScripts: { identity: "scripts/hudanimations-manifest.txt", revision: "vertical-layout-1", scripts: Object.freeze([]), activeConditions: Object.freeze([]) }, + customControls: Object.freeze([]), + reducedMotion: true, + onRequest() {}, + }) + if (!initialized.ok) throw new Error(initialized.diagnostic.code) + const runtime = initialized.runtime + const proportional = runtime.apply({ kind: "set-panel-state", panel: 1, proportional: true }) + if (!proportional.ok) throw new Error(proportional.diagnostic.code) + const create = runtime.apply({ + kind: "create-panel", + parent: 1, + control: "EditablePanel", + name: "BottomHudParent", + properties: [ + { name: "xpos", value: "20" }, + { name: "ypos", value: "100" }, + { name: "wide", value: "400" }, + { name: "tall", value: "300" }, + ], + }) + if (!create.ok || create.panel === undefined) throw new Error("parent creation failed") + const parent = create.panel + const replaced = runtime.apply({ + kind: "replace-resource", + parent, + document: { + logicalIdentity: "resource/ui/hud-bottom-vector.res", + revision: "vertical-layout-1", + root: object("Resource", [object("ScreenBottomPanel", [ + scalar("ControlName", "Panel"), + scalar("xpos", "r60"), + scalar("ypos", "r40"), + scalar("wide", "50"), + scalar("tall", "30"), + ]), object("ParentBottomPanel", [ + scalar("ControlName", "Panel"), + scalar("proportionalToParent", "1"), + scalar("xpos", "r60"), + scalar("ypos", "r40"), + scalar("wide", "50"), + scalar("tall", "30"), + ])]), + }, + selection: { activeConditions: Object.freeze([]), resolutionSuffixes: Object.freeze([]) }, + }) + if (!replaced.ok) throw new Error(replaced.diagnostic.code) + + const screenPanel = () => runtime.snapshot().panels.find((candidate) => candidate.name === "ScreenBottomPanel")! + const parentPanel = () => runtime.snapshot().panels.find((candidate) => candidate.name === "ParentBottomPanel")! + expect(screenPanel().bounds).toEqual({ x: 1190, y: 660, width: 75, height: 45 }) + expect(parentPanel().bounds).toEqual({ x: 510, y: 390, width: 75, height: 45 }) + + const resized = runtime.apply({ kind: "set-viewport", viewport: { width: 1024, height: 768, devicePixelRatio: 1 } }) + if (!resized.ok) throw new Error(resized.diagnostic.code) + expect(screenPanel().bounds).toEqual({ x: 928, y: 704, width: 80, height: 48 }) + expect(parentPanel().bounds).toEqual({ x: 544, y: 416, width: 80, height: 48 }) + }) +}) diff --git a/tools/playsrc/profile/hud-layout.profile.ts b/tools/playsrc/profile/hud-layout.profile.ts index d162ce666..e3e9f5efa 100644 --- a/tools/playsrc/profile/hud-layout.profile.ts +++ b/tools/playsrc/profile/hud-layout.profile.ts @@ -1,14 +1,20 @@ import { mkdir, rm, writeFile } from "node:fs/promises" +import { createHash } from "node:crypto" import path from "node:path" import { expect, test, type Page } from "@playwright/test" import { loadLocalConfig } from "../src/config" const TARGET = "jump_beef" const VIEWPORTS = Object.freeze([ - Object.freeze({ width: 1280, height: 720 }), - Object.freeze({ width: 1024, height: 768 }), - Object.freeze({ width: 1600, height: 900 }), - Object.freeze({ width: 1280, height: 720 }), + Object.freeze({ name: "initial", width: 1280, height: 720, devicePixelRatio: 1 }), + Object.freeze({ name: "height-only", width: 1280, height: 900, devicePixelRatio: 1 }), + Object.freeze({ name: "width-only", width: 1600, height: 900, devicePixelRatio: 1 }), + Object.freeze({ name: "four-three", width: 1024, height: 768, devicePixelRatio: 1 }), + Object.freeze({ name: "ultrawide", width: 2560, height: 1080, devicePixelRatio: 1 }), + Object.freeze({ name: "portrait", width: 390, height: 844, devicePixelRatio: 1 }), + Object.freeze({ name: "landscape", width: 844, height: 390, devicePixelRatio: 1 }), + Object.freeze({ name: "dpr-two", width: 1280, height: 720, devicePixelRatio: 2 }), + Object.freeze({ name: "restored", width: 1280, height: 720, devicePixelRatio: 1 }), ]) const PANELS = Object.freeze([ "HudViewport", @@ -23,10 +29,28 @@ const PANELS = Object.freeze([ ]) type Rect = Readonly<{ x: number; y: number; width: number; height: number }> +type DecodedPng = Readonly<{ width: number; height: number; rgb: Uint8Array; alpha: Uint8Array }> +type PixelMetric = Readonly<{ + name: "bottom-25-percent" | "bottom-10-percent" | "bottom-32-pixels" | "final-row" + x: number + y: number + width: number + height: number + alphaOccupancy: number + opaqueBlackOccupancy: number + meanAlpha: number + sha256: string +}> type Capture = Readonly<{ - viewport: Readonly<{ width: number; height: number }> - host: Rect + viewport: (typeof VIEWPORTS)[number] + innerViewport: Readonly<{ width: number; height: number; devicePixelRatio: number }> + visualViewport: Readonly<{ x: number; y: number; width: number; height: number; scale: number }> | null + ownerViewport: string + ownerRevision: number + rectangles: Readonly> + ownerRecords: Readonly> panels: Readonly>> + pixels: Readonly<{ canvas: readonly PixelMetric[]; hud: readonly PixelMetric[]; composed: readonly PixelMetric[] }> }> const scaled = (value: number, height: number): number => Math.trunc(value * height / 480) @@ -50,7 +74,166 @@ async function settle(page: Page): Promise { await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) } -test("profile TF2 HUD layout and viewport resize", async ({ page }) => { +async function isolatedScreenshot(page: Page, selector: string): Promise { + await page.evaluate((selected) => { + for (const element of [document.documentElement, document.body, document.getElementById("app"), document.querySelector("main")]) { + if (!(element instanceof HTMLElement)) continue + element.dataset.viewportEvidenceStyle = element.getAttribute("style") ?? "" + element.style.setProperty("background", "transparent", "important") + } + const selectedElement = document.querySelector(selected) + for (const child of document.querySelector("main")?.children ?? []) { + if (child === selectedElement) continue + const element = child as HTMLElement + element.dataset.viewportEvidenceStyle = element.getAttribute("style") ?? "" + element.style.setProperty("visibility", "hidden", "important") + } + }, selector) + try { + return new Uint8Array(await page.screenshot({ omitBackground: true })) + } finally { + await page.evaluate(() => { + for (const element of document.querySelectorAll("[data-viewport-evidence-style]")) { + const style = element.dataset.viewportEvidenceStyle ?? "" + if (style) element.setAttribute("style", style) + else element.removeAttribute("style") + delete element.dataset.viewportEvidenceStyle + } + }) + } +} + +function invariant(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +function readUint32(bytes: Uint8Array, offset: number): number { + return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, false) +} + +function paeth(left: number, above: number, upperLeft: number): number { + const estimate = left + above - upperLeft + const leftDistance = Math.abs(estimate - left) + const aboveDistance = Math.abs(estimate - above) + const upperLeftDistance = Math.abs(estimate - upperLeft) + if (leftDistance <= aboveDistance && leftDistance <= upperLeftDistance) return left + return aboveDistance <= upperLeftDistance ? above : upperLeft +} + +async function decodePng(bytes: Uint8Array): Promise { + const signature = [137, 80, 78, 71, 13, 10, 26, 10] + invariant(bytes.byteLength >= 33 && signature.every((value, index) => bytes[index] === value), "viewport PNG signature is invalid") + let offset = 8 + let width = 0 + let height = 0 + let channels = 0 + const compressedParts: Uint8Array[] = [] + while (offset < bytes.byteLength) { + invariant(offset + 12 <= bytes.byteLength, "viewport PNG chunk is truncated") + const length = readUint32(bytes, offset) + const type = new TextDecoder().decode(bytes.subarray(offset + 4, offset + 8)) + const dataStart = offset + 8 + const dataEnd = dataStart + length + invariant(dataEnd + 4 <= bytes.byteLength, "viewport PNG chunk range is invalid") + if (type === "IHDR") { + invariant(length === 13 && width === 0, "viewport PNG IHDR is invalid") + width = readUint32(bytes, dataStart) + height = readUint32(bytes, dataStart + 4) + const bitDepth = bytes[dataStart + 8] + const colorType = bytes[dataStart + 9] + invariant(bitDepth === 8 && (colorType === 2 || colorType === 6), "viewport PNG color profile is unsupported") + invariant(bytes[dataStart + 10] === 0 && bytes[dataStart + 11] === 0 && bytes[dataStart + 12] === 0, "viewport PNG encoding profile is unsupported") + channels = colorType === 2 ? 3 : 4 + } else if (type === "IDAT") compressedParts.push(bytes.slice(dataStart, dataEnd)) + else if (type === "IEND") { + invariant(length === 0 && dataEnd + 4 === bytes.byteLength, "viewport PNG IEND is invalid") + offset = bytes.byteLength + break + } + offset = dataEnd + 4 + } + invariant(width > 0 && height > 0 && channels > 0 && compressedParts.length > 0, "viewport PNG structure is incomplete") + invariant(width <= 5120 && height <= 4096, "viewport PNG dimensions exceed the evidence bound") + const compressedLength = compressedParts.reduce((sum, part) => sum + part.byteLength, 0) + const compressed = new Uint8Array(compressedLength) + let compressedOffset = 0 + for (const part of compressedParts) { compressed.set(part, compressedOffset); compressedOffset += part.byteLength } + const inflated = new Uint8Array(await new Response(new Blob([compressed]).stream().pipeThrough(new DecompressionStream("deflate"))).arrayBuffer()) + const stride = width * channels + invariant(inflated.byteLength === height * (stride + 1), "viewport PNG scanline length is invalid") + const samples = new Uint8Array(height * stride) + for (let y = 0; y < height; y += 1) { + const filter = inflated[y * (stride + 1)] + invariant(filter !== undefined && filter <= 4, "viewport PNG filter is unsupported") + const encodedStart = y * (stride + 1) + 1 + const outputStart = y * stride + for (let x = 0; x < stride; x += 1) { + const encoded = inflated[encodedStart + x] ?? 0 + const left = x >= channels ? samples[outputStart + x - channels] ?? 0 : 0 + const above = y > 0 ? samples[outputStart - stride + x] ?? 0 : 0 + const upperLeft = y > 0 && x >= channels ? samples[outputStart - stride + x - channels] ?? 0 : 0 + const predictor = filter === 0 ? 0 : filter === 1 ? left : filter === 2 ? above : filter === 3 ? Math.floor((left + above) / 2) : paeth(left, above, upperLeft) + samples[outputStart + x] = (encoded + predictor) & 0xff + } + } + const rgb = new Uint8Array(width * height * 3) + const alpha = new Uint8Array(width * height) + for (let source = 0, destination = 0, pixel = 0; source < samples.byteLength; source += channels, destination += 3, pixel += 1) { + rgb[destination] = samples[source] ?? 0 + rgb[destination + 1] = samples[source + 1] ?? 0 + rgb[destination + 2] = samples[source + 2] ?? 0 + alpha[pixel] = channels === 4 ? samples[source + 3] ?? 0 : 255 + } + return Object.freeze({ width, height, rgb, alpha }) +} + +function bottomMetrics(image: DecodedPng): readonly PixelMetric[] { + const regions = [ + { name: "bottom-25-percent" as const, height: Math.max(1, Math.ceil(image.height * 0.25)) }, + { name: "bottom-10-percent" as const, height: Math.max(1, Math.ceil(image.height * 0.1)) }, + { name: "bottom-32-pixels" as const, height: Math.min(32, image.height) }, + { name: "final-row" as const, height: 1 }, + ] + return Object.freeze(regions.map((region) => { + const y = image.height - region.height + const pixels = image.width * region.height + let alphaPixels = 0 + let opaqueBlackPixels = 0 + let alphaTotal = 0 + const samples = new Uint8Array(pixels * 4) + let sample = 0 + for (let row = y; row < image.height; row += 1) { + for (let x = 0; x < image.width; x += 1) { + const pixel = row * image.width + x + const rgb = pixel * 3 + const alpha = image.alpha[pixel] ?? 0 + const red = image.rgb[rgb] ?? 0 + const green = image.rgb[rgb + 1] ?? 0 + const blue = image.rgb[rgb + 2] ?? 0 + samples[sample++] = red + samples[sample++] = green + samples[sample++] = blue + samples[sample++] = alpha + if (alpha > 0) alphaPixels += 1 + if (alpha === 255 && red <= 2 && green <= 2 && blue <= 2) opaqueBlackPixels += 1 + alphaTotal += alpha + } + } + return Object.freeze({ + name: region.name, + x: 0, + y, + width: image.width, + height: region.height, + alphaOccupancy: Number((alphaPixels / pixels).toFixed(6)), + opaqueBlackOccupancy: Number((opaqueBlackPixels / pixels).toFixed(6)), + meanAlpha: Number((alphaTotal / pixels).toFixed(3)), + sha256: createHash("sha256").update(samples).digest("hex"), + }) + })) +} + +test("profile TF2 HUD layout and composed viewport ownership", async ({ page }) => { const local = await loadLocalConfig() const outputDirectory = path.join(local.sourceCacheDir, "profiles", "hud", TARGET) await rm(outputDirectory, { recursive: true, force: true }) @@ -74,18 +257,33 @@ test("profile TF2 HUD layout and viewport resize", async ({ page }) => { }, undefined, { timeout: 600_000, polling: 50 }) await page.keyboard.press("Backquote") + const client = await page.context().newCDPSession(page) const captures: Capture[] = [] for (let index = 0; index < VIEWPORTS.length; index += 1) { const viewport = VIEWPORTS[index]! - await page.setViewportSize(viewport) + await page.setViewportSize({ width: viewport.width, height: viewport.height }) + await client.send("Emulation.setDeviceMetricsOverride", { + width: viewport.width, + height: viewport.height, + deviceScaleFactor: viewport.devicePixelRatio, + mobile: false, + screenWidth: viewport.width, + screenHeight: viewport.height, + }) + await page.waitForFunction((expected) => devicePixelRatio === expected, viewport.devicePixelRatio) + await page.evaluate(() => window.visualViewport?.dispatchEvent(new Event("resize"))) await settle(page) - const capture = await page.evaluate(({ names, viewport }) => { + const geometry = await page.evaluate(({ names, viewport }) => { const readRect = (element: Element) => { const value = element.getBoundingClientRect() return { x: value.x, y: value.y, width: value.width, height: value.height } } - const host = document.querySelector("[data-vgui-runtime='tf2-hud']") - if (!host) throw new Error("TF2 HUD runtime host is unavailable") + const required = (selector: string): T => { + const value = document.querySelector(selector) + if (!value) throw new Error(`Viewport evidence element ${selector} is unavailable`) + return value + } + const host = required("[data-vgui-runtime='tf2-hud']") const panels: Record; rect: ReturnType }> = {} for (const name of names) { const element = host.querySelector(`[data-vgui-name="${name}"]`) @@ -102,28 +300,65 @@ test("profile TF2 HUD layout and viewport resize", async ({ page }) => { rect: readRect(element), } } - return { viewport, host: readRect(host), panels } + const main = required("main") + const ownerRecord = (selector: string) => required(selector).dataset.presentationViewport ?? "" + const visual = window.visualViewport + return { + viewport, + innerViewport: { width: innerWidth, height: innerHeight, devicePixelRatio }, + visualViewport: visual ? { x: visual.offsetLeft, y: visual.offsetTop, width: visual.width, height: visual.height, scale: visual.scale } : null, + ownerViewport: main.dataset.presentationViewport ?? "", + ownerRevision: Number(main.dataset.presentationViewportRevision), + rectangles: { + html: readRect(document.documentElement), body: readRect(document.body), app: readRect(required("#app")), main: readRect(main), + canvas: readRect(required(".world-canvas")), hudLayer: readRect(required(".hud-layer")), hudHost: readRect(host), + }, + ownerRecords: { + canvas: ownerRecord(".world-canvas"), startup: ownerRecord(".startup-layer"), loading: ownerRecord(".loading-layer"), + gameUi: ownerRecord(".gameui-layer"), hud: ownerRecord(".hud-layer"), options: ownerRecord(".options-layer"), developer: ownerRecord(".developer-layer"), + }, + panels, + } }, { names: PANELS, viewport }) - captures.push(capture) - await page.screenshot({ path: path.join(outputDirectory, `hud-${index + 1}-${viewport.width}x${viewport.height}.png`) }) + const canvas = await decodePng(await isolatedScreenshot(page, ".world-canvas")) + const hud = await decodePng(await isolatedScreenshot(page, ".hud-layer")) + const composed = await decodePng(new Uint8Array(await page.screenshot({ + omitBackground: false, + path: path.join(outputDirectory, `hud-${index + 1}-${viewport.name}-${viewport.width}x${viewport.height}-dpr${viewport.devicePixelRatio}.png`), + }))) + expect([composed.width, composed.height]).toEqual([viewport.width, viewport.height]) + expect([canvas.width, canvas.height]).toEqual([composed.width, composed.height]) + expect([hud.width, hud.height]).toEqual([composed.width, composed.height]) + captures.push(Object.freeze({ ...geometry, pixels: Object.freeze({ canvas: bottomMetrics(canvas), hud: bottomMetrics(hud), composed: bottomMetrics(composed) }) })) } - const report = Object.freeze({ - schema: "playsrc-tf2-hud-layout-profile-v1", - target: TARGET, - captures: Object.freeze(captures), + const duplicateRevision = captures.at(-1)!.ownerRevision + await page.evaluate(() => { + window.visualViewport?.dispatchEvent(new Event("resize")) + document.dispatchEvent(new Event("fullscreenchange")) + document.dispatchEvent(new Event("pointerlockchange")) }) + await settle(page) + expect(Number(await page.locator("main").getAttribute("data-presentation-viewport-revision"))).toBe(duplicateRevision) + + const report = Object.freeze({ schema: "playsrc-tf2-hud-layout-profile-v2", target: TARGET, captures: Object.freeze(captures) }) await writeFile(path.join(outputDirectory, "report.json"), `${JSON.stringify(report, null, 2)}\n`) for (const capture of captures) { - expect(capture.host).toEqual(rect(0, 0, capture.viewport.width, capture.viewport.height)) + const expectedViewport = rect(0, 0, capture.viewport.width, capture.viewport.height) + expect(capture.innerViewport).toEqual({ width: capture.viewport.width, height: capture.viewport.height, devicePixelRatio: capture.viewport.devicePixelRatio }) + if (capture.visualViewport) expect(capture.visualViewport).toMatchObject({ x: 0, y: 0, width: capture.viewport.width, height: capture.viewport.height, scale: 1 }) + for (const rectangle of Object.values(capture.rectangles)) expect(rectangle).toEqual(expectedViewport) + expect(new Set(Object.values(capture.ownerRecords))).toEqual(new Set([`${capture.ownerRevision}:${capture.viewport.width}x${capture.viewport.height}@${capture.viewport.devicePixelRatio}`])) + expect(capture.ownerViewport).toBe(`${capture.viewport.width}x${capture.viewport.height}@${capture.viewport.devicePixelRatio}`) const expectedPanels = expected(capture.viewport.width, capture.viewport.height) - for (const name of PANELS) expect(capture.panels[name]!.local, `${name} at ${capture.viewport.width}x${capture.viewport.height}`).toEqual(expectedPanels[name]) + for (const name of PANELS) expect(capture.panels[name]!.local, `${name} at ${capture.viewport.name}`).toEqual(expectedPanels[name]) + for (const metric of capture.pixels.canvas) expect(metric.alphaOccupancy, `canvas ${metric.name} at ${capture.viewport.name}`).toBeGreaterThan(0) + for (const metric of capture.pixels.composed) { + expect(metric.alphaOccupancy, `composed ${metric.name} at ${capture.viewport.name}`).toBe(1) + expect(metric.opaqueBlackOccupancy, `composed ${metric.name} at ${capture.viewport.name}`).toBeLessThan(1) + } } - const stableGeometry = (capture: Capture | undefined) => Object.fromEntries(Object.entries(capture?.panels ?? {}).map(([name, panel]) => [name, { - id: panel.id, - parent: panel.parent, - local: panel.local, - }])) + const stableGeometry = (capture: Capture | undefined) => Object.fromEntries(Object.entries(capture?.panels ?? {}).map(([name, panel]) => [name, { id: panel.id, parent: panel.parent, local: panel.local }])) expect(stableGeometry(captures.at(-1))).toEqual(stableGeometry(captures[0])) }) diff --git a/tools/playsrc/src/verify-browser.ts b/tools/playsrc/src/verify-browser.ts index bcf4c9c1a..1004b7199 100644 --- a/tools/playsrc/src/verify-browser.ts +++ b/tools/playsrc/src/verify-browser.ts @@ -863,6 +863,32 @@ async function unavailable(url: string): Promise { } } +type ViewportOwnershipEvidence = Readonly<{ + viewport: string + revision: number + rectangles: readonly Readonly<{ name: string; x: number; y: number; width: number; height: number }>[] + ownerRecords: readonly string[] +}> + +async function viewportOwnership(session: string, width: number, height: number): Promise { + await agent([ + "--session", session, "wait", "--fn", + `document.querySelector('main')?.dataset.presentationViewport===${JSON.stringify(`${width}x${height}@1`)}`, + "--timeout", "30000", + ]) + const evidence = parseJson(await agent([ + "--session", session, "eval", + `(()=>{const main=document.querySelector('main'),rect=(name,node)=>{const r=node.getBoundingClientRect();return{name,x:r.x,y:r.y,width:r.width,height:r.height}},owners=['.world-canvas','.startup-layer','.loading-layer','.gameui-layer','.hud-layer','.options-layer','.developer-layer'].map(selector=>document.querySelector(selector).dataset.presentationViewport);return{viewport:main.dataset.presentationViewport,revision:Number(main.dataset.presentationViewportRevision),rectangles:[rect('html',document.documentElement),rect('body',document.body),rect('app',document.querySelector('#app')),rect('main',main),rect('canvas',document.querySelector('.world-canvas')),rect('hud',document.querySelector('.hud-layer'))],ownerRecords:owners}})()`, + ])) + require(evidence.viewport === `${width}x${height}@1` && Number.isSafeInteger(evidence.revision) && evidence.revision > 0, + `application presentation viewport differs: ${JSON.stringify(evidence)}`) + require(evidence.rectangles.every((value) => value.x === 0 && value.y === 0 && value.width === width && value.height === height), + `application presentation rectangles differ: ${JSON.stringify(evidence.rectangles)}`) + require(new Set(evidence.ownerRecords).size === 1 && evidence.ownerRecords[0] === `${evidence.revision}:${width}x${height}@1`, + `application presentation owner records differ: ${JSON.stringify(evidence.ownerRecords)}`) + return evidence +} + export async function verifyBrowserAcceptance( config: LocalConfig, target: string | undefined, @@ -883,6 +909,7 @@ export async function verifyBrowserAcceptance( const body = await agent(["--session", session, "eval", "document.body.innerText"]) throw new BrowserEvidenceError(`${String(error)}; browser body: ${body}`) } + const desktopMenuViewport = await viewportOwnership(session, VIEWPORT_WIDTH, VIEWPORT_HEIGHT) const menuState = parseJson<{ active: Record; inactive: Record; eventDisplay: string }>(await agent([ "--session", session, "eval", "(()=>{const get=n=>document.querySelector(`[data-vgui-name=\"${n}\"]`),entry=n=>get(n)?.querySelector('[data-vgui-name=ModeButton]');return {active:Object.fromEntries(['SettingsButton','TF2SettingsButton','QuitButton'].map(n=>[n,get(n)?.getAttribute('aria-disabled')??null])),inactive:{CharacterSetupButton:get('CharacterSetupButton')?.getAttribute('aria-disabled')??null,FindAGameButton:get('FindAGameButton')?.getAttribute('aria-disabled')??null,...Object.fromEntries(['CasualEntry','CompetitiveEntry','MvMEntry','ServerBrowserEntry','TrainingEntry','CreateServerEntry'].map(n=>[n,entry(n)?.getAttribute('aria-disabled')??null]))},eventDisplay:getComputedStyle(get('EventEntry')).display}})()", @@ -1029,6 +1056,7 @@ export async function verifyBrowserAcceptance( await agent(["--session", session, "set", "viewport", "390", "844"]) await agent(["--session", session, "reload"]) const mobileStartup = await completeStartup(session, config, "mobile-390x844", "skip") + const mobileMenuViewport = await viewportOwnership(session, 390, 844) const mobileState = parseJson<{ settings: number[]; quit: number[] }>(await agent([ "--session", session, "eval", "(()=>{const rect=n=>{const r=document.querySelector(`[data-vgui-name=${n}]`).getBoundingClientRect();return [r.left,r.top,r.right,r.bottom]};return{settings:rect('SettingsButton'),quit:rect('QuitButton')}})()", ])) @@ -1091,6 +1119,7 @@ export async function verifyBrowserAcceptance( "--timeout", "30000", ]) + const gameplayViewport = await viewportOwnership(session, VIEWPORT_WIDTH, VIEWPORT_HEIGHT) const initialHudOperations = parseJson(await agent(["--session", session, "eval", "document.querySelector('main').dataset.hudOperationProbe"])) const initialHudOperationParts = initialHudOperations.split(":") require(initialHudOperationParts.length === 6 && initialHudOperationParts[0] === "1" && initialHudOperationParts[4] === "0" && initialHudOperationParts[5] === "1", @@ -1629,6 +1658,7 @@ export async function verifyBrowserAcceptance( gameUi: { menuPresentation, mobileInterface }, options: { keyboard: keyboardOptions, visualDefault: optionsVisualDefault, armedButton, comboDefault, comboHover, captures: optionsCaptures, conflict: conflictBindings, reset: resetBindings, keyboardAdvanced, videoAdvanced, advanced: advancedOptions }, hud: { initialOperations: initialHudOperations, initialPresentation: initialHudPresentation, pausedPresentation: pausedHudPresentation, pauseControls, animationTrace: hudAnimationTrace }, + presentationViewport: { desktopMenu: desktopMenuViewport, mobileMenu: mobileMenuViewport, gameplay: gameplayViewport }, audio: "exact-buffers-decoded-and-context-running", fixedCamera, fixedSpawn,