mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-07 22:00:40 +00:00
perf(client): tick-dispatch timing harness + main-thread tick optimizations (late-game p95 −65%) (#4512)
## Problem
Every 100 ms the main thread's worker `onmessage` callback processes a
full game tick (`gameView.update` → `webglBuilder.update` →
`renderer.tick`). At 60 fps this competes with the 16.7 ms frame budget,
and on the Giant World Map it takes several ms — frame drops on low-end
hardware.
## Harness (`npm run perf:client-tick`)
Headless-Chromium harness that times every worker→main `game_update`
dispatch on the main thread, with structured-clone deserialization
measured separately from the handler body (via a
`Worker.prototype.addEventListener` wrapper installed as a page init
script — no product-code changes). It reports windowed distributions,
captures `.cpuprofile` files at chosen ticks, writes raw samples and an
end-of-run screenshot. `AnalyzeCpuProfile.ts` breaks a profile down by
inclusive time under the dispatch subtree.
Init scripts are passed as **strings**: tsx compiles function-form init
scripts with esbuild `keepNames`, whose injected `__name` helper doesn't
exist in-page and silently kills the game worker setup.
## Baseline (Giant World Map, 400 bots, headless)
Dispatch handler ms — cost **grows with game progression**:
| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.22 | 2.20 | 3.40 | 5.00 |
| tick 1506 | 2.60 | 2.00 | 7.00 | 10.40 |
| tick 2000 | 2.67 | 1.90 | **8.70** | **12.70** |
Deserialization is negligible (0.12 ms mean). CPU profiles attributed
the growing tail to the leaderboard's once-per-second refresh: its
Max-troops column calls `config().maxTroops(p)` for **all ~508
players**, and `PlayerView.units()` scanned **every unit in the game**
per call — O(players × units), growing as units accumulate.
## Round 1 — algorithmic fixes
- **GameView**: new `unitsOwnedBy(smallID)` — an active-units-by-owner
index built lazily at most once per tick. `PlayerView.units()` reads its
own units from it: O(own units) instead of O(all units). Also speeds up
unit display, player panel, and buildables queries.
- **NamePass.updateNames**: reads player state directly from the
caller's map by smallID instead of rebuilding three lookup maps per
tick; skips the slot-assignment sweep once every player has a slot.
After (same map, same spawn tile):
| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.12 | 2.00 | 3.10 | 5.20 |
| tick 1506 | 1.86 | 1.80 | 2.90 | 4.30 |
| tick 2000 | 1.74 | 1.60 | **2.40** | **4.70** |
Late-game p95 −65% (8.7 → 2.4 ms), worst dispatch −63% (12.7 → 4.7 ms),
and per-dispatch cost no longer grows with game progression. The
leaderboard disappeared from the dispatch profile entirely.
## Round 2 — allocation churn + time slicing
Aimed at GC pauses and low-end CPUs; measures flat vs round 1 on a fast
machine, as expected:
- **`FrameData.changedTiles`** is now the plain tile-ref array GameView
already builds instead of a per-tile `{ref, state}` object copy — heavy
battle ticks allocated tens of thousands of objects per tick for a
`state` field that was always 0. `TilePair` removed; `TerritoryPass`
buckets refs synchronously, so the live reference is safe.
- **`UnitView.lastPos`** is only re-sliced when a move actually appended
a position — the unconditional `slice(-1)` allocated an identical
1-element array per unit per tick, including for structures that never
move.
- **`NamePass.updateNames`** refreshes slots round-robin, a quarter per
tick — the full per-player diff pass spreads over ~400 ms, under the
existing 500 ms troop-text cadence; positions lerp continuously. Unnamed
slots and snap passes (seeks) are always processed so nothing pops in
late. Dispatch share: 17% → 13%.
Not sliced on purpose: tile ingest and frame upload need a consistent
per-tick snapshot (stale `GameMap` reads would leak into hover queries,
minimap, attack targeting) — a correctness risk not worth ~1 ms while
the worst dispatch already fits in a quarter of the frame budget.
## Verification
- `npx tsc --noEmit`, eslint clean; full suite green (1929 tests)
- 6 new GameView tests cover the owner index (grouping, inactive
exclusion, ownership capture, death, type filtering, copy semantics);
changedTiles tests updated to the ref-array contract
- Headless end-of-run screenshots verified after each round: leaderboard
Max-troops values, map names + troop counts + flags all render correctly
(including with name slicing active)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"perf:game": "npx tsx tests/perf/fullgame/FullGamePerf.ts",
|
||||
"perf:client": "npx tsx tests/perf/client/ClientUpdatePerf.ts",
|
||||
"perf:client-mem": "npx tsx tests/perf/client/ClientMemoryPerf.ts",
|
||||
"perf:client-tick": "npx tsx tests/perf/client/ClientTickPerf.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"format": "prettier --ignore-unknown --write .",
|
||||
"format:map-generator": "cd map-generator && go fmt .",
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
NukeTelegraphData,
|
||||
PlayerState,
|
||||
PlayerStatusData,
|
||||
TilePair,
|
||||
UnitState,
|
||||
} from "../types";
|
||||
|
||||
@@ -21,7 +20,10 @@ export interface FrameUploadTarget {
|
||||
tileState: Uint16Array,
|
||||
trailState: Uint16Array,
|
||||
): void;
|
||||
uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void;
|
||||
uploadLiveDelta(
|
||||
tileState: Uint16Array,
|
||||
changedTiles: readonly number[],
|
||||
): void;
|
||||
uploadLiveTrailDelta(
|
||||
trailState: Uint16Array,
|
||||
dirtyRowMin: number,
|
||||
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
PlayerStatic,
|
||||
PlayerStatusData,
|
||||
RendererConfig,
|
||||
TilePair,
|
||||
UnitState,
|
||||
} from "../types";
|
||||
import type { SpawnCenter } from "./passes/SpawnOverlayPass";
|
||||
@@ -124,7 +123,10 @@ export class MapRenderer {
|
||||
|
||||
// ---- Data upload ----
|
||||
|
||||
uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void {
|
||||
uploadLiveDelta(
|
||||
tileState: Uint16Array,
|
||||
changedTiles: readonly number[],
|
||||
): void {
|
||||
this.renderer?.uploadLiveDelta(tileState, changedTiles);
|
||||
}
|
||||
uploadLiveTrailDelta(
|
||||
|
||||
@@ -23,7 +23,6 @@ import type {
|
||||
PlayerStatic,
|
||||
PlayerStatusData,
|
||||
RendererConfig,
|
||||
TilePair,
|
||||
UnitState,
|
||||
} from "../types";
|
||||
import { Camera } from "./Camera";
|
||||
@@ -653,7 +652,10 @@ export class GPURenderer {
|
||||
this.trailPass.setLiveRef(trailState);
|
||||
}
|
||||
|
||||
uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void {
|
||||
uploadLiveDelta(
|
||||
tileState: Uint16Array,
|
||||
changedTiles: readonly number[],
|
||||
): void {
|
||||
this.territoryPass.applyLiveDelta(tileState, changedTiles);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,5 @@ export type {
|
||||
PlayerState,
|
||||
PlayerStatic,
|
||||
RendererConfig,
|
||||
TilePair,
|
||||
UnitState,
|
||||
} from "../types";
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
* uploads across render frames.
|
||||
*/
|
||||
|
||||
import type { TilePair } from "../../types";
|
||||
import type { RenderSettings } from "../RenderSettings";
|
||||
import { getPaletteSize } from "../utils/ColorUtils";
|
||||
import { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils";
|
||||
@@ -229,11 +228,14 @@ export class TerritoryPass {
|
||||
* Stable per-ref hash means repeated updates to the same tile stay in
|
||||
* arrival order in the same bucket — last write wins when drained.
|
||||
*/
|
||||
applyLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void {
|
||||
applyLiveDelta(
|
||||
tileState: Uint16Array,
|
||||
changedTiles: readonly number[],
|
||||
): void {
|
||||
const N = this.nBuckets;
|
||||
const buckets = this.dripBuckets;
|
||||
for (let i = 0; i < changedTiles.length; i++) {
|
||||
const ref = changedTiles[i].ref;
|
||||
const ref = changedTiles[i];
|
||||
const b = ((ref * 2654435761) >>> 0) % N;
|
||||
buckets[b].push(ref, tileState[ref]);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ export class NamePass {
|
||||
|
||||
// Player management
|
||||
private playerByID: Map<string, PlayerStatic>;
|
||||
private smallIDToPlayerID: Map<number, string>;
|
||||
private slots: Map<string, PlayerSlot> = new Map();
|
||||
private maxPlayers: number;
|
||||
private playerColors: Map<string, [number, number, number]> = new Map();
|
||||
@@ -102,16 +101,15 @@ export class NamePass {
|
||||
private stringRow: Uint8Array;
|
||||
private cursorRow: Float32Array;
|
||||
|
||||
// Reusable per-tick lookup maps (avoid allocation + GC)
|
||||
private alivePlayerIDs = new Set<string>();
|
||||
private troopsByPlayerID = new Map<string, number>();
|
||||
/** Slots refreshed per updateNames call: index % UPDATE_SLICES === phase. */
|
||||
private static readonly UPDATE_SLICES = 4;
|
||||
private slicePhase = 0;
|
||||
|
||||
// Hovered player's small ID (0 = no highlight, matches TerritoryPass).
|
||||
private highlightOwnerID = 0;
|
||||
// Cursor in world coords — fades names under it (far off-map = no fade).
|
||||
private mouseWorldX = -1e9;
|
||||
private mouseWorldY = -1e9;
|
||||
private playerStateByID = new Map<string, PlayerState>();
|
||||
|
||||
constructor(
|
||||
gl: WebGL2RenderingContext,
|
||||
@@ -148,10 +146,8 @@ export class NamePass {
|
||||
|
||||
// Build player lookups and extract territory colors from palette
|
||||
this.playerByID = new Map();
|
||||
this.smallIDToPlayerID = new Map();
|
||||
for (const p of header.players) {
|
||||
this.playerByID.set(p.id, p);
|
||||
this.smallIDToPlayerID.set(p.smallID, p.id);
|
||||
const off = p.smallID * 4;
|
||||
this.playerColors.set(p.id, [
|
||||
paletteData[off],
|
||||
@@ -215,7 +211,6 @@ export class NamePass {
|
||||
for (const p of players) {
|
||||
if (this.playerByID.has(p.id)) continue;
|
||||
this.playerByID.set(p.id, p);
|
||||
this.smallIDToPlayerID.set(p.smallID, p.id);
|
||||
const off = p.smallID * 4;
|
||||
this.playerColors.set(p.id, [
|
||||
paletteData[off],
|
||||
@@ -294,75 +289,83 @@ export class NamePass {
|
||||
): void {
|
||||
const now = performance.now() / 1000;
|
||||
|
||||
// Build alive set and emoji lookup from smallID → playerID
|
||||
const alivePlayerIDs = this.alivePlayerIDs;
|
||||
alivePlayerIDs.clear();
|
||||
const troopsByPlayerID = this.troopsByPlayerID;
|
||||
troopsByPlayerID.clear();
|
||||
const playerStateByID = this.playerStateByID;
|
||||
playerStateByID.clear();
|
||||
for (const [, ps] of players) {
|
||||
const pid = this.smallIDToPlayerID.get(ps.smallID);
|
||||
if (!pid) continue;
|
||||
if (ps.isAlive) alivePlayerIDs.add(pid);
|
||||
troopsByPlayerID.set(pid, ps.troops ?? 0);
|
||||
playerStateByID.set(pid, ps);
|
||||
}
|
||||
|
||||
// Assign slot indices to players (stable ordering by header index)
|
||||
let nextSlotIndex = 0;
|
||||
for (const p of this.playerByID.values()) {
|
||||
if (!this.slots.has(p.id)) {
|
||||
const slot: PlayerSlot = {
|
||||
index: nextSlotIndex++,
|
||||
playerID: p.id,
|
||||
static: p,
|
||||
srcX: 0,
|
||||
srcY: 0,
|
||||
srcScale: 0,
|
||||
tgtX: 0,
|
||||
tgtY: 0,
|
||||
tgtScale: 0,
|
||||
startTime: now,
|
||||
alive: false,
|
||||
nameLen: 0,
|
||||
troopLen: 0,
|
||||
lastTroopStr: "",
|
||||
lastTroopBucket: -1,
|
||||
flagUrl: p.flag,
|
||||
flagLayerIdx: -1,
|
||||
emojiAtlasIdx: -1,
|
||||
nameHalfWidth: 0,
|
||||
crown: false,
|
||||
traitor: false,
|
||||
disconnected: false,
|
||||
alliance: false,
|
||||
allianceReq: false,
|
||||
target: false,
|
||||
embargo: false,
|
||||
nukeActive: false,
|
||||
nukeTargetsMe: false,
|
||||
inDoomsdayClock: false,
|
||||
doomsdayClockDraining: false,
|
||||
traitorRemainingTicks: 0,
|
||||
allianceFraction: 0,
|
||||
allianceRemainingTicks: 0,
|
||||
};
|
||||
this.slots.set(p.id, slot);
|
||||
this.resolveSlotFlag(slot);
|
||||
} else {
|
||||
nextSlotIndex = Math.max(
|
||||
nextSlotIndex,
|
||||
this.slots.get(p.id)!.index + 1,
|
||||
);
|
||||
// Assign slot indices to players (stable ordering by header index).
|
||||
// Both maps only ever grow, and every assignment pass leaves them the
|
||||
// same size — equal sizes means every player already has a slot.
|
||||
if (this.slots.size !== this.playerByID.size) {
|
||||
let nextSlotIndex = 0;
|
||||
for (const p of this.playerByID.values()) {
|
||||
if (!this.slots.has(p.id)) {
|
||||
const slot: PlayerSlot = {
|
||||
index: nextSlotIndex++,
|
||||
playerID: p.id,
|
||||
static: p,
|
||||
srcX: 0,
|
||||
srcY: 0,
|
||||
srcScale: 0,
|
||||
tgtX: 0,
|
||||
tgtY: 0,
|
||||
tgtScale: 0,
|
||||
startTime: now,
|
||||
alive: false,
|
||||
nameLen: 0,
|
||||
troopLen: 0,
|
||||
lastTroopStr: "",
|
||||
lastTroopBucket: -1,
|
||||
flagUrl: p.flag,
|
||||
flagLayerIdx: -1,
|
||||
emojiAtlasIdx: -1,
|
||||
nameHalfWidth: 0,
|
||||
crown: false,
|
||||
traitor: false,
|
||||
disconnected: false,
|
||||
alliance: false,
|
||||
allianceReq: false,
|
||||
target: false,
|
||||
embargo: false,
|
||||
nukeActive: false,
|
||||
nukeTargetsMe: false,
|
||||
inDoomsdayClock: false,
|
||||
doomsdayClockDraining: false,
|
||||
traitorRemainingTicks: 0,
|
||||
allianceFraction: 0,
|
||||
allianceRemainingTicks: 0,
|
||||
};
|
||||
this.slots.set(p.id, slot);
|
||||
this.resolveSlotFlag(slot);
|
||||
} else {
|
||||
nextSlotIndex = Math.max(
|
||||
nextSlotIndex,
|
||||
this.slots.get(p.id)!.index + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Round-robin time slicing: each call refreshes 1/UPDATE_SLICES of the
|
||||
// slots, spreading the per-player diff work across game ticks (full
|
||||
// refresh every UPDATE_SLICES ticks ≈ 400 ms — under the 500 ms troop
|
||||
// text cadence, and name positions lerp continuously anyway). Slots
|
||||
// without a written name (new player, refreshNames reset) and snap
|
||||
// passes (seek) are always processed so nothing pops in late.
|
||||
const phase = this.slicePhase;
|
||||
this.slicePhase = (phase + 1) % NamePass.UPDATE_SLICES;
|
||||
|
||||
for (const [playerID, entry] of names) {
|
||||
const slot = this.slots.get(playerID);
|
||||
if (!slot) continue;
|
||||
|
||||
const alive = alivePlayerIDs.has(playerID);
|
||||
if (
|
||||
!snap &&
|
||||
slot.nameLen !== 0 &&
|
||||
slot.index % NamePass.UPDATE_SLICES !== phase
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Per-player state straight from the caller's map — smallID is the key.
|
||||
const ps = players.get(slot.static.smallID);
|
||||
const alive = ps?.isAlive ?? false;
|
||||
|
||||
// Skip dead players already marked dead — no work needed
|
||||
if (!alive && !slot.alive) continue;
|
||||
@@ -394,7 +397,7 @@ export class NamePass {
|
||||
const troopBucket = Math.floor((now + (slot.index % 5) * 0.1) / 0.5);
|
||||
if (snap || slot.troopLen === 0 || troopBucket !== slot.lastTroopBucket) {
|
||||
slot.lastTroopBucket = troopBucket;
|
||||
const troops = troopsByPlayerID.get(playerID) ?? 0;
|
||||
const troops = ps?.troops ?? 0;
|
||||
const troopStr = renderTroops(troops);
|
||||
if (troopStr !== slot.lastTroopStr) {
|
||||
slot.troopLen = Math.min(troopStr.length, MAX_CHARS);
|
||||
@@ -433,7 +436,6 @@ export class NamePass {
|
||||
|
||||
// Resolve active broadcast emoji for this player
|
||||
let newEmoji = -1;
|
||||
const ps = playerStateByID.get(playerID);
|
||||
if (ps?.outgoingEmojis && ps.outgoingEmojis.length > 0) {
|
||||
for (const e of ps.outgoingEmojis) {
|
||||
if (e.recipientID === "AllPlayers") {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
NukeTelegraphData,
|
||||
PlayerState,
|
||||
PlayerStatusData,
|
||||
TilePair,
|
||||
UnitState,
|
||||
} from "./Renderer";
|
||||
|
||||
@@ -37,11 +36,11 @@ export interface FrameData {
|
||||
// ── Upload hints ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Changed tiles this frame for delta uploads.
|
||||
* Changed tile refs this frame for delta uploads.
|
||||
* - `null` → no delta info; full upload needed (first tick)
|
||||
* - array → only these tiles changed (empty = skip upload)
|
||||
*/
|
||||
readonly changedTiles: TilePair[] | null;
|
||||
readonly changedTiles: readonly number[] | null;
|
||||
readonly railroadDirty: boolean;
|
||||
readonly revealedRailTiles: number[];
|
||||
|
||||
|
||||
@@ -173,11 +173,6 @@ export interface ConquestFx {
|
||||
tickAge?: number;
|
||||
}
|
||||
|
||||
export interface TilePair {
|
||||
ref: number;
|
||||
state: number;
|
||||
}
|
||||
|
||||
export interface NameEntry {
|
||||
playerID: string;
|
||||
x: number;
|
||||
|
||||
@@ -21,7 +21,6 @@ export type {
|
||||
PlayerStatic,
|
||||
PlayerStatusData,
|
||||
RendererConfig,
|
||||
TilePair,
|
||||
UnitState,
|
||||
} from "./Renderer";
|
||||
|
||||
|
||||
+47
-14
@@ -34,7 +34,7 @@ import { computePlayerStatus } from "../render/frame/derive/PlayerStatus";
|
||||
import { buildRelationMatrix } from "../render/frame/derive/RelationMatrix";
|
||||
import { RailroadCache } from "../render/frame/RailroadCache";
|
||||
import { TrailManager } from "../render/frame/TrailManager";
|
||||
import type { FrameData, NameEntry, TilePair } from "../render/types";
|
||||
import type { FrameData, NameEntry } from "../render/types";
|
||||
import { STRUCTURE_TYPES } from "../render/types";
|
||||
import { PlayerView } from "./PlayerView";
|
||||
import { UnitView } from "./UnitView";
|
||||
@@ -81,6 +81,14 @@ export class GameView implements GameMap {
|
||||
private _teams = new Map<number, string>();
|
||||
private updatedTiles: TileRef[] = [];
|
||||
private updatedTerrainTiles: TileRef[] = [];
|
||||
/**
|
||||
* Active units grouped by owner smallID, built lazily at most once per
|
||||
* tick. Keeps per-player unit queries (PlayerView.units) at O(own units)
|
||||
* instead of O(all units) — the leaderboard asks for every player's units
|
||||
* in one pass, which is O(players × units) without this.
|
||||
*/
|
||||
private _unitsByOwner = new Map<number, UnitView[]>();
|
||||
private _unitsByOwnerStale = true;
|
||||
|
||||
// ── FrameData accumulators (renderer-bound state) ─────────────────────
|
||||
private trailManager!: TrailManager;
|
||||
@@ -88,7 +96,6 @@ export class GameView implements GameMap {
|
||||
/** Long-lived NameEntry map for the renderer's NamePass. */
|
||||
private _names = new Map<string, NameEntry>();
|
||||
/** Reusable scratch buffers for per-tick deltas. */
|
||||
private readonly _changedTilesScratch: TilePair[] = [];
|
||||
private readonly _trailIdsScratch: number[] = [];
|
||||
/**
|
||||
* The single long-lived FrameData object. Fields are mutated in place each
|
||||
@@ -167,9 +174,9 @@ export class GameView implements GameMap {
|
||||
this.railroadCache = new RailroadCache(mapW, mapH);
|
||||
|
||||
// Long-lived FrameData. Most fields are mutable references to long-lived
|
||||
// buffers (tileState, trailState, etc.); some (_changedTilesScratch,
|
||||
// derived arrays) are reused each tick. Properties marked `readonly` on
|
||||
// FrameData only prevent reassignment, not mutation through the reference.
|
||||
// buffers (tileState, trailState, etc.); changedTiles points at this
|
||||
// tick's updatedTiles. Properties marked `readonly` on FrameData only
|
||||
// prevent reassignment, not mutation through the reference.
|
||||
// events: fresh arrays we own; cleared and repopulated each tick.
|
||||
this._frame = {
|
||||
tick: 0,
|
||||
@@ -185,7 +192,7 @@ export class GameView implements GameMap {
|
||||
conquestEvents: [],
|
||||
bonusEvents: [],
|
||||
},
|
||||
changedTiles: this._changedTilesScratch,
|
||||
changedTiles: null,
|
||||
railroadDirty: false,
|
||||
revealedRailTiles: this.railroadCache.revealedRailTiles,
|
||||
trailDirtyRowMin: 0,
|
||||
@@ -261,6 +268,9 @@ export class GameView implements GameMap {
|
||||
}
|
||||
|
||||
public update(gu: GameUpdateViewData) {
|
||||
// Unit set/ownership changes below; rebuild the owner index on demand.
|
||||
this._unitsByOwnerStale = true;
|
||||
|
||||
this.toDelete.forEach((id) => {
|
||||
this._units.delete(id);
|
||||
this._unitStates.delete(id);
|
||||
@@ -437,7 +447,12 @@ export class GameView implements GameMap {
|
||||
|
||||
for (const unit of this._units.values()) {
|
||||
unit._wasUpdated = false;
|
||||
unit.lastPos = unit.lastPos.slice(-1);
|
||||
// Only trim when a move appended a position — slicing a ≤1-element
|
||||
// array would allocate an identical array per unit per tick, and most
|
||||
// units (structures) never move.
|
||||
if (unit.lastPos.length > 1) {
|
||||
unit.lastPos = unit.lastPos.slice(-1);
|
||||
}
|
||||
}
|
||||
gu.updates[GameUpdateType.Unit].forEach((update) => {
|
||||
let unit = this._units.get(update.id);
|
||||
@@ -513,12 +528,6 @@ export class GameView implements GameMap {
|
||||
this._trailIdsScratch,
|
||||
);
|
||||
|
||||
// Changed-tile delta refs (zero-copy: state field unused in live mode).
|
||||
this._changedTilesScratch.length = 0;
|
||||
for (let i = 0; i < this.updatedTiles.length; i++) {
|
||||
this._changedTilesScratch.push({ ref: this.updatedTiles[i], state: 0 });
|
||||
}
|
||||
|
||||
// Names map — rebuilt only when a placement record arrived or a player
|
||||
// was added (nameData values cannot change between those ticks). Entry
|
||||
// order is irrelevant for the renderer.
|
||||
@@ -608,7 +617,9 @@ export class GameView implements GameMap {
|
||||
f.structuresDirty = true; // force initial structure upload
|
||||
this._firstPopulate = false;
|
||||
} else {
|
||||
f.changedTiles = this._changedTilesScratch;
|
||||
// Live reference to this tick's changed refs — consumers copy what
|
||||
// they keep (TerritoryPass buckets them synchronously in the upload).
|
||||
f.changedTiles = this.updatedTiles;
|
||||
}
|
||||
|
||||
// Reset transient flags for next tick.
|
||||
@@ -1059,6 +1070,28 @@ export class GameView implements GameMap {
|
||||
(u) => u.isActive() && types.includes(u.type()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Active units owned by the given player (smallID). The grouping is built
|
||||
* lazily at most once per tick; the returned array must not be mutated.
|
||||
*/
|
||||
unitsOwnedBy(ownerSmallID: number): readonly UnitView[] {
|
||||
if (this._unitsByOwnerStale) {
|
||||
this._unitsByOwnerStale = false;
|
||||
this._unitsByOwner.clear();
|
||||
for (const u of this._units.values()) {
|
||||
if (!u.isActive()) continue;
|
||||
const sid = u.state.ownerID;
|
||||
const arr = this._unitsByOwner.get(sid);
|
||||
if (arr === undefined) {
|
||||
this._unitsByOwner.set(sid, [u]);
|
||||
} else {
|
||||
arr.push(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._unitsByOwner.get(ownerSmallID) ?? [];
|
||||
}
|
||||
unit(id: number): UnitView | undefined {
|
||||
return this._units.get(id);
|
||||
}
|
||||
|
||||
@@ -403,9 +403,11 @@ export class PlayerView {
|
||||
}
|
||||
|
||||
units(...types: UnitType[]): UnitView[] {
|
||||
return this.game
|
||||
.units(...types)
|
||||
.filter((u) => u.owner().smallID() === this.smallID());
|
||||
const owned = this.game.unitsOwnedBy(this.smallID());
|
||||
if (types.length === 0) {
|
||||
return [...owned];
|
||||
}
|
||||
return owned.filter((u) => types.includes(u.type()));
|
||||
}
|
||||
|
||||
nameLocation(): NameViewData | undefined {
|
||||
|
||||
@@ -663,23 +663,21 @@ describe("GameView.frameData() — renderer contract", () => {
|
||||
game.update(gu2);
|
||||
const ct = game.frameData().changedTiles;
|
||||
expect(ct).not.toBeNull();
|
||||
expect(ct!.map((t) => t.ref).sort((a, b) => a - b)).toEqual([3, 5, 9]);
|
||||
expect([...ct!].sort((a, b) => a - b)).toEqual([3, 5, 9]);
|
||||
});
|
||||
|
||||
it("changedTiles scratch array is reused across ticks (no per-tick alloc)", () => {
|
||||
it("changedTiles holds only the current tick's refs", () => {
|
||||
const game = makeGameView({ width: 4, height: 4 });
|
||||
game.update(makeEmptyGu(1)); // first populate (changedTiles = null)
|
||||
const gu2 = makeEmptyGu(2);
|
||||
gu2.packedTileUpdates = new Uint32Array([1, 0]);
|
||||
game.update(gu2);
|
||||
const ct1 = game.frameData().changedTiles;
|
||||
expect(game.frameData().changedTiles).toEqual([1]);
|
||||
|
||||
const gu3 = makeEmptyGu(3);
|
||||
gu3.packedTileUpdates = new Uint32Array([2, 0]);
|
||||
game.update(gu3);
|
||||
const ct2 = game.frameData().changedTiles;
|
||||
|
||||
expect(ct2).toBe(ct1); // same array instance
|
||||
expect(game.frameData().changedTiles).toEqual([2]);
|
||||
});
|
||||
|
||||
it("frame.units is === gameView.unitStates() (same long-lived map)", () => {
|
||||
@@ -771,3 +769,88 @@ describe("GameView.frameData() — renderer contract", () => {
|
||||
expect(relationMatrix[3 * relationSize + 1]).toBe(RELATION_NEUTRAL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GameView.unitsOwnedBy — per-tick owner index", () => {
|
||||
function withUnits(
|
||||
tick: number,
|
||||
units: ReturnType<typeof makeUnitUpdate>[],
|
||||
players: ReturnType<typeof makePlayerUpdate>[] = [],
|
||||
) {
|
||||
const gu = makeEmptyGu(tick);
|
||||
gu.updates[GameUpdateType.Unit] = units;
|
||||
gu.updates[GameUpdateType.Player] = players;
|
||||
return gu;
|
||||
}
|
||||
|
||||
it("groups active units by owner smallID", () => {
|
||||
const game = makeGameView();
|
||||
game.update(
|
||||
withUnits(1, [
|
||||
makeUnitUpdate({ id: 1, ownerID: 1, unitType: UnitType.City }),
|
||||
makeUnitUpdate({ id: 2, ownerID: 1, unitType: UnitType.Port }),
|
||||
makeUnitUpdate({ id: 3, ownerID: 2, unitType: UnitType.City }),
|
||||
]),
|
||||
);
|
||||
expect(game.unitsOwnedBy(1).map((u) => u.id())).toEqual([1, 2]);
|
||||
expect(game.unitsOwnedBy(2).map((u) => u.id())).toEqual([3]);
|
||||
expect(game.unitsOwnedBy(99)).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes inactive units", () => {
|
||||
const game = makeGameView();
|
||||
game.update(
|
||||
withUnits(1, [
|
||||
makeUnitUpdate({ id: 1, ownerID: 1, isActive: true }),
|
||||
makeUnitUpdate({ id: 2, ownerID: 1, isActive: false }),
|
||||
]),
|
||||
);
|
||||
expect(game.unitsOwnedBy(1).map((u) => u.id())).toEqual([1]);
|
||||
});
|
||||
|
||||
it("reflects ownership changes on the next tick (capture)", () => {
|
||||
const game = makeGameView();
|
||||
game.update(withUnits(1, [makeUnitUpdate({ id: 1, ownerID: 1 })]));
|
||||
expect(game.unitsOwnedBy(1)).toHaveLength(1);
|
||||
expect(game.unitsOwnedBy(2)).toHaveLength(0);
|
||||
|
||||
game.update(withUnits(2, [makeUnitUpdate({ id: 1, ownerID: 2 })]));
|
||||
expect(game.unitsOwnedBy(1)).toHaveLength(0);
|
||||
expect(game.unitsOwnedBy(2)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reflects unit death on the tick it happens", () => {
|
||||
const game = makeGameView();
|
||||
game.update(withUnits(1, [makeUnitUpdate({ id: 1, ownerID: 1 })]));
|
||||
expect(game.unitsOwnedBy(1)).toHaveLength(1);
|
||||
|
||||
game.update(
|
||||
withUnits(2, [makeUnitUpdate({ id: 1, ownerID: 1, isActive: false })]),
|
||||
);
|
||||
expect(game.unitsOwnedBy(1)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("PlayerView.units() filters the owner's units by type", () => {
|
||||
const game = makeGameView();
|
||||
const gu = withUnits(
|
||||
1,
|
||||
[
|
||||
makeUnitUpdate({ id: 1, ownerID: 1, unitType: UnitType.City }),
|
||||
makeUnitUpdate({ id: 2, ownerID: 1, unitType: UnitType.Port }),
|
||||
makeUnitUpdate({ id: 3, ownerID: 1, unitType: UnitType.City }),
|
||||
makeUnitUpdate({ id: 4, ownerID: 2, unitType: UnitType.City }),
|
||||
],
|
||||
[
|
||||
makePlayerUpdate({ id: "alice", smallID: 1 }),
|
||||
makePlayerUpdate({ id: "bob", smallID: 2 }),
|
||||
],
|
||||
);
|
||||
game.update(gu);
|
||||
|
||||
const alice = game.player("alice");
|
||||
expect(alice.units(UnitType.City).map((u) => u.id())).toEqual([1, 3]);
|
||||
expect(alice.units().map((u) => u.id())).toEqual([1, 2, 3]);
|
||||
// Returned arrays are copies — mutating them must not corrupt the index.
|
||||
alice.units().pop();
|
||||
expect(alice.units()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Inclusive-time breakdown of the tick-dispatch subtree in a .cpuprofile
|
||||
// captured by ClientTickPerf. Attributes every sampled stack under the
|
||||
// harness's "wrapped" listener to each enclosing function, so the phase
|
||||
// split (gameView.update vs uploadFrameData vs renderer.tick) is visible.
|
||||
// Usage: npx tsx tests/perf/client/AnalyzeCpuProfile.ts <file> [rootName]
|
||||
import fs from "fs";
|
||||
|
||||
interface ProfileNode {
|
||||
id: number;
|
||||
callFrame: { functionName: string; url: string; lineNumber: number };
|
||||
hitCount?: number;
|
||||
children?: number[];
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
nodes: ProfileNode[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
samples?: number[];
|
||||
timeDeltas?: number[];
|
||||
}
|
||||
|
||||
const file = process.argv[2];
|
||||
const rootName = process.argv[3] ?? "wrapped";
|
||||
const p: Profile = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
const byId = new Map(p.nodes.map((n) => [n.id, n]));
|
||||
const parent = new Map<number, number>();
|
||||
for (const n of p.nodes) {
|
||||
for (const c of n.children ?? []) parent.set(c, n.id);
|
||||
}
|
||||
|
||||
// Self micros per node id from samples/timeDeltas.
|
||||
const selfMicros = new Map<number, number>();
|
||||
const samples = p.samples ?? [];
|
||||
const deltas = p.timeDeltas ?? [];
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
selfMicros.set(
|
||||
samples[i],
|
||||
(selfMicros.get(samples[i]) ?? 0) + (deltas[i] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
// True when a node's ancestry (inclusive) contains a rootName frame.
|
||||
const underRoot = (id: number): boolean => {
|
||||
for (
|
||||
let cur: number | undefined = id;
|
||||
cur !== undefined;
|
||||
cur = parent.get(cur)
|
||||
) {
|
||||
if (byId.get(cur)?.callFrame.functionName === rootName) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Attribute each in-subtree sample to every enclosing function (inclusive
|
||||
// time), stopping at the root frame.
|
||||
let rootTotal = 0;
|
||||
const inclusiveByFn = new Map<string, number>();
|
||||
for (const [id, micros] of selfMicros) {
|
||||
if (!underRoot(id)) continue;
|
||||
rootTotal += micros;
|
||||
const seen = new Set<string>();
|
||||
for (
|
||||
let cur: number | undefined = id;
|
||||
cur !== undefined;
|
||||
cur = parent.get(cur)
|
||||
) {
|
||||
const cf = byId.get(cur)?.callFrame;
|
||||
if (!cf) break;
|
||||
const url = cf.url.replace(/^.*\/(src|node_modules)\//, "$1/");
|
||||
const key = `${cf.functionName || "(anonymous)"} ${url}:${cf.lineNumber + 1}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
inclusiveByFn.set(key, (inclusiveByFn.get(key) ?? 0) + micros);
|
||||
}
|
||||
if (cf.functionName === rootName) break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`total under "${rootName}": ${(rootTotal / 1000).toFixed(1)} ms of ${((p.endTime - p.startTime) / 1000).toFixed(0)} ms profile`,
|
||||
);
|
||||
const rows = [...inclusiveByFn.entries()].sort((a, b) => b[1] - a[1]);
|
||||
for (const [key, micros] of rows.slice(0, 40)) {
|
||||
const pct = ((micros / rootTotal) * 100).toFixed(1);
|
||||
console.log(
|
||||
`${(micros / 1000).toFixed(1).padStart(9)} ms ${pct.padStart(5)}% ${key}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* Main-thread tick-processing harness: drives a real singleplayer game in
|
||||
* headless Chromium and measures how long the worker→main "game_update"
|
||||
* message dispatch takes on the MAIN thread — the per-tick cost that competes
|
||||
* with the frame budget (16.7 ms at 60 fps) and causes frame drops on low-end
|
||||
* hardware when it runs long.
|
||||
*
|
||||
* What is measured, per dispatch:
|
||||
* - deserialization: first access to event.data (structured-clone decode)
|
||||
* - handler: WorkerClient.handleWorkerMessage → gameView.update →
|
||||
* webglBuilder.update → renderer.tick
|
||||
* Both are captured by wrapping Worker.prototype.addEventListener in an init
|
||||
* script, so no product code changes are needed.
|
||||
*
|
||||
* For attribution, CDP sampling profiles (chrome .cpuprofile files) can be
|
||||
* captured over tick windows and are summarized as top self-time functions.
|
||||
* Open them in Chrome DevTools → Performance → load profile for flame graphs.
|
||||
*
|
||||
* The harness starts its own vite dev server on a private port (default
|
||||
* 9017) so results always come from THIS checkout, even when another
|
||||
* working copy is serving port 9000.
|
||||
*
|
||||
* One-time browser setup (installs playwright + chromium libs, no sudo):
|
||||
* bash .claude/skills/run-openfront/setup.sh
|
||||
*
|
||||
* Usage:
|
||||
* npm run perf:client-tick -- --map "Giant World Map" --ticks 2000 \
|
||||
* --window 250 --profile-at 500,1500
|
||||
*
|
||||
* Flags:
|
||||
* --map <name> GameMapType value (default "Giant World Map")
|
||||
* --bots <n> bot count (default 400, the solo-modal default)
|
||||
* --difficulty <d> Easy|Medium|Hard|Impossible (default modal default)
|
||||
* --ticks <n> run until this game tick (default 2000)
|
||||
* --window <n> report stats every n ticks (default 250)
|
||||
* --profile-at <list> comma-separated ticks to start a CPU profile
|
||||
* --profile-window <n> ticks each CPU profile spans (default 100)
|
||||
* --spawn <x,y> fixed human spawn tile (default: auto-pick)
|
||||
* --port <n> vite dev-server port (default 9017)
|
||||
* --raf-interval <ms> rAF throttle; SwiftShader frames cost seconds of
|
||||
* CPU, so an unthrottled frame loop starves the sim
|
||||
* (default 3000)
|
||||
* --out-dir <dir> output dir (default tests/perf/output)
|
||||
*
|
||||
* Headless caveats: rendering uses SwiftShader and the rAF loop is throttled,
|
||||
* so this measures the tick-dispatch path, not draw calls. GL upload calls
|
||||
* issued inside the dispatch go through SwiftShader's command buffer and cost
|
||||
* differently than on real GPUs. Solo games are RNG-driven, so numbers vary
|
||||
* a few percent run-to-run; compare trends, not microseconds.
|
||||
*/
|
||||
import { ChildProcess, spawn as spawnProcess } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
interface Options {
|
||||
map: string;
|
||||
bots: number;
|
||||
difficulty: string | undefined;
|
||||
ticks: number;
|
||||
window: number;
|
||||
profileAt: number[];
|
||||
profileWindow: number;
|
||||
spawn: { x: number; y: number } | null;
|
||||
port: number;
|
||||
rafIntervalMs: number;
|
||||
outDir: string;
|
||||
}
|
||||
|
||||
/** One worker→main game-update dispatch, as recorded by the init script. */
|
||||
interface TickSample {
|
||||
/** Game tick of the (last) update in the dispatch. */
|
||||
tick: number;
|
||||
/** ms spent deserializing event.data (structured-clone decode). */
|
||||
deserMs: number;
|
||||
/** ms spent in the message handler (gameView/webglBuilder/renderer). */
|
||||
handlerMs: number;
|
||||
/** Updates in the dispatch (>1 for game_update_batch catch-up). */
|
||||
updates: number;
|
||||
}
|
||||
|
||||
interface WindowStats {
|
||||
label: string;
|
||||
fromTick: number;
|
||||
toTick: number;
|
||||
dispatches: number;
|
||||
updates: number;
|
||||
meanMs: number;
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
maxMs: number;
|
||||
meanDeserMs: number;
|
||||
maxDeserMs: number;
|
||||
/** Mean handler ms per game tick (normalizes batched dispatches). */
|
||||
meanPerUpdateMs: number;
|
||||
}
|
||||
|
||||
function parseArgs(): Options {
|
||||
const opts: Options = {
|
||||
map: "Giant World Map",
|
||||
bots: 400,
|
||||
difficulty: undefined,
|
||||
ticks: 2000,
|
||||
window: 250,
|
||||
profileAt: [],
|
||||
profileWindow: 100,
|
||||
spawn: null,
|
||||
port: 9017,
|
||||
rafIntervalMs: 3000,
|
||||
outDir: path.join("tests", "perf", "output"),
|
||||
};
|
||||
const argv = process.argv.slice(2);
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const next = () => argv[++i];
|
||||
switch (argv[i]) {
|
||||
case "--map":
|
||||
opts.map = next();
|
||||
break;
|
||||
case "--bots":
|
||||
opts.bots = parseInt(next(), 10);
|
||||
break;
|
||||
case "--difficulty":
|
||||
opts.difficulty = next();
|
||||
break;
|
||||
case "--ticks":
|
||||
opts.ticks = parseInt(next(), 10);
|
||||
break;
|
||||
case "--window":
|
||||
opts.window = parseInt(next(), 10);
|
||||
break;
|
||||
case "--profile-at":
|
||||
opts.profileAt = next()
|
||||
.split(",")
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
break;
|
||||
case "--profile-window":
|
||||
opts.profileWindow = parseInt(next(), 10);
|
||||
break;
|
||||
case "--spawn": {
|
||||
const [x, y] = next().split(",").map(Number);
|
||||
opts.spawn = { x, y };
|
||||
break;
|
||||
}
|
||||
case "--port":
|
||||
opts.port = parseInt(next(), 10);
|
||||
break;
|
||||
case "--raf-interval":
|
||||
opts.rafIntervalMs = parseInt(next(), 10);
|
||||
break;
|
||||
case "--out-dir":
|
||||
opts.outDir = next();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown flag: ${argv[i]}`);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
// ---------- dev server ----------
|
||||
|
||||
async function startViteServer(port: number): Promise<ChildProcess> {
|
||||
// --strictPort makes vite exit instead of silently picking another port —
|
||||
// that also guards against measuring a different checkout's server.
|
||||
const child = spawnProcess(
|
||||
"npx",
|
||||
["vite", "--port", String(port), "--strictPort"],
|
||||
{
|
||||
env: { ...process.env, SKIP_BROWSER_OPEN: "true" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: true, // own process group, so cleanup kills vite's children
|
||||
},
|
||||
);
|
||||
let output = "";
|
||||
child.stdout?.on("data", (d: Buffer) => (output += d.toString()));
|
||||
child.stderr?.on("data", (d: Buffer) => (output += d.toString()));
|
||||
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(
|
||||
`vite exited with code ${child.exitCode} (port ${port} busy?)\n${output}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/`);
|
||||
if (res.ok) return child;
|
||||
} catch {
|
||||
// not up yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`vite did not become ready on port ${port}\n${output}`);
|
||||
}
|
||||
|
||||
function stopViteServer(child: ChildProcess): void {
|
||||
if (child.pid !== undefined && child.exitCode === null) {
|
||||
try {
|
||||
process.kill(-child.pid, "SIGTERM"); // whole process group
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- stats ----------
|
||||
|
||||
function quantile(sorted: number[], q: number): number {
|
||||
if (sorted.length === 0) return 0;
|
||||
const idx = Math.min(sorted.length - 1, Math.ceil(q * sorted.length) - 1);
|
||||
return sorted[Math.max(0, idx)];
|
||||
}
|
||||
|
||||
function summarizeWindow(label: string, samples: TickSample[]): WindowStats {
|
||||
const handler = samples.map((s) => s.handlerMs).sort((a, b) => a - b);
|
||||
const deser = samples.map((s) => s.deserMs);
|
||||
const updates = samples.reduce((acc, s) => acc + s.updates, 0);
|
||||
const totalHandler = handler.reduce((acc, v) => acc + v, 0);
|
||||
return {
|
||||
label,
|
||||
fromTick: samples.length > 0 ? samples[0].tick : 0,
|
||||
toTick: samples.length > 0 ? samples[samples.length - 1].tick : 0,
|
||||
dispatches: samples.length,
|
||||
updates,
|
||||
meanMs: samples.length > 0 ? totalHandler / samples.length : 0,
|
||||
p50Ms: quantile(handler, 0.5),
|
||||
p95Ms: quantile(handler, 0.95),
|
||||
maxMs: handler.length > 0 ? handler[handler.length - 1] : 0,
|
||||
meanDeserMs:
|
||||
samples.length > 0
|
||||
? deser.reduce((acc, v) => acc + v, 0) / samples.length
|
||||
: 0,
|
||||
maxDeserMs: deser.length > 0 ? Math.max(...deser) : 0,
|
||||
meanPerUpdateMs: updates > 0 ? totalHandler / updates : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function printReport(windows: WindowStats[], opts: Options): void {
|
||||
console.log(
|
||||
`\n=== Main-thread tick dispatch (map=${opts.map}, bots=${opts.bots}) ===`,
|
||||
);
|
||||
console.log(
|
||||
"handler ms per worker game-update dispatch (frame budget at 60 fps: 16.7 ms)",
|
||||
);
|
||||
console.log(
|
||||
`${"window".padEnd(14)} ${"disp".padStart(5)} ${"upd".padStart(5)} ${"mean".padStart(7)} ${"p50".padStart(7)} ${"p95".padStart(7)} ${"max".padStart(7)} ${"deser".padStart(7)} ${"ms/upd".padStart(7)}`,
|
||||
);
|
||||
for (const w of windows) {
|
||||
console.log(
|
||||
`${w.label.padEnd(14)} ${String(w.dispatches).padStart(5)} ${String(w.updates).padStart(5)} ${w.meanMs.toFixed(2).padStart(7)} ${w.p50Ms.toFixed(2).padStart(7)} ${w.p95Ms.toFixed(2).padStart(7)} ${w.maxMs.toFixed(2).padStart(7)} ${w.meanDeserMs.toFixed(2).padStart(7)} ${w.meanPerUpdateMs.toFixed(2).padStart(7)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- CPU profile summarization ----------
|
||||
|
||||
interface CpuProfileNode {
|
||||
id: number;
|
||||
callFrame: {
|
||||
functionName: string;
|
||||
url: string;
|
||||
lineNumber: number;
|
||||
};
|
||||
hitCount?: number;
|
||||
children?: number[];
|
||||
}
|
||||
|
||||
interface CpuProfile {
|
||||
nodes: CpuProfileNode[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
samples?: number[];
|
||||
timeDeltas?: number[];
|
||||
}
|
||||
|
||||
/** Print the top-N functions by self time from a V8 sampling profile. */
|
||||
function summarizeProfile(profile: CpuProfile, top: number): void {
|
||||
// Self time per node = sum of timeDeltas for samples attributed to it.
|
||||
const selfMicros = new Map<number, number>();
|
||||
const samples = profile.samples ?? [];
|
||||
const deltas = profile.timeDeltas ?? [];
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const nodeId = samples[i];
|
||||
selfMicros.set(nodeId, (selfMicros.get(nodeId) ?? 0) + (deltas[i] ?? 0));
|
||||
}
|
||||
const byFunction = new Map<string, number>();
|
||||
for (const node of profile.nodes) {
|
||||
const micros = selfMicros.get(node.id) ?? 0;
|
||||
if (micros === 0) continue;
|
||||
const { functionName, url, lineNumber } = node.callFrame;
|
||||
const shortUrl = url.replace(/^.*\/(src|node_modules)\//, "$1/");
|
||||
const key = `${functionName || "(anonymous)"} ${shortUrl}:${lineNumber + 1}`;
|
||||
byFunction.set(key, (byFunction.get(key) ?? 0) + micros);
|
||||
}
|
||||
const totalMicros = profile.endTime - profile.startTime;
|
||||
const rows = [...byFunction.entries()].sort((a, b) => b[1] - a[1]);
|
||||
console.log(
|
||||
` top self-time functions (of ${(totalMicros / 1000).toFixed(0)} ms profiled):`,
|
||||
);
|
||||
for (const [key, micros] of rows.slice(0, top)) {
|
||||
const pct = ((micros / totalMicros) * 100).toFixed(1);
|
||||
console.log(
|
||||
` ${(micros / 1000).toFixed(1).padStart(8)} ms ${pct.padStart(5)}% ${key}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- main ----------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const opts = parseArgs();
|
||||
fs.mkdirSync(opts.outDir, { recursive: true });
|
||||
|
||||
console.log(`starting vite on port ${opts.port}…`);
|
||||
const vite = await startViteServer(opts.port);
|
||||
|
||||
// The skill driver reads OPENFRONT_URL at import time, so set it before
|
||||
// the dynamic imports below.
|
||||
process.env.OPENFRONT_URL = `http://localhost:${opts.port}`;
|
||||
const { launch, gotoHome, openSoloModal } =
|
||||
// @ts-expect-error untyped .mjs skill module
|
||||
await import("../../../.claude/skills/run-openfront/driver.mjs");
|
||||
const {
|
||||
startSoloGame,
|
||||
waitForGameReady,
|
||||
spawn,
|
||||
waitForSpawnPhaseEnd,
|
||||
waitForTick,
|
||||
gameState,
|
||||
} =
|
||||
// @ts-expect-error untyped .mjs skill module
|
||||
await import("../../../.claude/skills/run-openfront/game.mjs");
|
||||
|
||||
let browser: { close(): Promise<void> } | null = null;
|
||||
try {
|
||||
console.log("launching headless chromium…");
|
||||
const launched = await launch({
|
||||
rafIntervalMs: opts.rafIntervalMs,
|
||||
// Headless Chromium throttles backgrounded/occluded pages, which
|
||||
// starves the singleplayer turn loop on top of SwiftShader's cost.
|
||||
args: [
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
],
|
||||
});
|
||||
browser = launched.browser;
|
||||
const page = launched.page;
|
||||
|
||||
// Surface in-page failures in the harness log — a broken init script or
|
||||
// GL fault otherwise shows up only as an opaque ready-timeout. Blocked
|
||||
// external fetches (ads, auth, cosmetics) are expected noise; skip them.
|
||||
page.on("console", (msg: { type(): string; text(): string }) => {
|
||||
const text = msg.text();
|
||||
if (
|
||||
msg.type() === "error" &&
|
||||
!/Failed to load resource|Failed to fetch|ramp\./.test(text)
|
||||
) {
|
||||
console.log(`CONSOLE[error]: ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Headless Chromium only has SwiftShader, and the WebGL gate (#4324)
|
||||
// refuses software renderers by matching the unmasked renderer string.
|
||||
// Spoof the string so the gate passes; rendering still runs on
|
||||
// SwiftShader (hence the rAF throttle).
|
||||
await page.addInitScript(() => {
|
||||
const orig = WebGL2RenderingContext.prototype.getParameter;
|
||||
WebGL2RenderingContext.prototype.getParameter = function (p: number) {
|
||||
const v = orig.call(this, p);
|
||||
return typeof v === "string"
|
||||
? v.replace(/swiftshader|llvmpipe|software/gi, "PerfHarnessGPU")
|
||||
: v;
|
||||
};
|
||||
});
|
||||
|
||||
// Time every worker→main "message" dispatch. The first event.data access
|
||||
// pays the structured-clone deserialization, so it is timed separately
|
||||
// from the handler body. Only game updates are recorded.
|
||||
//
|
||||
// MUST stay a string, not a function: tsx compiles this file with
|
||||
// esbuild's keepNames, which rewrites named function expressions to
|
||||
// `__name(fn, "name")` — and the `__name` helper doesn't exist in the
|
||||
// page, so a function-form script throws ReferenceError inside
|
||||
// Worker.addEventListener and silently kills the game worker setup.
|
||||
// (Member-expression assignments like the GL spoof above escape
|
||||
// keepNames, which is why that one can stay a function.)
|
||||
await page.addInitScript(`(() => {
|
||||
// Init scripts also run in dedicated workers, where window is
|
||||
// undefined — only the page context is measured.
|
||||
if (typeof window === "undefined") return;
|
||||
const samples = [];
|
||||
window.__tickPerf = {
|
||||
take() {
|
||||
return samples.splice(0, samples.length);
|
||||
},
|
||||
};
|
||||
const origAdd = Worker.prototype.addEventListener;
|
||||
Worker.prototype.addEventListener = function (type, listener, options) {
|
||||
if (type !== "message" || typeof listener !== "function") {
|
||||
return origAdd.call(this, type, listener, options);
|
||||
}
|
||||
const wrapped = function (event) {
|
||||
const t0 = performance.now();
|
||||
const d = event.data; // first access → structured-clone decode
|
||||
const t1 = performance.now();
|
||||
const isTick = d && d.type === "game_update";
|
||||
const isBatch = d && d.type === "game_update_batch";
|
||||
if (!isTick && !isBatch) {
|
||||
return listener.call(this, event);
|
||||
}
|
||||
try {
|
||||
return listener.call(this, event);
|
||||
} finally {
|
||||
const t2 = performance.now();
|
||||
const updates = isBatch ? (d.gameUpdates ?? []) : [d.gameUpdate];
|
||||
const last = updates[updates.length - 1];
|
||||
samples.push({
|
||||
tick: last ? last.tick : -1,
|
||||
deserMs: t1 - t0,
|
||||
handlerMs: t2 - t1,
|
||||
updates: updates.length,
|
||||
});
|
||||
// Bounded so a stalled harness can't grow the page's heap.
|
||||
if (samples.length > 200000) samples.splice(0, 100000);
|
||||
}
|
||||
};
|
||||
return origAdd.call(this, type, wrapped, options);
|
||||
};
|
||||
})();`);
|
||||
|
||||
// Solo games are fully local: block external requests (ad scripts) and
|
||||
// all websockets — vite's HMR socket times out under heavy throttling
|
||||
// and force-reloads the page mid-game.
|
||||
await page.route("**/*", (route: any) => {
|
||||
const host = new URL(route.request().url()).hostname;
|
||||
return host === "localhost" || host === "127.0.0.1"
|
||||
? route.continue()
|
||||
: route.abort();
|
||||
});
|
||||
await page.routeWebSocket("**", () => {});
|
||||
|
||||
// CDP session against the page = the main thread only (the core sim
|
||||
// worker is a separate target and not included in CPU profiles).
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Profiler.enable");
|
||||
await cdp.send("Profiler.setSamplingInterval", { interval: 200 });
|
||||
|
||||
const takeSamples = async (): Promise<TickSample[]> =>
|
||||
(await page.evaluate(() => (window as any).__tickPerf.take())) ?? [];
|
||||
|
||||
console.log("loading home page + solo modal…");
|
||||
await gotoHome(page);
|
||||
await openSoloModal(page);
|
||||
|
||||
console.log(
|
||||
`starting solo game: map=${opts.map}, bots=${opts.bots}` +
|
||||
(opts.difficulty !== undefined
|
||||
? `, difficulty=${opts.difficulty}`
|
||||
: ""),
|
||||
);
|
||||
try {
|
||||
await startSoloGame(page, {
|
||||
map: opts.map,
|
||||
bots: opts.bots,
|
||||
...(opts.difficulty !== undefined
|
||||
? { difficulty: opts.difficulty }
|
||||
: {}),
|
||||
});
|
||||
} catch {
|
||||
// Giant maps can exceed the skill's 180 s ready timeout on a cold
|
||||
// headless start — give the load one more window before giving up.
|
||||
console.log("game not ready after 180 s, waiting another 300 s…");
|
||||
await waitForGameReady(page, 300_000);
|
||||
}
|
||||
|
||||
console.log("spawning…");
|
||||
const tile = await spawn(page, opts.spawn);
|
||||
console.log(`spawned at (${tile.x},${tile.y})`);
|
||||
await waitForSpawnPhaseEnd(page, 120_000);
|
||||
const spawnState = await gameState(page);
|
||||
const spawnedTick: number = spawnState?.ticks ?? 0;
|
||||
// Spawn-phase dispatches are not representative; drop them.
|
||||
await takeSamples();
|
||||
|
||||
const startWall = Date.now();
|
||||
const windows: WindowStats[] = [];
|
||||
const allSamples: TickSample[] = [];
|
||||
const pendingProfiles = [...new Set(opts.profileAt)].sort((a, b) => a - b);
|
||||
|
||||
const captureProfile = async (startTick: number): Promise<void> => {
|
||||
const endTick = startTick + opts.profileWindow;
|
||||
console.log(`[profile] capturing ticks ${startTick}–${endTick}…`);
|
||||
await cdp.send("Profiler.start");
|
||||
await waitForTick(page, endTick, opts.profileWindow * 2000 + 120_000);
|
||||
const { profile } = await cdp.send("Profiler.stop");
|
||||
const file = path.join(opts.outDir, `client-tick${startTick}.cpuprofile`);
|
||||
fs.writeFileSync(file, JSON.stringify(profile));
|
||||
console.log(`[profile] ${file}`);
|
||||
summarizeProfile(profile as CpuProfile, 25);
|
||||
};
|
||||
|
||||
const targets: number[] = [];
|
||||
for (let t = spawnedTick + opts.window; t < opts.ticks; t += opts.window) {
|
||||
targets.push(t);
|
||||
}
|
||||
if (opts.ticks > spawnedTick) targets.push(opts.ticks);
|
||||
|
||||
for (const target of targets) {
|
||||
// A due profile splits the window: profile spans real ticks inside it.
|
||||
while (pendingProfiles.length > 0 && pendingProfiles[0] < target) {
|
||||
const at = pendingProfiles.shift()!;
|
||||
const state = await gameState(page);
|
||||
const current: number = state?.ticks ?? 0;
|
||||
if (at > current) {
|
||||
// Generous timeout: headless sim speed varies wildly with map size
|
||||
// and bot count (0.5–10 ticks/s).
|
||||
await waitForTick(page, at, (at - current) * 2000 + 120_000);
|
||||
}
|
||||
await captureProfile(Math.max(at, current));
|
||||
}
|
||||
const state = await gameState(page);
|
||||
const current: number = state?.ticks ?? 0;
|
||||
if (target > current) {
|
||||
await waitForTick(page, target, (target - current) * 2000 + 120_000);
|
||||
}
|
||||
const samples = await takeSamples();
|
||||
allSamples.push(...samples);
|
||||
const w = summarizeWindow(`tick ${target}`, samples);
|
||||
windows.push(w);
|
||||
console.log(
|
||||
`[window] ${w.label}: ${w.dispatches} dispatches, mean ${w.meanMs.toFixed(2)} ms, p95 ${w.p95Ms.toFixed(2)} ms, max ${w.maxMs.toFixed(2)} ms`,
|
||||
);
|
||||
}
|
||||
// Any profiles requested at/beyond the final tick.
|
||||
while (pendingProfiles.length > 0) {
|
||||
await captureProfile(pendingProfiles.shift()!);
|
||||
}
|
||||
|
||||
const samplesFile = path.join(opts.outDir, "client-tick-samples.json");
|
||||
fs.writeFileSync(samplesFile, JSON.stringify(allSamples));
|
||||
console.log(`[samples] ${samplesFile} (${allSamples.length} dispatches)`);
|
||||
|
||||
// End-of-run screenshot — a cheap rendering sanity check (a black map
|
||||
// means the GL pipeline broke even if no pageerror surfaced).
|
||||
const shotPath = path.join(opts.outDir, "client-tick-final.png");
|
||||
await page.screenshot({ path: shotPath });
|
||||
console.log(`[screenshot] ${shotPath}`);
|
||||
|
||||
printReport(windows, opts);
|
||||
printReport([summarizeWindow("overall", allSamples)], {
|
||||
...opts,
|
||||
map: `${opts.map} (all windows)`,
|
||||
});
|
||||
const finalState = await gameState(page);
|
||||
console.log(
|
||||
`\nfinal state: ${JSON.stringify(finalState)}\ntotal wall time ${((Date.now() - startWall) / 1000 / 60).toFixed(1)} min`,
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
stopViteServer(vite);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user