Refactor: Controls as Real Windows, JS as Dumb Renderer

Status: STEPs 1-6 done. STEP 8 partially done (find dialog only — first dialog with zero JS-side state mirroring). STEP 7 (route guest CreateWindowExA EDIT/BUTTON/STATIC through WAT) next — high risk, see Risk register. About / calc / NSIS dialogs still on the JS controls[] path; their migration is the rest of STEP 6. Owner: TBD. Test gates:

Goal

Move all control state and behavior into WAT wndprocs so that:

  1. Every dialog control (Edit, Button, Static, etc.) is a real entry in WND_TABLE with its own hwnd, parentHwnd, wndproc, style, extra_ptr, text_ptr. Same model as a guest-created CreateWindowExA child.
  2. Input routing (mouse, keyboard, focus) is done by hwnd, not by special-case JS pointers like _focusedDialogEdit.
  3. Control logic — typing into an edit, pressing a button, toggling a checkbox, dialog tab traversal — lives in $wndproc_edit / $wndproc_button / $wndproc_static / $wndproc_dialog in WAT.
  4. JS becomes a "dumb renderer": it owns the canvas, fonts, color palette, and primitive draw functions. It does NOT track focus, hit-test controls, mutate edit text, or contain any dialog-specific code.

This is not justified as a fix for the "Find dialog won't open" bug — that's a separate notepad x86 issue (see project_notepad_find_dialog.md memory). The refactor's value is architectural: one window model, real Win32 semantics for GetDlgItem / EnumChildWindows / GetFocus, and far less JS code.

Current state (snapshot, 2026-04-08, after STEP 8 find-dialog landing)

Already in the tree:

Key design decisions (revisited)

Question Answer
Where do per-window records live? WND_RECORDS at 0x7000, 256 × 24 bytes. Each record carries state_ptr directly — no parallel index tables.
Where do "extra bytes" (per-class state) live? $heap_alloc from the existing guest heap. The wndproc allocates a WndState-shaped struct in WM_CREATE and stores its pointer in WND_RECORDS.state_ptr via $wnd_set_state_ptr. Same allocator that serves guest LocalAlloc / HeapAlloc / GlobalAlloc. No new heap region, no CONTROL_HEAP constant.
Where do control text buffers live? Inside the per-window state struct: state->text_ptr is itself a $heap_alloc'd guest buffer. SetWindowText frees old, allocs new, copies, updates state->text_ptr. Matches real Win32 USER32 semantics exactly.
What if the guest stomps a control's heap block? That happens in real Windows too (USER32 lives in process address space). Not a concern. WND_RECORDS itself at 0x00007000 is below GUEST_BASE (0x00012000) so the guest cannot reach it via image-relative pointers anyway.
New host imports? No high-level draw_button-style imports. JS exposes only GDI primitives (gdi_rectangle, gdi_fill_rect, gdi_draw_edge, gdi_draw_text, gdi_move_to / gdi_line_to, gdi_create_pen / gdi_create_solid_brush / gdi_select_object, gdi_bitblt, measure_text, get_text_metrics). WAT wndprocs compose buttons / edits / checkboxes from these in WM_PAINT. Same model as real USER32, which has no "draw button" syscall. Only add new gdi_* imports if a primitive is missing (e.g. gdi_clip_rect for scrolled edits — defer until needed).
Focus tracking? Single global in WAT: $focused_hwnd. WM_SETFOCUS / WM_KILLFOCUS go through normal dispatch. Delete JS-side _focusedDialogEdit.
JS still drives input? Yes: JS owns <canvas> events. onMouseDown(x,y,btn) → wasm.exports.host_mouse_down(x,y,btn). onChar(code) → wasm.exports.host_char(code). WAT does hit-test, focus assignment, message dispatch. JS is just a transport.
What about WAT-native help window? Already follows this model (sort of). Eventually fold $help_wndproc into the new framework as just another class. Out of scope for this refactor — it works today, leave it alone.

Memory layout (current, after ab21e36 table relocation)

 0x00004000  API_HASH_TABLE         12KB   (957 entries × 8 bytes today, headroom to ~1500)
 0x00007000  WND_RECORDS    256 × 24    ends 0x8800
 0x00008800  CONTROL_TABLE  256 × 16    ends 0x9800   (slated for deletion into state_ptr)
 0x00009800  CONTROL_GEOM   256 × 8     ends 0xA000   (parent-relative i16 quad per slot)
 0x0000A000  CLASS_RECORDS   64 × 48    ends 0xAC00
 0x0000AC00  TIMER_TABLE     16 × 20    ends 0xAD40
 0x0000AD40  PAINT_SCRATCH        16    ends 0xAD50
 0x0000AD50  (free → 0x12000 GUEST_BASE)
 0x00002000  (now free — old window/class table region)

The four window/class/control tables were moved out of the cramped 0x2000..0x4000 region in ab21e36 to give MAX_WINDOWS room to grow to 256 and to fix a latent overlap with TIMER_TABLE that would have stomped slots 41–63 had they been used. The old region is now free for future scratch use.

Per-window record fields:

 +0   hwnd
 +4   wndproc
 +8   parent
 +12  userdata    (GWL_USERDATA)
 +16  style
 +20  state_ptr   (heap ptr to WndState; 0 if none)

Per-class record fields:

 +0   name_hash   (0 = empty slot)
 +4   atom        (assigned at registration)
 +8   WNDCLASSA[40]  (lpfnWndProc lives at record+12)

The pre-flight assumption that 0x2700–0x2980 was free was wrong — that region was being used by RegisterClassA to back GetClassInfoA, with no named global declared for it. Commit A reorganized that into CLASS_RECORDS, and ab21e36 later moved the whole table cluster out to 0x7000+ to make room for 256/64 caps.

Per-class state struct layouts (allocated via $heap_alloc)

Each wndproc allocates one of these in WM_CREATE and stores the pointer in WND_RECORDS.state_ptr via $wnd_set_state_ptr(hwnd, ptr). The pointer is read back via $wnd_get_state_ptr(hwnd).

 EditState  (32 bytes, allocated in WM_CREATE)
   +0   text_buf_ptr   guest ptr from $heap_alloc
   +4   text_len
   +8   text_cap
   +12  cursor
   +16  sel_start
   +20  scroll_top
   +24  flags          bit0=multiline bit1=password bit2=readonly bit3=focused
   +28  max_length     0 = unlimited

 ButtonState  (16 bytes)
   +0   text_buf_ptr
   +4   text_len
   +8   flags          bit0=pressed bit1=checked bit2=default bit3=focused
   +12  ctrl_id

 StaticState  (16 bytes)
   +0   text_buf_ptr
   +4   text_len
   +8   style          (SS_LEFT, SS_CENTER, SS_RIGHT, SS_ICON, SS_BITMAP)
   +12  reserved

 DialogState  (16 bytes — for the dialog window itself)
   +0   child_count
   +4   focused_child_idx
   +8   default_btn_hwnd
   +12  flags

Lifetime rule: the wndproc that allocates the state struct in WM_CREATE is responsible for freeing it AND any sub-allocations (text buffers) in WM_DESTROY, then calling $wnd_set_state_ptr(hwnd, 0).

Host imports — what's there, what's needed

Rule: controls are wndproc compositions, drawn via primitives. JS exposes only GDI primitives; WAT wndprocs compose buttons / edits / checkboxes by issuing primitive calls. There are no high-level draw_button / draw_edit / draw_checkbox imports — those would put look-and-feel in the renderer instead of in the wndprocs and break the "JS as dumb renderer" goal. (See feedback memory feedback_compositions_in_wat.md.)

Already in the tree (WAT → JS, the GDI primitives WAT will call)

gdi_rectangle      (hdc, l, t, r, b)              filled rect with current pen+brush
gdi_fill_rect      (hdc, l, t, r, b, hbrush)      fill with given brush
draw_rect          (x, y, w, h, color)            simple raw-color fill (legacy, ok to use)
gdi_draw_edge      (hdc, l, t, r, b, edge, flags) ◄ THE button bevel: BF_RECT | BDR_RAISED*
gdi_draw_text      (hdc, text_ptr, n_count, rect, format, isWide)  with DT_CENTER, DT_VCENTER, etc.
draw_text          (x, y, text_ptr, len, color)   simple positioned text (no DC)
gdi_move_to        (hdc, x, y)                    for checkmark glyphs, focus rect, etc.
gdi_line_to        (hdc, x, y)
gdi_ellipse        (hdc, l, t, r, b)              for the radio button dot
gdi_arc            (hdc, l, t, r, b, xs, ys, xe, ye)
gdi_create_pen     (style, width, color) → handle
gdi_create_solid_brush (color) → handle
gdi_select_object  (hdc, handle) → previous
gdi_delete_object  (handle)
gdi_bitblt         (dst, dx, dy, w, h, src, sx, sy, rop)  for icon buttons
measure_text       (hdc, text_ptr, n_count) → pixel_width
get_text_metrics   (hdc) → (height | (avg_char_width << 16))

These are sufficient to draw a Win98 button:

  1. gdi_fill_rect background with the face color brush.
  2. gdi_draw_edge with BF_RECT | BDR_RAISEDOUTER | BDR_RAISEDINNER (or BDR_SUNKENOUTER | BDR_SUNKENINNER when pressed).
  3. gdi_draw_text with DT_CENTER | DT_VCENTER | DT_SINGLELINE.
  4. If focused: a 1px-inset dotted focus rectangle via gdi_move_to / gdi_line_to with a stock DC_PEN-style alternating pen, or just four short segments.

Same template for checkbox (small box + edge + checkmark glyph + text), radio (ellipse + dot + text), groupbox (edge with text gap), edit (sunken-edge frame + text + caret line + selection rect).

Will likely need to add (small WAT → JS additions, judgment call)

Already in the tree (JS → WAT, the input pump)

JS already calls host_check_input / host_check_input_lparam / host_check_input_hwnd which WAT polls from the message loop. That covers mouse + keyboard + focus events through a single channel. No new mouse/keyboard imports needed — the existing event-poll pattern fits the new wndproc model fine.

Step-by-step migration plan

Batch A — Foundation (no behavior change)

STEP 0: Test gate. (DONE 2026-04-08)

STEP 1: Unify window memory layout. (DONE 2026-04-08)

Done as two commits, both verified against notepad/calc/mspaint and the test gate:

CONTROL_TABLE at 0x2980 is unchanged; its fields (ctrl_class, ctrl_id, check_state) will move into the per-class state struct in a later step and the table will be deleted.

STEP 2: Verify the existing GDI primitives are sufficient.

No new high-level draw imports — see "Host imports" section above. The GDI primitives WAT will need (gdi_rectangle, gdi_fill_rect, gdi_draw_edge, gdi_draw_text, gdi_move_to / gdi_line_to, gdi_create_pen / gdi_create_solid_brush / gdi_select_object, gdi_ellipse, gdi_bitblt, measure_text, get_text_metrics) are all already in src/01-header.wat and lib/host-imports.js.

Action: write a tiny prototype WAT function (in 09c3-controls.wat, near the existing $button_wndproc skeleton) that draws a fake button at fixed coordinates as a sanity check that the primitives compose the right Win98 look. Call it from a debug entry point only — do not wire it to any wndproc yet. Once the look matches, delete the prototype and move on to STEP 3.

If the prototype reveals a missing primitive (most likely candidate: clipping), add it as a new gdi_* import — never as a draw_button composition import.

Build gate: bash tools/build.sh && node test/test-find-typing.js. Must still report 6/6 (no regression).

Batch B — First real wndproc

STEP 3: Flesh out $button_wndproc + add $static_wndproc. (DONE 2026-04-08)

Done in a single commit. Test gate stayed at 6/6 (test-find-typing.js), plus notepad/calc/mspaint smoke clean. Code is dormant — no path delivers WM_CREATE to a button today, so the new state-based branches sit unused until STEP 5 wires WAT-side dialog creation.

Implementation notes:

Original step description (kept for reference):

Batch C — Edit + first migrated dialog

STEP 4: $wndproc_edit.

STEP 5: $create_findreplace_dialog in WAT. (DONE 2026-04-08, dormant addition in commit d95052b, then activated in STEP 6)

Landed as additions to src/09c3-controls.wat rather than a new file:

Did NOT delete lib/renderer.js: showFindDialog() or the host.show_find_dialog import — both still in use as the visual dialog and as the source of the [FindTextA] log line the test gate looks for. Visual deletion deferred to STEP 8 (requires renderer-side support for WAT-managed child windows).

STEP 6: Flip the test gate to read WAT-side EditState. (DONE 2026-04-08, commit c3f8ecf)

$handle_FindTextA now calls $create_findreplace_dialog after $host_show_find_dialog, so each find-dialog open creates parallel WAT state alongside the JS dialog. New WASM exports drive the test bridge:

test/run.js focus-find / keypress / dump-find event handlers prefer the WAT path when those exports are available and fall back to the legacy JS scan otherwise. The test gate stays at 6/6 but the "editText=ABC" assertion now reads from $edit_wndproc's EditState, not from editCtrl.editText in JS.

Critical bug fixed during STEP 6: STEPs 4-5 dormant code repeatedly used i32.and as a logical AND on pointer/length pairs (e.g. (if (i32.and src len) ...)). For src=0x40e5c4, len=1 the bitwise AND is 0 because bit 0 of 0x40e5c4 is 0, so the guarded memcpy was silently skipped. Found via three rounds of debug instrumentation. Fixed across $edit_ensure_cap, $edit_insert_char, $edit_wndproc (WM_GETTEXT, WM_PAINT text + caret), $button_wndproc (WM_GETTEXT), and the new get_edit_text export by nesting two single-arg ifs instead.

Batch D — Sweep the rest

STEP 6 (continuation): Migrate other JS-fabricated dialogs.

Find dialog already done — see STEP 8 below for what landed. Remaining:

STEP 7: Route guest CreateWindowExA for EDIT/BUTTON/STATIC to WAT wndprocs.

STEP 8: Delete dead JS code. (Find dialog half DONE 2026-04-08, commit 01d70cc)

The find dialog is now the first dialog with zero JS-side state mirroring. What landed for it:

Net deletion at STEP 8 landing: ~167 lines of JS dead code. The remaining JS dead code below stays alive only because About / calc / NSIS still use the JS controls[] path:

Keep: drawButton, drawEditArea, drawCheckbox, drawRadioButton, drawGroupBox, drawStaticText, drawTitleBar, drawMenuBar, drawWindowFrame, color/font tables. These are the primitives the WAT-managed dialog path already calls into.

Risk register

What this refactor explicitly does NOT do

Pre-flight checklist (resolved)

Open questions

Meta

This document is the source of truth for the refactor. If reality diverges (file paths, function names, table layouts), update this doc first, then the code. Don't let the doc rot — when finishing a step, update its checkbox here. When discovering a wrong assumption, fix it here before fixing it in code.