Tic Tac Drop (Win16, Entertainment Pack 4)

Registry id wep16_tictacdp: test/binaries/wep16/WEP4/TICTACDP.EXE, a Visual Basic 3 program, with TicTacDp.brd, CMDIALOG.VBX and THREED.VBX. The board is drawn into one picture box, 0x10017 (narrow 0x134): the play area at client (0,32), 580x379. The bins and balls are windowless image controls on that picture box. The 22 hidden children (0x10018..0x1002d) are off-board sprite strips, not drop targets.

SOUND through Declare (fixed 2026-10-10, 218fb34a2)

The game Declares the Windows 3.0 tone generator: OpenSound, SetVoiceSound, StartSound, WaitSoundState and CloseSound from sound.drv. Declare is GetProcAddress by name, and the SOUND module had no name table, so every drop raised "Sub or Function not defined". The names now resolve (all 17 exports, held as constants in $win16_sound_ordinal). The game then loops on WaitSoundState(1) until it answers 0. Nothing is ever queued (OpenSound still answers "device not available"), so every wait state is already reached.

Route (headless, default batch size)

The splash clears and the 8x8 board is up by batch 2000. Drag the top red ball out of the left bin and release it above the board, over a column top:

--input=1990:mousemove:169:152,2000:mousedown:169:152,2010:mousemove:180:140,2020:mousemove:195:130,\
2030:mousemove:210:112,2040:mouseup:210:112

The ball drops to the bottom of column 2 (for x=210), and the status bar says "Computer's turn". A release over the board itself (screen y 137..250) is rejected: the ball animates back into the bin, the same result for any x.

Two-player mode is playable end to end. Open Options (104,31), then Players (120,51), then Two (290,74) with press-gap-release clicks. Then:

  1. Red: the drag above.
  2. Blue: drag from the right bin (473,152) and release at (400,112). It lands in column 8.

The turn passes back ("Player 1's turn", "Two Player Game"). Only one-player mode needs the computer (see Open).

Open

Picture1_MouseUp, traced dynamically (2026-10-10, w5)

All of this is from --screen=1024x768 at origin/main 841a0036e. Board at batch 1500; drag 361,296 -> 400,262 (above column 1, accepted) or -> 400,298 (over the top cell, rejected).

Tracing a late window works. Launch with --control=PORT --frozen --trace-eip-range=0x140000-0x14ffff --trace-eip-detail --trace-eip-from=999999 so the flag is present but never auto-armed. Then ctl.js eval 'exports.set_trace_eip_range(1,0x140000,0x14ffff)' before the input and (0,0,0) after. Detailed output stops after about 252k [EIP] lines, so arm at most a few hundred batches at a time. Breakpoints work too: eval 'exports.set_bp(0x14298a)', then step 1 stops at the first hit in each batch. At the hit, get_sreg_es(), get_esi() and get_ebp() give the p-code position and frame. The p-code data segment (DS) is 0x47f during dispatch, not 0x17.

Where things are. MouseUp is p-code segment 0x52f (entry 0x30, returns at 0x716). The drop animation is 0x68f. It calls the delay routine 0x60f, a bare For i = .. To 2000 : Next. 365e is VB's per-statement tick (decrement SS:0x86, then yield every 32768 statements), not DoEvents. So the animation spins rather than waits, and it finishes. Win checks run as separate event invocations in 0x53f/0x537 -> 0x65f/0x667. Then the app is idle in VBRUN's message loop. No p-code runs at all after that, and the timer setter (70:0xf6, runtime 0x5800f6) is never entered after the move.

Opcodes needed for MouseUp's conditions, read from VBRUN100 seg 2:

Op Meaning
232c X push global, via [DS:X] into seg SS:[0x2e2a] (0x457)
23ab X push [DS:X] (0xd2 = 0/False, 0xf4 = True)
2498 X push local [BP+[DS:X]]
264d X store a local
2502 X store a global
2c03 / 2c0e / 2c14 push 1 / 2 / 3
2bfb push 0
2d37 =
2d96 <
2d83 >
2d5d <=
3041 / 3008 float < / > (fcompp/fnstsw/sahf at 2:0x2fcd)
2da6 Not
2db1 And
2dbc Or
3753 nop
298a T / 2987 T if top == 0 goto T
2a71 T goto
3d79 n S / 3d76 n S call p-code sub S with n args
3fd6 return

tools/vb-pcode.js decode loses sync on the variable-length ops (3d76, 37d2, 38de). The dynamic dispatch list (ES:SI-2 = op) is the reliable listing.

Globals. G398 (0x457:0x1e4, linear 0x9901e4) is NUMOFPLAYERS. 0x4bf:0x918..0x994 reads it with a Declare'd profile call on the literal "NUMOFPLAYERS" and keeps 1..2, default 1. So the value 1 here is right for a one-player game. G420 = 0.

MouseUp's decisions (locals: L828 = column, L830 = row, L838 = a flag, offsets through the DS:0x47f table):

The over-board test is not the bug. The array is the cell table at 0x747:0006 (24-byte records: two doubles for the cell centre, then a word at +18). Board setup (p-code 0x64f:0x53e) sets +18 = True for every cell on purpose, so it reads "free". The over-board release actually goes to the Else at 0x5de: If X > 0 And Y > 0 And Y < Pic.Height And X < Pic.Width Then Call 0e18(...) Else return the ball (0x63c). Its 298a at 0x620 jumps to the return. Bisecting the release y over column 1 at 1024x768: y <= 266 is accepted and y >= 270 is rejected.

WIN87EM __fpmath is a no-op, and VB1's Int()/Fix() go through it. $win16_win87em answers every ordinal-1 call with AX=0 and changes nothing. A temporary BX/AX log showed what VBRUN100 actually calls:

BX=6 rounds ST(0) by AX AND 0x0C00. A no-op leaves every VB1 Int/Fix unrounded. Implementing it moves the accept boundary down to the grid top (y ~282), which is exactly what a floor predicts. It does not start the computer's turn. It also makes test-win16-jigsawed-menus.js fail: Solve after Scramble+Hint stalls after one piece, with no BX=6 calls during the stall, while an isolated Solve still completes identical to Fast Solve. So it is not landed. The patch, a unit test (passes with it, fails without) and the captures are in scratch/runs/20261010T1915Z-win87em-fpmath-round. Explain the JigSawed stall before landing it.

Computer's turn — fixed: Win16 CreateWindow now sends WM_NCCREATE (2026-10-10, w5)

Cause. In the EXE the game only ever writes Enabled = False to its timers. There are 28 38de 08fc (Enabled) stores. Timer refs 37d2 08f2 / 37d2 0907 (ctrl 0xcba, 250 ms, and 0xd52, 1000 ms) are set only to False, from p-code 0x4d7:0x0bd8/0x0bf4 at new game. The third timer, ctrl 0xd06 (1000 ms), is never assigned at all. It keeps VB's design-time default, Enabled = True, and is what moves the computer.

ThunderTimer's control procedure (VBRUN100 70:0x0) sets that default on message 0x81, WM_NCCREATE (70:0x36: or byte [ctrl+0x42], 1). Win16 CreateWindow showed WM_NCCREATE only to a WH_CALLWNDPROC hook and went straight to WM_CREATE. So all three timers reached the start routine (70:0x1a0) with Enabled = 0, and SetTimer was never called.

Fix. $win16_create_nccreate (09e) delivers WM_NCCREATE to a far guest window procedure, with lParam pointing at a real 16-bit CREATESTRUCT. It runs after the hook and before WM_CREATE, through the new $WIN16_CONT_NCCREATE (0xFF8C). Built-in and WAT-native procedures are not sent it. After a red drop at 640x480 (210,112), the status goes "Computer's turn >" -> "< Player 1's turn" and a blue piece lands (test-win16-wep4-gameplay.js tictacdrop-reply; fails without the fix).

The whole test-win16-*.js suite passes except test-win16-hearts-vlan.js, which fails on unpatched main too (8 FAILs there, 1-2 with the fix: a flaky two-process test).

Evidence: scratch/runs/20261010T1200Z-wep16_tictacdp-sound-byname-w6; scratch/runs/20261010T1803Z-wep16_tictacdp-player-move (w5).