Diablo II Shareware demo
Package and installer
The local package is Blizzard's DiabloIIDemo.exe:
- size: 138,309,685 bytes
- SHA-256:
89352716523e474514553e2092a1ae9349c5c7ff9e79c7861dd65fe19be88b61
test/test-diablo2-demo-installer.js drives setup without fixed-batch dialog
timing. It waits for Diablo II Shareware Setup, clicks the launcher's Install
button, waits for each real InstallShield control, accepts the default path,
and reaches the shortcut/copy phase. A full acceptance run continued through
the 127 MiB copy. The worker finished after about 205 seconds; the outer setup
window remained on Adding shortcuts to the start menu.., but the complete
installed payload was present in the VFS. The retained acceptance screenshot
is /private/tmp/diablo2-installer-complete.png.
Saving that VFS exposed a host-export collision: setup creates both the file
C:\support\images\msproxy and descendants below a directory with that name.
lib/vfs-export.js now preserves the file as msproxy.__vfs_file__ rather
than failing the whole export. test/test-vfs-export.js pins that behavior.
The installer-produced payload is staged locally under
test/binaries/candidates/diablo-2-demo-installer/installed-extracted/. Its
core pins are:
| File | Size | SHA-256 |
|---|---|---|
diablo ii.exe |
2,154,496 | d0aa0d30b55f8313e04026cca560ef0d178ee76b2ece6c3ccdc1fca4af46b3f1 |
d2data.mpq |
44,301,122 | 82ed65b7f574234a22a36abb4a6d6a1e7f8bebc4746192f39cdb6603ee382d49 |
d2music.mpq |
32,743,265 | 631172d59cc4a8d9b42faade73b194140b6a327811ea556562df9c89f857a694 |
storm.dll |
266,280 | 2b6a27f223aac30d2d383f185705be55f43f37a687aa76ebe307a1035b6eea2d |
Registry evidence
The raw captures remain under /private/tmp and are not corpus fixtures.
Relative to a clean profile, setup wrote:
HKCU\Software\Battle.net\Configuration,Server List=exodus.battle.netHKLM\SOFTWARE\Battle.net\Configuration,Server List=exodus.battle.net
A clean installed-game launch reaches the menu without pre-seeding either
key. The game itself creates
HKCU\Software\Blizzard Entertainment\Diablo II Shareware with:
CmdLine=ii.exe -skiptobnetInstallPath=C:\UseCmdLine= DWORD0
It also probes CompressedData and preference values, but none is required to
launch this package.
Compatibility fixes
The installed executable and its DLL graph exposed three concrete gaps:
- CRTDLL
_vsnprintfwas missing. The bounded formatter now returns-1without a terminator on truncation, matching the Win9x CRT contract. - D2Sound imports authentic Win98 DSOUND ordinals 1 and 2. They now resolve
to
DirectSoundCreateandDirectSoundEnumerateA. - The original 64-entry synchronization table filled during startup. Storm's
later transient
CreateEventAreturned NULL, so a one-byte MPQ member (data\local\use) reportedERROR_HANDLE_EOFand the game stopped inArchive.cppline 143. A 256-entry diagnostic build showed startup fill every slot throughe00ff; the final 512-entry/8 KiB table leaves space for the fixed pool and streaming events. The same run then read all MPQs, initialized DirectDraw and sound, and rendered the main menu.
The final installer-produced game screenshot is
/private/tmp/diablo2-installed-sync512.png: an 800x600 DirectDraw frame with
the animated Diablo II logo, Single Player button, Shareware v1.04 label and
Exit Diablo II button. test/test-diablo2-demo-installed.js reproduces this
from a clean profile, skips the intro with normal keyboard input, pins the
EXE/data archives, and validates those menu regions.
One non-fatal diagnostic remains: the loader's first bounded D2CMP DllMain
pass reports an incomplete return and the game's recovery path logs an
SMemReAlloc() message before normal initialization repeats successfully.
It is not present in the rendered game UI and does not prevent the menu or MPQ
streaming, but it is the next cleanup target if DLL initialization is made
fully resumable.
Character creation and gameplay
The installed demo originally crashed immediately after confirming a new
Barbarian. A normal-input trace (Single Player, double-click Barbarian, focus
the name field, type TEST, press Enter) stopped at batch 588 in two adjacent
CRTDLL imports:
strncmp(0x01e4face, 0x009facf4, 10)jumped through0x009c4d7eto the fail-fast unimplemented handler.- After implementing that function,
_strnicmp(0x01e4f898, 0x005cb3a4, 0x7fffffff)did the same through0x009c4d78.
Both are now real cdecl handlers: strncmp compares unsigned bytes and both
functions stop at the first difference, a shared NUL, or the requested count;
_strnicmp additionally folds ASCII A-Z. Zero count returns equality without
dereferencing either pointer. test/test-strncmp.js pins those edge cases and
the cdecl ABI.
With both imports present, the same input sequence clears the animated Act I
loading portal and reaches the playable Rogue Encampment at batch 1600 with a
one-million-block batch budget. The retained frame is
/private/tmp/d2world-1600.png; it contains the Barbarian and NPC, rain,
torches, terrain, belt and skill bar, plus the red life and blue mana orbs. A
ground click later scrolls the world to the wagon without a trap; that frame is
/private/tmp/d2world-2200.png. test/test-diablo2-demo-gameplay.js reproduces
hero creation from --app=diablo2_demo and asserts the rendered terrain and
HUD color regions, not merely process survival.
Performance and SIMD
The playable trace is CPU-emulation bound rather than blocked on an obvious host-side subsystem. Over 400 seconds it made 1,754,049 Win32 API calls (about 4.4K/s), while the interpreter recorded three active cooperative guest threads, 17,089,938 block decodes, 46,860 live-cache evictions and 1,672 full cache clears. The page's DirectDraw path already coalesces dirty presentation to at most one upload per animation frame. No API, audio, timer, networking or surface-upload storm was identified.
The cache-clear pressure came from fixed reservation rather than typical compiled size. The old allocator reserved 16KB for every compiled 4KB guest page; a fixed-allocator sample of 795 Diablo II pages found a 2,411-byte mean, with 78.1% fitting in 4KB, 97.1% in 8KB and 99.5% in 12KB. Compiled pages now use 4/8/12/16KB classes, grow only when emitted code requires it, and recycle safely retired chunks. A directory clock also makes the separate 128-index limit explicit instead of refusing all later pages. On the final replay through batch 1660 this reduced full resets to 13; the fixed-size trace recorded 128 resets during batches 1500–1600 alone and 1,672 by batch 2300. Because the host load varied substantially, this establishes the cache-pressure improvement but is not presented as a wall-clock FPS measurement.
MMX is advertised and implemented, with packed operations lowered to
WebAssembly SIMD, but this run retired exactly zero MMX instructions. Static
SIMD clusters exist in ijl11.dll, binkw32.dll and smackw32.dll; the active
game and DirectDraw renderer DLLs contain no credible SIMD routine. The main
EXE's three isolated SSE-looking byte sequences are low-confidence scan
coincidences, SSE is not advertised, and no SSE path executes. Implementing
more SIMD is therefore unlikely to be the first-order gameplay speedup for
this shareware build; interpreter/cache/thread efficiency is the measured
target.
Gameplay hot-loop census
A post-cache gameplay histogram over batches 1500..1660 mapped runtime block
entries back through each PE's load delta and then checked every hot backward
edge in disassembly. The table groups blocks belonging to the same loop nest;
top entries is the hottest constituent block, not a sum or an instruction
count. This avoids double-counting nested loops.
| Guest instance | Runtime range | Original PE range | Top entries | What it does |
|---|---|---|---|---|
| main | 0x86e1ed..0x86e275 |
d2gfx 0x100031ed..0x10003275 |
924,243 | clipped rows; inner one-source palette translation at 0x86e24e |
| main | 0x4d8e70..0x4d8e9f |
EXE, same VA | 739,112 | scans an array of rectangle/region pointers and performs four bounds tests |
| main | 0x86c141..0x86c2e2 |
d2gfx 0x10001141..0x100012e2 |
434,355 | 15-row Duff/jump-table renderer with 32 unrolled one-source LUT pixels |
| main | 0x86c34d..0x86c6c3 |
d2gfx 0x1000134d..0x100016c3 |
324,300 | 15-row Duff renderer with 32 unrolled two-source 64K blend-table pixels |
| main | 0x86e34d..0x86e418 |
d2gfx 0x1000334d..0x10003418 |
251,520 | clipped rows; inner two-source palette/blend translation at 0x86e3ca |
| main | 0x655465..0x6554af |
d2cmp 0x6fe28465..0x6fe284af |
235,366 | signed RLE command stream: skip, change row, or REP MOVS literal run |
| main | 0x6556a1..0x6557b5 |
d2cmp 0x6fe286a1..0x6fe287b5 |
226,217 | clipped RLE row decoder; optional in-place two-input palette translation |
| main | 0x4946e4..0x494748 |
EXE, same VA | 217,280 | nested 8x8 lighting/color-grid sampling through 0x430df0 |
| main | 0x65813f..0x658186 |
d2cmp 0x6fe2b13f..0x6fe2b186 |
217,088 | nearest-color search using three squared channel distances |
| main | 0x42f8d9..0x42f90c, 0x42fbe0..0x42fc1c |
EXE, same VAs | 119,808 | initialize and populate the 48x48 map/light grid, including region queries |
| main | 0x430b73..0x430c60 |
EXE, same VA | 112,632 | nested lighting interpolation and per-cell contribution calls |
| main | 0x5025ff..0x5026ed, 0x502a53..0x502b66 |
EXE, same VAs | 107,520 | scan fixed bucket arrays and linked game-object/client records |
| main | 0x86c736..0x86c8e0, 0x86c9a1..0x86ccff, 0x86cdc3..0x86cff8 |
d2gfx 0x10001736..0x10001ff8 |
112,010 | compressed CEL RLE renderers with unrolled LUT/blend suffixes and clipping |
| main | 0x88f753..0x88f790 |
d2ddraw 0x10003753..0x10003790 |
96,354 | short DirectDraw buffer/scanline loop |
| main | 0x86e471..0x86e73e |
d2gfx 0x10003471..0x1000373e |
84,900 | visible-tile/object traversal that selects and calls the renderer variants above |
| main | 0x42145d..0x421475 |
EXE, same VA | 120,960 | 128-bucket array plus linked-list callback walk |
| main | 0x493f84..0x49433b |
EXE, same VA | 60,148 | object/cell render preparation with nested fixed 6x6 grids |
| main | 0x4b54fb..0x4b5561 |
EXE, same VA | 60,175 | Bresenham-like collision/region walk with bounds queries |
| main | 0x651188..0x651252 |
d2cmp 0x6fe24188..0x6fe24252 |
65,174 | scan zero/nonzero spans and emit bounded RLE literal/skip commands |
Smaller measured backedges (hottest block 20K–53K) are the same classes, not a
new dominant idiom: more clipped d2gfx CEL variants (0x86d0da..0x86d4d5),
D2CMP state/color transforms (0x652851..0x65294c,
0x653f8f..0x65408b), EXE object/list traversals (0x421274..0x4212c1,
0x4300d6..0x43049c, 0x494b22..0x49514e), short memory scans/fills
(0x41fd7e..0x41fda6, 0x4c9e2e..0x4c9e45), and two short coordinate loops
at 0x4dea38 and 0x4deae2. Most contain calls, pointer chasing, multiple
branches, or fixed two-dimensional control and are not safe LUT_RUN shapes.
Generalized H418 LUT_RUN removes both genuinely hot byte-translation
self-loops: d2gfx runtime 0x86e24e and d2cmp runtime 0x655762. In the same
160-batch main window it reduced handlers from 262,313,891 to 257,258,415
(1.93%) while processing 1,381,859 pixels. The remaining first-order local
target was the straight-line/unrolled d2gfx family, especially
0x86c141..0x86c2e2; it needed an unrolled-LUT recognizer, not broader
self-loop recognition.
H431 now handles that fixed-span form at decode time. It recognizes the exact
descending one-source suffix and symbolically validates MSVC's scheduled
two-source blend suffix, then continues into the ordinary outer row tail. On a
full replay of the same batches 1500..1660, main handlers fell again from
257,258,415 to 179,778,066: 77,480,349 fewer, or 30.12% beyond H418 alone
(31.46% from the original pre-LUT 262,313,891). The d2gfx suffix landings no
longer appear in the hot-block top twenty; the jump-table and row-head blocks
at 0x86c167, 0x86c141, 0x86c38e and 0x86c34d remain, as expected,
because H431 is nonterminal and does not absorb their control flow. The batch
1652 Rogue Encampment capture remained healthy: terrain 87,447, life orb 3,612,
mana orb 2,910 and 187 quantized colors. Host load forced the replay to its
320-second cap exactly at batch 1680, so this is an instruction-count result,
not a wall-time/FPS claim.
The clipped two-source inner loop at runtime 0x86e3ca (d2gfx original
0x100033ca) now uses H418 as well. Descriptor version one adds a second
advancing byte stream, an auxiliary low-byte register, an absolute table
displacement and a selector for which source cursor terminates the run. A
separate exact recognizer proves the observed xor/xor, two loads, shl 8,
three increments, 64KB blend lookup, store and cmp source2,bound / jb order;
the executor remains the shared universal LUT kernel.
In a fresh batches-1500..1660 profile, 0x86e3ca fell from the prior capture's
251,520 per-pixel block entries to 26,522 budget resumptions. H418 processed
1,205,355 pixels in 96,689 aggregate runs, while the new canonical ESP load-run
recognition drove H408 2,869,328 times and removed H343 -> H343 from the top
pairs. That fresh scene retired 145,589,954 main handlers and scored terrain
102,464, life 3,612, mana 2,910 and 189 colors. Its terrain workload differs
from the earlier H431 capture and host load exceeded 80, so neither the total
handler difference nor wall time is presented as an isolated speed percentage.
High-level meaning of the post-LUT hot blocks
The remaining block heads are easier to understand as engine operations than as instruction pairs. Counts in this table are correlated entries within loop nests and therefore must not be added together.
| Operation | Current evidence | Interpretation |
|---|---|---|
| Fixed-shade isometric tile blit | d2gfx 0x10001130, runtime row heads 0x86c141 (322,448) and 0x86c167 (345,480) |
Draws the 15 diamond rows through a Duff jump table. Each source palette index passes through one selected 256-byte row of the 64K table before reaching the 8bpp framebuffer. |
| Per-pixel-lit isometric tile blit | d2gfx 0x10001340, runtime row heads 0x86c34d (289,814) and 0x86c38e (310,515) |
Combines a tile byte with a byte from the coordinate-selected light field at 0x10014004, using (light << 8) + pixel into the 64K table. This is palette lighting/shading, not a 16/32bpp arithmetic alpha blend. |
| Clipped palette blit/blend | H418 aggregate 1,205,355 pixels in 96,689 runs; d2gfx 0x100031ed/0x1000334d |
The same fixed-shade and per-pixel-lit operations with horizontal clipping. H418 now absorbs their actual pixel loops; the surrounding row setup remains. |
| Tile/collision mask query | EXE 0x4d8e10, hot scan blocks 0x4d8e70..0x4d8e9f at roughly 307K--384K entries |
Finds which room rectangle contains a world coordinate, resolves that room's row-offset table, loads a 16-bit tile/collision word and applies the caller's mask. This is simulation/spatial-query work, not renderer clipping. |
| Light-grid sampling/build | EXE 0x4946cd inner 8x8 blocks at 186,048 entries and clamped sampler 0x430df0 at about 194K |
Repeatedly clamps coordinates to a 48x48 grid and copies one or four light/color bytes while constructing the small lighting grid consumed by the per-pixel tile renderer. |
| CEL/RLE expansion | d2cmp 0x6fe28465, runtime 0x655465 at 198,960 entries |
Interprets signed commands: negative values skip output or advance a row; positive values copy a literal run. Other d2gfx paths apply the LUT/light operation while decoding compressed CEL rows. |
H431 processed 11,825,842 fixed-span pixels in 718,737 invocations in this capture. Dividing the two fixed-tile row-head counts by their 15-row shape gives about 40.8K full-tile equivalents, only as a scale estimate because clipping and variant dispatch make it non-exact. The important consequence is that H431 has already removed most pixel-by-pixel dispatch, so the remaining d2gfx cost is increasingly row setup, diamond-shape jump dispatch and function control.
That changes the next optimization level. An exact full-tile handler covering
the fixed-shade and per-pixel-light variants could consume all 15 rows per call
and subsume H431 internally. Separate candidates are the room collision-mask
query, the fixed 8x8 light-grid builder and the signed D2CMP command decoder.
Those are whole engine primitives; generic XOR -> LOAD8 or CMP -> Jcc
fusions would only shave pieces of all four.
Browser CPU attribution of pixels versus row control
A subsequent Chrome/V8 sampling profile measured actual Rogue Encampment gameplay rather than inferring native cost from handler counts. The driver did not arm the handler histogram during the CPU window, moved the Barbarian with a ground click, and rejected the sample until the rendered frame contained green terrain plus both life and mana orbs. It then took a separate short histogram window in the same live instance. Two headless runs captured 46,462 samples over 15.53s and 34,918 samples over 10.49s. A state-aware intro driver then repeated the measurement in a real headful/compositor-backed Chrome window: 38,251 samples over 10.43s. Host load was still high (roughly 8--14), so these are CPU self-time shares only, not FPS or throughput measurements.
The result narrows the multi-row claim considerably:
| Native function | Headless 1 | Headless 2 | Headful | Meaning |
|---|---|---|---|---|
$next |
22.98% | 21.53% | 22.44% | Threaded dispatch itself remains the largest single native cost. |
H431 $th_lut_span |
1.68% | 1.58% | 1.97% | The already-folded fixed-span pixel kernel is no longer a dominant cost. |
H418 $th_lut_run |
0.30% | 0.36% | 0.32% | Clipped palette translation/blending is smaller again. |
| all Wasm | 90.0% | 90.4% | 91.1% | Browser presentation/JS is not the principal ceiling in this capture. |
Separating samples whose ancestry goes through thread-manager.js puts H431
at 1.82--2.21% of main-path CPU and H418 at 0.36--0.42%; neither ran on the
worker path. Main-thread $next alone was 20.15--21.53%. The worker share
varied from 10.7% to 18.3% in these three windows, so the earlier larger blue
HUD share is phase-dependent rather than a fixed split.
The matched histogram explains what remains around H431. In the repeat window,
the fixed-shade row head ran 134,596 times and the per-pixel-lit row head
122,360 times, while H431 ran 265,541 times in total. Accounting for the
existing ESP load-run fusion, the two fixed-tile outer bodies represent about
5.84M row-setup/control handler dispatches out of 62.86M total (about 9.3%);
including their H431 calls makes the theoretical handler-count ceiling about
9.7%. A multi-row handler would not remove the pixel work or all address
calculation, however. At uniform dispatch cost it saves only about two points
of total CPU from $next; direct WAT row setup could save some additional
generic-handler cost. Consequently the honest expected ceiling is a few
percent until an A/B prototype measures it, not evidence that the outer tile
loop dominates the whole browser.
Per-present guest-operation attribution
A temporary guest-EIP range timer measured the main instance between actual DirectDraw presents. The steady Rogue Encampment window contains 57 frames; menus, loading and the first loading-to-gameplay spike are excluded. A matched empty-range control retained the frame/timer hooks but removed all hot-range transitions. The detailed probe raised median active main-guest time from 77.96ms to 89.04ms (14.2%), so uncorrected probe time is not an honest browser frame-time result. A standalone Wasm-to-JS timer-import calibration measured 85.7--92.4ns per transition; the full launch made 4.04M transitions.
The table reports exclusive loop-body buckets. “Adjusted” divides the raw
medians and p90s by the measured 1.142 probe inflation. It is a deterministic
Node/V8 CLI estimate of computation per DirectDraw present, not browser wall
time, and helpers outside a listed EIP range remain in other.
| Exclusive operation bucket | Raw median | Adjusted median | Adjusted p90 | Mean active share |
|---|---|---|---|---|
| Fixed/per-pixel-lit isometric tile bodies | 12.64ms | 11.07ms | 18.47ms | 14.48% |
| Lighting-grid build/sample/interpolation | 8.75ms | 7.66ms | 11.88ms | 9.87% |
| Object/list/visible-scene traversal | 8.01ms | 7.01ms | 11.68ms | 9.32% |
| CEL renderer bodies | 7.51ms | 6.57ms | 11.69ms | 8.60% |
| Collision walk and room-mask query | 5.94ms | 5.20ms | 7.46ms | 6.56% |
| D2CMP RLE encode/decode/transform | 2.42ms | 2.12ms | 3.15ms | 3.03% |
| Clipped palette/light blits | 2.40ms | 2.10ms | 3.30ms | 2.71% |
| Nearest-palette-color search | 1.97ms | 1.72ms | 2.73ms | 2.34% |
| DirectDraw scanline loop | 0.31ms | 0.27ms | 0.40ms | 0.36% |
| Everything outside those ranges | 37.90ms | 33.18ms | 52.40ms | 42.74% |
Thus the larger named buckets are now ranked per present, but these numbers do
not prove inclusive whole-function cost or transfer directly to browser
milliseconds. The lower-overhead Chrome sampling result above remains the
browser authority: $next is 22.44% of total CPU while H431 and H418 themselves
are only 1.97% and 0.32%. The operation timer says where the surrounding guest
work is concentrated; it does not overturn that native attribution.
Cooperative workers and real browser threads
The browser HUD's blue threads phase is literal wall time spent in
ThreadManager.runBudgeted, but the name does not mean Web Workers. The three
guest worker WASM instances currently run synchronously and round-robin on the
browser's main JavaScript thread. A sequential per-instance histogram over
batches 1500..1650 found:
| Instance | 50-batch handlers | Dominant work |
|---|---|---|
| T1, Fog service thread | 0 | parked in WaitForSingleObject |
| T2, Storm async worker | 56,384,573 | MPQ Huffman/bitstream decode and ADPCM expansion |
| T3, D2Sound worker | 53,312 | mostly waits and DirectSound service calls |
T2's hottest nests are Storm runtime 0x9a1f40..0x9a21b3 (original
0x6ffbbf40..0x6ffbc1b3, Huffman bit refill/tree traversal; 433,747 entries in
its hottest block) and 0x9a2d30..0x9a2e9f (original
0x6ffbcd30..0x6ffbce9f, ADPCM code expansion and predictor/step-index clamps;
432,499). Its secondary loops build/walk the decode trees at
0x9a1c40..0x9a1e6e and perform smaller output transforms at
0x9a31a6..0x9a31f4. Neither is LUT_RUN, and no worker instance executed H418
during the full replay.
Consequently, real Web Workers should materially improve browser responsiveness
for this workload: nearly the entire blue phase could overlap the main guest
instead of blocking input and paint. At the sampled rates main averaged about
1.61M handlers/batch and T2 1.13M; perfect independent overlap would put a
rough upper bound near 1.7x for their combined CPU phase. That is a ceiling,
not an FPS forecast: main sometimes waits for worker events, host imports such
as audio/window/storage need a main-thread broker, shared emulator allocators
still need locking, and the green d2gfx renderer remains single-threaded. The
existing shared WASM memory, per-thread instances, atomic wait table and
partitioned decode caches provide useful groundwork; the missing broker and
race audit are the implementation cost documented in
docs/design-real-threads.md.
Isolated-Worker bounded MPQ waits
The isolated browser backend originally omitted the bounded-wait poll floor
already used by the cooperative main scheduler. Its guest clock can advance
past Storm's 255ms MPQ completion wait after only one or two concurrent Worker
slices. resolveMainWorkerWait() then returned WAIT_TIMEOUT while the Storm
decompression worker was still runnable; Storm accepted the resulting short
read, and D2CMP later reported Codec.cpp line 1563, top >= 0, while decoding
the incomplete data.
ThreadManager.resolveWait() now requires both elapsed guest time and up to
the same bounded number of scheduler polls while an isolated main thread still
has runnable guest workers. A signal remains immediate, and a permanently
unsignalled finite wait still times out after the poll ceiling. The focused
regression advances the guest clock by 1000ms during a 255ms wait, proves it
does not complete after the second Worker slice, then signals the event and
proves normal completion.