Skip to main content
Unofficial Guild Wars Reforged App — a fan project, not affiliated with ArenaNet or NCSoft

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:

wasm
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_CLICK

The 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:

FlagValue
FLAG_DBL_CLICK0x1
FLAG_DUE_TO_CLICK0x2
FLAG_NO_INTERACT0x4

#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:

CallerPointer modeSource of the double-click bit
#62930, mousemsg[4] & 1always 0 on the web client
#63041, gamepadFrGamepad.cpp #6372, a 400 ms window
#63092, touchthe FrTouch double-tap detector

The gamepad path and the touch path work. The mouse path is missing one byte.

The press timestamps in #6293 are not a double-click detector
#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

js
// Gw.jspi.js, fillMouseEventData
// Add one field to the marshalled struct.
HEAP32[(ptr + 48) >> 2] = e.detail;
c
// 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 0 for 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:

SourceDelivered byFLAG_DBL_CLICK
The first press of the player#6293 to #62690
The second press of the player#6293 to #62690
Synthetic tap 1#6309 to #62690
Synthetic tap 2#6309 to #62691

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:

wasm
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.

GWonMac

Play Guild Wars on your Mac. GWonMac runs ArenaNet's official client natively on Apple Silicon — free, open source, signed and notarized.

© GWonMac. All rights reserved.