Skip to content

Latest commit

 

History

History
1409 lines (1232 loc) · 83.6 KB

multip.md

File metadata and controls

1409 lines (1232 loc) · 83.6 KB

MULTIP

MULTIP.DLL is module 35, installed as MULTIPLAYER.DLL. It owns the DirectPlay session, the local and remote player records, network messages and the extrapolated aircraft state handed to the rest of the simulator.

MULTIP has passed x86 whole-module acceptance and startup smoke testing. All eleven public entries, worker lifecycles, protocols, dialogs and CHAIN callbacks are implemented and compared with retail. The latest evidence is in the acceptance checkpoint. It is included in MODULES; make install maps bin/MULTIP.DLL to MULTIPLAYER.DLL. The temporary smoke test restores the previous game DLL rather than deploying this build permanently.

Continuation contract

This section is the handoff for a fresh context. The current project decision for all MULTIP code is snake_case, the standard <stdint.h> types (uint32_t, int16_t, and so on), and no types.h. This supersedes stale instructions that request Hungarian names or the old typedefs. Preserve the retail offsets and ABI, but use descriptive snake-case names where semantics are known and offset-based names where they are not.

Restart here

Read CLAUDE.md, README.md, docs/method.md, src/multip/AGENTS.md, then this section and the receive/lifecycle checkpoint. Check git status --short before editing: this checkpoint includes modified and untracked files. Preserve the entire working tree; do not stage, commit, push or install MULTIP. A fresh context in this workspace can use these files immediately; a separate checkout also needs the uncommitted changes.

  • Verified: all 43 top-level suites pass 20,000 scenarios each, zero differences, plus eight dispatch-family reruns. The acceptance checkpoint adds a mixed public lifecycle and guarded malformed packet boundaries. The earlier host-fixture correction remains covered by players, system, lifecycle and dispatch.
  • Latest acceptance work: multip_whole.inc and multip_bounds.inc in the harness, isolated mutation selectors, and tools/multip_smoke.sh. Private entries 0–269 remain unchanged; init/deinit and their two callbacks occupy 266–269, mapping retail RVAs 0x1020/0x1070/0x1090/0x1220.
  • Mutation status: all 456 targets match exactly once and all 456 are caught at 1,200 scenarios. The command slice also passes all 14 checks with the compiler idle during watchdog measurements. Interrupted attempts and fixture failures were excluded from the completed-result ledger.
  • Smoke/build: forced make -B all is warning-free. Startup with the exact replacement passes 27/27 modules and 95 gauges; the previous DLL is restored and its checksum verified. MULTIP is now in MODULES with the existing MULTIPLAYER.DLL mapping.
  • Next: MPCHAT is the next module. Further MULTIP validation can cover real provider interoperability, live multiplayer sessions and non-x86 fallbacks.
  • Limits: scripted x86 comparisons and startup smoke do not validate native non-x86 behavior, real network providers or OS thread scheduling races.

Build with make multipdiff; run make multipdiff-run N=20000 for the full checkpoint. From bin/difftest, run WINEDEBUG=-all wine multipdiff.exe 1200 init or replace init with lifecycle, dispatch, dialogs or lobby for focused comparisons. Run mutations from the repository root with python3 -B tools/multip_breaks.py --slice init (or another named slice). Run Wine tests serially: concurrent runs previously produced timeouts. The generator reads the retail orig/Modules/multip.dll; do not depend on scratch disassembly under /tmp surviving a restart. .text offsets are relative to VA 0x20851000; fixture RVAs are relative to 0x20850000.

The working files are:

  • src/multip/multip.c, .def, and .rc: the in-progress DLL;
  • tools/difftest/multipdiff.c, multip_send.inc, multip_receive.inc, and multip_workers.inc and multip_directplay.inc: query, transport, controlled worker, and loader/interface-lifetime harness;
  • tools/multip_breaks.py: isolated mutation checks for recovered owners;
  • the multipdiff, multipdiff-run, and explicit bin/MULTIP.DLL targets in the top-level Makefile.

Do not add MULTIP to MODULES or install it into the game until all public slots, both worker lifecycles, and the CHAIN callbacks are implemented and a smoke test passes. These gates are now satisfied; see the acceptance checkpoint.

Session, aircraft and message continuation

Additional implementation lives in multip_profile.inc, multip_session.inc, multip_aircraft.inc, multip_records.inc, multip_tracking.inc, multip_messages.inc, multip_players.inc, multip_callbacks.inc, multip_motion.inc, multip_commands.inc, multip_weather.inc and multip_tick.inc, multip_system.inc, multip_app_players.inc, multip_updates.inc, multip_textures.inc, multip_texture_receive.inc, multip_transfer_messages.inc, multip_texture_send.inc, multip_model_send.inc, multip_model_cache.inc, multip_model_receive.inc, and multip_title_receive.inc under src/multip/. The differential fixtures with the same names live under tools/difftest/ (record enumeration shares the aircraft copy fixture). The generated profile field order comes from tools/gen_multip_profile.py; initialized image data and pointer fixups come from tools/gen_multip_data.py. Neither generator embeds executable code.

The following internal paths are now implemented and compared against retail:

  • Profile read/write at .text+0x8f30/+0x93e0: 33 reads and 25 writes, with unsigned clamps and the derived packet interval. Retail spells the key ALLOW_EXTURE_RECEIVE; its player-list update key writes +0x174 while validation changes +0x178. Both behaviors are retained.
  • Host/join and named groups at +0x2340/+0x2380/+0x23c0/+0x2400, +0x3890/+0x3940, membership swaps at +0x97a0/+0x97f0. Enumeration treats only three specific HRESULTs as failure; membership changes ignore errors.
  • Session-description allocation at +0xa7a0, capacity packing/unpacking at +0x9f40/+0xa020, name type/host encoding at +0xa050/+0xa100, local name publication at +0xa1a0, collision timer at +0xa590, and type counts at +0xa8d0. Name publication leaks its allocation on success as retail does.
  • Aircraft selection/release/copy at +0x8a20/+0x8aa0/+0x8b30. Copy preserves partial reallocations, a zero-filled replacement header, and separately allocated model title. ACONTAIN uses nonzero success, host memory services use zero success. The fixtures include both ownership modes and failures.
  • Browser record construction/copy/free, name-list cleanup and player enumeration at +0xa2a0 through +0xa540. An invalid final name leaves its appended record in the list; EnumPlayers errors still publish partial results. Cleanup ignores a walker error and frees through a temporary.
  • Track-name display and CHAIN 0x19 callback at +0x96b0/+0x9710/+0x9740, selection reset/get/apply at +0x8cb0/+0xa830/+0xa6b0, view guard at +0xa680, and local model references at +0x9ea0. The callback preserves the argument as its return when it skips drawing. View application preserves the different state destinations, word writes, and copy-error boundaries.
  • Sixteen message constructors at +0x5a80 through +0x6060: tags 1001,1008,1002,1009,1003,1004,1005,1006,100f,1010,1011,100a,100c,100e,1007,1012. Tag 100e carries a variable title, its terminator, and a visibility byte. Four set request-pending/time only after successful enqueue; four allocate an extra packet and leak it on enqueue failure. The unused tails of retail's 1000-byte stack buffers are omitted because they are never sent.

The private test table now has entries 0–265; send_internal in tools/difftest/multip_send.inc maps each to its retail RVA. Entries 25–33 are profile and group/session helpers, 34–41 description/name/counter helpers, 42–44 aircraft ownership, 45–50 browser records/enumeration, 51–58 tracking and selection, 59–72 message constructors, and 73–78 player ownership helpers. Entries 79–80 are local creation/start, 81–84 are peer enumeration, receive membership, ping and host departure; 85–87 own the session CHAIN 0x14 callback, and 88–91 own drawing/CHAIN 0x10. Entries 92–96 own extrapolation/CHAIN 0x13, 97–103 view commands/CHAIN 0x0c, 104–106 weather and sync messages, and 107–114 tick callbacks/CHAIN 0x02. Entries 115–119 are system notifications, 120–135 weather/player handlers, 136–140 state updates, 141–152 texture files/catalogues, 153–162 texture reception, 163–167 multipart packet constructors, 168–177 texture sending, 178–185 model sending, 186–189 cache files/replacement, 190–194 model reception, and 195–196 title lookup/announcements, 197–200 state constructors, and 201–205 control/publication helpers and the verified control callback. The callback fixture drives real publication and collision helpers. Production exports still contain only ImportTable and Linkage.

New comparison labels are profile load/save, session/group primitives, session descriptors/name metadata, aircraft select/release, aircraft copy/session browser records, track-name display, application message constructors, player lifecycle helpers, local player creation/start, session CHAIN 0x14, and player drawing/CHAIN 0x10. They compare ordered callbacks/imports, outputs, partial state, heap bytes, failure leaks and queue contents as applicable. Real critical sections/events are used for message enqueueing; the worker suite runs last because it replaces the Win32 import cells.

Mutation checking accepts --slice profile, groups, metadata, aircraft, records, tracking, messages, players, local, frame, and draw in addition to the original slices. Each variant runs 1,200 scenarios so independently generated invalid/valid name bytes reach all type characters. Constructor snapshots must be saved before later enumeration resets the embedded list; a stride mutation exposed this fixture gap and now fails the comparison.

The current forty-suite differential run passes 20,000 scenarios per suite with zero differences. make multipdiff builds both production and test DLLs without warnings; the production export table contains only ImportTable and Linkage. These remain slice results, not whole-module acceptance. The earlier 154-mutation sweep passed; the current larger sweep status is recorded below. All five data includes reproduce exactly from the updated generator. No crash or fixture error counts as a catch.

Remote creation/removal and list ownership at +0x82c0/+0x84b0/+0x8930, unknown-type initialization at +0x8980, local entry removal at +0xa8a0, and remove-all at +0x8f00 now pass the fourteenth suite. The small name-label model at RVA 0x2cf4 is extracted by the data generator into multip_nametag.inc. The fixture retains moving-array banks so retail's stale local pointer is observable, and checks per-action live-record hashes, heap bytes/ownership, partial setup, duplicate IDs, model failures, host capacity updates, selected-player reset, and CHAIN list publications. Local creation at +0x8820 and startup at +0x8700 are now recovered too; see the additional boundary notes below.

Local creation/start has a separate fixture (multip_local.inc) that paints the unwritten DPNAME reserved word. Retail reads it at entry ESP-0x400 in +0x8820, or ESP-0x404 through +0x8700; the C bodies receive it through small x86 entry shims. Adjacent slots are painted differently so a wrong read cannot pass. Non-x86 entries use zero because there is no x86 caller stack. Startup preserves the initial CHAIN list publication, local marker, host counts (including wrap), join requests, name/capacity publication, collision timer and second publication, with partial failures retained. CreatePlayer's result, hosting flag and requested type are independently seeded; correlated values initially hid every successful host path.

Peer enumeration at +0x86b0/+0x86d0 skips non-player records before inspecting their names, stops on add failure, and returns the raw EnumPlayers HRESULT. +0x9960 applies descriptive-receive group membership. Message 1007 at +0x6010 updates a matching peer's +0xfc timestamp before enqueue, uses flags zero before the guaranteed-message policy, and still sends for absent IDs. Message 1012 at +0x6060 is the allocated host-departure broadcast. Those paths extend the player/message fixtures.

The session CHAIN 0x14 callback at +0x2c10 and registration at +0x2bc0/+0x2be0 are in multip_callbacks.inc. The compressed event switch is extracted into multip_frame_actions.inc by the data generator. Its fixture exhausts codes 0–255 plus wrapped/high codes, checking aircraft reference refresh and title messages, collision reset, view flags, and raw code-0x21 returns on default/flag-only paths. Registration sets +0x954 before calling CHAIN, retaining it on failure.

The CHAIN 0x10 renderer at +0x2d20/+0x2d30 and registration at +0x2cd0/+0x2cf0 are recovered in the same source include. It captures the list before reading the clock, initializes the previous time to now-200, walks peers in reverse, gates on local/type/state/visibility/display flags, uses ACONTAIN for owned models and G3D for received models, and optionally adds the name-label vector through UTIL before drawing it. The renderer results are ignored. Its fixture mutates state/options inside the draw stubs to prove reloads, covers first/repeated timestamps and wrap, and self-checks that owned, direct and label draws are all reached. A 20,000-scenario run observed 1,878 ACONTAIN draws, 2,781 direct draws, and 2,403 labels.

The harness exits immediately on an unhandled exception, printing suite, scenario, side and fault address. A fault or timeout remains a fixture/owner finding and never counts as a mutation catch. Selected self-contained suites can run alone: multipdiff.exe 1200 local, frame, draw, players, or messages. The mutation runner uses those selectors where available.

Public init/deinit, session toggle and dialog entries are now recovered in the later lobby/configuration and init checkpoints below. All six callback-registration groups, aggregate start/stop, common completion at +0x9c70 and disconnect at +0x8cf0 are now recovered and tested together. Lobby/provider and configuration/dialog callbacks are also now recovered; init/deinit now have integration coverage in the final checkpoint. Keep MULTIP outside MODULES until the whole-module acceptance gates below pass.

Extrapolation, commands, weather and tick checkpoint

multip_motion.inc recovers +0x3330/+0x33f0 and the CHAIN 0x13 owner at +0x2e30/+0x2e50/+0x2e80. Its feedback retains x87 intermediate stores, heading-dependent signed/unsigned conversion, longitude/metres conversion at the saved latitude, and partial model updates on copy failure. The fixture uses retail UTIL's stateless conversions. Its unsigned DIV overflow is repaired exactly as the host does: quotient UINT32_MAX, remainder zero. This is required for normal negative longitude steps, not a filtered input domain.

multip_commands.inc recovers +0x2f40, +0x3020, +0x3200, +0x3250 and CHAIN 0x0c at +0x2ea0/+0x2ec0/+0x2ef0. Cycling preserves retail's empty/no-eligible spin and invalid-pointer outcomes; a main-thread watchdog and vectored exception boundary compare these outcomes. The 20k run observes 79 spins and 78 pointer faults. Follow placement keeps its two caller-stack padding words (entry ESP-0x24 and ESP-0x1c; through the command callback, ESP-0x28 and ESP-0x20) via entry shims. Observer conversion preserves capacity wrap, counts, name publication, request-pending state and failure ordering.

multip_weather.inc adds messages 1013, 1014, and 1017 at +0x60d0/+0x6140/+0x67e0. The 28 weather fields are extracted in call order by tools/gen_multip_weather.py into multip_weather_fields.inc. They initialize bytes +8 through +0x257, while retail sends 0x25c bytes: the last four bytes are caller stack at entry ESP-0xa8, preserved by a shim. The tick maintenance caller contributes another eight bytes, so its shim reads entry ESP-0xb0. Both fixtures paint adjacent slots differently. Sync timestamps are read after allocation and before peer fields, and sync traffic starts with flags zero.

multip_tick.inc adds all four CHAIN 0x02 callbacks and their +0x2460/+0x24d0 owner. Sync at +0x2590 runs after the counter exceeds the period, includes wrapping counters, and preserves incoming EAX when disabled. Interval accumulation at +0x2550 retains upper incoming EAX and returns the raw x87 status word in AX; tests vary precision/rounding and include NaN and infinite values. Maintenance at +0x25f0 decrements the track countdown and clears weather-pending before sending. Guidance at +0x2630 plus +0x2730/+0x2790 preserves panel word writes, ADF bearing, autopilot heading, GLOBAL+0x84c's disabling side effect, and state reloads after range calls.

These four new suites pass 20,000 scenarios each. Extrapolation's 12, commands' 14, weather's 12 and tick's 14 deliberate errors are caught. Crashes, fixture exhaustion and timeouts are not catches.

Multipart traffic and receive checkpoint

System notifications (multip_system.inc) cover player/group creation, player destruction/data and host migration. Destruction returns -1 even after successful removal. Host migration publishes its flag before querying the session and retains partial changes and leaks on failure. Application player handlers in multip_app_players.inc preserve capacity equality checks, reverse lookup, local-name publication for remote grants and the ignored publication failure in the sender-ID fallback. These suites catch 8 and 12 mutations respectively.

multip_updates.inc covers messages 1000, 1017, 1018 and 1019, including sequence wrapping, the zero-byte anchor copy, signed velocity conversions, caller-stack padding and the running timing average. Thirteen mutations are caught. Malformed matching lengths below 0x6c still need explicit guarded coverage before whole-module acceptance.

multip_textures.inc covers catalogue/path/read/write/cleanup and messages 1015/1016. The fixture uses real retail G3D record stepping and a memory filesystem; guarded path inputs produce 34,958 pointer-fault outcomes in 20,000 scenarios. All fourteen mutations are caught. Host +0x304 clears only the list count and returns the original list pointer; it does not free or reset the backing array.

multip_texture_receive.inc covers 101d/e/f and both linked-list levels. The 101d wire fields are size at +0x18, texture ID at +0x1c, and segments at +0x20. The internal add signature is owner/name/size/ID/segments. Removing a non-head owner still replaces the global head with that owner's successor, detaching earlier owners without freeing them. Ten mutations are caught; the alternative unlink mutation repairs both links so it completes rather than crashing on a dangling owner.

multip_transfer_messages.inc builds 101a/b/c/d/e, including raw fifteen-byte names, ignored title/strcpy results, and initial packet leaks on enqueue failure. The model header ignores its second argument and reads the current GLOBAL aircraft total after callbacks. Twelve mutations are caught.

multip_texture_send.inc covers the file cache, recipient lists, CHAIN 2 registration and timed chunks plus the mutable 1015 request. Cached file data is retained when a file node is removed. It subtracts 1.0 after reaching the timer threshold; multip_model_send.inc instead resets its accumulator to -1.0. Both compare the extended sum before the double store and retain raw FNSTSW returns and pointer returns. Texture tests compare all fifteen name bytes, including after NUL. The suites catch twelve and ten mutations.

multip_model_cache.inc serializes four fields in order 0x20, 0x28, 0x14, 0x1c, then header and model bytes. Path-join failure on the initial path returns success, as retail does. Loading reallocates both buffers, closes the file, then allocates the model's path string. Replacement retains the old position and frees the temporary path/model/header in that order. Eight file mutations are caught. The fixture has byte cursors and independently chosen short-transfer stages, so later short reads/writes are reachable.

multip_model_receive.inc covers 101a/b/c and the transfer list. Tests include duplicate announcements, out-of-order chunks, wrapped segment counts, missing peers, failed frees and completion through cache publication and peer replacement. Twenty thousand scenarios match, with 57 pointer-fault outcomes from retained retail failure paths; all twelve mutations are caught. The completion fixture currently uses an empty texture catalogue; nonempty catalogues are covered independently by the texture suite.

The previous full checkpoint had 35 suites of 20,000 scenarios with zero differences. The receive/lifecycle checkpoint below adds two top-level suites. multip_title_receive.inc adds title announcements (100e), local ACONTAIN selection, UTIL +0xa0 cache probing, cache replacement and optional 1010 requests. Its 20,000-scenario suite and seven mutations pass. Outgoing full state and compact position constructors in multip_state_messages.inc pass 20,000 scenarios and ten mutations. They preserve both compact two-byte padding words and the full-state packet's 0x6c-byte uninitialized tail through painted caller-stack fixtures. multip_control_updates.inc adds control change detection, collision cooldowns and state throttling; its helper suite passes 20,000 scenarios and twelve mutations. The complete control callback at +0x2870 now has a separate 20,000-scenario fixture. It drives flight-model +0x2c/+0x30 services, raw failure returns, signed timesteps, wrapped clock arithmetic, state publication and collisions. Retry dispatch uses messages 1008 and 1009 for configured types 1 and 2; the first integration comparison exposed and corrected the wrong retry tags. The fixture varies all three x87 precision modes and four rounding modes, paints the flight model's callee-left padding word, and compares per-action rate/velocity state, timestamps, retries, queues, heap and GLOBAL. All thirteen deliberate errors are caught by --slice control_callback; the serial rerun completed after an earlier concurrent Wine timeout.

The x86 callback has the retail stack layout needed for packet padding; the non-x86 C callback is implemented but is not covered by this x86 fixture.

The full mutation sweep remains incomplete: the 324-case run stopped on a Wine timeout in the drawing slice. The current 427 mutation targets all match exactly once, but the full sweep still needs completion. The earlier 154-case sweep and individual new owner slices passed before the naming cleanup. Do not count a timeout or fixture abort as a catch.

The earlier naming audit covered 70 MULTIP source/include/resource, generator, fixture and mutation files. The cleanup renamed 119 identifiers across 18 files and retained standard <stdint.h> types. All project-owned identifiers are snake_case; external Windows/COM names and required exports are preserved. src/multip/AGENTS.md records the user's naming correction for future work.

Image and public interface

Retail is based at 0x20850000. It has 43,318 bytes of .text, 0xa00 bytes of initialised .data, and uses the rest of the .data page for zero-filled runtime state. ImportTable is at .data+0x000 and Linkage at +0x058. The imports and their pointer cells are:

Import-table offset Pointer offset Module
+0x00 +0x04 host
+0x08 +0x0c GLOBAL
+0x10 +0x14 ACONTAIN
+0x18 +0x1c CHAIN
+0x20 +0x24 HG2D
+0x28 +0x2c G3D
+0x30 +0x34 MAIN
+0x38 +0x3c NUMERIC
+0x40 +0x44 UTIL
+0x48 +0x4c WEATHERM
Slot Retail address ABI/meaning Port
+0x04 .text+0x0020 no arguments; register module and first CHAIN 0x14 callback stub
+0x08 .text+0x0070 no arguments; unregister the host module handle stub
+0x18 .text+0x8e60 no arguments; load transport and start/join/stop a session verified toggle
+0x1c .text+0x39a0 no arguments; options/session dialog entry verified dialog
+0x20 .text+0x9840 stdcall (to_id, payload, size, flags); enqueue an application message exact enqueue
+0x24 .text+0x9de0 no arguments; return {count, records} exact
+0x28 .text+0x8680 stdcall (player_id); return a player record exact
+0x2c .text+0x8690 no arguments; return local-player record exact
+0x30 .text+0xa560 no arguments; return multiplayer status bits exact
+0x34 .text+0xa5b0 stdcall (int32_t *result); conditional MAIN result override exact
+0x38 .text+0xa5f0 stdcall (int32_t *result); selected aircraft range exact

The functions are widely separated because Linkage points to public entries among many internal MSVC functions; the byte counts printed by dumplinkage.py are gaps between public entries, not function sizes.

Player records

Remote records are 0x100 bytes. The live count is at .data+0x660 and the allocated array pointer at +0x678. The local record pointer is at +0xa3c. The first fields established so far are:

Offset Meaning
+0x00 name pointer; MPCHAT displays this directly
+0x04 DirectPlay player ID
+0x08 record type (1 and 2 are counted separately internally)
+0x0c state flag; zero on a type-1 record sets status bit 2
+0x28 associated remote-aircraft state; tested by +0x34 and +0x38

The local-record refresh at .text+0x9ea0 also establishes the following fields. Their deeper meaning is not yet required by the implemented slice, so the source data is more reliable than speculative names:

Offset Source written by retail
+0x20 GLOBAL[GLOBAL+0x11e0]+0x04
+0x24 GLOBAL[GLOBAL+0x11e0]+0x08
+0x28 pointer at GLOBAL+0x11e4
+0x2c GLOBAL[GLOBAL+0x11e0]+0x18
+0x30 GLOBAL[GLOBAL+0x11e0]+0x0c
+0x34 literal 1
+0x38 zero-extended byte at GLOBAL+0x609

+0x28 tests the local record first, then searches the remote array backwards. That reverse order is observable if duplicate IDs exist and is covered by the differential test. +0x24 writes the count and pointer to the stable two-dword result at .data+0xef8 and returns that result's address. DYNAMIC consumes exactly this shape and filters records whose +0x08 is 1 and +0x0c is zero.

The +0x30 result is a mask:

Bit State
0 the DirectPlay loader succeeded (.data+0xa48)
1 a session is connected (.data+0xaac)
2 while connected, at least one type-1 remote record has +0x0c == 0

MAIN uses these bits to enable and check its multiplayer menu items.

+0x34 always returns zero. It writes -1 through its sole argument only when a session is connected, DISTANCE_DISPLAY is enabled, the player mode is 1, the active player ID resolves, and that record's +0x28 is nonzero. MAIN's caller makes this a result override; “collision result” is the current interpretation, not a confirmed field name.

+0x38 also always returns zero and leaves its output unchanged unless the session is connected, DISTANCE_DISPLAY is enabled, the player mode is nonzero, the active ID resolves, and the record has an aircraft-state pointer at +0x28. It asks UTIL +0x64 for the distance between GLOBAL+0x560 and aircraft-state +0x04, using flags 3, then writes the truncated value of distance * 0.0005396115152459395 * 10. The two multiplications and MSVCRT _ftol truncation are reproduced with x87 operations. The current source name M_AIRCRAFT_ACTIVE for .data+0xab4 is provisional and should eventually be renamed to M_DISTANCE_DISPLAY; the profile-key mapping proves the latter.

Confirmed runtime state

The initialized .data ends at +0x9ff, but the loader maps the complete page. Retail uses both the initialized tail and the zero-filled remainder. Keep the offsets stable because the differential harness seeds them directly.

Data offset Confirmed or bounded meaning
+0x174 session-list update time and destination passed for the player-list read
+0x178 player-list update time value subsequently range-checked by retail
+0x660 remote-record count
+0x678 pointer to the contiguous 0x100-byte remote-record array
+0x980 loaded DirectPlayLobbyCreateA export
+0x984 session/open transition flag; exact public meaning unresolved
+0x988 live IDirectPlay3A *
+0x98c loaded DirectPlayCreate export
+0x990 loaded DirectPlayEnumerateA export
+0x998 configured maximum players, clamped to 1..255 (zero becomes 8)
+0x99c configured maximum observers, clamped to at most 255
+0x9a0 live count of type-1 records
+0x9a4 live count of type-2 records
+0x9a8 player-name buffer, profile read with capacity 0x13
+0x9c0 0x50-byte session/open descriptor scratch block
+0xa10 configured maximum track-name display time, clamped to 5..30
+0xa34 host module-registration handle/output
+0xa38 value returned by host +0x58, used in session descriptors
+0xa3c local 0x100-byte player-record pointer
+0xa40 local DirectPlay player-created flag
+0xa44 successful DirectPlay interface-query flag
+0xa48 DirectPlay loader succeeded; status bit 0
+0xa5c player mode; zero disables selected-player lookup
+0xa60 selected/active DirectPlay player ID
+0xa6c CONNECTION_TYPE option
+0xa70 PROTOCOL_SELECTION option
+0xaa0, +0xaa4 DirectPlay group IDs used by add/remove helpers
+0xaa8 HOST_SESSION option/current host-session flag
+0xaac connected/open-session flag; status bit 1
+0xab0 ADF_TRACK option
+0xab4 DISTANCE_DISPLAY option
+0xab8 AUTOPILOT_LOCK option
+0xabc, +0xac0 descriptive-message send/receive options
+0xac4, +0xac8 texture send/receive options
+0xacc, +0xad0 aircraft-model send/receive options
+0xae0 session-name buffer, profile read with capacity 0x1d
+0xb00 0x104-byte internet-address/path buffer
+0xf00 GUARANTEED_MESSAGES option
+0xf1c derived value 18 * maximum_track_name_display_time
+0xf24 both worker subsystems started flag
+0xf28 one-byte checksum/cache used by an aircraft update path
+0xef8 stable {remote_count, remote_records} result block

Offsets not listed here are not free. Large initialized descriptor tables run through +0x93f, and transport, interpolation, queue, and dialog state occupy much of +0x940..+0xeff. Name an unknown by offset until its reads and writes establish its role.

Initialization and CHAIN lifecycle

The public init/deinit entries are small, but init exposes two larger callback paths and therefore must not be implemented with a dummy callback.

  • .text+0x0020 calls host +0x298(Linkage, &data[0xa34]), saves host +0x58's result at +0xa38, and adds .text+0x0090 to CHAIN list 0x14.
  • .text+0x0070 calls host +0x29c(data[0xa34]). It does no other teardown.
  • The first callback at .text+0x0090 recognizes messages 0x36 and 0x3a. Message 0x36 loads the profile at .text+0x8f30, fills the 0x104-byte address/path buffer through host +0x2a0, registers the second callback, starts the lower-level subsystems through .text+0x9e00, then enters the lobby/connection path at .text+0x9990. Message 0x3a deep-copies and relocates a supplied compound block, conditionally passing it to .text+0x9a20 when DirectPlay is loaded.
  • The second callback at .text+0x0220 recognizes message 0x37. It runs the disconnect path at .text+0x8cf0, saves the profile at .text+0x93e0, tears down supporting state, and calls .text+0x9e20.

Those message numbers and call boundaries are confirmed; descriptive event names are intentionally deferred until the CHAIN producer is traced. The init fixture now logs registration, dispatches 0x36, 0x37, and 0x3a, and compares host/CHAIN calls, heap ownership and modified state with retail.

The profile section name is MULTIPLAYER. The loader at .text+0x8f30 and saver at .text+0x93e0 cover these keys:

Key Primary data destination
ALWAYS_SHOW_INITIAL_DIALOG +0x180
SESSION_NAME +0xae0
HOST_SESSION +0xaa8
PLAYER_NAME +0x9a8
GUARANTEED_MESSAGES +0xf00
EXTRAPOLATION +0x184
MAX_PLAYERS / MAX_OBSERVERS +0x998 / +0x99c
TICKS_PER_SYNC / PACKETS_PER_SEC +0x164 / +0x1a8
COLLISION_SECS / FOLLOWING_MULTIPLIER +0x160 / +0x168
MAX_TRACK_NAME_DISPLAY_TIME +0xa10
MAX_SEGMENT_SIZE / MAX_SEGMENTS_PER_SEC +0x16c / +0x170
SESSION_LIST_UPDATE_TIME read into and validated at +0x174
PLAYER_LIST_UPDATE_TIME retail passes +0x174 to the reader, then validates +0x178
CONNECTION_TYPE / PROTOCOL_SELECTION +0xa6c / +0xa70
INTERNET_ADDRESS +0xb00
ALLOW_DESCRIPTIVE_SEND / RECEIVE +0xabc / +0xac0
ALLOW_TEXTURE_SEND / RECEIVE +0xac4 / +0xac8
ALLOW_PLANE_MODEL_SEND / RECEIVE +0xacc / +0xad0
ADF_TRACK / DISTANCE_DISPLAY / AUTOPILOT_LOCK +0xab0 / +0xab4 / +0xab8
ALLOW_NAME_TAG_DISPLAY / ALLOW_KEEPALIVE +0x188 / +0x18c
TIME_BETWEEN_PLAYER_TYPE_QUERIES +0x17c
TIME_BETWEEN_CONNECTION_ATTEMPTS +0x194

The mismatched destination and validation for PLAYER_LIST_UPDATE_TIME are visible in the retail instructions and may be a retail bug; reproduce them unless the host service's contract proves it writes a larger overlapping type.

Transport and worker boundaries

The public send entry at .text+0x9840 is only a four-argument stdcall wrapper around .text+0x87e0. The latter applies the GUARANTEED_MESSAGES option and passes the request to .text+0x7890, which allocates a 0x20-byte queue record, copies the payload, chooses one of two send queues, and signals an event. .text+0x79d0 removes one queued record and calls IDirectPlay3A::Send; it retries DPERR_BUSY (0x8877010e) without discarding the record.

The enqueue path is now ported. Its record layout is {to_id, payload, size, flags, reserved[4]} (0x20 bytes). Each queue is {count, head, tail, CRITICAL_SECTION} (0x24 bytes); its 8-byte nodes hold {record, next}. Appending holds the queue lock while allocating and linking the node.

Retail only ORs flag bit 0 when GUARANTEED_MESSAGES == 1, and selects queue +0xe60 for any nonzero flags, otherwise +0xe64. It does not inspect CONNECTED. Payload allocation and copying happen even for zero length. Record, payload, copy, or queue failures return 0xffffffff without cleaning up earlier allocations. Node allocation failure additionally leaves the queue lock held. A failed SetEvent also returns failure, but the record is already queued. The port preserves these observed orderings. Host allocation flag 1 normally makes allocation exhaustion fatal; the harness injects nonzero host returns to check the branches that remain in the retail code.

Send-one (.text+0x79d0), normal draining (+0x7970), and full flushing (+0x7aa0) are now ported and tested against retail. Send-one removes the queue node before calling IDirectPlay3A::Send, using the local record's +0x04 as from-ID. Only DPERR_BUSY retries; all other HRESULTs, including generic errors, proceed to freeing the payload and record and return success if cleanup succeeds. Node-free failure returns before sending and leaves the record/payload allocated; payload-free failure leaves both allocated; record-free failure leaves the record with its payload pointer cleared. The queue has already advanced in each case, and its lock is released even when node freeing fails.

Normal draining empties plain queue +0xe64 before sending at most one flagged message from +0xe60. Full flush empties both queues in that order. A missing queue counts as empty, but send-one on a missing queue returns failure, so a normal drain with a missing flagged queue fails whereas a full flush can succeed. Empty, non-null queues succeed without sending.

The receive helper at .text+0x5820 calls IDirectPlay3A::Receive at .text+0x5875. Its work record contains from-ID, to-ID, a buffer pointer and buffer size. On DPERR_BUFFERTOOSMALL (0x8877001e) it allocates or resizes through host +0x20c/+0x210 and retries. A successful record is queued with the value returned by host +0x38, after which higher-level message dispatch owns it.

Receive-one (.text+0x5820) and receive-dequeue (+0x5980) are ported. A receive record is 0x14 bytes: {from_id, to_id, payload, size, timestamp}. Each receive-one starts with a fresh zero-filled record and calls Receive with flags 1 and a null buffer/zero capacity. DPERR_BUFFERTOOSMALL allocates the first buffer and reallocates on subsequent growth, retrying until another HRESULT arrives. A successful result sets the caller's received flag to 1, reads host +0x38 into timestamp, and appends to queue +0xdf8.

Other receive HRESULTs, including no-message and generic errors, free the buffer (if present) and record and return zero with received=0. Allocation, reallocation, free and queue failures return -1; earlier allocations remain where retail leaves them. In particular, successful receive followed by queue append failure leaves received=1, and node allocation failure leaves the queue lock held. Receive-dequeue sets its received flag only after a successful queue take; node-free failure can return a non-null record while leaving that flag zero. Dequeued records and their payloads belong to the higher-level consumer; these helpers do not free them.

There are two independent event/thread subsystems:

Role Setup Teardown Start Stop Thread entry Main unit of work State
receive .text+0x56a0 +0x56e0 +0x5730 +0x5770 +0x57a0 +0x5820 events +0xdf0/+0xdf4, queue +0xdf8, thread +0xdfc, id +0xe00, running +0xe04
send .text+0x7700 +0x7750 +0x77b0 +0x77f0 +0x7840 enqueue +0x7890, drain +0x7970, send +0x79d0 events +0xe58/+0xe5c, queues +0xe60/+0xe64, thread +0xe68, id +0xe6c, running +0xe70

Both worker lifecycles and queue construction/clear/destruction are ported and verified. Setup creates the work event before the stop event, both unnamed, auto-reset and initially unsignalled. It ignores event failures and returns only queue construction failures, without rolling back partial setup. Receive builds one queue; send builds flagged then plain queues. Teardown reverses queue construction order, then closes work and stop events, returning on the first failure. Repeated setup overwrites prior pointers and handles; teardown leaves event cells unchanged.

Start sets its running flag before CreateThread and returns zero even if creation fails, leaving running=1. Repeated starts do nothing while that flag is set. The security attributes at data +0x538 and +0x548 are {12, 0, 0}. Both entries wait on stop/work handles with WaitForMultipleObjects, wait-all=false and timeout=INFINITE. Stop clears running; work drains receive until no message remains, or invokes normal send-drain once. Other wait results loop again. A receive error exits with -1 without clearing running; the send worker ignores drain failures.

Receive stop signals its stop event, ignores the result of the 100 ms wait, and retains its thread handle. Send stop first clears flagged then plain queues, signals stop, ignores the 100 ms wait result, and returns the thread handle close result. Queue clear frees the record and node for each entry, leaving record-owned payloads allocated. It advances head before freeing, retains its lock on a free failure, and clears count/tail only after complete success. Queue destruction then deletes its critical section and frees the queue, returning immediately if either clearing or freeing fails.

The combined wrappers are also ported: .text+0x9e00 sets up receive then send, and +0x9e20 tears down send then receive. +0x9e40 starts send then receive only while data +0xf24 is zero, and sets that shared flag after both calls. +0x9e70 stops receive then send while the flag is nonzero, and clears it only after both calls succeed. They do not roll back failures. Public session entries reach the workers through completion; the init checkpoint also covers subsystem setup/teardown through both entry callbacks.

The observed Win32 imports are InitializeCriticalSection, DeleteCriticalSection, EnterCriticalSection, LeaveCriticalSection, CreateEventA, SetEvent, CloseHandle, CreateThread, WaitForSingleObject, WaitForMultipleObjects, Sleep, wsprintfA, ShellExecuteA, and MSVCRT _ftol. ShellExecuteA is called at .text+0x41d8 with the runtime buffer at +0xb00; a harness must log and suppress it.

DirectPlay interface

The main object at .data+0x988 is conclusively IDirectPlay3A, not a custom COM-shaped interface. The evidence is both static and structural:

  • .rdata+0x070 is byte-for-byte IID_IDirectPlay3A and is passed to QueryInterface while filling .data+0x988.
  • Calls through the object use the inherited IDirectPlay2A vtable at the documented offsets: AddPlayerToGroup +0x0c, Close +0x10, CreateGroup +0x14, CreatePlayer +0x18, DeletePlayerFromGroup +0x1c, DestroyGroup +0x20, DestroyPlayer +0x24, EnumGroups +0x2c, EnumPlayers +0x30, EnumSessions +0x34, GetPlayerName +0x54, GetSessionDesc +0x58, Open +0x60, Receive +0x64, Send +0x68, SetPlayerName +0x78, and SetSessionDesc +0x7c.
  • The usual QueryInterface (+0x00) and Release (+0x08) calls bracket its lifetime.

The loader boundary is also known. A descriptor at .data+0x148 names the three output cells +0x98c, +0x990, and +0x980; host +0x124 fills them at .text+0x9cf0, and host +0x128 unloads them at .text+0x9d20. .text+0x9d50 calls DirectPlayCreate at +0x98c, queries the temporary object first with IID_IDirectPlay2A at .rdata+0x050 and then with IID_IDirectPlay3A at .rdata+0x070, stores the latter at +0x988, and releases the temporary interfaces. DirectPlayLobbyCreateA at +0x980 is used only by the lobby/connection path after host +0x120; the observed lobby object calls Release at vtable +0x08 and SetConnectionSettings at +0x30.

The loader/interface-lifetime helpers above are now ported and verified. The host boundaries are stdcall with one argument: host +0x124 at 0x413570 and host +0x128 at 0x413620 both end in ret 4. The module's descriptor contains three addresses of output cells, with no names or terminator; host code owns the DLL and export names. The port initializes those addresses in multip_install to preserve relocation and the data +0x148 ABI.

Load normalizes any nonzero host result to -1. On host success, it sets loaded flag +0xa48 to 1 only when DirectPlayEnumerateA is non-null. An absent enumerator, or load failure, leaves a previous flag unchanged. Other exports are not checked here. Unload releases a non-null +0x988 interface, clears its cell, and then calls the host unloader; it leaves both loaded +0xa48 and query-success +0xa44 flags unchanged, regardless of the host return.

Create (.text+0x9d50) takes a provider GUID pointer (nullable), clears query flag +0xa44, and calls DirectPlayCreate(provider, &created, NULL). Factory failure returns -1 without releasing any partially returned object. The first query, for IID_IDirectPlay2A, also returns -1 on nonzero HRESULT without releasing the factory reference. Both IID queries use the factory object; the first queried interface is only retained for later release. The second query writes directly to global +0x988. Zero sets query flag=1, but a nonzero second-query result is not propagated: both temporary interfaces are released (first queried, then factory), and the function returns zero. Query output writes on failure are not repaired, and a pre-existing global interface is not released before replacement. These are retail behavior, not cleanup fixes.

This reduces the transport model to a well-defined fake IDirectPlay3A plus a very small fake IDirectPlayLobbyA. The receive loop treats DPERR_BUFFERTOOSMALL (0x8877001e) as a resize and retry, while the send loop retries DPERR_BUSY (0x8877010e). The first fake can therefore implement only the methods listed above, scripted sessions/players, and a deterministic receive queue.

The fake should expose call logs as data, not assertions buried inside COM methods. At minimum each scripted call records the method, object identity, player/group IDs, flags, buffer size, and relevant input bytes, then returns a queued HRESULT and outputs. It needs:

  • export shims for DirectPlayCreate, DirectPlayEnumerateA, and DirectPlayLobbyCreateA;
  • reference counting and both observed QueryInterface IIDs;
  • lobby Release and SetConnectionSettings behavior;
  • deterministic ID allocation for CreateGroup and CreatePlayer;
  • scripted EnumSessions, EnumGroups, and EnumPlayers callbacks;
  • mutable player names and session descriptors for the get/set pairs;
  • group membership for AddPlayerToGroup, DeletePlayerFromGroup, DestroyGroup, and DestroyPlayer;
  • a receive queue capable of the buffer-too-small first pass;
  • a send script capable of one or more busy results before success;
  • Open and Close state with injectable failures at each stage.

Use the real retail DLL on one side and the same fake object/script on the OpenFS98 side. Normalize allocated pointers, COM object addresses, handles, thread IDs, and callback addresses before comparing results.

Verification

make multipdiff-run defaults to 20,000 deterministic randomized scenarios. It loads retail and OpenFS98 together, seeds equivalent local and remote records plus fake GLOBAL and UTIL imports, and compares normalized results for the recovered +0x24 through +0x38 slice: the result block, local and remote lookups, reverse duplicate-ID selection, missing IDs, all three status bits, active/inactive collision overrides, every active-range guard, the UTIL call shape, and the range conversion. The current result is 20,000 scenarios with zero differences.

A separate 20,000-scenario run covers +0x20 enqueue. Each scenario sends six messages through real Win32 critical sections and an auto-reset event backed by a deterministic fake host heap. It compares return values, per-send event signals, ordered allocation/copy calls, both queue chains, payload bytes, reserved fields, leaked partial records, and lock recursion counts. Inputs cover zero-length payloads, flags 0/1/2/random, guaranteed option values 0..3, connected/disconnected state, missing queues, invalid event handles, and injected failures at each host-call position after two initial sends. Caller buffers are overwritten after each send to check that queued data is copied.

A third 20,000-scenario suite invokes send-one/drain/flush directly, after enqueueing zero to six messages. A fake IDirectPlay3A::Send at vtable +0x68 logs object identity, from/to IDs, flags, sizes, payload bytes and scripted HRESULTs, interleaved with host allocation/copy/free calls. The suite compares live allocations, remaining queue chains, return values and queue counts after each drain step, including repeated flushes, multiple messages in each queue, zero to three busy retries, success and generic errors, missing queues, and failures at node, payload and record frees. Full-flush cases begin with both queues populated to check ordering independently of normal draining.

Retail's private helpers are called at loaded-image RVAs 0x89d0, 0x8970 and 0x8aa0; receive-one and dequeue use 0x6820 and 0x6980. make multipdiff builds a separate MULTIP_test.dll with MULTIP_DIFFTEST, exposing the multip_test_entries address table. The normal bin/MULTIP.DLL exports only ImportTable and Linkage; no public slots are added to reach internal helpers. Staging copies use temporary files and atomic renames so rebuilding a fixture cannot truncate a DLL already mapped by Wine.

make multip-send-breaks builds temporary source mutations and DLLs in /tmp. The unmodified baseline must pass, then nine mutations must produce completed delivery comparisons with differences: busy retry, ignored HRESULT, send arguments, payload cleanup, the plain drain loop, the flagged drain limit, dequeue tail, full-flush ordering, and node-free failure. Crashes, timeouts and fixture errors do not count as catches.

A fourth 20,000-scenario suite covers receive-one/dequeue with three receive attempts and four dequeue attempts per scenario. The fake Receive logs the input IDs, capacity, flags and buffer identity as well as output IDs, required sizes, HRESULTs and payload bytes. Cases include immediate zero-byte success, no-message/generic errors, one to four buffer-growth passes, moving realloc with preserved contents, timestamp values, missing queues, and injected allocation/reallocation/free failures. Snapshots compare queue counts after each receive, dequeue pointers and flags, the complete call transcript, live allocations, leaked partial records, and held locks. The fake heap records freed storage for comparison but does not reuse it within a scenario.

make multip-breaks includes the forty-two transport/worker/DirectPlay mutations and the additional owner checks described above; the send-only alias is make multip-send-breaks and the receive-only alias is make multip-receive-breaks. The six receive mutations cover retry, flags, timestamp, successful-receive flag, error cleanup, and dequeue flag behavior. make multip-worker-breaks checks sixteen lifecycle mutations: event order, repeated start, thread creation failure result, security attributes, receive handle retention, shutdown wait duration, send handle close, send queue clear, ignored send errors, receive-until-empty, running flag after receive failure, record freeing, held locks on clear failure, critical-section deletion, and the shared start/stop flag.

A fifth 20,000-scenario suite covers worker lifecycles. It patches the two DLLs' Win32 import cells with deterministic event, thread, wait, close and critical-section services. Thread creation records the callback; callbacks run synchronously under scripted waits, sometimes when the stop call waits for completion. Completed callbacks are not rerun. Twenty-four action slots mix individual and combined setup/start/stop/teardown, repeated setup/start, restart after completion, queued work, repeated stops and partial teardown.

Each side receives independent but equivalent fake handles and heap records. The suite compares a unified ordered Win32/host/DirectPlay call transcript, worker state after every action (including shared +0xf24), queue headers, locks, live and leaked allocations, event signalling, handle lifetime and callback results. Inputs inject event/thread/SetEvent/CloseHandle and host allocation/free failures, failed/timeout waits, transport errors and busy retries. Failure schedules are independent of deferred execution so worker error paths are reached. This proves controlled callback behavior and ABI; it does not exercise OS thread scheduling races.

A sixth 20,000-scenario suite covers DirectPlay loader/interface lifetime at private RVAs 0xacf0, 0xad20 and 0xad50, exposed as test-table indexes 22–24. Eight actions mix repeated load, create/query, unload, and create after unload. Four fake COM identities distinguish factory, first query, second query and pre-existing global interfaces. The suite compares ordered host and COM calls, exact IID/provider bytes, output-cell addresses, state visible inside callbacks, per-action module/status snapshots, and reference counts (including leaked or overwritten references).

Scripts cover nullable provider GUIDs, each export-presence combination, zero/positive/error HRESULTs, partial outputs on failed calls, query failure that clears or preserves its destination, and arbitrary Release returns. Factory availability is seeded independently before calling create, so a missing factory export is compared as loader state rather than executed as null code. The original lifetime fake is separate from the send/receive fixtures. The receive/lifecycle fixture now combines session, player, group and transport contracts; the later lobby fixture also exercises creation and connection through completion and disconnect. No real DirectPlay DLL or network is used.

make multip-directplay-breaks checks eleven mutations: loader export selection, loaded-flag retention, export descriptor order, unload flag and release-state ordering, first-query leak, second-query target and error return, temporary release order, factory outer argument, and query-flag reset. As with the other slices, a mutation counts only when its comparison completes with differences; a crash or fixture error is not a catch.

This loader/lifetime slice is not a whole-module 20k result. Receive dispatch, session integration, lobby/configuration callbacks and public +0x18/+0x1c are verified in the later checkpoints below. Init/deinit and their entry callbacks are implemented in the final checkpoint. Worker lifecycles are verified through controlled internal calls and the later completion/lobby fixtures.

Run the current checkpoint with:

make multipdiff-run
make multipdiff-run N=20000
make multip-breaks

The first command already defaults to 20,000; the explicit form is useful in logs. The expected differential checkpoint lines are:

MULTIP +0x24..+0x38: 20000 scenarios, 0 differ
MULTIP +0x20 enqueue: 20000 scenarios, 0 differ
MULTIP send-one/drain/flush: 20000 scenarios, 0 differ
MULTIP receive-one/dequeue: 20000 scenarios, 0 differ
MULTIP DirectPlay loader/lifetime: 20000 scenarios, 0 differ
MULTIP profile load/save: 20000 scenarios, 0 differ
MULTIP session/group primitives: 20000 scenarios, 0 differ
MULTIP session descriptors/name metadata: 20000 scenarios, 0 differ
MULTIP aircraft select/release: 20000 scenarios, 0 differ
MULTIP aircraft copy/session browser records: 20000 scenarios, 0 differ
MULTIP track-name display: 20000 scenarios, 0 differ
MULTIP application message constructors: 20000 scenarios, 0 differ
MULTIP player lifecycle helpers: 20000 scenarios, 0 differ
MULTIP local player creation/start: 20000 scenarios, 0 differ
MULTIP session CHAIN 0x14: 20000 scenarios, 0 differ
MULTIP player drawing/CHAIN 0x10: 20000 scenarios, 0 differ
MULTIP aircraft extrapolation/CHAIN 0x13: 20000 scenarios, 0 differ
MULTIP view commands/CHAIN 0x0c: 20000 scenarios, 0 differ
MULTIP weather/sync messages: 20000 scenarios, 0 differ
MULTIP tick callbacks/CHAIN 0x02: 20000 scenarios, 0 differ
MULTIP worker lifecycles: 20000 scenarios, 0 differ

Receive dispatch and session lifecycle checkpoint

multip_dispatch.inc implements the receive callback (+0x47f0), top-level sender split (+0x4890), DirectPlay system dispatch (+0x48e0), application dispatch (+0x4960), and reverse-order unknown-peer expiry (+0x89a0). The application switch reaches all 31 handlers in 1000–101f; 100b and unknown tags forward {sender, packet, size} to CHAIN 0x1d/5, ignoring its return. tools/gen_multip_dispatch.py extracts and checks the retail jump table before emitting named C/assembly calls, rather than executable bytes. There is no added minimum-packet-size check. Only 1017 receives the timestamp. The x86 app dispatcher retains its 12-byte frame and handler nesting, including 1013 weather-reply and 1019 position padding below the entry stack. The top sender dispatcher normalizes nonzero handler results to -1; the app/system switches preserve their raw returns.

Receive pumping expires unknown peers before dequeuing. It frees payload then record only after successful dispatch. A NULL payload leaks its record; failed dispatch, node free, payload free or record free preserves retail's partial state. The callback propagates host-free failures unchanged.

multip_lifecycle.inc adds receive/control registration (+0x27c0/+0x2810), aggregate registration/removal (+0x8220/+0x8270), common completion (+0x9c70) and disconnect (+0x8cf0). Owner flags survive partial failures, registration order is preserved, and there is no added rollback. Completion starts workers, registers callbacks, creates/publishes the local player, applies receive membership, sets the started-local flag, enumerates peers, then sends the initial ping/type queries. Disconnect broadcasts host departure, flushes sends, clears receive records, closes its progress display, removes callbacks, stops workers, resets selection, destroys players, closes DirectPlay and unloads it. Its positive signed delay is now - start + 3000, preserving retail's addition of elapsed time. Progress calls are void and DestroyPlayer errors are ignored; Close's raw HRESULT is retained unless a later unload fails.

Handlers 1005, 1006, 1016 and system 31 now use that disconnect path. Rejected 1005 calls DirectPlay Close before clearing the connected flag; its following disconnect then does nothing. Their confirmation ordering and error boundaries are compared with active and already disconnected sessions.

The dispatch fixture runs the existing player, timing/state, weather, system, model/texture receive, title and model-send scenarios through the recovered switches. The lifecycle fixture runs repeated registration/removal, completion, queued receive processing, expiry, notification-triggered disconnect and repeated close over deterministic COM, CHAIN and worker services. It compares ordered calls, callback identities, packet hashes, return values, flags, GLOBAL, heap ownership and queue states. Failure injection reaches late teardown; clock steps include zero and wrap, with positive and negative signed sleep delays. The completion entry keeps retail's nesting so local startup reads its reserved DPNAME word after earlier CHAIN/worker calls. The fixture paints the slot, changes it during registration, and paints its neighbors differently. No real threads, networking or sleep are used by this integrated fixture. Failed completion can leave no local player: subsequent send flushing can fault in retail, and those faults are compared as outcomes before the scenario ends. Fixture errors and process crashes remain verification failures.

The integrated fixture exposed a host-stub error: HOST +0x300 at 0x410210 computes an unsigned element index, then 0x410470 unconditionally decrements the count and skips copying when the signed index is at or beyond the new end. It does not reject a stale out-of-range record pointer. The player fixture now models that behavior; MULTIP's stale-local-pointer behavior is retained.

make multipdiff-run N=20000 passes all 37 top-level suites with zero differences; the dispatch suite additionally repeats eight handler families through their real switches. --slice dispatch catches all four mutations; --slice lifecycle catches all nineteen, including both completion stack-word checks. The full 382-target mutation sweep remains pending.

The private test entries 206–220 cover these owners, handlers and switches. The non-x86 C fallbacks are implemented but are not validated by the x86 fixture. This receive checkpoint is internal integration, not whole-module acceptance. The later dialog checkpoint enables public +0x18/+0x1c; the init checkpoint enables +0x04/+0x08.

Recommended continuation order

The transport units and worker lifecycles in stages 1–4 are complete, including queue construction/clear/destruction and the combined subsystem wrappers. DirectPlay lifetime, profiles, host/join/group primitives, session descriptions, player creation/removal, and all six session callback owners described above are also verified, including receive/control ownership at +0x27c0, receive dispatch, and integration of all callback owners and workers in common completion +0x9c70 and disconnect +0x8cf0. Lobby/provider and configuration/dialog wiring for public +0x18/+0x1c is now recovered in the checkpoint below. Init/deinit and their callbacks follow in the final checkpoint. The loader flag alone does not prove the factory is available, and create's first query failure leaks a reference; retain those boundaries in integrated cases.

The next public-entry boundaries are confirmed in .text+0x8e60: load first; when connected, UTIL +0xa4 supplies confirmation before disconnect +0x8cf0. The disconnected path first calls +0x3d60; data +0x1a4 == 0 unloads and == 3 returns 1. Otherwise data +0xaa8 chooses host helper +0x3890 or join helper +0x2340, followed by common completion +0x9c70. Disconnect includes CHAIN messages, send flush, receive queue clear, worker stop, player destruction, IDirectPlay3A::Close, and unloader calls. These are now also exercised through the public session toggle and its configuration flow in the lobby fixture. Disconnect's wait is Sleep((int32_t)(now - start + 3000)) when that signed value is positive: retail adds elapsed time. Preserve that arithmetic. The view-command callback at +0x2ef0 dispatches 0x1016c–0x1016f into +0x3200/+0x2f40/+0x3020/+0x3250; the cycle helpers can loop forever when no record is eligible and can read record zero even when count is zero. Their existing command fixture compares fault/hang outcomes rather than filtering those inputs.

Each stage should extend the harness before the corresponding Linkage slot is enabled. Keep the test output labeled with the exact covered slice until the last stage.

  1. Build the fixtures. Add the host heap/memory services, deterministic events, CHAIN registration/dispatch, call logging, the DirectPlay export loader cells, and the fake IDirectPlay3A. Hook ShellExecuteA so retail can never launch anything.
  2. Port the queue primitives and transport units. Start with receive +0x5820, send enqueue +0x7890, send drain +0x7970, and send-one +0x79d0. Test allocation/reallocation failures, empty queues, DPERR_BUFFERTOOSMALL, repeated DPERR_BUSY, success, and generic errors.
  3. Port public send +0x20. Cover both guaranteed-message settings, both queues, zero and nonzero payload lengths, disconnected state, and failure propagation. MPCHAT is the easiest real caller to inspect for argument meaning.
  4. Port worker setup/start/stop. Test idempotent starts and stops, event creation failure at each index, thread creation failure, ordered draining, and the retail 100 ms shutdown wait.
  5. Port DirectPlay object/session helpers and public +0x18. Cover loader failure, interface-query failure, host and join paths, session enumeration, group/player creation, open/close, every cleanup edge, and repeated calls while already connected or disconnected.
  6. Port player/message dispatch. Recover every on-wire message tag and length check before naming structures. Cover joins, leaves, type changes, names, aircraft updates, multipart model/texture traffic, malformed input, duplicate IDs, and reverse lookup behavior.
  7. Port public dialog +0x1c and profile round-trip. Model host dialog 0x110b, UTIL +0x8c/+0x90, enumeration callbacks, and the suppressed ShellExecuteA path.
  8. Port init/deinit and both CHAIN callbacks. Only now enable +0x04 and +0x08, because their callbacks can reach every subsystem above.
  9. Promote the harness to whole-module coverage. Then, and only then, change the output from a slot range to MULTIP whole module.

Whole-module acceptance

A final 20,000-scenario claim means all eleven public Linkage entries are non-stubs and invoked, plus asynchronous work is drained after every action. The scenario generator must mix:

  • fresh init, repeated init-like messages, orderly shutdown, and injected failures at every host allocation, event, thread, and DirectPlay call;
  • disconnected, hosting, joining, connected, and closing session states;
  • zero, one, maximum, duplicate-ID, observer, and player record sets;
  • all DirectPlay enumeration and name/session descriptor callbacks;
  • empty, single, and multiple receive/send queues, including retry HRESULTs;
  • every recovered application-message type, malformed sizes, and multipart boundaries;
  • all profile booleans and boundary values, including retail clamps;
  • pointer-normalized public returns, state snapshots, ordered host/CHAIN/COM call logs, sent payload bytes, and queued/received payload bytes.

Before installation, also require a clean make all, git diff --check, the whole-module differential run with zero differences, and a game smoke test. Only after those pass should MULTIP be added to MODULES; its installed name must remain MULTIPLAYER.DLL through the existing Makefile mapping.

Provider catalogue checkpoint

multip_providers.inc recovers DirectPlay provider enumeration (.text+0x41f0), its five-argument callback (+0x4210), and catalogue cleanup (+0x42a0). Private test entries 221–223 map to cleanup, callback and enumeration respectively. These are configuration +0x3d60 dependencies; their original checkpoint kept public entries guarded until the later lobby/configuration work below.

The callback appends a zeroed 28-byte record before allocating a zero-filled name of strlen(description) + 1 bytes. Allocation failure returns false and leaves that record appended. String and 16-byte GUID copy failures are ignored; major/minor version writes still happen and the callback returns true. The callback ignores its context.

Cleanup never advances its record pointer: it frees the first name, then rechecks that now-null field for every remaining iteration. Other names leak. A failed free returns -1 before clearing the list count. Enumeration consumes that cleanup failure as success (zero), without calling DirectPlayEnumerateA. Otherwise any nonzero enumeration HRESULT, including positive values, becomes -1. HOST +0x304 clears only the count and retains the backing array.

The synthetic providers fixture compares five successive actions, ordered host calls, callback identity/context, returns, descriptor state, GUID/version bytes, name heap bytes and ownership, and partial records. It varies empty and multi-provider catalogues, null first names hiding later names, empty and long descriptions, positive/error HRESULTs and allocation/free/copy failures. Known retail outcomes independently assert the first-name leak, swallowed cleanup failure, version fields and positive-HRESULT normalization. List append is a successful fixed-capacity host model; fatal host growth failures and real DirectPlay/network behavior are outside this slice.

make multipdiff-run N=20000 passes all 38 top-level suites with zero differences, including the retail boundary assertions in the provider fixture. All eleven provider mutations are caught at 1,200 scenarios. The production DLL builds without warnings and still exports only ImportTable and Linkage; git diff --check passes. This remains slice coverage, not whole-module acceptance. MULTIP has not been installed or added to MODULES.

Focused verification: make multipdiff, then from bin/difftest, WINEDEBUG=-all wine multipdiff.exe 20000 providers. Mutation checks: python3 -B tools/multip_breaks.py --slice providers. The earlier 382-target full mutation sweep remains unfinished; the provider mutations extend that set to 393 targets at that checkpoint. The later lobby/configuration work below recovers the remaining browser/dialog owners and enables public +0x18/+0x1c.

Lobby and configuration dialog checkpoint

multip_configuration.inc recovers setup, validation, provider selection, reset, acceptance, options and external launch for dialog 0x110a (.text+0x3d60–+0x41e0). multip_browser.inc covers session discovery, matching/stale-record pruning, nested player lists, deep-copy callbacks and cleanup (+0x42f0–+0x47ec). multip_options.inc recovers public +0x1c and dialog 0x110b (+0x39a0–+0x3d57). The shared player-details dialog 0x110c, including capacity edits, periodic refresh, removal confirmation and cleanup (+0x7b10–+0x805d), is in multip_details.inc. The callback pointers in generated data are bound to these owners at DLL attachment.

multip_lobby.inc recovers startup with host-supplied or explicit connection settings, lobby creation/application, host/join and interface connection (+0x9990–+0x9c60). multip_toggle.inc implements public session toggle +0x18 (+0x8e60). Both public entries are enabled. The later init checkpoint enables init/deinit as well; MULTIP remains outside MODULES.

Preserved behavior includes:

  • Configuration setup/dialog failures return cancellation (zero) without cleanup. Cancellation clears host/type state. Accepted launch mode calls ShellExecuteA only for mode 2 and ignores its result. Reverse name cleanup decrements the published count before freeing and ignores browser-clear errors.
  • Session refresh marks old records stale, reinitializes matching records without freeing their previous name/list, and publishes only after successful EnumSessions. TIMEOUT returns false without inspecting a descriptor. Failed record setup returns -1, which DirectPlay treats as a true callback result. Marker/prune walker errors are consumed. Browser cleanup ignores walker errors, then frees its backing array through a temporary and resets fields only on success.
  • Options save publishes the local name before swapping receive-group membership. Both name validations and both numeric parses run where retail runs them; invalid fields retain their individual control error codes. The weather-button entry retains the four caller-stack bytes transmitted at the end of message 1014.
  • Player-details refresh uses unsigned elapsed time and a strict greater-than comparison to period * 1000, including wrap. Removing a player sends only after confirmation; early send/free errors leak the allocated strings. Success moves the next refresh time forward by 2000 ms. Capacity edits preserve the minimum-two clamp and per-control parse results.
  • Lobby creation and SetConnectionSettings HRESULTs are ignored. Host takes priority when both connection bits are set. Connect's second QueryInterface failure is consumed and leaves the queried flag unchanged; its temporary interface is still released. Later failure leaks the lobby interface. Explicit-connection startup returns -1 even after successful completion.
  • Public toggle loads first, confirms before disconnect, and retains retail's mix of cancellation, zero, one and failure returns. It also proceeds from a nonzero configuration failure according to the retained type/host fields.

The integrated toggle fixture found an ordering gap in the previously recovered multip_session_host: retail reads the interface's vtable before writing the final capacity and name-pointer fields. A null interface faults before those writes. The owner now preserves that ordering with a volatile vtable read. Scenarios 7419 and 13537 exposed this gap in the 20,000-case lobby run.

The dialogs fixture drives the real callback blocks over deterministic host, UTIL exchange and NUMERIC boundaries, session/player enumerators, and a stable heap. It visits all three dialogs, repeated refresh/prune, valid/invalid fields, allocation/copy/free failures, capacity publication, weather traffic and player removal. ShellExecuteA is patched on both DLLs before any callback runs, so the fixture cannot launch an external program. The only real UI-formatting import used by the fixture is wsprintfA.

The lobby fixture drives all six lobby owners and public +0x18 through real completion, options +0x1c and repeated disconnect. COM, CHAIN, thread creation, events, waits and Sleep are scripted; no network, OS worker threads or real sleep are used. Lobby, temporary connected and queried global interfaces have distinct fixture identities, and settings start with nonzero flags independent of MULTIP's configured session flags. Open failures are scripted independently of memory/callback failures. Distinct deep stack words and a word changed by registration check completion's unwritten DPNAME field through host/join/apply/start/toggle nesting. Heap ownership/bytes, input session settings, queue metadata, returns, module state and ordered calls are compared after actions. A deliberate null-interface host call also targets the capacity/name write ordering directly, and refresh inputs are aimed at each side's published timer boundary. Coverage assertions require all dialog families, pruning, shell interception, player removal, lobby creation/release and painted completion words to occur.

The host list stand-in follows 0x4102a0/0x410470: iteration increments the nesting depth and defers removals, then applies queued indexes in reverse even when a visitor returned an error. Growth uses the descriptor's actual growth step. Valid memory-copy/set/string operations write before reporting injected failure, preventing artificial uninitialized stack strings. List growth remains fatal rather than a fallible callback boundary.

Faults caused by retained partial records are compared as outcomes. A failed local CreatePlayer can leave +0x0c clear and its name pointing at .data+0x9a8; disconnect then tries to free module memory. That exact invalid-free boundary is modeled without calling the process allocator. Other invalid fixture frees remain harness errors. These x86 comparisons do not validate native fallbacks, real DirectPlay providers, Windows dialog rendering, or OS thread scheduling.

make multipdiff-run N=20000 passes all 40 top-level suites, zero differences. The new dialog suite observes 19,219 configuration dialogs, 56,406 options dialogs, 37,203 player-details dialogs, 18,867 prune calls, 36,959 player-removal confirmations and 22,484 intercepted shell calls. The lobby suite observes 3,317 lobby creations, 2,526 releases, 10,657 checked completion stack words and 457 public-toggle completions. The 980 dialog and 1,809 lobby fault outcomes are compared, not filtered away. Production and fixture DLLs compile without warnings; the production export table still has only ImportTable and Linkage. git diff --check passes.

All 21 dialog mutations and all 13 lobby/toggle mutations are caught at 1,200 scenarios each. All 427 targets match exactly once; the full 427-target sweep remains unfinished. The mutation runner uses each new slice's own unmodified baseline, while the full regression still exercises all owners.

Focused commands from bin/difftest: WINEDEBUG=-all wine multipdiff.exe 20000 dialogs and WINEDEBUG=-all wine multipdiff.exe 20000 lobby. Mutations run from the repository root with python3 -B tools/multip_breaks.py --slice dialogs or --slice lobby. Run Wine serially.

Init/deinit checkpoint

multip_init.inc implements public init +0x04 (.text+0x20), deinit +0x08 (+0x70), and their two CHAIN callbacks (+0x90, +0x220). All eleven public entries now have recovered owners. At this checkpoint MULTIP remained outside MODULES; the later acceptance checkpoint completes the remaining gates and promotes it.

Init ignores the host registration result, saves the application GUID pointer, and returns CHAIN registration's raw result. Only a zero registration result clears the three DirectPlay export addresses. Deinit returns the raw host unregistration result and performs no session cleanup.

The first callback returns an unrecognized event's code. Event 0x36 loads the profile, ignores the resource-path result, merges flag 0x40 when +0x18c is nonzero, resets four startup fields, registers the shutdown callback, constructs the worker subsystems and starts the lobby path. Each failure retains the work already done; repeated startup can retain old queues.

Event 0x3a reads the compound connection size at payload +0x110, copies the bytes at +0x114, and relocates session, session-name, name-descriptor, short-name, long-name and address offsets against the allocation. It clears the session password pointer. Offset zero is still relocated. Allocation, copy and free results remain raw. Copy failure leaks the block; a loaded DirectPlay instance also retains it because explicit lobby startup always returns -1, even after successful completion. No pointer validation was added.

The second callback handles 0x37 by disconnecting, saving the profile, deleting catalogued texture files and tearing down workers, stopping at the first failure. Other events preserve incoming EAX. The x86 wrappers retain the retail frame depth and use the first callback's caller-owned code cell as allocation scratch. The portable fallback cannot express incoming EAX and is not validated by these x86 comparisons.

The init fixture drives registration, repeated startup, compound connections, unrecognized events and repeated shutdown. It compares returns, ordered calls, module state, heap bytes, liveness, retained allocations and fault outcomes. Host/CHAIN returns include positive and negative values independent of memory failure injection. A mutation removing the worker-setup error branch initially survived the generic failure schedule. Targeted failures now cover each startup queue allocation, and that mutation is caught. Scripted texture paths and deletion failures test shutdown ordering without filesystem writes. Win32 events and critical sections are deterministic stand-ins; thread creation, waits, COM and ShellExecuteA reuse the controlled lobby backend. Painted stack words check completion through both entry-callback paths. Faults from null events and malformed relative offsets remain compared outcomes.

Run the focused fixture from bin/difftest with WINEDEBUG=-all wine multipdiff.exe 20000 init; run its mutations from the repository root with python3 -B tools/multip_breaks.py --slice init. Run Wine serially.

make multipdiff-run N=20000 passes all 41 top-level suites, zero differences. The init suite observes 6,248 startup registrations, 301,415 integer-profile writes, 6,877 compound copies, 3,569 checked completion stack words and 1,075 queue allocation failures. All 3,755 fault outcomes are compared. All 23 init mutations are caught at 1,200 scenarios each; all 450 mutation targets match exactly once. The complete 450-target sweep remains unfinished. Both DLL builds are warning-free, production exports remain ImportTable and Linkage, and git diff --check passes.

Whole-module acceptance checkpoint

The harness now combines the specialized protocol, profile, player, dialog, worker and dispatch suites with two additional acceptance suites:

  • whole retains one module state across public init, CHAIN startup, host/join completion, peer additions, all public queries, sending, receiving, options, repeated startup, public toggle, shutdown and deinit. Eligible receive/dispatch/send work runs synchronously after every action. Failures retain their queues, leaks and partial state; the scheduler does not repair those outcomes. It compares ordered calls, normalized pointers, heap bytes, module state, and complete sent packet bytes, including retry attempts.
  • bounds drives message 1000 both directly and through the actual application dispatcher, with matching lengths 0..0x6b, 0x6c, 0x6d, 0xffffffff and 0x80000000. Readable pages followed by PAGE_NOACCESS expose wrapped copy lengths and bound real copy accesses. Both source and destination boundaries are exercised, along with missing peers/models, stale/equal/new sequences and mismatching lengths. Partial writes, requested counts, sequence state and guard faults are compared. Retail's unchecked subtraction remains intact.

The 20,000-case mixed trace passes with zero differences: 235,815 actions, 128,462 send attempts, 11,892 received packets and 5,880 traces reaching all eleven public entries. Coverage assertions require disconnected, hosting, joining and closing states and at least eight player records. Repeated startup can retain eleven records, matching retail. Its 551 fault outcomes are compared. The boundary suite also passes 20,000 scenarios: 1,090 wrapped copies and 1,100 guard faults. The complete initialized-pointer catalogue was audited: all 31 code targets have recovered bindings.

The complete regression passes all 43 top-level suites at 20,000 scenarios each, plus eight dispatch-family reruns, and reports MULTIP whole module: 20000 scenarios per suite, 0 differences.

Mutation slices now select their own fixtures instead of running unrelated suites after a deliberate error. Each selected slice first checks an unmodified baseline. Standalone receive installs its own allocator callbacks, and the group/metadata fixtures initialize their own COM objects; they no longer depend on preceding suites. An early full sweep exposed these dependencies and an unrelated lifecycle heap exhaustion; neither was counted as a catch. --start-index supports resuming an explicitly reviewed contiguous prefix without treating interrupted attempts as completed comparisons. The baseline DLL is built once. Two compiler jobs prepare independent mutant DLLs; installation and Wine comparisons remain serial, with the previous process finished before the next DLL is copied into the test directory.

make multip-smoke builds the module, temporarily swaps it into the staged game as Modules/MULTIPLAYER.DLL, and uses the existing startup assertions. It additionally requires the smoke log to confirm the exact OpenFS98 MULTIP build, and restores the previous DLL with a bytewise check on exit. The smoke run passed: 27/27 modules, 95 gauges, SEHFIX present, with the replacement MULTIP confirmed. The prior DLL's SHA-256 was also verified after restoration. The other installed game modules were retained; a forced rebuild changes PE timestamps, so differing DLL bytes alone do not establish retail provenance. The generic smoke diagnostic now states that the exact build was not tested instead of claiming the installed DLL must be retail.

make -B all completes without warnings. make multip-acceptance reproduces the full 20,000-case harness, all mutation checks, whitespace checks and the temporary game smoke test in sequence. Focused selectors are whole and bounds; their new mutation slices contain four and two checks respectively. Run Wine serially. These checks do not validate non-x86 fallbacks, real provider interoperability or OS thread scheduling races.

The complete 456-target mutation sweep passes, with each target matched exactly once and every comparison finishing normally with differences. The reviewed first 37 catches were preserved across a deliberate interruption; the resumed run completed the remaining 419 in corpus order, including the six new checks. The combined ledger was checked against the exact ordered corpus. All 14 command mutations were additionally rechecked with compiler jobs idle during watchdog measurements. Crashes, exhausted fixtures and interrupted attempts did not count.

With the acceptance gates passed, MULTIP is included in MODULES. The install mapping remains MULTIP.DLL to MULTIPLAYER.DLL. Startup smoke verifies this replacement temporarily and restores the exact previous DLL; normal deployment is through make install. Non-x86 behavior, OS scheduling races and live provider interoperability remain outside the automated claim.

After promotion, make all rebuilds MULTIP without warnings and the install mapping check returns MULTIPLAYER.DLL. The rebuilt production .text is byte-identical to the accepted implementation; the source edit only updates the status comment. Startup smoke was repeated with this final production build and again passed 27/27 modules, 95 gauges, with the exact replacement confirmed and the original game DLL's SHA-256 verified after restoration. Production exports remain only ImportTable and Linkage. The 14 quiet command rechecks reproduce the same differing-scenario counts as the full sweep. git diff --check passes. Changes remain uncommitted.