From 2dbd3c48f93751f8537fca9d1e82bfdb7aa26ec2 Mon Sep 17 00:00:00 2001 From: Krarilotus <51748815+Krarilotus@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:49:10 +0200 Subject: [PATCH 1/4] Reimplement tick orchestration and document game-loop ownership --- docs/wiki/game-loop-ownership.md | 108 ++++++++++ .../GameStateStructures/processGameTick.cpp | 184 ++++++++++++++++++ status/addresses-SHC-3BB0A8C1.txt | 2 +- 3 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 docs/wiki/game-loop-ownership.md create mode 100644 src/OpenSHC/Game/GameStateStructures/processGameTick.cpp diff --git a/docs/wiki/game-loop-ownership.md b/docs/wiki/game-loop-ownership.md new file mode 100644 index 000000000..7442c7ace --- /dev/null +++ b/docs/wiki/game-loop-ownership.md @@ -0,0 +1,108 @@ +# Game-loop ownership + +This describes the original Crusader 1.41 executable with SHA-256 +`3bb0a8c1e72331b3a30a5aa93ed94beca0081b476b04c1960e26d5b45387ac5a`. +Addresses identify original instructions, not replacement DLL addresses. +Extreme must be inspected separately. + +## The outer iteration + +`WinMain` (`57BE10`) owns the Windows message pump and the budget of calls into +the game. A pending Windows message takes the dispatch path instead of the +ordinary update/render path. An unfocused solitary/single-player game takes a +waiting path; multiplayer can continue without focus. + +The ordinary path has this order: + +1. Time-based message/video selection, modifier keys, menu transitions, ambient + music, network receive, and focused mouse/cursor updates. +2. Synchrony chooses `gameTicksThisLoop` (`487A30`). Synchronized autosave and + viewport bookkeeping run before the inner loop. +3. For each budgeted iteration: execute waiting commands (`4892F0`), call + `processGameTick` (`45CD10`), receive network traffic (`490690`), process Bink + frames (`409200`), and update tactical help (`4D9D90`). `DAT_GameHalted` can + break the loop before `performedGameTicksThisLoop` increments. +4. Lag checks, focused menu/input handling, world rendering, another Bink + processing call, menu rendering, audio and display presentation. + +Consequently, a rendered frame, a command batch, a call to `processGameTick`, and +a match-clock increment are distinct events. A zero budget omits step 3 but not +all menu, input, audio or receive activity. Multiple budgeted iterations occur +before the next ordinary render/input pass. + +The existing [WinMain reconstruction (PR #202)](https://github.com/sourcehold/OpenSHC/pull/202) +contains this outer call order. The implementation here complements that work +by reconstructing the tick coordinator it calls. + +## The tick orchestrator + +[processGameTick.cpp](../../src/OpenSHC/Game/GameStateStructures/processGameTick.cpp) +preserves the original order and delegates subsystem work through existing +resolvers. It does not implement those subsystems again. + +| Phase | Native evidence | Owner and behavior | +| --- | --- | --- | +| Save/synchronization | `45CD10`–`45CDE3` | Synchrony may service a host state machine, advance a client handshake and queue its reply, or return without world work. | +| Timed external decision | `45CDE4`–`45CE15` | An expired quit-vote request queues a command using Windows time. This precedes the clock gate. | +| Clock advancement | `45CE16`–`45CE68` | In-game/menu/pause checks guard RNG2, RNG1, match-clock increment, then `processSingleTimeTick` (`45CA20`). | +| Map maintenance | `45CE69`–`45CEF7` | Orientation, height/view layers, navigation, building linkage, siege starting costs and minimap markers. This phase does not require a clock increment. | +| World admission | `45CEF8`–`45CF55` | Rotation/refresh can bypass the later logical/menu pause test. Editor halt and in-game checks follow. | +| World update | `45CF56`–`45D055` | Reset statistics; wind/navigation; buildings; terrain/events; defeat/AI; units/entities; food/population; tribes/wildlife; remaining map/UI bookkeeping. | + +The two pause gates are not interchangeable. A height/view refresh can set +`DAT_RotateMapOrPullDownTerrain`, permitting world updates without a clock +increment. A negative logical-pause value also has explicit behavior at the +later gate: it is reset to zero. A replacement must preserve these branches +even if their ownership would be different in a redesigned engine. + +`processSingleTimeTick` is itself an owner of scheduled game work, rather than +just an integer increment: it dispatches work from `gameTicksLoadBalancer`, +advances its [200-step cycle](load-balancing-table.md) and handles +calendar/network timing. The subsequent +world phase must not be mistaken for the only simulation owner. + +The native calendar call uses the global game-state receiver. The statistics, +food/population and final army-limit calls use the incoming `this` receiver. +That distinction is preserved in the C++ implementation. + +## Cross-boundary dependencies + +- Commands are constructed/received before they are selected and executed. + Their handlers run before the tick call in `WinMain`; the selector's limit + of 100 applies to one selection invocation, not to a unique match-clock value. +- `updateBuildings` reads `performedGameTicksThisLoop` to derive + `isFirstTickInLoop`. This is an explicit dependency on outer-loop grouping. + The direct readers found are armory/granary visual activity and + `updateVisuallyActiveState` (`410290`); the mill temporarily forces this flag + while updating its visual activity. These writes identify the next ownership + boundary, not proof that every downstream reader is presentation-only. +- The field `BuildingsState::unknownCountdown01` is not a time counter in its + observed writers. `setupBuildingData` (`420D20`) decrements it; + `updateBuildings` (`422E20`, store at `423333`) rebuilds it as + `2000 - structCount`. AI placement compares it with remaining-capacity + thresholds. Its reset to 2000 during preparation is distinct from restoring + serialized scheduling fields. +- Crusader's `TacticalPowersFill` (`4D9D90`) decrements a help-display timeout + and clears the display flag when it reaches zero. This function does not + replenish an army or execute a tactical action. +- `processBinkFrames` operates on two Bink slots and their sound/end-of-video + flags. `stopBinkPlayback` (`408E30`) calls `BinkClose` and clears the slot's + handle. Closing slot 1 also clears `GameCore::isBinkVideoPlaying` and sets + `GameCore::countdown` to 1. The latter is consumed by viewport/menu refresh + bookkeeping (`46BB20`, `WinMain`), rather than the match clock. + +The Bink library owns its internal allocations; clearing a game-side handle is +not proof of leak-free library behavior. Likewise, classifying these direct +writes does not certify every downstream menu handler as simulation-neutral. + +## Scope of the reconstruction + +The tick also has callers outside `WinMain`, including map preparation +(`512778`) and UI/editor paths (`42FA04`, `4ABFA4`). Entering or returning from it +alone does not establish an ordinary match step, a complete load, or a safe +point to replace the world beneath an outer caller. + +This orchestration map establishes where to inspect a state difference: the +responsible subsystem, the phase in which it runs, its input fields, and their +writers. It does not attribute a particular desynchronization to an unmeasured +counter, frame grouping, resource leak or callback. diff --git a/src/OpenSHC/Game/GameStateStructures/processGameTick.cpp b/src/OpenSHC/Game/GameStateStructures/processGameTick.cpp new file mode 100644 index 000000000..4649a3891 --- /dev/null +++ b/src/OpenSHC/Game/GameStateStructures/processGameTick.cpp @@ -0,0 +1,184 @@ +#include "../GameStateStructures.func.hpp" + +#include "OpenSHC/AI/AIVState.func.hpp" +#include "OpenSHC/Game/GameCore.func.hpp" +#include "OpenSHC/Global.func.hpp" +#include "OpenSHC/Map/Buildings/BuildingsState.func.hpp" +#include "OpenSHC/Map/Entities/EntityState.func.hpp" +#include "OpenSHC/Map/LandscapeState.func.hpp" +#include "OpenSHC/Map/MapPropertiesState.func.hpp" +#include "OpenSHC/Map/Navigation/PathFindingState.func.hpp" +#include "OpenSHC/Map/TileMapState.func.hpp" +#include "OpenSHC/Map/Units/TribesState.func.hpp" +#include "OpenSHC/Map/Units/TroopValueState.func.hpp" +#include "OpenSHC/Map/Units/UnitsState.func.hpp" +#include "OpenSHC/Map/WallAndPitchState.func.hpp" +#include "OpenSHC/Random/RNG.func.hpp" +#include "OpenSHC/Synchrony/GameSynchronyState.func.hpp" +#include "OpenSHC/UI/HoveredState.func.hpp" +#include "OpenSHC/UI/MinimapViewState.func.hpp" + +#include "OpenSHC/Globals/DAT_AIVState.hpp" +#include "OpenSHC/Globals/DAT_BuildingsState.hpp" +#include "OpenSHC/Globals/DAT_EntityState.hpp" +#include "OpenSHC/Globals/DAT_GameCore.hpp" +#include "OpenSHC/Globals/DAT_GameState.hpp" +#include "OpenSHC/Globals/DAT_GameSynchronyState.hpp" +#include "OpenSHC/Globals/DAT_HoveredState.hpp" +#include "OpenSHC/Globals/DAT_LandscapeState.hpp" +#include "OpenSHC/Globals/DAT_MapPropertiesState.hpp" +#include "OpenSHC/Globals/DAT_MinimapViewState.hpp" +#include "OpenSHC/Globals/DAT_PathFindingState.hpp" +#include "OpenSHC/Globals/DAT_RotateMapOrPullDownTerrain.hpp" +#include "OpenSHC/Globals/DAT_TileMapState.hpp" +#include "OpenSHC/Globals/DAT_TribesState.hpp" +#include "OpenSHC/Globals/DAT_TroopValueState.hpp" +#include "OpenSHC/Globals/DAT_UnitsState.hpp" +#include "OpenSHC/Globals/DAT_WallAndPitchState.hpp" +#include "OpenSHC/Globals/SEC_RNG.hpp" + +namespace OpenSHC { +namespace Game { + // FUNCTION: STRONGHOLDCRUSADER 0x0045CD10 + void GameStateStructures::processGameTick() + { + // A call is not necessarily a clock step. Save/synchronization owns + // the early paths; none of the world-update phase runs on those paths. + DAT_RotateMapOrPullDownTerrain::instance = 0; + if (DAT_GameSynchronyState::instance.saveRelated != 0) { + if (DAT_GameSynchronyState::instance.isHost) { + MACRO_CALL_MEMBER(Synchrony::GameSynchronyState_Func::checkGameLagStatusAndKickAccordingly, + DAT_GameSynchronyState::ptr)(); + } + return; + } + if (DAT_GameSynchronyState::instance.syncStatus != 0) { + if (DAT_GameSynchronyState::instance.isHost) { + if (DAT_GameSynchronyState::instance.syncStatus == 10) { + MACRO_CALL_MEMBER( + Synchrony::GameSynchronyState_Func::checkLagAndSyncStatus, DAT_GameSynchronyState::ptr)(); + } else if (DAT_GameSynchronyState::instance.syncStatus == 1) { + MACRO_CALL_MEMBER(Synchrony::GameSynchronyState_Func::broadcastDesyncResyncCommands, + DAT_GameSynchronyState::ptr)(); + } else if (DAT_GameSynchronyState::instance.syncStatus == 2) { + MACRO_CALL_MEMBER(Synchrony::GameSynchronyState_Func::sendPendingResyncCommandsInBudget, + DAT_GameSynchronyState::ptr)(); + } else if (static_cast(DAT_GameSynchronyState::instance.syncStatus) > 2) { + MACRO_CALL_MEMBER(Synchrony::GameSynchronyState_Func::advanceSyncStatusAndKickLaggers, + DAT_GameSynchronyState::ptr)(); + } + } else if (DAT_GameSynchronyState::instance.syncStatus == 10) { + DAT_GameSynchronyState::instance.syncStatus = 11; + MACRO_CALL_MEMBER(Synchrony::GameSynchronyState_Func::queueCommand, DAT_GameSynchronyState::ptr)( + Commands::GCT_SHARE_GAME_STATE_PARTIAL_HASHES); + } else if (DAT_GameSynchronyState::instance.syncStatus == 1) { + DAT_GameSynchronyState::instance.syncStatus = 2; + MACRO_CALL_MEMBER(Synchrony::GameSynchronyState_Func::queueCommand, DAT_GameSynchronyState::ptr)( + Commands::GCT_SHARE_DESYNC_HASHES); + } else if (DAT_GameSynchronyState::instance.syncStatus == 3) { + DAT_GameSynchronyState::instance.syncStatus = 4; + MACRO_CALL_MEMBER(Synchrony::GameSynchronyState_Func::queueCommand, DAT_GameSynchronyState::ptr)( + Commands::GCT_SEND_KEEP_ALIVE); + } + return; + } + + if (DAT_GameSynchronyState::instance.quitGameVoteRelated != 0 + && timeGetTime() - DAT_GameSynchronyState::instance.quitGameVoteRequestTime > 11000u) { + DAT_GameSynchronyState::instance.DAT_GameCommandParam0 = 2; + MACRO_CALL_MEMBER(Synchrony::GameSynchronyState_Func::queueCommand, DAT_GameSynchronyState::ptr)( + Commands::GCT_SEND_QUIT_GAME_VOTE); + } + + // Clock/RNG advancement has its own menu/pause gate. Map rotation may + // bypass logical pause; a halting menu still prevents clock advancement. + if (MACRO_CALL_MEMBER(GameCore_Func::getAreWeInAInGameMenu, DAT_GameCore::ptr)()) { + if (DAT_GameCore::instance.gamePausedLogical != 0 + && static_cast(DAT_TileMapState::instance.DAT_FutureMapOrientation) >= 8) { + goto maintenance; + } + if (!MACRO_CALL_MEMBER(GameCore_Func::isGameHaltingMenuOpen, DAT_GameCore::ptr)()) { + MACRO_CALL_MEMBER(Random::RNG_Func::nextRandomNumber2, SEC_RNG::ptr)(); + MACRO_CALL_MEMBER(Random::RNG_Func::nextRandomNumber1, SEC_RNG::ptr)(); + ++DAT_GameCore::instance.mapTimeInTicks; + MACRO_CALL_MEMBER(Game::GameStateStructures_Func::processSingleTimeTick, DAT_GameState::ptr)(); + } + } + if (static_cast(DAT_TileMapState::instance.DAT_FutureMapOrientation) < 8) { + DAT_RotateMapOrPullDownTerrain::instance = 1; + } + + maintenance: + // These owners run even when no clock increment occurred. A view + // refresh can itself set the rotation flag consumed by the later gate. + MACRO_CALL_MEMBER(Map::TileMapState_Func::processMapOrientationChange, DAT_TileMapState::ptr)(); + MACRO_CALL_MEMBER(Map::TileMapState_Func::updateShowHiLayerOrResetChangedLayer, DAT_TileMapState::ptr)(); + MACRO_CALL_MEMBER(Map::TileMapState_Func::rotateFearFactorBuildingVariations, DAT_TileMapState::ptr)(); + MACRO_CALL_MEMBER( + Map::Buildings::BuildingsState_Func::updatePathLinkageForGatesKeepsSiegeTowers, DAT_BuildingsState::ptr)(); + MACRO_CALL_MEMBER(Map::Navigation::PathFindingState_Func::updateSeparateAreaTileMap, DAT_PathFindingState::ptr)( + 0); + MACRO_CALL_MEMBER(Map::TileMapState_Func::updateLogicalTileMapRelatedSections, DAT_TileMapState::ptr)(); + MACRO_CALL_MEMBER(Map::TileMapState_Func::updateMacroLayer, DAT_TileMapState::ptr)(); + MACRO_CALL_MEMBER(Map::TileMapState_Func::updateGfxLayer, DAT_TileMapState::ptr)(); + MACRO_CALL_MEMBER(Map::TileMapState_Func::updateGFXLayers, DAT_TileMapState::ptr)(); + MACRO_CALL_MEMBER( + Map::Navigation::PathFindingState_Func::setChangedLayerZeroBasedOn40x40Layer, DAT_PathFindingState::ptr)(0); + MACRO_CALL_MEMBER(Map::Buildings::BuildingsState_Func::subtractPitchAndMoatFromStartingGoldInSiegeThatMode, + DAT_BuildingsState::ptr)(); + MACRO_CALL_MEMBER(UI::MinimapViewState_Func::clearOldSpawnMoment, DAT_MinimapViewState::ptr)(); + if (DAT_RotateMapOrPullDownTerrain::instance == 0) { + if (static_cast(DAT_GameCore::instance.gamePausedLogical) > 0) { + return; + } + if (DAT_GameCore::instance.gamePausedLogical == 0) { + if (MACRO_CALL_MEMBER(GameCore_Func::isGameHaltingMenuOpen, DAT_GameCore::ptr)()) { + return; + } + } else { + DAT_GameCore::instance.gamePausedLogical = 0; + } + } + if (DAT_GameCore::instance.gameMode_2 == GM_EDITOR && DAT_GameCore::instance.isTimeHalted2 != 0) { + return; + } + if (!MACRO_CALL_MEMBER(GameCore_Func::getAreWeInAInGameMenu, DAT_GameCore::ptr)()) { + return; + } + + // Preserve dependency order: counters, buildings, AI, units, entities, + // population and tribes. The calendar call above uses the global game + // state; the statistics/economy calls below use this receiver. + MACRO_CALL_MEMBER(Game::GameStateStructures_Func::resetCountsAndStatistics, this)(); + MACRO_CALL_MEMBER(Game::GameStateStructures_Func::resetBuildingsCurrentIndexCounter, this)(); + MACRO_CALL_MEMBER(Map::LandscapeState_Func::updateWind, DAT_LandscapeState::ptr)(); + DAT_PathFindingState::instance.field48_0x90 = 0; + MACRO_CALL_MEMBER(Map::Navigation::PathFindingState_Func::updateClimbData, DAT_PathFindingState::ptr)(); + MACRO_CALL_MEMBER(Map::Buildings::BuildingsState_Func::updateBuildings, DAT_BuildingsState::ptr)(); + MACRO_CALL_MEMBER(Map::TileMapState_Func::updatePitchDitches, DAT_TileMapState::ptr)(); + MACRO_CALL_MEMBER(Map::LandscapeState_Func::updateTrees, DAT_LandscapeState::ptr)(); + MACRO_CALL_MEMBER(Map::MapPropertiesState_Func::processSingleplayerEvents, DAT_MapPropertiesState::ptr)(); + MACRO_CALL_MEMBER(Synchrony::GameSynchronyState_Func::checkSkirmishGameDefeat, DAT_GameSynchronyState::ptr)(); + MACRO_CALL_MEMBER(AI::AIVState_Func::updateBuildingsStateAndUpdateAIBuildingDecisions, DAT_AIVState::ptr)(); + MACRO_CALL_MEMBER( + Map::Units::TroopValueState_Func::aiControlNonSkirmishUnitMovement, DAT_TroopValueState::ptr)(); + MACRO_CALL_MEMBER(Map::Units::UnitsState_Func::updateUnits, DAT_UnitsState::ptr)(); + MACRO_CALL_MEMBER(Map::Entities::EntityState_Func::updateEntities, DAT_EntityState::ptr)(); + MACRO_CALL_MEMBER( + Map::Units::UnitsState_Func::removeUnitsSameTileLinkageIfNoLongerApplicable, DAT_UnitsState::ptr)(); + MACRO_CALL_MEMBER(Game::GameStateStructures_Func::processFoodConsumption, this)(); + MACRO_CALL_MEMBER(Game::GameStateStructures_Func::computePopulationStatistics, this)(); + MACRO_CALL_MEMBER(Map::Units::TribesState_Func::updateTribeUnitAssignments, DAT_TribesState::ptr)(); + MACRO_CALL_MEMBER(Map::Units::TribesState_Func::updateTribes, DAT_TribesState::ptr)(); + MACRO_CALL_MEMBER(Map::Units::TribesState_Func::spawnQueuedReinforcementWaves, DAT_TribesState::ptr)(); + MACRO_CALL_MEMBER(Map::Units::TribesState_Func::respawnDeer, DAT_TribesState::ptr)(); + MACRO_CALL_MEMBER(Map::TileMapState_Func::generateDustClouds, DAT_TileMapState::ptr)(); + MACRO_CALL_MEMBER(Map::TileMapState_Func::updateGameRelatedValue, DAT_TileMapState::ptr)(); + MACRO_CALL(Global_Func::DoNothing)(); + MACRO_CALL_MEMBER( + Map::WallAndPitchState_Func::updateDestructionConfirmationCountdown, DAT_WallAndPitchState::ptr)(); + MACRO_CALL_MEMBER(UI::HoveredState_Func::clearInvalidatedHoverStates, DAT_HoveredState::ptr)(); + MACRO_CALL_MEMBER(Game::GameStateStructures_Func::computeArmySizeLimit, this)(); + } +} +} diff --git a/status/addresses-SHC-3BB0A8C1.txt b/status/addresses-SHC-3BB0A8C1.txt index 9d65c5f8e..d79604210 100644 --- a/status/addresses-SHC-3BB0A8C1.txt +++ b/status/addresses-SHC-3BB0A8C1.txt @@ -18143,7 +18143,7 @@ SHC_3BB0A8C1_0x0045C9E0 | 0.0% | Pending SHC_3BB0A8C1_0x0045CA20 | 0.0% | Pending -SHC_3BB0A8C1_0x0045CD10 | 0.0% | Pending +SHC_3BB0A8C1_0x0045CD10 | 99.0% | Reimplemented SHC_3BB0A8C1_0x0045D060 | 0.0% | Pending From e2cf2bf9c077ab1863c8ac202c2e8296cea564a4 Mon Sep 17 00:00:00 2001 From: Krarilotus <51748815+Krarilotus@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:20:06 +0200 Subject: [PATCH 2/4] Trace paused navigation consumers and distinguish network scheduling owners --- docs/wiki/game-loop-ownership.md | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/wiki/game-loop-ownership.md b/docs/wiki/game-loop-ownership.md index 7442c7ace..87b49e578 100644 --- a/docs/wiki/game-loop-ownership.md +++ b/docs/wiki/game-loop-ownership.md @@ -67,6 +67,21 @@ That distinction is preserved in the C++ implementation. ## Cross-boundary dependencies +- Navigation maintenance has its own schedule. `updateSeparateAreaTileMap` + (`4995E0`) decrements `MapAndTimeState::counterForUpdatingSeparateAreaTileMaps` + (`117CAD8`) per invocation. It returns while the count is positive, otherwise + resets it to 200 and checks `PathFindingState + 6C`. A dirty map increments + the revision at `+74`, clears that flag and rebuilds connectivity regions, + zone sizes and building linkage. The countdown is serialized in section 1023; + it is not derived solely from the match clock. + `canUnitReachAdjacentTile` (`4105F0`) reads the unit's and neighboring tiles' + connectivity regions and passes them to the navigation query (`4A5320`). + That adjacent-tile query is called by the unit-controls UI (`4348D0`). There + is also a simulation consumer: `UpdateLord` calls + `canNavigateToDefensiveBuilding` (`40AC80`) at `56C8A1` and `56D0E9`; its + returned building ID controls the lord's state and target writes. Thus the + maintenance phase owns inputs to gameplay decisions, even on a paused call. + This establishes a dependency, not a reproduced desynchronization cause. - Commands are constructed/received before they are selected and executed. Their handlers run before the tick call in `WinMain`; the selector's limit of 100 applies to one selection invocation, not to a unique match-clock value. @@ -95,6 +110,30 @@ The Bink library owns its internal allocations; clearing a game-side handle is not proof of leak-free library behavior. Likewise, classifying these direct writes does not certify every downstream menu handler as simulation-neutral. +## Scheduling is distinct from network agreement + +`determineGameTicksToPerform` (`487A30`) owns how much work the outer loop +attempts before rendering. It returns zero outside a match or when +`DAT_GameHalted` is set. Otherwise it uses elapsed milliseconds, carry and game +speed to choose the budget. Its single-player path updates synchrony's local +clock from the match clock; its multiplayer path first calls the peer-time +adjustment routine (`47E5B0`). The logical pause field checked inside +`processGameTick` is a different gate. A zero work budget, a network halt and +a paused tick must therefore be distinguished when tracing a stalled game. + +The budget does not itself certify agreement between peers. Timed-command +selection owns due/order decisions; immediate receive handlers can mutate state +outside that selection; synchronization checks and replacement transfers have +their own owners. The [command/network reconstruction (PR #220)](https://github.com/sourcehold/OpenSHC/pull/220) +describes those paths. Following the chain requires comparing the first differing +decision and its inputs, not inferring a transport defect from a later checksum. + +One concrete question is whether a clock-only trace has omitted intervening +maintenance calls. Another is whether recovery has restored the state consumed +by the next command or tick. The navigation dependency above explains why these +are separate proof obligations. Neither observation establishes that the +original multiplayer synchronization algorithm is faulty. + ## Scope of the reconstruction The tick also has callers outside `WinMain`, including map preparation From b39af3ced60ed50264b7215953b3fddcc349f276 Mon Sep 17 00:00:00 2001 From: Krarilotus <51748815+Krarilotus@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:23:51 +0200 Subject: [PATCH 3/4] Clarify forced connectivity rebuild callers --- docs/wiki/game-loop-ownership.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/wiki/game-loop-ownership.md b/docs/wiki/game-loop-ownership.md index 87b49e578..216e44bb2 100644 --- a/docs/wiki/game-loop-ownership.md +++ b/docs/wiki/game-loop-ownership.md @@ -82,6 +82,10 @@ That distinction is preserved in the C++ implementation. returned building ID controls the lord's state and target writes. Thus the maintenance phase owns inputs to gameplay decisions, even on a paused call. This establishes a dependency, not a reproduced desynchronization cause. + The tick passes `forceUpdate = 0`; nonzero callers force the count to zero + and mark the map dirty before the decrement. Preparation and + `traceAndCommitPathPlan` also call this owner. Equal countdown samples alone + therefore cannot establish equal call history. - Commands are constructed/received before they are selected and executed. Their handlers run before the tick call in `WinMain`; the selector's limit of 100 applies to one selection invocation, not to a unique match-clock value. From f8391c769a8afc970c5ed673af467c81a47d7f2c Mon Sep 17 00:00:00 2001 From: Krarilotus <51748815+Krarilotus@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:15:37 +0200 Subject: [PATCH 4/4] Document navigation recovery and worker population ownership --- docs/wiki/game-loop-ownership.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/wiki/game-loop-ownership.md b/docs/wiki/game-loop-ownership.md index 216e44bb2..78e8fd2e5 100644 --- a/docs/wiki/game-loop-ownership.md +++ b/docs/wiki/game-loop-ownership.md @@ -114,6 +114,35 @@ The Bink library owns its internal allocations; clearing a game-side handle is not proof of leak-free library behavior. Likewise, classifying these direct writes does not certify every downstream menu handler as simulation-neutral. +## Navigation maintenance and worker population + +The connectivity map also participates in ordinary workers' movement recovery. +In `UnitsState::processUnitMove` (`578C40`), a failed tile move or an outstanding +retry enters the recovery path. After the retry delay expires, it validates the +destination and reads the source and destination `PathConnectionLayer` regions. +A zero destination region rejects the route immediately. Otherwise the movement +owner calls `calculateCanPlayerUnitsNavigateToAreaFromArea` (`4A5320`), whose +initial checks reject a route between different regions if either region is zero. + +An invalid or unreachable destination does not have the same result for every +unit. Lords and selectable units return without being marked for removal; a +caged dog has its walking state cleared. An ordinary nonselectable worker is +assigned `US_DISAPPEAR` and its update tracker is cleared (`579211`). This is a +state transition, not an immediate free of the unit slot. The unit lifecycle +subsequently removes it, and the population census observes the changed counts. + +This connects two phases of the coordinator: navigation maintenance supplies a +movement decision, and `computePopulationStatistics` runs after the unit update. +The next clocked `processPeasantSpawnAndDespawnCycle` can admit a peasant when +that census has freed population capacity. A ready population clock alone is +therefore insufficient to explain when a peasant spawns. + +Buildings own tile occupancy and linkage changes; navigation owns connectivity +rebuilding; movement owns recovery and disappearance; the census owns population +accounting. The refresh countdown schedules a rebuild rather than directly +spawning or deleting a unit. Its per-call scheduling matters because maintenance +can run while the match clock is stationary. + ## Scheduling is distinct from network agreement `determineGameTicksToPerform` (`487A30`) owns how much work the outer loop