diff --git a/changes/lane-tier-flag.md b/changes/lane-tier-flag.md new file mode 100644 index 000000000..8f7787d9e --- /dev/null +++ b/changes/lane-tier-flag.md @@ -0,0 +1,10 @@ +### Added + +- `shoot`, `drive` and `perf` take `--quality ` and pin that graphics tier on the game page (`?quality=`). Each run reads the tier back from the shell (`data-jg-quality`) and fails when it is missing or different, so a capture or perf record always names the tier it measured. Every capture summary prints `tier `; `perf` echoes `perf: tier ` and records it in `quality`. +- The old `--quality draft|full` iteration mode is now the `--draft` switch (low tier, half viewport, `-draft` file names), on `shoot`, `drive` and `perf`; `perf:gate` takes `--draft` too. An unknown `--quality` value fails naming all four tiers. + +### Changed + +- A capture at a non-default tier writes its tier into the file name (`-low`, `-medium`, `-ultra`), so an ultra shot cannot overwrite the high one it was compared against. + +Root cause: `--quality` carried the iteration preset, not the graphics tier, so a culling lane that asked for ultra got the game's default `high` on both sides of its comparison and could not prove a tier claim. diff --git a/packages/jgengine/src/templates/captureArgs.test.ts b/packages/jgengine/src/templates/captureArgs.test.ts index 875cd5ac4..da1b04fff 100644 --- a/packages/jgengine/src/templates/captureArgs.test.ts +++ b/packages/jgengine/src/templates/captureArgs.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { materializeHarness } from "../harness"; -import { CAPTURE_FLAGS, describeFlow, describeStepFile, parseCaptureArgs, parseStepFile, rejectMixedStepFlags, type CaptureFlag, type CaptureTool } from "./captureArgs"; +import { CAPTURE_FLAGS, describeFlow, describeStepFile, parseCaptureArgs, parseGraphicsTier, parseStepFile, rejectMixedStepFlags, type CaptureFlag, type CaptureTool } from "./captureArgs"; /** A valid value for each flag, so every flag can be exercised by every host. */ export function sampleArgv(entry: CaptureFlag): string[] { @@ -13,7 +13,7 @@ export function sampleArgv(entry: CaptureFlag): string[] { "--steps": "steps.json", "--flow": "to-menu", "--log": "drive.log.jsonl", "--click": "Start", "--double-click": "Tile", "--click-at": "10,20", "--right-click-at": "30,40", "--fill": "Name=Ada", "--key": "KeyW+KeyD:250", "--wait": "100", "--shot": "after", "--rpc": '{"method":"debug_snapshot"}', "--probe": "p0", "--url": "http://127.0.0.1:5173/", "--port": "5199", - "--device": "mobile", "--width": "800", "--height": "600", "--size": "half", "--quality": "draft", "--settle": "500", "--timeout": "90", + "--device": "mobile", "--width": "800", "--height": "600", "--size": "half", "--quality": "ultra", "--settle": "500", "--timeout": "90", "--param": "gamepad=2", "--out": "/tmp/x.png", "--seed": "3", "--sample": "200", "--softlock": "1500", "--epsilon": "0.01", "--game": "demo", "--mode": "play", "--state": "two-towers", "--run": "game.start", "--view": "hero", "--look": "1,2", "--look-from": "12,5", "--spawn": "1,0,1", "--style": "toon", @@ -39,8 +39,25 @@ describe("parseCaptureArgs", () => { expect(() => parseCaptureArgs("shoot", ["--click", "Start"], CAPTURE_FLAGS)).toThrow("drive option"); }); - test("keeps steps ordered and defaults debug_snapshot to the summary", () => { - const parsed = parseCaptureArgs("drive", ["demo", "--click-at", "10,20", "--key", "mouse0:1200", "--rpc", '{"method":"debug_snapshot"}', "--shot", "/tmp/a.png"], CAPTURE_FLAGS); + test("--quality pins one of the four graphics tiers and rejects anything else by name", () => { + for (const tool of tools) { + for (const tier of ["low", "medium", "high", "ultra"]) { + expect(parseCaptureArgs(tool, ["--quality", tier], CAPTURE_FLAGS).options.quality).toBe(tier); + } + expect(() => parseCaptureArgs(tool, ["--quality", "draft"], CAPTURE_FLAGS)).toThrow("--quality must be one of low, medium, high, ultra (got draft)"); + expect(() => parseCaptureArgs(tool, ["--quality", "HIGH"], CAPTURE_FLAGS)).toThrow("must be one of low, medium, high, ultra"); + } + expect(parseGraphicsTier("ultra")).toBe("ultra"); + expect(() => parseGraphicsTier(undefined)).toThrow("--quality must be one of low, medium, high, ultra (got nothing)"); + }); + + test("--draft is the iteration preset, so it cannot ride with --quality", () => { + expect(parseCaptureArgs("shoot", ["--draft"], CAPTURE_FLAGS).options.draft).toBe(true); + expect(parseCaptureArgs("drive", ["--draft", "--size", "half"], CAPTURE_FLAGS).options.draft).toBe(true); + expect(() => parseCaptureArgs("drive", ["--draft", "--quality", "ultra"], CAPTURE_FLAGS)).toThrow("--draft is the iteration preset"); + }); + + test("keeps steps ordered and defaults debug_snapshot to the summary", () => { const parsed = parseCaptureArgs("drive", ["demo", "--click-at", "10,20", "--key", "mouse0:1200", "--rpc", '{"method":"debug_snapshot"}', "--shot", "/tmp/a.png"], CAPTURE_FLAGS); expect(parsed.positionals).toEqual(["demo"]); expect(parsed.steps).toEqual([ { kind: "click-at", spec: "10,20", button: "left" }, diff --git a/packages/jgengine/src/templates/captureArgs.ts b/packages/jgengine/src/templates/captureArgs.ts index fbb5b9298..01072c925 100644 --- a/packages/jgengine/src/templates/captureArgs.ts +++ b/packages/jgengine/src/templates/captureArgs.ts @@ -11,6 +11,21 @@ /** Which CLI a flag belongs to. */ export type CaptureTool = "shoot" | "drive"; +/** The graphics tiers a capture can pin; the game shell resolves the same four. */ +export type GraphicsTier = "low" | "medium" | "high" | "ultra"; + +/** + * Parse a `--quality` value into a graphics tier, naming every valid tier when it is not one. + * A tier claim that cannot be forced is not provable, so an unknown value fails instead of + * falling back to the game's default. + */ +export function parseGraphicsTier(value: string | undefined): GraphicsTier { + const tiers = ["low", "medium", "high", "ultra"]; + const found = tiers.find((tier) => tier === value); + if (found === undefined) throw new Error("--quality must be one of " + tiers.join(", ") + " (got " + (value ?? "nothing") + ")"); + return found as GraphicsTier; +} + /** One capture CLI flag. `engineOnly` flags parse everywhere but only the engine dev runner honors them. */ export interface CaptureFlag { flag: string; @@ -53,7 +68,8 @@ export const CAPTURE_FLAGS: readonly CaptureFlag[] = [ { flag: "--width", tools: ["shoot", "drive"], value: "required", arg: "", help: "viewport width override in CSS pixels" }, { flag: "--height", tools: ["shoot", "drive"], value: "required", arg: "", help: "viewport height override in CSS pixels" }, { flag: "--size", tools: ["shoot", "drive"], value: "required", arg: "", help: "half halves both dimensions for cheap judge shots; full for final/PR shots" }, - { flag: "--quality", tools: ["shoot", "drive"], value: "required", arg: "", engineOnly: RUNNER + " (draft pins ?quality=low and halves the viewport)", help: "draft = fast iteration run: low graphics tier and half size, -draft file names; full (default) for PR evidence" }, + { flag: "--quality", tools: ["shoot", "drive"], value: "required", arg: "", engineOnly: RUNNER + " (the tier is pinned on the page URL the runner opens)", help: "graphics tier the game renders at; the run fails if the page reports a different tier" }, + { flag: "--draft", tools: ["shoot", "drive"], value: "none", engineOnly: RUNNER + " (the iteration preset)", help: "fast iteration run: low tier and half size, -draft file names; drop --quality" }, { flag: "--settle", tools: ["shoot", "drive"], value: "required", arg: "", help: "extra wait after the page reports an honest frame" }, { flag: "--timeout", tools: ["shoot", "drive"], value: "required", arg: "", help: "readiness/capture timeout in seconds (default 60)" }, { flag: "--param", tools: ["shoot", "drive"], value: "required", repeat: true, arg: "", help: "add a query parameter to the page URL (repeatable)" }, @@ -267,6 +283,13 @@ export function parseCaptureArgs(tool: CaptureTool, argv: readonly string[], fla } const size = out.options.size; if (typeof size === "string" && size !== "full" && size !== "half") throw new Error("--size must be full or half (got " + size + ")"); + const quality = out.options.quality; + if (typeof quality === "string" && !["low", "medium", "high", "ultra"].includes(quality)) { + throw new Error(tool + ": --quality must be one of low, medium, high, ultra (got " + quality + ")"); + } + if (out.options.draft === true && typeof quality === "string") { + throw new Error(tool + ": --draft is the iteration preset (low tier, half size), so drop it or drop --quality " + quality); + } const device = out.options.device; if (typeof device === "string" && !["desktop", "mobile", "mobile-landscape"].concat(tool === "shoot" ? ["both"] : []).includes(device)) { throw new Error("--device must be desktop, mobile, mobile-landscape" + (tool === "shoot" ? ", or both" : "") + " (got " + device + ")"); diff --git a/packages/shell/src/GamePlayerShell.tsx b/packages/shell/src/GamePlayerShell.tsx index 9c6abf167..5e95efc8a 100644 --- a/packages/shell/src/GamePlayerShell.tsx +++ b/packages/shell/src/GamePlayerShell.tsx @@ -333,6 +333,14 @@ export function GamePlayerShell({ delete document.documentElement.dataset.jgPresentation; }; }, [playable]); + // The tier the shell actually resolved, so a capture can prove which one it measured. + useEffect(() => { + if (typeof document === "undefined") return; + document.documentElement.dataset.jgQuality = graphics.quality; + return () => { + delete document.documentElement.dataset.jgQuality; + }; + }, [graphics.quality]); useEffect(() => { if (ctx === null || typeof document === "undefined") return; const hud = resolveRigKind(playable.camera) === "none" || playable.presentation === "hud"; diff --git a/scripts/browser-lib.test.ts b/scripts/browser-lib.test.ts index 6ba0ca4af..ebd624fc7 100644 --- a/scripts/browser-lib.test.ts +++ b/scripts/browser-lib.test.ts @@ -28,9 +28,10 @@ import { screencastCapturesFully, windowsPersistentChromeCommand, applyCaptureQuality, - parseQualityArg, + assertQualityApplied, + draftSuffix, qualitySuffix, - sizeForQuality, + sizeForDraft, PROFILE_OWNER_FILE, PROFILE_RUN_FILE, ensureCaptureFreeSpace, @@ -528,18 +529,27 @@ describe("captureViewportPng", () => { }); describe("capture quality", () => { - test("draft halves the viewport unless --size is explicit and pins the low tier", () => { - expect(sizeForQuality("draft", undefined)).toBe("half"); - expect(sizeForQuality("draft", "full")).toBe("full"); - expect(sizeForQuality("full", undefined)).toBe("full"); - expect(applyCaptureQuality(new URL("http://x/?game=g"), "draft").searchParams.get("quality")).toBe("low"); - expect(qualitySuffix("draft")).toBe("-draft"); - }); - - test("full leaves the URL and file name untouched", () => { - expect(applyCaptureQuality(new URL("http://x/?game=g"), "full").toString()).toBe("http://x/?game=g"); - expect(qualitySuffix("full")).toBe(""); - expect(() => parseQualityArg("low")).toThrow("--quality must be draft or full"); + test("draft halves the viewport unless --size is explicit, and pins the low tier", () => { + expect(sizeForDraft(true, undefined)).toBe("half"); + expect(sizeForDraft(true, "full")).toBe("full"); + expect(sizeForDraft(false, undefined)).toBe("full"); + expect(applyCaptureQuality(new URL("http://x/?game=g"), "low").searchParams.get("quality")).toBe("low"); + expect(draftSuffix(true)).toBe("-draft"); + }); + + test("no tier leaves the URL and file name untouched, and a non-default tier names itself", () => { + expect(applyCaptureQuality(new URL("http://x/?game=g"), undefined).toString()).toBe("http://x/?game=g"); + expect(applyCaptureQuality(new URL("http://x/?game=g"), "ultra").searchParams.get("quality")).toBe("ultra"); + expect(qualitySuffix(undefined)).toBe(""); + expect(qualitySuffix("high")).toBe(""); + expect(qualitySuffix("ultra")).toBe("-ultra"); + expect(draftSuffix(false)).toBe(""); + }); + + test("the tier the page reports must be the one the flag asked for", () => { + expect(() => assertQualityApplied("ultra", "ultra")).not.toThrow(); + expect(() => assertQualityApplied("ultra", "high")).toThrow("the page reports tier high"); + expect(() => assertQualityApplied("ultra", null)).toThrow("no graphics tier was reported"); }); }); diff --git a/scripts/browser-lib.ts b/scripts/browser-lib.ts index 994356745..e059d3b39 100644 --- a/scripts/browser-lib.ts +++ b/scripts/browser-lib.ts @@ -21,6 +21,7 @@ import { checkoutIdentity, isUp, markerPath, readMarker, writeMarker } from "./d import { localGet } from "./localhostHttp"; import { computeShotMetrics, deadViewport } from "./shot-metrics"; import { decodePng } from "./png-reader"; +import type { GraphicsTier } from "../packages/jgengine/src/templates/captureArgs"; export { checkoutIdentity, isUp }; @@ -2091,32 +2092,50 @@ export function parseSizeArg(value: string | undefined): SizeMode { /** * `draft` is the iteration profile: half-size viewport and the low graphics tier (`?quality=low`, - * pinned for the page only, never persisted). `full` is the untouched honest path for PR evidence. + * pinned for the page only, never persisted). The tier itself is `--quality ` + * and is independent of the viewport, so a perf or capture run can pin a real tier. */ -export type CaptureQuality = "full" | "draft"; +export type CaptureQuality = GraphicsTier; -/** Validate a raw `--quality` argument, throwing the shared usage error. */ -export function parseQualityArg(value: string | undefined): CaptureQuality { - if (value !== "full" && value !== "draft") { - throw new Error(`--quality must be draft or full (got ${value ?? "nothing"})`); - } - return value; +/** Add the shell's graphics-tier pin to a capture URL; no tier leaves the URL untouched. */ +export function applyCaptureQuality(url: URL, tier: GraphicsTier | undefined): URL { + if (tier !== undefined) url.searchParams.set("quality", tier); + return url; } -/** Size a capture runs at: an explicit `--size` wins, otherwise draft halves the viewport. */ -export function sizeForQuality(quality: CaptureQuality, explicit: SizeMode | undefined): SizeMode { - return explicit ?? (quality === "draft" ? "half" : "full"); +/** Size a capture runs at: an explicit `--size` wins, otherwise `--draft` halves the viewport. */ +export function sizeForDraft(draft: boolean, explicit: SizeMode | undefined): SizeMode { + return explicit ?? (draft ? "half" : "full"); } -/** Add the shell's graphics-tier override for a draft capture; full leaves the URL untouched. */ -export function applyCaptureQuality(url: URL, quality: CaptureQuality): URL { - if (quality === "draft") url.searchParams.set("quality", "low"); - return url; +/** Filename suffix naming a non-default tier, so an ultra shot never overwrites the high one. */ +export function qualitySuffix(tier: GraphicsTier | undefined): string { + return tier === undefined || tier === "high" ? "" : `-${tier}`; } /** Filename suffix marking a draft shot, so it never overwrites or passes for a full one. */ -export function qualitySuffix(quality: CaptureQuality): string { - return quality === "draft" ? "-draft" : ""; +export function draftSuffix(draft: boolean): string { + return draft ? "-draft" : ""; +} + +/** + * Throw when the page's reported tier is not the one `--quality` asked for. A tier flag that + * changes nothing is worse than no flag: the comparison would be two runs at the game's default + * and nobody would know until the numbers came out identical. + */ +export function assertQualityApplied(requested: GraphicsTier, reported: string | null): void { + if (reported === null || reported.length === 0) { + throw new Error(`--quality ${requested} never reached the page — no graphics tier was reported (data-jg-quality). The run would prove the game's default tier.`); + } + if (reported !== requested) { + throw new Error(`--quality ${requested} did not take — the page reports tier ${reported}, so this run would prove the wrong tier.`); + } +} + +/** The graphics tier the page reports it resolved (`data-jg-quality`), or null when it reports none. */ +export async function readReportedQuality(session: CdpSession): Promise { + const value = await session.evaluate(`document.documentElement.dataset.jgQuality ?? null`); + return typeof value === "string" && value.length > 0 ? value : null; } export interface BrowserSession { diff --git a/scripts/capture-preset.test.ts b/scripts/capture-preset.test.ts index 4a57de2f9..35215856b 100644 --- a/scripts/capture-preset.test.ts +++ b/scripts/capture-preset.test.ts @@ -36,12 +36,12 @@ describe("--quick is one preset, parsed by both scripts", () => { }); test("the printed line carries the wall time and the claims it cannot support", () => { - const line = quickTimingLine("shoot", { seconds: 4.321, width: 960, height: 540, phases: ["ready=1.00s", "frame(screencast)=0.20s"] }); - expect(line).toContain("quick 960x540 ready=1.00s frame(screencast)=0.20s"); + const line = quickTimingLine("shoot", { seconds: 4.321, width: 960, height: 540, tier: "low", phases: ["ready=1.00s", "frame(screencast)=0.20s"] }); + expect(line).toContain("quick 960x540 tier low ready=1.00s frame(screencast)=0.20s"); expect(line).toContain("total=4.32s"); expect(line).toContain("no 1600x900 pixel claim"); expect(line).toContain("no frame-time claim"); - expect(quickTimingLine("drive", { seconds: 2, width: 960, height: 540, device: "mobile" })).toContain("quick mobile 960x540"); + expect(quickTimingLine("drive", { seconds: 2, width: 960, height: 540, device: "mobile" })).toContain("quick mobile 960x540 tier n/a"); }); }); diff --git a/scripts/capture-preset.ts b/scripts/capture-preset.ts index 326417441..8c561b25a 100644 --- a/scripts/capture-preset.ts +++ b/scripts/capture-preset.ts @@ -99,9 +99,10 @@ export function assertQuickIsIterationOnly(request: QuickRequest): void { */ export function quickTimingLine( tool: CaptureTool, - args: { seconds: number; width: number; height: number; device?: string; phases?: readonly string[] }, + args: { seconds: number; width: number; height: number; device?: string; phases?: readonly string[]; tier?: string | null }, ): string { const device = args.device === undefined || args.device === "desktop" ? "" : ` ${args.device}`; const phases = args.phases === undefined || args.phases.length === 0 ? "" : ` ${args.phases.join(" ")}`; - return `${tool}: quick${device} ${args.width}x${args.height}${phases} total=${args.seconds.toFixed(2)}s — iteration only: ${QUICK_NOTICE}; re-run without --quick for the shot that goes in the PR`; + const tier = ` tier ${args.tier ?? "n/a"}`; + return `${tool}: quick${device} ${args.width}x${args.height}${tier}${phases} total=${args.seconds.toFixed(2)}s — iteration only: ${QUICK_NOTICE}; re-run without --quick for the shot that goes in the PR`; } diff --git a/scripts/drive-dev.ts b/scripts/drive-dev.ts index c44226945..498a21faa 100644 --- a/scripts/drive-dev.ts +++ b/scripts/drive-dev.ts @@ -22,25 +22,26 @@ import { CdpSession, applyCaptureQuality, applyDevice, + assertQualityApplied, captureProfile, captureViewportPng, + draftSuffix, ensureDevServer, ensureWebServer, forwardPageConsole, isUp, navigateCapturePageWithRetry, openPageSession, - parseQualityArg, parseSizeArg, qualitySuffix, readCapturePageState, - sizeForQuality, + readReportedQuality, + sizeForDraft, screencastCapturesFully, sizeSuffix, sweepCaptureProfiles, withBrowserSession, writePngAtomic, - type CaptureQuality, type SizeMode, type Device, type ViewportOverride, @@ -57,7 +58,7 @@ import { splitQuickFlag, } from "./capture-preset"; import { driveInputPointExpr } from "../packages/jgengine/src/templates/driveInput"; -import { CAPTURE_FLAGS, captureFlagHelp, describeFlow, describeStepFile, parseCaptureArgs, parseStepFile, rejectMixedStepFlags, type CaptureStep, type FlowFile, type FlowSource } from "../packages/jgengine/src/templates/captureArgs"; +import { CAPTURE_FLAGS, captureFlagHelp, describeFlow, describeStepFile, parseCaptureArgs, parseGraphicsTier, parseStepFile, rejectMixedStepFlags, type CaptureStep, type FlowFile, type FlowSource, type GraphicsTier } from "../packages/jgengine/src/templates/captureArgs"; import { awaitDriveCondition, awaitGameView, holdDriveInput, type DriveConditionClock, type DriveInputDeps, type DriveSession } from "../packages/jgengine/src/templates/driveSteps"; import { acquireDrivePointerLock, aimDriveAt, lookDrivePointer, moveDrivePointer, type DrivePoint } from "../packages/jgengine/src/templates/driveAim"; import { driveProjectDir, flowSourceFrom, flowsDirOf, listFlowNames, missingFlowsMessage } from "./driveFlows"; @@ -90,7 +91,10 @@ type Args = { modeExplicit: boolean; device: Device; size: SizeMode; - quality: CaptureQuality; + /** The tier `--quality` pinned; undefined leaves the game's own tier, which the summary still reports. */ + quality?: GraphicsTier; + /** `--draft`: the low-tier half-size iteration preset. */ + draft: boolean; connect?: number; keep: boolean; help: boolean; @@ -249,13 +253,16 @@ function parseArgs(argv: string[]): Args { // `--headed` is parsed here, not in const str = (name: string): string | undefined => (typeof o[name] === "string" ? (o[name] as string) : undefined); const num = (name: string): number | undefined => (str(name) === undefined ? undefined : Number(str(name))); if (parsed.positionals.length > 1) throw new Error(`drive: one game id, got ${parsed.positionals.join(" ")}`); + const draft = o.draft === true; + const quality = draft ? "low" : str("quality") === undefined ? undefined : parseGraphicsTier(str("quality")); const args: Args = { game: str("game") ?? parsed.positionals[0] ?? "", mode: str("mode") ?? "play", modeExplicit: str("mode") !== undefined, device: parseCaptureDevice(str("device") ?? "desktop"), - quality: parseQualityArg(str("quality") ?? "full"), - size: sizeForQuality(parseQualityArg(str("quality") ?? "full"), str("size") === undefined ? undefined : parseSizeArg(str("size"))), + ...(quality === undefined ? {} : { quality }), + draft, + size: sizeForDraft(draft, str("size") === undefined ? undefined : parseSizeArg(str("size"))), connect: num("connect"), keep: o.keep === true, cold, @@ -745,7 +752,7 @@ for (const step of args.steps) { const preparedShots = new Map(); for (const step of args.steps) { if (step.kind !== "shot") continue; - const path = step.out ?? join(outDir, `${args.game}-${step.name}${sizeSuffix(args.size)}${qualitySuffix(args.quality)}${quickSuffix(args.quick)}.png`); + const path = step.out ?? join(outDir, `${args.game}-${step.name}${sizeSuffix(args.size)}${qualitySuffix(args.quality)}${draftSuffix(args.draft)}${quickSuffix(args.quick)}.png`); if (!preparedShots.has(path)) preparedShots.set(path, clearShotTarget(path)); } @@ -815,6 +822,10 @@ async function driveSession(daemon: ShootDaemonState | null): Promise { if (args.playtest) url.searchParams.set("seed", String(args.seed)); if (!args.reuseStorage) await clearOriginStorage(session, new URL(url.toString()).origin); await navigateCapturePageWithRetry(session, url.toString(), dev.base, args.timeoutMs, 2, { legacyCanvas: args.url !== undefined }); + // The tier the game resolved, not the flag: a tier that never reached the render path is + // the failure this readout exists to catch, and the run summary repeats it. + const reportedTier = await readReportedQuality(session); + if (args.quality !== undefined) assertQualityApplied(args.quality, reportedTier); if (aim !== undefined) { const applied = await session.evaluate(`document.documentElement.dataset.jgLook ?? null`); if (typeof applied !== "string" || applied.length === 0) { @@ -901,6 +912,7 @@ async function driveSession(daemon: ShootDaemonState | null): Promise { const done = await runDriveSteps(args.steps, { now: () => performance.now(), line: (text) => console.error(text), + tier: reportedTier, ...(recorder === null ? {} : { virtualNow: () => recorder!.virtualNow }), ...(logPath === undefined ? {} : { record: (entry: DriveStepRecord) => appendFileSync(logPath, JSON.stringify(entry) + "\n") }), run: async (step, index) => { @@ -964,7 +976,7 @@ async function driveSession(daemon: ShootDaemonState | null): Promise { } else { if (step.kind !== "shot") throw new Error("drive: steps[" + index + "] is a " + step.kind + " step with no executor"); // A recording owns the screencast pump; a shot must not start/stop it underneath. - const path = step.out ?? join(outDir, `${args.game}-${step.name}${sizeSuffix(args.size)}${qualitySuffix(args.quality)}${quickSuffix(args.quick)}.png`); + const path = step.out ?? join(outDir, `${args.game}-${step.name}${sizeSuffix(args.size)}${qualitySuffix(args.quality)}${draftSuffix(args.draft)}${quickSuffix(args.quick)}.png`); const previous = preparedShots.has(path) ? preparedShots.get(path) : clearShotTarget(path); preparedShots.delete(path); await screenshot( @@ -1134,3 +1146,4 @@ if (warm.daemon !== null && exitCode !== 0 && (await daemonDiedUnder(warm.daemon exitCode = await attempt(null); } process.exit(exitCode); + diff --git a/scripts/driveRun.test.ts b/scripts/driveRun.test.ts index 633673ee6..b12fd2eaa 100644 --- a/scripts/driveRun.test.ts +++ b/scripts/driveRun.test.ts @@ -63,13 +63,19 @@ describe("runDriveSteps", () => { expect(host.lines.some((line) => line.startsWith("#3 shot after FAIL") && line.includes("did not become true"))).toBe(true); }); + test("the step summary states the graphics tier the page resolved", async () => { + const host = harness(async () => ""); + await runDriveSteps(steps, { ...host.deps, tier: "ultra" }); + expect(host.lines.at(-1)).toBe(" tier ultra"); + }); + test("one log line and one JSONL row per executed step, ending with the summary", async () => { const host = harness(async (step) => (step.kind === "key" ? "before 18ms after 21ms" : "")); const summary = await runDriveSteps(steps, host.deps); expect(summary).toEqual({ passed: 4, failed: 0, wallMs: expect.any(Number), virtualMs: 0 }); expect(host.lines[0]).toBe("#1 click \"Free skirmish\" ok total 26ms"); expect(host.lines[3]).toBe("#4 key KeyH 120ms ok before 18ms after 21ms total 26ms"); - expect(host.lines.slice(-5)).toEqual(["drive: step summary", " passed 4", " failed 0", expect.stringContaining(" wall "), " virtual 0ms"]); + expect(host.lines.slice(-6)).toEqual(["drive: step summary", " passed 4", " failed 0", expect.stringContaining(" wall "), " virtual 0ms", " tier n/a"]); const parsed = host.records.map((entry) => JSON.parse(JSON.stringify(entry)) as DriveStepRecord); expect(parsed).toHaveLength(parsed.length); expect(parsed).toHaveLength(4); diff --git a/scripts/driveRun.ts b/scripts/driveRun.ts index f2fed28e4..f57c70a4d 100644 --- a/scripts/driveRun.ts +++ b/scripts/driveRun.ts @@ -35,6 +35,8 @@ export interface DriveStepRunnerDeps { /** Wall clock; a lockstep recording also reports virtual time. */ now(): number; virtualNow?(): number; + /** The graphics tier the page resolved, so the summary states what the run measured. */ + tier?: string | null; } /** Totals for the run, printed before a failure leaves the function. */ @@ -93,6 +95,7 @@ export async function runDriveSteps(steps: readonly CaptureStep[], deps: DriveSt deps.line(" failed " + summary.failed); deps.line(" wall " + summary.wallMs + "ms"); deps.line(" virtual " + summary.virtualMs + "ms"); + deps.line(" tier " + (deps.tier ?? "n/a")); } return summary; } diff --git a/scripts/perf-gate.test.ts b/scripts/perf-gate.test.ts index bb5630b93..77fd41f11 100644 --- a/scripts/perf-gate.test.ts +++ b/scripts/perf-gate.test.ts @@ -33,7 +33,7 @@ function record(overrides: Partial = {}, render: Partial { }); describe("parseGateArgs", () => { - test("defaults to every reference game at full quality, four at a time", () => { - expect(parseGateArgs([])).toEqual({ games: [...PERF_GATE_GAMES], quality: "full", seconds: 6, jobs: 4 }); + test("defaults to every reference game at each game's own tier, four at a time", () => { + expect(parseGateArgs([])).toEqual({ games: [...PERF_GATE_GAMES], draft: false, seconds: 6, jobs: 4 }); + }); + + test("pins one tier for every game, and reads it back into the argument table", () => { + expect(parseGateArgs(["--quality", "ultra"]).quality).toBe("ultra"); + const drafted = parseGateArgs(["--draft"]); + expect(drafted.draft).toBe(true); + expect(drafted.quality).toBeUndefined(); + expect(() => parseGateArgs(["--quality", "highish"])).toThrow("--quality must be one of low, medium, high, ultra"); + expect(() => parseGateArgs(["--draft", "--quality", "low"])).toThrow("--draft measures the low tier at half viewport"); }); test("narrows to named games for a local run", () => { @@ -343,7 +352,7 @@ describe("gateVerdicts", () => { describe("summarizeRecord", () => { test("a run without overrides prints the same line as before", () => { expect(summarizeRecord(record(), BUDGET)).toBe( - "harbor-heat ok drawsFrame 7,100/7,600 (340 frames) draws 7,000 triangles 3,600,000/3,850,000 nodes 11,000/11,900 links 0/0 framebuffers 0/0 allocations 0 1600x900 full", + "harbor-heat ok drawsFrame 7,100/7,600 (340 frames) draws 7,000 triangles 3,600,000/3,850,000 nodes 11,000/11,900 links 0/0 framebuffers 0/0 allocations 0 1600x900 high", ); }); @@ -353,3 +362,4 @@ describe("summarizeRecord", () => { ); }); }); + diff --git a/scripts/perf-gate.ts b/scripts/perf-gate.ts index dbbffa620..3a32b86c6 100644 --- a/scripts/perf-gate.ts +++ b/scripts/perf-gate.ts @@ -8,11 +8,10 @@ import { killPid, killProcessTree, openPageSession, - parseQualityArg, withBrowserSession, - type CaptureQuality, } from "./browser-lib"; import { measureGame } from "./perf-play"; +import { parseGraphicsTier, type GraphicsTier } from "../packages/jgengine/src/templates/captureArgs"; import { checkBudget, loadBudget, summarizeRecord, type LoadedBudget } from "./perf-budget"; import { runPool } from "./type-shard"; @@ -75,7 +74,10 @@ export const PERF_GATE_GAMES = [ export interface GateArgs { games: string[]; - quality: CaptureQuality; + /** The tier every game is measured at; undefined measures each game's own tier. */ + quality?: GraphicsTier; + /** Half viewport at the low tier, for a smoke pass over the gate itself. */ + draft: boolean; seconds: number; jobs: number; } @@ -87,12 +89,17 @@ export function debugPortFor(index: number): number { export function parseGateArgs(argv: readonly string[]): GateArgs { let games: string[] = [...PERF_GATE_GAMES]; - let quality: CaptureQuality = "full"; + let quality: GraphicsTier | undefined; + let draft = false; let seconds = DEFAULT_SECONDS; let jobs = DEFAULT_JOBS; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]!; if (arg === "--help" || arg === "-h") throw new Error("usage"); + if (arg === "--draft") { + draft = true; + continue; + } if (arg === "--set") { throw new Error( "--set measures a setting on top of the defaults; budgets describe the default build — use `bun run perf --set key=value` and compare the two records", @@ -105,7 +112,7 @@ export function parseGateArgs(argv: readonly string[]): GateArgs { if (arg === "--games") { games = value.split(",").map((id) => id.trim()).filter((id) => id.length > 0); } else if (arg === "--quality") { - quality = parseQualityArg(value); + quality = parseGraphicsTier(value); } else if (arg === "--jobs") { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 1) throw new Error(`--jobs must be a whole number of games at least 1 (got ${value})`); @@ -120,14 +127,17 @@ export function parseGateArgs(argv: readonly string[]): GateArgs { throw new Error(`unknown argument ${arg}`); } if (games.length === 0) throw new Error("--games needs at least one game id"); - return { games, quality, seconds, jobs }; + if (draft && quality !== undefined) throw new Error(`--draft measures the low tier at half viewport, so drop it or drop --quality ${quality}`); + return { games, ...(quality === undefined ? {} : { quality }), draft, seconds, jobs }; } -const USAGE = `usage: bun run perf:gate [--games a,b] [--seconds n] [--quality draft|full] [--jobs n] +const USAGE = `usage: bun run perf:gate [--games a,b] [--seconds n] [--quality low|medium|high|ultra] [--draft] [--jobs n] --games comma-separated game ids (default: the 8 reference games) --seconds measured window per game (default ${DEFAULT_SECONDS}) - --quality graphics tier: full (default) or draft + --quality the graphics tier each game is measured at (low, medium, high, ultra). Without + it each game runs its own default tier, and every record names the tier it got. + --draft low tier at half viewport, for a smoke pass you will not quote --jobs games measured at once (default ${DEFAULT_JOBS}); each gets its own browser --set not taken here: a setting override measures a cost against the defaults, not a budget. Use \`bun run perf --set key=value\` and compare records.`; @@ -193,7 +203,7 @@ async function main(): Promise { console.log(USAGE); return; } - const { games, quality, seconds, jobs } = parseGateArgs(parsed); + const { games, quality, draft, seconds, jobs } = parseGateArgs(parsed); // Budgets are read before any browser starts: a missing file must fail fast, // not after ten minutes of software-GL rendering. @@ -230,6 +240,7 @@ async function main(): Promise { base: server.base, game, quality, + draft, seconds, warmupMs: WARMUP_MS, profile: false, diff --git a/scripts/perf-play.test.ts b/scripts/perf-play.test.ts index 8a010ad85..8415c5505 100644 --- a/scripts/perf-play.test.ts +++ b/scripts/perf-play.test.ts @@ -27,19 +27,24 @@ import { test("parsePerfArgs reads one game with its flags", () => { expect(parsePerfArgs(["harbor-heat"])).toEqual({ kind: "run", - args: { game: "harbor-heat", quality: "full", profile: false, seconds: 6, telemetry: "on", settings: [], frameClock: "capped", params: [] }, + args: { game: "harbor-heat", draft: false, profile: false, seconds: 6, telemetry: "on", settings: [], frameClock: "capped", params: [] }, }); - expect(parsePerfArgs(["deepward", "--quality", "draft", "--profile", "--seconds", "3"])).toEqual({ + expect(parsePerfArgs(["deepward", "--quality", "ultra", "--profile", "--seconds", "3"])).toEqual({ kind: "run", - args: { game: "deepward", quality: "draft", profile: true, seconds: 3, telemetry: "on", settings: [], frameClock: "capped", params: [] }, + args: { game: "deepward", quality: "ultra", draft: false, profile: true, seconds: 3, telemetry: "on", settings: [], frameClock: "capped", params: [] }, }); + const drafted = parsePerfArgs(["deepward", "--draft"]); + expect(drafted.kind === "run" && drafted.args.draft).toBe(true); + expect(drafted.kind === "run" && drafted.args.quality).toBeUndefined(); expect(parsePerfArgs(["--help"]).kind).toBe("help"); }); test("parsePerfArgs rejects unusable input", () => { expect(() => parsePerfArgs([])).toThrow("a game id is required"); expect(() => parsePerfArgs(["a", "b"])).toThrow("one game per run"); - expect(() => parsePerfArgs(["a", "--quality", "low"])).toThrow("--quality must be draft or full"); + expect(() => parsePerfArgs(["a", "--quality", "highish"])).toThrow("--quality must be one of low, medium, high, ultra"); + expect(() => parsePerfArgs(["a", "--quality", "draft"])).toThrow("--quality must be one of low, medium, high, ultra"); + expect(() => parsePerfArgs(["a", "--draft", "--quality", "ultra"])).toThrow("--draft is the iteration preset"); expect(() => parsePerfArgs(["a", "--seconds", "0"])).toThrow("--seconds must be a positive number"); expect(() => parsePerfArgs(["a", "--bogus"])).toThrow("unknown flag --bogus"); }); @@ -65,7 +70,6 @@ test("--param reaches the page as a query param, and cannot take a param the har const url = perfPageUrl({ base: "http://127.0.0.1:4601", game: "deepward", - quality: "full", state: "paused", params: parsed.kind === "run" ? parsed.args.params : [], }); @@ -163,7 +167,7 @@ test("a value the engine does not offer fails, so a fallback is never measured a }); test("the URL a run opens carries the harness's own params and nothing the override invented", () => { - const url = perfPageUrl({ base: "http://127.0.0.1:4601", game: "harbor-heat", quality: "full" }); + const url = perfPageUrl({ base: "http://127.0.0.1:4601", game: "harbor-heat" }); expect(url.searchParams.get("game")).toBe("harbor-heat"); expect(url.searchParams.get("mode")).toBe("play"); expect(url.searchParams.get("device")).toBe("desktop"); @@ -171,9 +175,12 @@ test("the URL a run opens carries the harness's own params and nothing the overr expect(url.searchParams.get("quality")).toBeNull(); expect(url.searchParams.get("telemetry")).toBeNull(); - const draft = perfPageUrl({ base: "http://127.0.0.1:4601", game: "deepward", quality: "draft", telemetry: "off" }); - expect(draft.searchParams.get("quality")).toBe("low"); - expect(draft.searchParams.get("telemetry")).toBe("off"); + const pinned = perfPageUrl({ base: "http://127.0.0.1:4601", game: "harbor-heat", quality: "ultra" }); + expect(pinned.searchParams.get("quality")).toBe("ultra"); + + const quiet = perfPageUrl({ base: "http://127.0.0.1:4601", game: "deepward", quality: "low", telemetry: "off" }); + expect(quiet.searchParams.get("quality")).toBe("low"); + expect(quiet.searchParams.get("telemetry")).toBe("off"); }); test("the override script writes the settings storage key the game reads at boot", () => { @@ -387,3 +394,4 @@ test("whole-frame draws are the p95 of the sampled frames, so a cadenced pass is expect(summarizeFrameDraws([])).toEqual({ frames: 0, min: 0, p50: 0, p95: 0, max: 0 }); expect(summarizeFrameDraws([Number.NaN, -4, 12])).toMatchObject({ frames: 1, p95: 12 }); }); + diff --git a/scripts/perf-play.ts b/scripts/perf-play.ts index c3254dd69..cacdeee9c 100644 --- a/scripts/perf-play.ts +++ b/scripts/perf-play.ts @@ -6,15 +6,16 @@ import { CdpSession, applyCaptureQuality, applyDevice, + assertQualityApplied, ensureDevServer, openPageSession, - parseQualityArg, + readReportedQuality, sweepCaptureProfiles, waitCaptureReady, withBrowserSession, - type CaptureQuality, type FrameClock, } from "./browser-lib"; +import { parseGraphicsTier, type GraphicsTier } from "../packages/jgengine/src/templates/captureArgs"; import { acquireWarmCapture, daemonDiedUnder, @@ -48,7 +49,7 @@ import { * counts, GL allocations made after warmup, and the hottest self-time * functions. * - * bun run perf [--quality draft|full] [--profile] [--seconds n] [--state name] [--param k=v ...] [--set key=value ...] + * bun run perf [--quality low|medium|high|ultra] [--draft] [--profile] [--seconds n] [--state name] [--param k=v ...] [--set key=value ...] * * The JSON goes to stdout alone so it can be piped and diffed; notes and the * `--profile` inclusive table go to stderr. `measureGame` is the same sequence @@ -115,7 +116,10 @@ const HARNESS_PARAMS: ReadonlySet = new Set(["game", "capture", "device" export interface PerfArgs { game: string; - quality: CaptureQuality; + /** The tier `--quality` pinned; undefined measures the game's own tier, which the record still names. */ + quality?: GraphicsTier; + /** `--draft`: the low-tier half-size iteration preset. */ + draft: boolean; profile: boolean; seconds: number; /** In-page sampler on/off for the A/B frame-time overhead comparison. */ @@ -136,14 +140,17 @@ export interface PerfArgs { export type ArgResult = { kind: "run"; args: PerfArgs } | { kind: "help" }; -const USAGE = `usage: bun run perf [--quality draft|full] [--profile] [--seconds n] [--telemetry on|off] +const USAGE = `usage: bun run perf [--quality low|medium|high|ultra] [--draft] [--profile] [--seconds n] [--telemetry on|off] [--state name] [--param k=v ...] [--set key=value ...] [--uncapped] [--headed] [--cold] game id under Games/ (for example harbor-heat) --cold skip the warm daemon and boot Chrome + Vite directly: fresh caches, larger numbers than a warm run, for an evidence run that must prove a clean start. Every run prints which it got: warm: reused :9261 (0.2s) / cold: started :9261 (4.1s) - --quality graphics tier: full (default) or draft (?quality=low, half viewport) + --quality the graphics tier the game renders at (low, medium, high, ultra), pinned on the + page URL. The run fails when the page reports a different tier, so a record + always names the tier it measured. + --draft iteration preset: low tier and half viewport, for a smoke run you will not quote --profile also print the inclusive-time table to stderr --seconds measured window in seconds (default ${DEFAULT_SECONDS}) --telemetry in-page report sampler: on (default) or off (?telemetry=off) for an A/B run @@ -270,7 +277,7 @@ export function undeclaredSettingErrors( export function perfPageUrl(options: { base: string; game: string; - quality: CaptureQuality; + quality?: GraphicsTier; telemetry?: "on" | "off"; state?: string; params?: readonly (readonly [string, string])[]; @@ -316,7 +323,8 @@ export function droppedOverrides( export function parsePerfArgs(argv: readonly string[]): ArgResult { if (argv.includes("--help") || argv.includes("-h")) return { kind: "help" }; let game: string | undefined; - let quality: CaptureQuality = "full"; + let quality: GraphicsTier | undefined; + let draft = false; let profile = false; let seconds = DEFAULT_SECONDS; let telemetry: "on" | "off" = "on"; @@ -345,6 +353,10 @@ export function parsePerfArgs(argv: readonly string[]): ArgResult { cold = true; continue; } + if (arg === "--draft") { + draft = true; + continue; + } if (arg === "--state") { const value = argv[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error("--state needs a capture.states name"); @@ -383,7 +395,7 @@ export function parsePerfArgs(argv: readonly string[]): ArgResult { const value = argv[index + 1]; if (value === undefined) throw new Error(`${arg} needs a value`); index += 1; - if (arg === "--quality") quality = parseQualityArg(value); + if (arg === "--quality") quality = parseGraphicsTier(value); else if (arg === "--telemetry") { if (value !== "on" && value !== "off") throw new Error(`--telemetry must be on or off (got ${value})`); telemetry = value; @@ -399,7 +411,8 @@ export function parsePerfArgs(argv: readonly string[]): ArgResult { game = arg; } if (game === undefined) throw new Error("a game id is required"); - return { kind: "run", args: { game, quality, profile, seconds, telemetry, settings, frameClock, ...(headed ? { headed: true } : {}), ...(cold ? { cold: true } : {}), ...(state === undefined ? {} : { state }), params } }; + if (draft && quality !== undefined) throw new Error(`--draft is the iteration preset (low tier, half viewport), so drop it or drop --quality ${quality}`); + return { kind: "run", args: { game, ...(quality === undefined ? {} : { quality }), draft, profile, seconds, telemetry, settings, frameClock, ...(headed ? { headed: true } : {}), ...(cold ? { cold: true } : {}), ...(state === undefined ? {} : { state }), params } }; } /** One sampled stack frame reduced to a readable key and its self time. */ @@ -795,7 +808,10 @@ export interface MeasureOptions { /** Dev-server base URL, e.g. `http://127.0.0.1:4601`. */ base: string; game: string; - quality?: CaptureQuality; + /** The graphics tier to pin; omitted measures whatever tier the game itself resolves. */ + quality?: GraphicsTier; + /** Half viewport at the low tier, for a quick smoke run. */ + draft?: boolean; seconds?: number; warmupMs?: number; /** Run the CPU profiler pass afterwards; the budget gate does not need it. */ @@ -877,7 +893,8 @@ async function confirmSettings(session: CdpSession, settings: readonly PerfSetti * and scene counts, and read the GL objects created after warmup. */ export async function measureGame(options: MeasureOptions): Promise { - const { session, base, game, quality = "full", seconds = DEFAULT_SECONDS, warmupMs = WARMUP_MS } = options; + const { session, base, game, quality, seconds = DEFAULT_SECONDS, warmupMs = WARMUP_MS } = options; + const draft = options.draft ?? false; const profileRun = options.profile ?? true; const settings = options.settings ?? []; const params = options.params ?? []; @@ -898,10 +915,14 @@ export async function measureGame(options: MeasureOptions): Promise if (settings.length > 0) { await session.send("Page.addScriptToEvaluateOnNewDocument", { source: perfSettingsScript(settings) }); } - await applyDevice(session, "desktop", quality === "draft" ? "half" : "full"); + await applyDevice(session, "desktop", draft ? "half" : "full"); const url = perfPageUrl({ base, game, quality, telemetry: options.telemetry, state: options.state, params }); await session.send("Page.navigate", { url: url.toString() }); await waitCaptureReady(session, 90_000); + // The tier the game resolved, not the flag: a record that names a tier it never rendered at is + // exactly the evidence gap this flag closes. + const reportedTier = await readReportedQuality(session); + if (quality !== undefined) assertQualityApplied(quality, reportedTier); if (settings.length > 0) await confirmSettings(session, settings); await session.evaluate(`window.__jgengineAgent?.handle?.({ method: "debug_perf_reset" })`); @@ -963,7 +984,7 @@ export async function measureGame(options: MeasureOptions): Promise const record: PerfRecord = { game, gpu, - quality, + quality: reportedTier ?? quality ?? "unknown", viewport: `${size?.w ?? 0}x${size?.h ?? 0}`, seconds: Math.round((windowMs / 1000) * 10) / 10, at: Date.now(), @@ -1023,7 +1044,7 @@ async function main(): Promise { console.log(USAGE); return; } - const { game, quality, profile, seconds, telemetry, settings, state, params, frameClock, headed } = parsed.args; + const { game, quality, draft, profile, seconds, telemetry, settings, state, params, frameClock, headed } = parsed.args; sweepCaptureProfiles(); @@ -1063,7 +1084,8 @@ async function main(): Promise { async ({ debugPort }) => { const session = await openPageSession(debugPort); try { - const measured = await measureGame({ session, base: server.base, game, quality, seconds, telemetry, settings, state, params, frameClock }); + const measured = await measureGame({ session, base: server.base, game, quality, draft, seconds, telemetry, settings, state, params, frameClock }); + console.error(`perf: tier ${measured.record.quality}${draft ? " (draft: low tier, half viewport)" : ""}`); const pin = perfStateLine(state, params); if (pin !== null) console.error(pin); const clock = perfFrameClockLine(frameClock); diff --git a/scripts/shoot-dev.ts b/scripts/shoot-dev.ts index ddd8903f4..e94f747a7 100644 --- a/scripts/shoot-dev.ts +++ b/scripts/shoot-dev.ts @@ -16,8 +16,10 @@ import { DEVICES, applyCaptureQuality, applyDevice, + assertQualityApplied, captureViewportPng, checkoutIdentity, + draftSuffix, ensureDevServer, ensureWebServer, forwardPageConsole, @@ -28,15 +30,14 @@ import { isUp, navigateCapturePageWithRetry, openPageSession, - parseQualityArg, + readReportedQuality, parseSizeArg, qualitySuffix, - sizeForQuality, + sizeForDraft, sizeSuffix, sweepCaptureProfiles, withBrowserSession, writePngAtomic, - type CaptureQuality, type Device, type SizeMode, } from "./browser-lib"; @@ -45,7 +46,7 @@ import { clearOriginStorage } from "./capture-storage"; import { headRevision } from "./captureRevision"; import { externalCaptureUrl } from "./captureTarget"; import { assertQuickIsIterationOnly, quickProfile, quickSuffix, quickTimingLine, splitQuickFlag } from "./capture-preset"; -import { CAPTURE_FLAGS, captureFlagHelp, parseCaptureArgs } from "../packages/jgengine/src/templates/captureArgs"; +import { CAPTURE_FLAGS, captureFlagHelp, parseCaptureArgs, parseGraphicsTier, type GraphicsTier } from "../packages/jgengine/src/templates/captureArgs"; import { lookSearchParams, parseLookAim } from "./lookArg"; import { MIN_SAMPLED_SHARE, @@ -94,7 +95,10 @@ type Args = { mode: Mode; device: DeviceArg; size: SizeMode; - quality: CaptureQuality; + /** The tier `--quality` pinned; undefined leaves the game's own tier, which the summary still reports. */ + quality?: GraphicsTier; + /** `--draft`: the low-tier half-size iteration preset. */ + draft: boolean; stage?: boolean; state?: string; preview?: string; @@ -174,12 +178,15 @@ function parseArgs(argv: string[]): Args { if (!["ui", "play", "poster", "preview"].includes(mode)) throw new Error(`--mode must be ui, play, poster, or preview (got ${mode})`); const run = str("run")?.split(",").map((name) => name.trim()).filter((name) => name.length > 0); const view = str("view"); + const draft = o.draft === true; + const quality = draft ? "low" : str("quality") === undefined ? undefined : parseGraphicsTier(str("quality")); const args: Args = { game: str("game") ?? parsed.positionals[0] ?? "world-of-warcraft", mode: mode as Mode, device: (str("device") ?? "desktop") as DeviceArg, - quality: parseQualityArg(str("quality") ?? "full"), - size: sizeForQuality(parseQualityArg(str("quality") ?? "full"), str("size") === undefined ? undefined : parseSizeArg(str("size"))), + ...(quality === undefined ? {} : { quality }), + draft, + size: sizeForDraft(draft, str("size") === undefined ? undefined : parseSizeArg(str("size"))), stage: o.stage === true ? true : undefined, state: str("state"), preview: str("preview") ?? (mode === "preview" ? "" : undefined), @@ -243,7 +250,7 @@ function outPathFor(args: Args, device: Device, outDir: string): string { if (dot <= 0) return `${resolved}-mobile`; return `${resolved.slice(0, dot)}-mobile${resolved.slice(dot)}`; } - const suffix = `${device === "mobile" ? "-mobile" : ""}${sizeSuffix(args.size)}${qualitySuffix(args.quality)}${quickSuffix(args.quick)}`; + const suffix = `${device === "mobile" ? "-mobile" : ""}${sizeSuffix(args.size)}${qualitySuffix(args.quality)}${draftSuffix(args.draft)}${quickSuffix(args.quick)}`; if (args.fixture !== undefined && args.fixture.length > 0) { const key = args.fixture.replace(/[^A-Za-z0-9._-]+/g, "_"); return join(outDir, `fixture-${key}${suffix}.png`); @@ -269,25 +276,30 @@ function outPathFor(args: Args, device: Device, outDir: string): string { } function targetUrl(args: Args, device: Device, devBase: string): string { + return applyCaptureQuality(rawTargetUrl(args, device, devBase), args.quality).toString(); +} + +/** The page a shot opens, before the tier pin: `--quality` reaches every target the same way. */ +function rawTargetUrl(args: Args, device: Device, devBase: string): URL { if (args.url !== undefined) { const url = new URL(args.url); url.searchParams.set("capture", "1"); url.searchParams.set("device", device === "mobile-landscape" ? "mobile" : device); - return url.toString(); + return url; } if (args.site !== undefined) { const path = args.site.startsWith("/") ? args.site : `/${args.site}`; const url = new URL(path, devBase); url.searchParams.set("capture", "1"); url.searchParams.set("device", device === "mobile-landscape" ? "mobile" : device); - return url.toString(); + return url; } if (args.fixture !== undefined && args.fixture.length > 0) { const url = new URL(devBase); url.searchParams.set("fixture", args.fixture); url.searchParams.set("device", device === "mobile-landscape" ? "mobile" : device); url.searchParams.set("capture", "1"); - return url.toString(); + return url; } const url = new URL(devBase); url.searchParams.set("game", args.game); @@ -306,7 +318,7 @@ function targetUrl(args: Args, device: Device, devBase: string): string { if (aim !== undefined) { for (const [key, value] of Object.entries(lookSearchParams(aim))) url.searchParams.set(key, value); } - return applyCaptureQuality(url, args.quality).toString(); + return url; } function shotCommand(): string { @@ -399,6 +411,10 @@ async function shootOne( await navigateCapturePageWithRetry(session, url, devBase, args.timeoutMs, CAPTURE_MAX_ATTEMPTS, { legacyCanvas: args.url !== undefined }); mark("ready"); await reportAppliedAim(session, args); + // Read the tier the game resolved, not the flag: a tier that never reached the render path is + // the failure this readout exists to catch, and the summary line repeats it for the record. + const reportedTier = await readReportedQuality(session); + if (args.quality !== undefined) assertQualityApplied(args.quality, reportedTier); await new Promise((r) => setTimeout(r, 600)); mark("settle"); const expected = captureProfile(device, args.size, viewportSize); @@ -435,7 +451,7 @@ async function shootOne( console.log(outPath); mark("score"); const profile = DEVICES[device]; - const label = `${device} ${profile.width}x${profile.height}${args.size === "half" ? " (half-res)" : ""}${args.quality === "draft" ? " (draft)" : ""}`; + const label = `${device} ${profile.width}x${profile.height}${args.size === "half" ? " (half-res)" : ""}${args.draft ? " (draft)" : ""} tier ${reportedTier ?? "n/a"}`; let ok = true; if (overflow !== null) { console.error(`HUD OVERFLOW [${label}]: panels escape the viewport — ${overflow}`); @@ -475,8 +491,9 @@ async function shootOne( height: Math.round(expected.height * expected.deviceScaleFactor), device, phases: timings, + tier: reportedTier, }) - : `shoot: timing ${device} ${timings.join(" ")} total=${total.toFixed(2)}s`, + : `shoot: timing ${device} tier ${reportedTier ?? "n/a"} ${timings.join(" ")} total=${total.toFixed(2)}s`, ); return ok; } finally {