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:
- Red: the drag above.
- 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
- The computer never moves. After "Computer's turn" the game idles in PeekMessage/WaitMessage. It creates
no timer (SetTimer is never called), and nothing pending explains the wait. Narrowed down so far:
- The EXE has
Timer1_Timer,Timer2_TimerandTimer3_Timerhandlers, plusxRndCol,NextRColChkandNextDGColChk, so the computer's move almost certainly runs from a VB Timer control. - None of the three is ever enabled. There is no USER.10 SetTimer in the 2,000 startup batches or after a move, and no GetTickCount/GetCurrentTime either: the splash does not use a timer.
- VB timers themselves work here. Rattler's VBRUN100 calls SetTimer from its timer code (eip 0x5801f7)
and its snakes step on WM_TIMER. So TicTacDrop's own code stops before it would set
TimerN.Enabled. The EXE hasAnimationErr/ApiErrorlabels, so a VB runtime error swallowed byOn Erroris a candidate. - After the drop, Picture1_MouseUp (this game drags by hand: Picture1_MouseDown/MouseMove/MouseUp with
BltMouseObject, not VB drag-and-drop) only updates the two status labels and returns. - VBRUN100's Timer control code is segment 70 of VBRUN100.DLL, at arena 0x580000 in both Rattler and
TicTacDrop. It works as follows:
- The property setter is at 70:0xf6. Property 2 is Enabled: nonzero goes to 70:0x108 and calls the start routine 70:0x1a0; zero goes to 70:0x116 and calls the stop routine 70:0x212.
- Property 3 is Interval. It stores the dword at ctrl+0x44 and jumps to 70:0x108.
- The start routine calls SetTimer only when four conditions hold: the interval at ctrl+0x44 is nonzero, DS:0x396c == 2 (run mode), the Enabled bit (ctrl+0x42 bit 0) is set, and bits 1-2 are clear.
--countin the computer-first scenario (Options > Who's First? > Player 2), 2,600 batches:- The setter ran 9 times: 6 Enabled sets, all False (70:0x116 = 6), and 3 Interval sets.
- The start routine ran 3 times (all from the Interval sets) and stopped at the Enabled-bit test each time (70:0x1db = 0).
- So the VB timer machinery is fine, and the game never executes
TimerN.Enabled = True, with the computer first or after a player move. Turning Sound off changes nothing. - Demo menu handler, decoded with
tools/vb-pcode.js(2026-10-10):- It is p-code segment 0x4ef (linear 0xac0000), offsets 0x0f0..0x1f2, ending at
3fd6. - Every dispatch after the click stays in that segment.
3802 0001 0026,3802 0001 0008and38e5 0001 04b2are therefore runtime/library calls, not calls into other p-code. - The status label's Caption is set by
37dd 00b2 / 37d2 054e / 2c92 "To stop the Demo..." / 15cc / ... / 38de 02f8. - The handler never touches a timer and simply returns. Nothing enables one afterwards (setter counts above).
- It is p-code segment 0x4ef (linear 0xac0000), offsets 0x0f0..0x1f2, ending at
- So what should drive the computer's move is not in this handler. Candidates:
- another event procedure (the timers' design-time state looks deliberate);
- a branch on the INI-derived globals (
NUMOFPLAYERS,FIRSTPLAYER,g_fDemo) in a procedure not reached here; - a Paint event of a hidden picture (
Picture2Paint/Picture3Paint).
- Next step: name the core opcodes (
37dd/37d2control refs,38deproperty access,2502/23abvariable store/load), then decodePicture1_MouseUpandForm_Load. - Capture recipe:
--trace-eip-range=0x140000-0x14ffff --trace-eip-detailarmed from batch 0 (a late--trace-eip-fromlogged nothing here, cause not found). Dispatches are the rows where EAX equals EIP's low word. Bytes come from--trace-at=<handler> --trace-at-mem=es:esi-N:LEN.
- The EXE has
- A release at x=210 lands in column 2, not column 1. It is not yet known whether that is the game's own arithmetic.
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):
- 0x0ac sets
L838 = False. ThenIf 0 < X < f() And 0 < col <= G10c Then If row < 1 Then L838 = True(0x164..0x178). That is the above-the-board release, and it is the only one accepted. - Over-the-board release at 400,298: col = 1, row = 1, so L838 stays False. The next test, 0x182..0x196,
is
If Not <array 009e/00ee>(row, col) = 0 Then GoTo 0x25a. It jumps, which skips the block at 0x19a..0x258 (bounce.wav, and L838 = False or True). At 0x25eIf L838then fails and the ball goes back. Open: is that array element (fInUse?) really set for an empty top cell, or is the 2-D index in075a(2:0x75a, calls 2:0xa51) wrong? - The final guard at 0x6b2..0x6da is
If Not G420 And ((G398 = 1 And Not L838) Or G398 = 2) Then Call 0c1e : ctl244.prop416 = 1. With an accepted drop, L838 = True, so it is skipped. - Forcing that guard True (writing -1 over the condition at the
298abreakpoint, 52f:0x6dc) did not bring a computer move in 600 batches. So that block alone is not the computer's move.
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=10 (status query, 1,230 times in a short run)
- BX=6 with AX=0x0400 (
Int(), round down) and AX=0x0C00 (Fix(), truncate) - BX=0, 1, 3, 4 (control word 0x1332) and 11
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).