UR-07: double-click never fires because MouseEvent.detail is not marshalled
The client carries a double-click flag from the input record to FrMouse, but the Emscripten glue never writes it because it does not marshal MouseEvent.detail. One byte fixes double-click on the web client.
The client can't double-click on the web platform, even though nearly all of
the machinery is in place: a per-press double-click flag travels from the
Emscripten input record all the way to FrMouse, and every stage past that
byte works. The glue just never writes it — fillMouseEventData doesn't
marshal MouseEvent.detail, and the mousedown callback zeroes the record
without ever touching that offset.
Fix: low effort, small blast radius — one marshalled field and one assignment. The flag slot is zero on every path today, so nothing can depend on the current value, and the gamepad and touch paths have their own flag sources.
Build used for the measurements below: 38735 (version.json 1.1.7), size
8,196,702 bytes.
The channel, end to end
Chromium MouseEvent
detail <- dropped here, never marshalled
| fillMouseEventData (Gw.jspi.js) writes 0,8,12,16,20,24-27,28,30,32,36,40,44
| of a 64-byte struct; bytes 48-63 are never written by any path
#2448 table slot 903, the mousedown callback
memset(rec, 0, 24)
rec[0] = 18 kind: mouse button down
rec[4] = targetX * devicePixelRatio
rec[8] = targetY * devicePixelRatio
rec[12] = event.button
rec[16] = -- never written -- <- the flag slot
#791 enqueue Base/Os/Emscripten/EmscriptenInput.cpp
|
#794 dequeue, #828 per-frame pump, event loop #883 (Engine/Event/EvtApi.cpp)
|
#829 translate record to engine message, 36-way br_table on rec[0]
kind 18 -> engine message 30, payload 24 bytes:
msg[0] = rec[12] button
msg[4] = rec[16] <- the flag, structurally zero
msg[8..15] = cursor x, y as f32
msg[16] = button state bitfield
msg[20] = modifier state
|
#6293 table slot 1736, bound to engine message 30 by #6659 (Engine/Frame/FrApi.cpp)
sets pointer mode 0 (mouse), then
#6269(msg + 8, msg[0], msg[4] & 1)
^^^^^^^^^^ the double-click argument#6269 is Engine/Frame/FrMouse.cpp. It asserts underMouse at line 736. It
is the one click-delivery routine. Its signature is
(Vec2* pos, unsigned button, bool doubleClick). At offset +182 to +201
the third argument sets the flag:
local.get 2 ;; doubleClick
i32.eqz
br_if ;; keep the word unchanged when the value is false
i32.load offset=12
i32.const 1
i32.or
i32.store offset=12 ;; selectFlags |= FLAG_DBL_CLICKThe flag values come from the two asserts in #15917, which the compiler
folded into (flags & 3) == 1, and from the br_table at #6269+93:
| Flag | Value |
|---|---|
FLAG_DBL_CLICK | 0x1 |
FLAG_DUE_TO_CLICK | 0x2 |
FLAG_NO_INTERACT | 0x4 |
#6270 (FrMouse.cpp:192) then moves the bit into its own field —
msg[40] = evt[4] & 1 — before it posts to the widget under the cursor.
#6269 has exactly three callers, each supplying the argument from its own
source:
| Caller | Pointer mode | Source of the double-click bit |
|---|---|---|
#6293 | 0, mouse | msg[4] & 1 — always 0 on the web client |
#6304 | 1, gamepad | FrGamepad.cpp #6372, a 400 ms window |
#6309 | 2, touch | the FrTouch double-tap detector |
The gamepad path and the touch path work. The mouse path is missing one byte.
#6293 does keep press timestamps — offsets +44 and +51 save the previous
press time, +213 stamps the current one, and #6290 returns the difference.
But the only consumers are three FrGamepad functions comparing against
i32.const 2001, which is an idle check ("did the player use the mouse
recently"). Nothing in FrMouse.cpp does time arithmetic; every i32.sub in
that file is stack-frame setup or list-pointer math. We chased this lead so
you don't have to.Expected behaviour
fillMouseEventData in Gw.jspi.js marshals MouseEvent.detail, and the
mousedown callback sets the double-click bit at record offset 16 when the
click count is even — the same condition Windows uses to raise
WM_LBUTTONDBLCLK.
Nothing after that byte needs to change: FrMouse.cpp, #829, and #6293
already do the right thing.
Proposed change
// Gw.jspi.js, fillMouseEventData
// Add one field to the marshalled struct.
HEAP32[(ptr + 48) >> 2] = e.detail;// Base/Os/Emscripten/EmscriptenInput.cpp, the mousedown callback (#2448)
// After the existing writes:
rec[16] = (mouseEvent->detail % 2 == 0) ? 1 : 0;The field offsets above are ours, from the module. Use your own names.
Acceptance criteria
- A deliberate double-click on an inventory item uses the item.
- A deliberate double-click on a party member opens the correct panel.
- The client delivers exactly two clicks for one double-click. The second
click carries
FLAG_DBL_CLICK. - Two slow single clicks on the same position deliver two clicks, and neither carries the flag.
- The behaviour follows the double-click preference of the operating system, as it does on Windows.
- A single click delivers one click with no flag.
- The pointer mode stays
0for all mouse input.
Why we removed our own tap-based workaround
Before build 2026.8, our host synthesised a pair of touch taps to fake a double-click. We measured what that costs and deleted it.
A tap is not a hint. #6614 calls #6309 once per tap, and #6309 ends in
#6269 — the one click-delivery routine. So one synthesised double-click
delivers four clicks where the Windows client delivers two:
| Source | Delivered by | FLAG_DBL_CLICK |
|---|---|---|
| The first press of the player | #6293 to #6269 | 0 |
| The second press of the player | #6293 to #6269 | 0 |
| Synthetic tap 1 | #6309 to #6269 | 0 |
| Synthetic tap 2 | #6309 to #6269 | 1 |
On top of the extra clicks, #6309 warps the cursor to the tap position,
force-releases captured buttons, switches the pointer mode to touch until the
next real press, and enters the drag machinery through #6273 — the cause of
"a double-click in my inventory moves the item to a random slot". Any widget
that acts on a plain click gets two extra activations per deliberate
double-click, and no holdback timer can change that. Only the client-side flag
removes the extra clicks.
Supporting negatives
The seventeen emscripten_set_*_callback imports include no
emscripten_set_dblclick_callback, and the strings dblclick, clickCount,
and DblClick occur nowhere in the binary.
How to reproduce
No host change is needed.
Read the glue
Instrument fillMouseEventData in Gw.jspi.js. The file reads e.detail
nowhere.
Decode the callback
Decode #2448. #267 zeroes the 24-byte record. The callback writes four
fields. Offset 16 is not one of them.
Decode the consumer
Decode #6293. It reads msg[4] & 1 and passes the value to #6269.
Our workaround today
The host appends one exported mutable i32 global to the module, then splices
three instructions into the mousedown callback:
local.get 3 ;; the frame pointer that holds the record
global.get $flag ;; what the host wrote before this press
i32.store offset=24 ;; record+16, the word that #829 copies into msg[4]The host writes the Chromium click count into the global on every trusted press, so the client receives what Windows receives, under the player's double-click preference. The transform adds no function and moves no index or table entry — but it still has to be certified for every new build. One marshalled field in the glue retires it.