diff --git a/docs/wiki.rst b/docs/wiki.rst index ce0e938e..993a3eae 100644 --- a/docs/wiki.rst +++ b/docs/wiki.rst @@ -33,7 +33,7 @@ The Game itself - AI Behavior - Graphics and Sound Systems - Modding Support -- Multiplayer Architecture +- :doc:`Multiplayer command ownership and replay boundaries ` (Coming Soon!) diff --git a/docs/wiki/multiplayer-command-flow.md b/docs/wiki/multiplayer-command-flow.md new file mode 100644 index 00000000..66e51b4d --- /dev/null +++ b/docs/wiki/multiplayer-command-flow.md @@ -0,0 +1,98 @@ +# Multiplayer command ownership and replay boundaries + +This note records original-code evidence from Crusader 1.41, SHA-256 +`3bb0a8c1e72331b3a30a5aa93ed94beca0081b476b04c1960e26d5b45387ac5a`. +Addresses refer to that executable. Generated names help navigation; the original +instructions establish behavior. No multiplayer match was run for this analysis. + +## From transport to simulation + +1. `queueCommand` (`0x489100`) builds a command in a 200-entry ring. Each entry is + `0x4F8` bytes, with tick at offset 0, sender handle at 4, category at 8, state + at 9 and parameters at 10. The handler participates in both serialization + and execution; invoking it does not necessarily mean the command executed. +2. `transmitCommand` (`0x487C50`) serializes the low **three bytes** of the tick + after the category byte. The generated `Packet` labels divide this into + `short time` and `byte unknown`, but the native copy at `0x46A830` writes all + three bytes of the timestamp. Payloads above 200 bytes normally use envelope + category 125; category 65 has a separate exception. Most commands use + guaranteed DirectPlay delivery; categories 12 and 117 have special handling. +3. `receiveAllTransmittedCommands` (`0x490690`) unwraps category 125 and zeroes a + 32-bit tick before copying those three bytes into it. DirectPlay system + messages, synchronization packets and ordinary commands take different paths. + Host migration and player removal are processed here, outside timed dispatch. +4. `scheduleReceivedCommand` (`0x480210`) invokes the handler in receive/parse + mode. Timed payloads go into the ring. Commands with signed time <= 0 copy to + the fixed parameter area and execute immediately at `0x480425`, then clear + their temporary ring entry. They never reach `processWaitingCommands`. +5. `getCommandIDFromCommandSelectionStuff` (`0x480440`) selects due, unprocessed + entries, with a batch cap of 100. It translates sender handles and stably + orders the selected entries by logical player slot. A recorder must observe + actual dispatch order; transport arrival order is insufficient. +6. `processWaitingCommands` (`0x4892F0`) translates each sender again, sets the + execution action/parameter state, calls the handler and marks the entry + processed. The translator is `0x47EAF0`, reimplemented by this contribution. + +The timestamp is thus 24-bit on the wire and 32-bit in the ring. The examined +receive routine does not reconstruct a higher epoch. Long-duration recording +and replay must preserve that distinction rather than assuming identical wire +and internal formats. This observation does not establish a reproduced wrap bug. + +## System messages bypass command dispatch + +After a successful `IDirectPlay4A::Receive`, the receiver compares the sender +with zero (`DPID_SYSMSG`). Its system-type switch begins at `0x490735` +(Extreme `0x490895`). Failed receives and ordinary nonzero senders branch away +before this point. Values below also agree with the DirectX SDK `dplay.h`: + +- `DPSYS_DESTROYPLAYERORGROUP` (`5`) reads the handle at message offset 8 and + calls the identity translator at `0x490755`. The next call is + `removePlayerFromLobby` at `0x49075B`, without reloading ECX. This is another + concrete native caller requiring this reimplementation to retain ECX. +- `DPSYS_HOST` (`0x101`) sets `isHost`, resets the hash countdown, assigns a new + `timeGetTime()` value to the autosave timer and clears both nine-entry player + timing arrays. Chat and out-of-match lobby ordering also change. A host-only + transition can therefore change native scheduling state without a changed + player roster or any timed command. +- `DPSYS_CREATEPLAYERORGROUP` (`3`) and `DPSYS_SESSIONLOST` (`0x31`) fall through + to the next receive iteration without special handling in this switch. This + describes the original routine; it does not imply that a transport or replay + implementation may disregard the broader connection lifecycle. + +Polling roster and synchronization fields at simulation boundaries cannot +establish that no system event occurred between them. Recorder diagnostics can +observe the type-switch entry before mutation, but replaying those events still +requires their semantics and timing. DirectPlay system structures can contain +process pointers; copying their bytes is not a portable replay format. + +## Why one save and a timed-command log are insufficient + +The original save section table does not cover active mode or the full network +handle array. It does cover a saved mode copy, AI-slot values and local player +slot. The single-player load-dialog path (`0x4950B0`) clears network handles and +recreates the local entry. Multiplayer playback therefore needs explicit roster +restoration and command ownership, not merely a changed mode flag or slot zero. + +`queueSynchronizedAutosaveProtocol` (`0x48C660`) uses host wall-clock elapsed time, +then queues a save command carrying the simulation tick and a unit checksum. +`checkGameSync` (`0x48CB00`) compares peer evidence and starts the resync state +machine. `recomputeHashesAndSendResync` (`0x48CC90`) hashes selected game arrays; +`sendPendingResyncCommandsInBudget` (`0x48E680`) sends mismatching sections in +bounded batches, retaining its category/item position between calls. These +include units, buildings, player data and tile-map regions. Resync replaces +simulation data; treating every immediate command as presentation-only is wrong. + +A useful recorder must account separately for timed command execution, immediate +state-changing commands, roster/host transitions and resync transfers. Reliable +transport delivery alone cannot restore missing replay state or make +frame/audio-driven RNG calls deterministic. An extension's ordinary save +integration also does not demonstrate integration with native resync transfers. + +## Validation scope + +The accompanying player-identity checker compiles and executes the actual C++ +function against the originals in both variants. It covers last-match semantics, +sentinels, missing handles, relocated receivers and the retained ECX value. +Other routines in this note were inspected in the named Ghidra project and +original assembly; they are not newly reimplemented or live-validated here. +See [reproduction instructions](../../tools/reimplementation-tests/PLAYER-IDENTITY.md). diff --git a/src/OpenSHC/Synchrony/GameSynchronyState/translateMultiplayerIDsIntoPlayerIDs.cpp b/src/OpenSHC/Synchrony/GameSynchronyState/translateMultiplayerIDsIntoPlayerIDs.cpp new file mode 100644 index 00000000..f0bdfa00 --- /dev/null +++ b/src/OpenSHC/Synchrony/GameSynchronyState/translateMultiplayerIDsIntoPlayerIDs.cpp @@ -0,0 +1,36 @@ +#include "../GameSynchronyState.func.hpp" + +namespace OpenSHC { +namespace Synchrony { + + // FUNCTION: STRONGHOLDCRUSADER 0x0047EAF0 + uint GameSynchronyState::translateMultiplayerIDsIntoPlayerIDs(int playerHandle) + { + uint player = 0; + if (this->currentGameMode == Game::GM_SOLITARY || this->currentGameMode == Game::GM_SKIRMISH_SINGLE_PLAYER) { + return this->currentPlayerSlotID; + } + + // Do not return on the first match: the native function lets the last + // matching slot win, including duplicate or sentinel-valued handles. + if (this->currentPlayerFullIDArray[1] == playerHandle) + player = 1; + if (this->currentPlayerFullIDArray[2] == playerHandle) + player = 2; + if (this->currentPlayerFullIDArray[3] == playerHandle) + player = 3; + if (this->currentPlayerFullIDArray[4] == playerHandle) + player = 4; + if (this->currentPlayerFullIDArray[5] == playerHandle) + player = 5; + if (this->currentPlayerFullIDArray[6] == playerHandle) + player = 6; + if (this->currentPlayerFullIDArray[7] == playerHandle) + player = 7; + if (this->currentPlayerFullIDArray[8] == playerHandle) + player = 8; + return player; + } + +} // namespace Synchrony +} // namespace OpenSHC diff --git a/status/addresses-SHC-3BB0A8C1.txt b/status/addresses-SHC-3BB0A8C1.txt index 10a0c2b0..4c2888fa 100644 --- a/status/addresses-SHC-3BB0A8C1.txt +++ b/status/addresses-SHC-3BB0A8C1.txt @@ -22385,7 +22385,7 @@ SHC_3BB0A8C1_0x0047E8F0 | 0.0% | Pending SHC_3BB0A8C1_0x0047EA40 | 0.0% | Pending -SHC_3BB0A8C1_0x0047EAF0 | 0.0% | Pending +SHC_3BB0A8C1_0x0047EAF0 | 85.29% | Reimplemented SHC_3BB0A8C1_0x0047EB80 | 0.0% | Pending diff --git a/tools/reimplementation-tests/PLAYER-IDENTITY.md b/tools/reimplementation-tests/PLAYER-IDENTITY.md new file mode 100644 index 00000000..a30e70cb --- /dev/null +++ b/tools/reimplementation-tests/PLAYER-IDENTITY.md @@ -0,0 +1,55 @@ +# Player-handle translation + +`GameSynchronyState::translateMultiplayerIDsIntoPlayerIDs` resolves a transport +handle to the logical player that owns a command. It is called both during +selection/sorting and immediately before native dispatch. Replaying the raw +handle through ordinary single-player dispatch therefore executes as the local +player, regardless of the recorded actor. + +The source preserves the original behavior: + +- Modes 0 and 99 return the receiver's local slot without examining the handle. +- Other modes scan slots 1 through 8; slot 0 is ignored. +- A missing handle returns zero. Duplicate handles return the highest matching + slot, including sentinel values. This routine itself does not validate a roster. +- It does not mutate game state and preserves ECX, which native callers reuse. + +The addresses are `0x47EAF0` in Crusader and `0x47ECC0` in Extreme. Original +instructions use ECX-relative state, not the fixed globals suggested by Ghidra's +decompiler. Both variants use mode offset `0x618` and handles at `0x6A8`; the +local-slot offsets are `0x109E74` and `0x166304` respectively. The OpenSHC source +uses its existing Crusader structure layout; the checker populates each native +variant's own layout for comparison. + +## Reproduce + +From a checkout with the project's toolchain/dependencies initialized: + +```powershell +$taskRoot = (Get-Location).Path +$taskMsvc = Join-Path $taskRoot 'MSVC1400-SP1' +$env:PATH = "$taskMsvc/Common7/IDE;$taskMsvc/VC/bin;$env:PATH" +$env:INCLUDE = "$taskMsvc/VC/include;$taskMsvc/VC/PlatformSDK/Include" +New-Item -ItemType Directory -Force tmp | Out-Null +& "$taskMsvc/VC/bin/cl.exe" /nologo /c /O2 /EHsc /DOPEN_SHC_DLL ` + /I "$taskRoot/src" /I "$taskRoot/dependencies/ucp3/include" ` + /I "$taskRoot/dependencies/lua/include" /I "$taskRoot/dependencies/DXSDK_Aug2007/include" ` + /FI "$taskRoot/src/precomp/pch.h" /Fotmp/playerIdentity.obj ` + src/OpenSHC/Synchrony/GameSynchronyState/translateMultiplayerIDsIntoPlayerIDs.cpp +if ($LASTEXITCODE -ne 0) { throw 'Compilation failed' } +python -m pip install pefile unicorn==2.1.4 +python tools/reimplementation-tests/player_identity.py tmp/playerIdentity.obj 'PATH/Stronghold Crusader.exe' +python tools/reimplementation-tests/player_identity.py tmp/playerIdentity.obj 'PATH/Stronghold_Crusader_Extreme.exe' +``` + +The checker requires the known original executable SHA-256, compares the actual +compiled source and native routine against an independent last-match model, and +checks receiver relocation, missing/duplicate/sentinel handles, all slots, +single-player/end-of-game modes, memory read/write bounds and stack/register +behavior. Use Python 3.10+ without `-O`. + +This is readable C++03 with no generated-header or resolver changes. MSVC emits +137 bytes, the same size as the original, but places the single-player return +block differently. Exact-byte matching, a linked-DLL reccmp score and live +multiplayer behavior are not claimed. The contribution supplies a verified +native identity primitive; it does not enable multiplayer replay. diff --git a/tools/reimplementation-tests/player_identity.py b/tools/reimplementation-tests/player_identity.py new file mode 100644 index 00000000..b46a522e --- /dev/null +++ b/tools/reimplementation-tests/player_identity.py @@ -0,0 +1,107 @@ +"""Compare the compiled player-handle translator to both original game variants. + +Requires pefile and unicorn. Reads files only; never launches the game. +""" +import argparse +import hashlib +from pathlib import Path +import random +import struct + +import pefile +from unicorn import Uc, UC_ARCH_X86, UC_MODE_32, UC_HOOK_MEM_READ, UC_HOOK_MEM_WRITE +from unicorn.x86_const import (UC_X86_REG_EAX, UC_X86_REG_EBX, UC_X86_REG_ECX, + UC_X86_REG_ESI, UC_X86_REG_EDI, UC_X86_REG_EBP, UC_X86_REG_ESP, UC_X86_REG_EIP) + +SYMBOL = b'?translateMultiplayerIDsIntoPlayerIDs@GameSynchronyState@Synchrony@OpenSHC@@QAEIH@Z' +VARIANTS = { + '3bb0a8c1e72331b3a30a5aa93ed94beca0081b476b04c1960e26d5b45387ac5a': (0x47eaf0, 0x109e74), + '55648e6b05d67d37a5773fe699bbb17a2d6ad4de1bb9dbded9a21caef82bd7fb': (0x47ecc0, 0x166304), +} + + +def object_code(path): + data = path.read_bytes() + machine, sections, _, symbols, count, optional, _ = struct.unpack_from('