Split the debug hook into one file per topic (#215)

debugHook.ts had grown to 1097 lines of unrelated test hooks. They
now live in seven files under src/editor/debug/, grouped by topic.
debugHook.ts only assembles them and is down to 86 lines.

- The shape of window.__gm is unchanged for tools/. Measured in the
  browser before and after: 120 top-level keys and 313 leaves with
  their arity, byte-identical JSON.
- The Text import is explicit in the helpers. Without it the name
  fell back to the DOM type and instanceof would never have matched.

Findings:
- The comment for vectorArt sat above pixelArt. It is back in place.
- An orphaned duplicate comment on assetRow is gone.

Tests:
- ./check.sh: 145 files, 2192 tests, CHECK EXIT 0 (unchanged)
- e2e 1-16,37,39-43,47,48: ALL PASSED, 734 checks
- The shape probe turns red against a deliberately misnested hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 10:20:06 +02:00
1 parent b830f9c747
commit ed29b2f3dc
9 files changed
+1114 -1031

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
import { type FrameRect, resolvePicture } from '../../core/model/texture';
import { effectiveScale, gameClock } from '../../core/time';
import { afterFrames, isRendering, renderCount, requestRender } from '../../ui';
import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { type Container, Mesh, Rectangle } from 'pixi.js';
import type { HookContext } from './hookHelpers';
/** What was really drawn, and the clocks behind it: pixels, textures, frames, shaders and particles. */
export function renderHooks({ app, state, editor }: HookContext) {
const { viewport, gameView } = editor;
/** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */
const pixelAt = (x: number, y: number): string => {
const { pixels } = app.renderer.extract.pixels({
target: app.stage,
frame: new Rectangle(x, y, 1, 1),
});
const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0');
return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`;
};
return {
pixelAt,
/**
* A horizontal run of pixels in one read. A check that has to tell a repeated pattern from a
* stretched one needs dozens of samples, and dozens of round trips would cost more than the
* step it belongs to.
*/
pixelRow: (x: number, y: number, count: number) => {
const width = Math.max(1, Math.min(1024, Math.round(count)));
const row: string[] = [];
// One extract per pixel, not one strip: a wider frame comes back resampled, and a row that
// blends its neighbours cannot tell a hard border from a soft one – which is what it is for.
for (let i = 0; i < width; i++) row.push(pixelAt(x + i, y));
return row;
},
/** The filter and wrap the GPU sampler actually got, next to the model's – only the driver knows the former. */
textureFilter: (file: string, rect: FrameRect | null = null) => {
const texture = textureLibrary.peek(file, rect);
if (!texture) return null;
const source = texture.source;
const renderer = app.renderer as unknown as {
gl?: WebGL2RenderingContext;
uid: number;
texture: { bindSource(source: unknown, location: number): void };
};
const gl = renderer.gl;
const uploaded = Boolean(
(source as unknown as { _gpuData: Record<number, unknown> })._gpuData[renderer.uid],
);
let magFilter = 0;
let wrapT = 0;
if (gl) {
// Through Pixi rather than a raw `bindTexture`, so its idea of what is bound stays true.
renderer.texture.bindSource(source, 0);
magFilter = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER) as number;
wrapT = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T) as number;
}
return {
scaleMode: source.scaleMode,
gpu: magFilter === gl?.NEAREST ? 'nearest' : magFilter === gl?.LINEAR ? 'linear' : 'none',
wrap:
wrapT === gl?.CLAMP_TO_EDGE
? 'clamp'
: wrapT === gl?.REPEAT
? 'repeat'
: wrapT === gl?.MIRRORED_REPEAT
? 'mirror'
: 'none',
uploaded,
};
},
/**
* A picture an asset draws itself (#129, #185): the key it is kept under, the canvas the model
* says it is, and the size it actually lies on the GPU in – which is the only place the
* supersampling of the rasteriser is visible. `kept` false means the key is not in memory,
* which is what a check reads to see an old revision let go.
*/
drawnPicture: (assetId: string) => {
const ref = resolvePicture(state.project, assetId);
if (!ref) return null;
const texture = textureLibrary.peek(ref.file);
return {
file: ref.file,
width: ref.sourceWidth,
height: ref.sourceHeight,
pixelWidth: texture?.source.pixelWidth ?? 0,
pixelHeight: texture?.source.pixelHeight ?? 0,
kept: texture !== null,
};
},
fps: () => editor.fps,
/**
* What the render loop did: frames actually drawn, and whether it is currently awake. The
* only way to see a missing repaint from a check script – `pixelAt` re-renders on demand and
* would answer correctly even if the screen had been frozen for a minute.
*/
renderStats: () => ({ drawn: renderCount(), awake: isRendering() }),
/** Draw again, whatever happens next – what a screenshot needs before it is taken. */
wake: () => requestRender(),
/**
* Resolves once the editor has drawn `count` more frames. The checks await this instead of
* sleeping a guessed number of milliseconds: a frame is the editor saying it has caught up,
* where a deadline is only the harness hoping it has.
*/
afterFrames: (count = 2, timeoutMs = 2000) => afterFrames(count, timeoutMs),
/** Visible nodes carrying a filter – one extra render pass each; since #77 a shader is none. */
filterCount: () => {
let n = 0;
const walk = (node: Container): void => {
if (!node.visible) return;
if (((node.filters as readonly unknown[] | undefined)?.length ?? 0) > 0) n += 1;
for (const child of node.children) walk(child);
};
walk(app.stage);
return n;
},
/** Visible sprite meshes drawing with a shader of their own – one draw call each, no pass (#77). */
shaderCount: () => {
let n = 0;
const walk = (node: Container): void => {
if (!node.visible) return;
if (node instanceof Mesh && node.shader) n += 1;
for (const child of node.children) walk(child);
};
walk(app.stage);
return n;
},
/** The global clock: the second it stands at, the scale set, and the one a pause – either kind – makes of it. */
globalTime: () => ({
now: gameClock.now,
scale: gameClock.scale,
effective: effectiveScale(gameClock.scale, state.paused || editor.playInfo().gamePaused),
}),
/** Sets the global time scale – there is no slider for it in the GUI yet. */
setTimeScale: (scale: number) => {
gameClock.scale = scale;
},
/** Holds the shader clock at `at` seconds, so a Time shader's pixels can be measured; null releases it. */
shaderTime: (at: number | null) => shaderLibrary.freezeTime(at),
/** The same for particle time, kept apart so one can be held without the other. */
particleTime: (at: number | null) => particleLibrary.freezeTime(at),
/** Every source's clock back to zero on the current (usually frozen) second. */
particlesRestart: () => {
viewport.restartParticles(particleLibrary.now());
gameView.restartParticles(particleLibrary.now());
},
/** What a source is drawing in the scene tab, or in the game view while playing. */
particlesOf: (objectId: string, where: 'scene' | 'game' = 'scene', limit = 8) =>
(where === 'game' ? gameView : viewport).particleInfo(objectId, limit),
/** Graphs compiled so far; a moved node must not add one (#65). */
shaderCompiles: () => shaderLibrary.compiles,
/** Which views redraw every tick because a Time shader is on screen. */
animates: () => ({ scene: viewport.animates, game: gameView.animates }),
lowLatency: () =>
(app.renderer as unknown as { gl?: WebGL2RenderingContext }).gl?.getContextAttributes()
?.desynchronized === true,
};
}