diff --git a/.omp/skills/tracy-web-gui-test/SKILL.md b/.omp/skills/tracy-web-gui-test/SKILL.md new file mode 100644 index 00000000..ce74e898 --- /dev/null +++ b/.omp/skills/tracy-web-gui-test/SKILL.md @@ -0,0 +1,340 @@ +--- +name: tracy-web-gui-test +description: Build, serve, and drive the Tracy Profiler's emscripten (web) GUI in a headless browser with verified mouse/keyboard semantics. Use when developing or testing the profiler GUI. +--- + +# Test the Tracy web GUI in a browser + +Build the emscripten profiler GUI into an instrumented temporary workspace, +serve it, and drive it with the browser tool. The harness exports +(`debug_snapshot`/`debug_clear`) expose the app-side mouse/ID state — they +are the ground truth for targeting and verification, not screenshots. + +Applies to this repo's emscripten build with ImGui 1.92.9b-docking and +Emscripten 5.0.7. Constants marked M are workstation-dependent — re-verify +them. Everything else is stable; do not re-derive it. + +## Setup + +```sh +sh /setup.sh # workspace=/tmp/tracy-protocol +# or: TRACY_WEB_WS=/elsewhere TRACY_WEB_REPO= sh setup.sh +``` + +`` is the project-scoped `.omp/skills/tracy-web-gui-test/` +under the active project root — not a fixed home directory. If multiple +copies exist (one per project that has used this skill), use the copy +inside the repo being tested; `TRACY_WEB_REPO` controls which repo is +copied into the workspace. + +The script: fresh source copy (excludes `.git` and build dirs) → applies +`harness.patch` (adds `debug_snapshot`/`debug_clear` exports + event/frame +recording to the emscripten backend, in the COPY only) → configures → builds +Release with the emsdk at `$EMSDK_DIR` (default `~/emsdk`) and the CPM cache at +`~/.cache/cpm`. + +**Pitfall:** if the workspace is deleted while a `tracy-web` server is running, +stop the hub process first — a stale server keeps the *deleted* cwd and silently +returns empty responses while the port still accepts connections. + +**Pitfall:** a `tracy-web` that died in an earlier session leaves the daemon in +`failed` state: `stop` only reports the old failure, and `start` keeps returning +"Daemon tracy-web has unacknowledged completion notifications" while the port +stays free. Drain the daemon with `hub op=logs name=tracy-web` (the log also +shows the cause), then relaunch with `hub op=restart name=tracy-web` (retained +spec). If the port is NOT free, a stale process owns it — often one serving an +earlier (now-stale) build directory: the new `httpd.py` dies on the bind and +your requests silently hit the old directory. Check `ss -tlnp | grep 8000` and +kill the owner. After any start/restart, verify the server is actually +serving: `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/index.html` +must return 200 — the daemon message is not proof of a server. + +**After editing the GUI in the repo:** copy the changed files into +`$WS/src/` (same relative path), re-run `cmake --build $WS/build -j$(nproc)`, +reload the page. If a change touches `profiler/src/BackendEmscripten.cpp` or +`profiler/CMakeLists.txt`, the harness patch may no longer apply — re-apply it +manually (keep the `Dbg*` harness section, the `DbgPushEvent`/`DbgPushFrame` +calls in the mouse callbacks + `NewFrame`, and the two `debug_*` exports in +`-sEXPORTED_FUNCTIONS`). + +## Serve + open + ready + +Server (long-running → `hub`): + +``` +hub op=start name=tracy-web application=python3 args=["$WS/build/httpd.py"] + cwd=$WS/build ready={port:8000} persist=true +``` + +`httpd.py` sends the COOP/COEP headers required for pthreads/SharedArrayBuffer — +use it, not a bare http.server. + +Browser: + +``` +browser open url=http://127.0.0.1:8000/index.html viewport={width:1600,height:900} +``` + +**Ready signal** (in a `run` cell; `run` code executes in Node scope — page JS +goes through `tab.evaluate`): + +```js +let title = null; +for (let i = 0; i < 360; i++) { + title = await tab.evaluate(() => document.title); + if (/ - Tracy Profiler/.test(title)) break; + await new Promise(r => setTimeout(r, 250)); +} +``` + +Title is `Tracy Profiler X.Y.Z` until the preloaded `embed.tracy` (DarkRL +capture) finishes loading, then ` (embed.tracy) - Tracy Profiler X.Y.Z` +(`profiler/src/main.cpp` `SetWindowTitleCallback`). Wait ~1.5 s after, then +confirm the app loop lives: `snap().frames.at(-1).tick > 0`. + +## Harness API (ground truth) + +```js +const snap = () => tab.evaluate(() => JSON.parse(Module.ccall('debug_snapshot','string',[],[]))); +const clear = () => tab.evaluate(() => Module.ccall('debug_clear','void',[],[])); +``` + +`snap()` → `{ dpr, innerW, innerH, bufW, bufH, rectL, rectT, rectW, rectH, +title, cursor, frames:[…64], events:[…64] }` (newest last). + +- `frames[]`: one sample **per rAF tick, including idle ticks**. `tick` = main-loop + count (always advances), `f` = `ImGui::GetFrameCount()` (advances only when a + frame actually renders — the idle gate, §Idle), `mx/my` = app mouse position, + `md[3]` = mouse button states, `q` = pending input events, `wa` = + `tracy::s_wasActive` at tick start, `hi`/`ai` = ImGui `HoveredId`/`ActiveId`. +- `events[]`: raw callback data as the app received it. `type` 0=move 1=down + 2=up 3=enter 4=leave 5=wheel; `tx/ty` = Emscripten int `targetX/Y`; `ax/ay` = + position passed to ImGui (`-1` for non-move); `t` = DOMHighResTimeStamp. + +## Coordinate mapping + +``` +appX = floor( trunc(cssX - rectL) * dpr ) // device px, what ImGui hit-tests +cssX = appX / dpr // inverse for targeting +screenshot_px = cssX * dpr +``` + +Chain: DOM `clientX` → Emscripten `targetX = int(clientX - (rect.left|0))` +(`src/lib/libhtml5.js`, `EmscriptenMouseEvent.targetX` is an `int`) → Tracy +`AddMousePosEvent(targetX * dpr, …)` (`BackendEmscripten.cpp`) → ImGui `ImFloor` +(`imgui.cpp` `AddMousePosEvent`). Canvas fills the viewport, so `rectL/rectT` +are 0 (re-read anyway). + +**M: `dpr` on this workstation's headless browser is 1.25** (canvas buffer +2000×1125 for a 1600×900 viewport; screenshots are captured at device px). +Never assume 1. App positions come in multiples of `dpr`; verify targeting +within ±1 px via `frames[].mx/my`. + +## Click protocol + +Click = move, press, refresh through the hold, release, verify — all +against rendered frames. The idle gate (§Idle) renders only 3 frames per +wake, and a hold with no mouse events at all refreshes nothing, so a +fixed-wait down/up from a cold idle can be swallowed (both events received, +zero frames rendered) or released after the wake budget expires. Issuing +mouse moves while the button is down keeps the budget alive: a same-coord +re-move is delivered (verified: it reaches the canvas handler, wakes the +app, and a same-coord hold click straddles cleanly) and is the safest +refresh — zero hover drift; a ±`dx` px wiggle works too, but must stay +inside the hit area: + +```js +async function click(cssX, cssY, holdMs = 400) { + await page.mouse.move(cssX, cssY); // 1. ALWAYS move first + await new Promise(r => setTimeout(r, 150)); // 2. >= 1 rendered frame + const pre = await snap(); + const pf = pre.frames.at(-1); + await clear(); + await page.mouse.down(); // 3. press + let df = null, hiDuring = null; + const t0 = Date.now(); + while (Date.now() - t0 < holdMs) { + await new Promise(r => setTimeout(r, 70)); + await page.mouse.move(cssX, cssY); // 4. same-coord re-move refreshes the wake budget + const f = (await snap()).frames.at(-1); + if (f.md[0] === 1) { df ??= f.f; hiDuring = f.hi; } + } + await page.mouse.up(); // 5. release + let uf = null; + for (let i = 0; i < 40; i++) { + await new Promise(r => setTimeout(r, 40)); + const f = (await snap()).frames.at(-1); + if (f.md[0] === 0) { uf = f.f; break; } + } + await new Promise(r => setTimeout(r, 400)); + const s = await snap(); + const d = s.dpr, ex = Math.floor(Math.trunc(cssX - s.rectL) * d), ey = Math.floor(Math.trunc(cssY - s.rectT) * d); + return { hoverID: pf.hi, hiDuring, appPos: [pf.mx, pf.my], expected: [ex, ey], + straddle: { df, uf }, + ok: df !== null && uf !== null && uf > df && hiDuring === pf.hi && + Math.abs(pf.mx - ex) <= 1 && Math.abs(pf.my - ey) <= 1 }; +} +``` + +Rules: + +1. **Move before every click.** The emscripten mousemove handler is the only + thing that sets the app mouse position; mousedown/mouseup only queue button + state. Fresh page position is invalid (`-FLT_MAX`) — a click without a prior + move activates nothing. +2. **Press and release must each render** in separate frames with the item + hovered (ImGui's `PressedOnClickRelease` — doc table at + `imgui_widgets.cpp:489-497`; input trickling, + `ConfigInputTrickleEventQueue`, defers a same-frame release). The + same-coord refresh is what guarantees rendered frames across the hold + from a cold idle. +3. **Verify every click:** `ok: true` (position landed, press and release each + rendered, hover held — `hiDuring === hoverID`) plus a screenshot diff. + Non-ImGui targets (timeline zone bars) have `hi = 0` by design — for those + verify by screenshot, not hover. +4. **Double-click trap:** two same-position clicks <300 ms apart = double-click + (`MouseDoubleClickTime = 0.3 s`) → zoom/thread-info actions fire. Wait + ≥350 ms between repeated clicks at one spot. +5. **Drag:** move → down → (move + wait 70 ms)×n → up. **Wheel:** + `page.mouse.wheel({ deltaY: ±500 })` after positioning. **Keys:** + `page.keyboard.type(..., { delay: 50 })` after focusing the input by click; + `page.keyboard.press('Enter')`. +6. **Danger:** toolbar device x 10–32 (css 8–26) = red power button (leftmost + toolbar icon, hover ID 3313137574), closes the trace view (back to "Get + started"). Never click it unless intended. + +## Idle and power saving + +`main.cpp DrawContents` keeps an `activeFrames = 3` budget refreshed on any +input event (`tracy::s_wasActive`), a connected client, view animation, or a +queued input; exhausted → `sleep 16 ms` with **no `ImGui::NewFrame` and no GL +draw** (canvas keeps the last frame). Expect ~97 % of ticks to skip rendering +while idle. + +- Clicks from a cold idle land only via the click protocol's hold refresh + (§Click protocol) — a fixed-wait down/up can be swallowed by the idle gate + (both events received, zero frames rendered). **Before interacting, assert + `tick`/`f` is advancing**; a stalled main loop (initial trace parse, hidden + tab) renders nothing and drops everything. During the initial load the + cursor style is `wait`. +- `focusLostLimit` (reduce render rate on focus loss) is desktop-only; it does + not exist in the emscripten path. +- `canvas.style.cursor` is **not** a hover oracle: this ImGui build sets the + Hand cursor only in `TextLink`, not on buttons. + +## Targeting + +Screenshots never resolve targeting. Full-page shots arrive downscaled JPEG +(1024 px wide for a 2000-px device buffer; the factor varies), and +`page.screenshot({ clip })` returns a PNG at device scale — a W×H CSS clip +returns W·dpr × H·dpr px (CSS = clip offset + px/dpr). Icon buttons are +15–25 device px wide, so visual estimates carry ±20 px error, enough to hit +the neighboring button or a gap. Target from the app's own hit-testing — +sweep the row and click the run's center: + +```js +async function sweepRow(dpr, cssY, devFrom, devTo, step = 4) { + const ranges = []; let cur = null; + for (let dev = devFrom; dev <= devTo; dev += step) { + await page.mouse.move(dev / dpr, cssY); + await new Promise(r => setTimeout(r, 50)); + const s = await snap(); + const hi = s.frames[s.frames.length - 1].hi; + if (cur && hi === cur.id) cur.end = dev; + else { if (cur) ranges.push(cur); cur = { id: hi, start: dev, end: dev }; } + } + ranges.push(cur); + return ranges.filter(r => r.id !== 0 && r.end - r.start >= 4); +} +``` + +- Same non-zero `hi` across consecutive steps = one widget; `hi = 0` = gap. + Click the run's center: `click((r.start + r.end) / 2 / dpr, cssY)`. +- **Layout is state-dependent:** docked panels (Statistics, Flame, …) resize the + main window and shift the toolbar — **re-sweep after any state change.** +- **Close popups before sweeping:** while a popup is open, hovers outside the + popup can read `hi = 0` even over live widgets (verified with ZoomPopup over + the toolbar). A % click in ZoomPopup does not close the popup — click an + empty area first. +- Hover IDs are stable across sessions (label hashes) — the map below holds for + the default state (fresh load, no panels, 1600×900, dpr 1.25). +- Zoomed region shots (for correlating IDs with icons): `page.screenshot({clip})` + returns a Buffer — write it with Node `fs`, then `read` the file: + +```js +const fs = require('fs'); +const buf = await page.screenshot({ clip: { x: 0, y: 0, width: 720, height: 28 } }); +fs.writeFileSync('/tmp/toolbar-zoom.png', buf); +``` + +### Toolbar map — default state (device px; css = device/1.25; row y css 15) + +| device x | hover ID | function | +|---|---|---| +| 10–32 | 3313137574 | ⏻ power (red, leftmost) — **closes the trace view (DANGER)** | +| 44–68 | 608241489 | ⚙ Options gear — opens the Options window (clickable; do not confuse with the power button) | +| 81–185 | 2246346107 | 💬 Messages panel toggle | +| 199–259 | 3177774389 | 🔍 Find zone toggle (search input hover ID 3637961452, css ~x 100–440, y ~90) | +| 271–367 | 1419322276 | 📊 Statistics toggle | +| 383–451 | 322727105 | 🔥 Flame graph toggle | +| 463–551 | 37993378 | 💾 Memory graph toggle | +| 567–667 | 2133895361 | ⚖ Compare toggle | +| 679–735 | 1808024465 | ⌗ Info (trace information) toggle | +| 751–775 | 2535445525 | 🛠 wrench → ToolsPopup (Playback, CPU data, Annotations, Limits, Wait stacks, Frame statistics) | +| 791–811 | 3101356487 | 📖 book → User manual toggle | +| 827–851 | 2335673581 | 🔍+ → ZoomPopup (50–300 % user scale) | +| 864–888 | 1263603982 | ◀ previous frame — focuses the timeline on the previous frame (view range shrinks to the frame's timespan) | +| ~890–1010 | 0 | frame set name + count text ("Frames: N") — LMB jumps to a specified frame (custom hit-test, like timeline zones) | +| 1012–1036 | 2142681495 | ▶ next frame — focuses the timeline on the next frame (verified: view range 25.82 s → 203.38 ms) | +| 1052–1072 | 2961929287 | ▼ frame set selection (switch the active frame set) | + +Button names and functions: the user manual, *Control menu* (`manual/tracy.md`). The +web build omits *Connection* (live capture only) and *Tracy Assist* (desktop only). + +## User scale (DPI zoom) + +The 🔍+ button (hover ID 2335673581) — *Display scale* in the user manual — +opens a ZoomPopup with 50–300 % steps. +In the web build this scales the UI **layout only**: fonts, +`Style::ScaleAllSizes`, and window sizes (`main.cpp SetupDPIScale`, +`scale = devicePixelRatio × userScale`). The canvas buffer and the mouse→app +mapping stay at `devicePixelRatio` (`BackendEmscripten.cpp`) — hit regions +become physically larger in the same coordinate space, targeting precision is +unchanged, and the standard click protocol works as-is. + +Use it when small buttons are hard to hit or details are hard to read in +screenshots. At 200 % every toolbar hit region is roughly 2× (verified: power +16→40 px, gear 25→50 px, Messages 105→190 px wide; row height 24→46 device +px). The layout **reflows completely** at any scale — re-sweep after changing +it and after setting it back. `userScale` is session-only unless Options → +"Save UI scale" is on; a fresh page load starts at 100 %. + +## Limitations + +- A *connected* live-streaming client cannot be tested in the web build (the + browser GUI cannot open a TCP server). The embedded-trace GUI is the + live-app proxy: real trace data, full GUI. +- `embed.tracy` (10 MB) is downloaded from share.nereid.pl at configure time — + network needed once per fresh workspace; the file then lives in `$WS/build`. + +## References + +Sources for the behavior the rules above encode — consult them when re-verifying +a constant or a rule fails: + +- `profiler/src/BackendEmscripten.cpp` — emscripten input handlers, canvas/dpr + sizing, cursor mapping. +- `profiler/src/main.cpp:540-574` — idle gate (`activeFrames` budget). +- `profiler/src/profiler/TracyMouse.cpp` — per-frame click state cache. +- `profiler/CMakeLists.txt:334-353` — emscripten link options. +- ImGui 1.92.9b (vendored copy in the CPM cache, `~/.cache/cpm/imgui/`): + `imgui_widgets.cpp:483-545` (click semantics table), `imgui.cpp:11278-11403` + (event trickling), `imgui.cpp:1736` (trickle default on), `imgui.cpp:1992-2016` + (AddMousePosEvent floor/dedup). +- Emscripten 5.0.7: `src/lib/libhtml5.js` (`fillMouseEventData`, + `targetX = e.clientX - (rect.left|0)` into HEAP32), `system/include/emscripten/html5.h` + (`EmscriptenMouseEvent.targetX` is `int`; `EmscriptenWheelEvent` embeds a + `mouse` sub-struct). +- Upstream: imgui issue #4921 (new input event API — events spread over + multiple frames), PR #2525 (same-frame click/release), issue #1992 (rapid + down/up "soft clicks" dropped). diff --git a/.omp/skills/tracy-web-gui-test/harness.patch b/.omp/skills/tracy-web-gui-test/harness.patch new file mode 100644 index 00000000..6d441947 --- /dev/null +++ b/.omp/skills/tracy-web-gui-test/harness.patch @@ -0,0 +1,221 @@ +diff -r a/profiler/CMakeLists.txt b/profiler/CMakeLists.txt +--- a/profiler/CMakeLists.txt ++++ b/profiler/CMakeLists.txt +@@ -340,7 +340,7 @@ + -sMAXIMUM_MEMORY=4gb + -sSTACK_SIZE=1048576 + -sPTHREAD_POOL_SIZE=8 +- -sEXPORTED_FUNCTIONS=_main,_nativeOpenFile,_tracy_paste_clipboard ++ -sEXPORTED_FUNCTIONS=_main,_nativeOpenFile,_tracy_paste_clipboard,_debug_snapshot,_debug_clear + -sEXPORTED_RUNTIME_METHODS=ccall + -sENVIRONMENT=web,worker + --preload-file embed.tracy +diff -r a/profiler/src/BackendEmscripten.cpp b/profiler/src/BackendEmscripten.cpp +--- a/profiler/src/BackendEmscripten.cpp ++++ b/profiler/src/BackendEmscripten.cpp +@@ -1,6 +1,8 @@ + #include + #include + #include ++#include ++#include + #include + #include + #include +@@ -10,7 +12,146 @@ + #include "Backend.hpp" + #include "RunQueue.hpp" + #include "profiler/TracyImGui.hpp" ++#include "imgui_internal.h" + ++// ================= DEBUG HARNESS (temporary copy, not for upstream) ================= ++struct DbgEvent ++{ ++ int type; // 0=move 1=down 2=up 3=enter 4=leave 5=wheel ++ int button; ++ int targetX; ++ int targetY; ++ double appX; // what was passed to ImGui (targetX * dpr), -1 if n/a ++ double appY; ++ double t; // DOMHighResTimeStamp ++ int wheelX; ++ int wheelY; ++}; ++struct DbgFrame ++{ ++ double t; ++ int tick; // emscripten main-loop tick count (rAF) ++ int imguiFrame; // ImGui::GetFrameCount() ++ float mx, my; // io.MousePos ++ int md0, md1, md2; ++ int queue; // pending ImGui input events ++ int wasActive; // tracy::s_wasActive at tick start ++ unsigned hi; // ImGui hovered item ID ++ unsigned ai; // ImGui active item ID ++}; ++static DbgEvent s_dbgEvents[512]; ++static int s_dbgEventHead = 0, s_dbgEventN = 0; ++static DbgFrame s_dbgFrames[512]; ++static int s_dbgFrameHead = 0, s_dbgFrameN = 0; ++static int s_dbgTick = 0; ++ ++static void DbgPushEvent( int type, int button, int tx, int ty, double ax, double ay, double t, int wx = 0, int wy = 0 ) ++{ ++ auto& e = s_dbgEvents[s_dbgEventHead]; ++ e.type = type; e.button = button; e.targetX = tx; e.targetY = ty; ++ e.appX = ax; e.appY = ay; e.t = t; e.wheelX = wx; e.wheelY = wy; ++ s_dbgEventHead = ( s_dbgEventHead + 1 ) % 512; ++ if ( s_dbgEventN < 512 ) s_dbgEventN++; ++} ++ ++static void DbgPushFrame() ++{ ++ auto& f = s_dbgFrames[s_dbgFrameHead]; ++ auto& io = ImGui::GetIO(); ++ f.t = EM_ASM_DOUBLE( { return performance.now(); } ); ++ f.tick = s_dbgTick; ++ f.imguiFrame = ImGui::GetFrameCount(); ++ f.mx = io.MousePos.x; f.my = io.MousePos.y; ++ f.md0 = io.MouseDown[0]; f.md1 = io.MouseDown[1]; f.md2 = io.MouseDown[2]; ++ auto ctx = ImGui::GetCurrentContext(); ++ f.queue = ctx ? int( ctx->InputEventsQueue.Size ) : -1; ++ f.wasActive = tracy::s_wasActive; ++ f.hi = ctx ? ctx->HoveredId : 0; ++ f.ai = ctx ? ctx->ActiveId : 0; ++ s_dbgFrameHead = ( s_dbgFrameHead + 1 ) % 512; ++ if ( s_dbgFrameN < 512 ) s_dbgFrameN++; ++} ++ ++extern "C" EMSCRIPTEN_KEEPALIVE void debug_clear() ++{ ++ s_dbgEventHead = s_dbgEventN = s_dbgFrameHead = s_dbgFrameN = 0; ++} ++ ++// Escape a C string for embedding in a JSON string literal (", \, control chars). ++static void JsonEscape( char* out, size_t outsz, const char* in ) ++{ ++ size_t o = 0; ++ for( const unsigned char* p = ( const unsigned char* )in; *p; p++ ) ++ { ++ if ( *p == '"' || *p == '\\' ) ++ { ++ if ( o + 2 >= outsz ) break; ++ out[o++] = '\\'; ++ out[o++] = char( *p ); ++ } ++ else if ( *p < 0x20 ) ++ { ++ if ( o + 6 >= outsz ) break; ++ o += size_t( snprintf( out + o, outsz - o, "\\u%04x", *p ) ); ++ } ++ else ++ { ++ if ( o + 1 >= outsz ) break; ++ out[o++] = char( *p ); ++ } ++ } ++ out[o] = 0; ++} ++ ++extern "C" EMSCRIPTEN_KEEPALIVE const char* debug_snapshot() ++{ ++ static char buf[512 * 1024]; ++ size_t off = 0; ++ auto put = [ & ]( const char* fmt, ... ) ++ { ++ if ( off + 1 >= sizeof( buf ) ) return; ++ va_list args; ++ va_start( args, fmt ); ++ int n = vsnprintf( buf + off, sizeof( buf ) - off - 1, fmt, args ); ++ va_end( args ); ++ if ( n > 0 ) off = std::min( off + size_t( n ), sizeof( buf ) - 1 ); ++ }; ++ const auto dpr = EM_ASM_DOUBLE( { return window.devicePixelRatio; } ); ++ const auto innerW = EM_ASM_INT( { return window.innerWidth; } ); ++ const auto innerH = EM_ASM_INT( { return window.innerHeight; } ); ++ const auto bufW = EM_ASM_INT( { return document.getElementById( 'canvas' ).width; } ); ++ const auto bufH = EM_ASM_INT( { return document.getElementById( 'canvas' ).height; } ); ++ const auto rectL = EM_ASM_DOUBLE( { var r = document.getElementById( 'canvas' ).getBoundingClientRect(); return r.left; } ); ++ const auto rectT = EM_ASM_DOUBLE( { var r = document.getElementById( 'canvas' ).getBoundingClientRect(); return r.top; } ); ++ const auto rectW = EM_ASM_DOUBLE( { var r = document.getElementById( 'canvas' ).getBoundingClientRect(); return r.width; } ); ++ const auto rectH = EM_ASM_DOUBLE( { var r = document.getElementById( 'canvas' ).getBoundingClientRect(); return r.height; } ); ++ char title[256]; ++ EM_ASM( { stringToUTF8( document.title, $0, 256 ); }, title ); ++ char cursor[64]; ++ EM_ASM( { stringToUTF8( document.getElementById( 'canvas' ).style.cursor || "", $0, 64 ); }, cursor ); ++ char title_e[1536]; ++ char cursor_e[384]; ++ JsonEscape( title_e, sizeof( title_e ), title ); ++ JsonEscape( cursor_e, sizeof( cursor_e ), cursor ); ++ put( "{\"dpr\":%.6f,\"innerW\":%d,\"innerH\":%d,\"bufW\":%d,\"bufH\":%d,\"rectL\":%.3f,\"rectT\":%.3f,\"rectW\":%.3f,\"rectH\":%.3f,\"title\":\"%s\",\"cursor\":\"%s\",\"frames\":[", dpr, innerW, innerH, bufW, bufH, rectL, rectT, rectW, rectH, title_e, cursor_e ); ++ const int fn = std::min( s_dbgFrameN, 64 ); ++ for ( int i = 0; i < fn; i++ ) ++ { ++ auto& f = s_dbgFrames[ ( s_dbgFrameHead - fn + i + 512 ) % 512 ]; ++ put( "%s{\"t\":%.3f,\"tick\":%d,\"f\":%d,\"mx\":%.1f,\"my\":%.1f,\"md\":[%d,%d,%d],\"q\":%d,\"wa\":%d,\"hi\":%u,\"ai\":%u}", i ? "," : "", f.t, f.tick, f.imguiFrame, f.mx, f.my, f.md0, f.md1, f.md2, f.queue, f.wasActive, f.hi, f.ai ); ++ } ++ put( "],\"events\":["); ++ const int en = std::min( s_dbgEventN, 64 ); ++ for ( int i = 0; i < en; i++ ) ++ { ++ auto& e = s_dbgEvents[ ( s_dbgEventHead - en + i + 512 ) % 512 ]; ++ put( "%s{\"type\":%d,\"btn\":%d,\"tx\":%d,\"ty\":%d,\"ax\":%.3f,\"ay\":%.3f,\"t\":%.3f,\"wx\":%d,\"wy\":%d}", i ? "," : "", e.type, e.button, e.targetX, e.targetY, e.appX, e.appY, e.t, e.wheelX, e.wheelY ); ++ } ++ put( "]}"); ++ buf[off] = 0; ++ return buf; ++} ++ + static std::function s_redraw; + static std::function s_scaleChanged; + static std::function s_isBusy; +@@ -223,32 +364,38 @@ + emscripten_set_mousedown_callback( "#canvas", nullptr, EM_TRUE, []( int, const EmscriptenMouseEvent* e, void* ) -> EM_BOOL { + ImGui::GetIO().AddMouseButtonEvent( e->button == 0 ? 0 : 3 - e->button, true ); + tracy::s_wasActive = true; ++ DbgPushEvent( 1, e->button, e->targetX, e->targetY, -1, -1, e->timestamp ); + return EM_TRUE; + } ); + emscripten_set_mouseup_callback( "#canvas", nullptr, EM_TRUE, []( int, const EmscriptenMouseEvent* e, void* ) -> EM_BOOL { + ImGui::GetIO().AddMouseButtonEvent( e->button == 0 ? 0 : 3 - e->button, false ); + tracy::s_wasActive = true; ++ DbgPushEvent( 2, e->button, e->targetX, e->targetY, -1, -1, e->timestamp ); + return EM_TRUE; + } ); + emscripten_set_mousemove_callback( "#canvas", nullptr, EM_TRUE, []( int, const EmscriptenMouseEvent* e, void* ) -> EM_BOOL { + const auto scale = EM_ASM_DOUBLE( { return window.devicePixelRatio; } ); + ImGui::GetIO().AddMousePosEvent( e->targetX * scale, e->targetY * scale ); + tracy::s_wasActive = true; ++ DbgPushEvent( 0, -1, e->targetX, e->targetY, e->targetX * scale, e->targetY * scale, e->timestamp ); + return EM_TRUE; + } ); +- emscripten_set_mouseleave_callback( "#canvas", nullptr, EM_TRUE, []( int, const EmscriptenMouseEvent*, void* ) -> EM_BOOL { ++ emscripten_set_mouseleave_callback( "#canvas", nullptr, EM_TRUE, []( int, const EmscriptenMouseEvent* e, void* ) -> EM_BOOL { + ImGui::GetIO().AddFocusEvent( false ); + tracy::s_wasActive = true; ++ DbgPushEvent( 4, -1, e->targetX, e->targetY, -1, -1, e->timestamp ); + return EM_TRUE; + } ); +- emscripten_set_mouseenter_callback( "#canvas", nullptr, EM_TRUE, []( int, const EmscriptenMouseEvent*, void* ) -> EM_BOOL { ++ emscripten_set_mouseenter_callback( "#canvas", nullptr, EM_TRUE, []( int, const EmscriptenMouseEvent* e, void* ) -> EM_BOOL { + ImGui::GetIO().AddFocusEvent( true ); + tracy::s_wasActive = true; ++ DbgPushEvent( 3, -1, e->targetX, e->targetY, -1, -1, e->timestamp ); + return EM_TRUE; + } ); + emscripten_set_wheel_callback( "#canvas", nullptr, EM_TRUE, []( int, const EmscriptenWheelEvent* e, void* ) -> EM_BOOL { + ImGui::GetIO().AddMouseWheelEvent( e->deltaX * -0.05, e->deltaY * -0.05 ); + tracy::s_wasActive = true; ++ DbgPushEvent( 5, -1, e->mouse.targetX, e->mouse.targetY, -1, -1, e->mouse.timestamp, int( e->deltaX ), int( e->deltaY ) ); + return EM_TRUE; + } ); + emscripten_set_keydown_callback( EMSCRIPTEN_EVENT_TARGET_WINDOW, nullptr, EM_TRUE, [] ( int, const EmscriptenKeyboardEvent* e, void* ) -> EM_BOOL { +@@ -298,6 +445,8 @@ + + void Backend::NewFrame( int& w, int& h ) + { ++ s_dbgTick++; ++ DbgPushFrame(); + const auto scale = GetDpiScale(); + if( scale != s_prevScale ) + { diff --git a/.omp/skills/tracy-web-gui-test/setup.sh b/.omp/skills/tracy-web-gui-test/setup.sh new file mode 100755 index 00000000..3620caf1 --- /dev/null +++ b/.omp/skills/tracy-web-gui-test/setup.sh @@ -0,0 +1,102 @@ +#!/bin/sh +# setup.sh - build the instrumented Tracy web GUI workspace (see SKILL.md). +# +# Usage: +# sh setup.sh +# TRACY_WEB_WS=/custom/ws TRACY_WEB_REPO= sh setup.sh +# +# Idempotent: reuses an existing source copy and build dir, skips +# already-applied harness patch hunks, rebuilds. +set -e + +WS="${TRACY_WEB_WS:-/tmp/tracy-protocol}" +# The skill is project-scoped: /.omp/skills/tracy-web-gui-test/ +SKILL_DIR=$(cd "$(dirname "$0")" && pwd) +if [ -n "$TRACY_WEB_REPO" ]; then + REPO="$TRACY_WEB_REPO" +else + REPO=$(cd "$SKILL_DIR/../../.." 2>/dev/null && pwd) || REPO="" +fi +EMSDK="${EMSDK_DIR:-$HOME/emsdk}" + +[ -x "$EMSDK/emsdk" ] || { echo "error: emsdk not found at $EMSDK (set EMSDK_DIR)" >&2; exit 1; } +[ -f "$REPO/profiler/CMakeLists.txt" ] || { echo "error: not a Tracy repo: '$REPO' (set TRACY_WEB_REPO)" >&2; exit 1; } + +. "$EMSDK/emsdk_env.sh" >/dev/null 2>&1 || true +command -v emcc >/dev/null || { echo "error: emcc not on PATH after emsdk_env.sh" >&2; exit 1; } + +if [ ! -f "$WS/src/profiler/CMakeLists.txt" ]; then + echo "[setup] fresh source copy -> $WS/src (from $REPO)" + mkdir -p "$WS/src" + # -C applies to both archive creation and extraction; exclude all build dirs + tar -C "$REPO" \ + --exclude=.git \ + --exclude='*/build*' \ + -cf - . | tar -xf - -C "$WS/src" +else + echo "[setup] reusing source copy in $WS/src (copy changed repo files into it first)" +fi + +# The harness is fully in place iff each patch hunk's signature line is present. +# patch --forward skips a whole file once its first hunk is detected as already +# applied, so a failed later hunk is invisible in patch's output and exit code — +# verify the content instead of trusting either. +harness_complete() +{ + grep -q '_debug_snapshot' "$WS/src/profiler/CMakeLists.txt" \ + && grep -qF 'EMSCRIPTEN_KEEPALIVE const char* debug_snapshot' "$WS/src/profiler/src/BackendEmscripten.cpp" \ + && grep -qF 'DbgPushEvent( 1,' "$WS/src/profiler/src/BackendEmscripten.cpp" \ + && grep -qF 's_dbgTick++;' "$WS/src/profiler/src/BackendEmscripten.cpp" +} + +echo "[setup] applying instrumentation harness" +if harness_complete; then + echo "[setup] harness already applied" + rm -f "$WS/src/profiler/CMakeLists.txt.rej" "$WS/src/profiler/src/BackendEmscripten.cpp.rej" +else + rm -f "$WS/src/profiler/CMakeLists.txt.rej" "$WS/src/profiler/src/BackendEmscripten.cpp.rej" + patch -p1 -d "$WS/src" --forward --no-backup-if-mismatch < "$SKILL_DIR/harness.patch" \ + || { + if harness_complete; then + # mixed run: some hunks already present (skipped), the rest applied + rm -f "$WS/src/profiler/CMakeLists.txt.rej" "$WS/src/profiler/src/BackendEmscripten.cpp.rej" + else + rej=$(ls "$WS/src/profiler/CMakeLists.txt.rej" "$WS/src/profiler/src/BackendEmscripten.cpp.rej" 2>/dev/null || true) + if [ -n "$rej" ]; then + kept="The unapplied hunks are kept in: +$(printf '%s\n' $rej | sed 's/^/ /')" + else + kept="No .rej files were produced (fatal patch error); the patch output above is the only evidence." + fi + cat >&2 </setup.sh +EOF + exit 1 + fi + } +fi + +if [ ! -f "$WS/build/CMakeCache.txt" ]; then + echo "[setup] configuring" + cmake -S "$WS/src/profiler" -B "$WS/build" \ + -DCMAKE_TOOLCHAIN_FILE="$EMSDK/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCPM_SOURCE_CACHE="$HOME/.cache/cpm" +fi + +echo "[setup] building (first full build ~3-5 min, incremental ~30 s)" +cmake --build "$WS/build" -j "$(nproc)" + +cat <