Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions changes/lane-tier-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
### Added

- `shoot`, `drive` and `perf` take `--quality <low|medium|high|ultra>` and pin that graphics tier on the game page (`?quality=<tier>`). 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 <tier>`; `perf` echoes `perf: tier <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.
25 changes: 21 additions & 4 deletions packages/jgengine/src/templates/captureArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ 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[] {
const samples: Record<string, 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",
Expand All @@ -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" },
Expand Down
25 changes: 24 additions & 1 deletion packages/jgengine/src/templates/captureArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -53,7 +68,8 @@ export const CAPTURE_FLAGS: readonly CaptureFlag[] = [
{ flag: "--width", tools: ["shoot", "drive"], value: "required", arg: "<px>", help: "viewport width override in CSS pixels" },
{ flag: "--height", tools: ["shoot", "drive"], value: "required", arg: "<px>", help: "viewport height override in CSS pixels" },
{ flag: "--size", tools: ["shoot", "drive"], value: "required", arg: "<full|half>", help: "half halves both dimensions for cheap judge shots; full for final/PR shots" },
{ flag: "--quality", tools: ["shoot", "drive"], value: "required", arg: "<full|draft>", 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: "<low|medium|high|ultra>", 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: "<ms>", help: "extra wait after the page reports an honest frame" },
{ flag: "--timeout", tools: ["shoot", "drive"], value: "required", arg: "<s>", help: "readiness/capture timeout in seconds (default 60)" },
{ flag: "--param", tools: ["shoot", "drive"], value: "required", repeat: true, arg: "<key=value>", help: "add a query parameter to the page URL (repeatable)" },
Expand Down Expand Up @@ -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 + ")");
Expand Down
8 changes: 8 additions & 0 deletions packages/shell/src/GamePlayerShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
38 changes: 24 additions & 14 deletions scripts/browser-lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ import {
screencastCapturesFully,
windowsPersistentChromeCommand,
applyCaptureQuality,
parseQualityArg,
assertQualityApplied,
draftSuffix,
qualitySuffix,
sizeForQuality,
sizeForDraft,
PROFILE_OWNER_FILE,
PROFILE_RUN_FILE,
ensureCaptureFreeSpace,
Expand Down Expand Up @@ -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");
});
});

Expand Down
53 changes: 36 additions & 17 deletions scripts/browser-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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 <low|medium|high|ultra>`
* 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<string | null> {
const value = await session.evaluate<string | null>(`document.documentElement.dataset.jgQuality ?? null`);
return typeof value === "string" && value.length > 0 ? value : null;
}

export interface BrowserSession {
Expand Down
6 changes: 3 additions & 3 deletions scripts/capture-preset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});

Expand Down
5 changes: 3 additions & 2 deletions scripts/capture-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
Loading
Loading