From ebfb7d07325d56ae10ac342ffd779aef497f6207 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 29 Aug 2026 02:48:40 +0300 Subject: [PATCH 01/35] Implement a spawn.ini reader https://github.com/OpenTS-Developers/OpenTS/issues/18 --- code/spawnerconfig.cpp | 354 ++++++++++++++++++++++ code/spawnerconfig.h | 176 +++++++++++ manual/changes/spawn-ini-reader.md | 12 + manual/data/ini-read-exclusions.yaml | 10 + tests/CMakeLists.txt | 1 + tests/spawner/CMakeLists.txt | 42 +++ tests/spawner/spawncontract.cpp | 423 +++++++++++++++++++++++++++ 7 files changed, 1018 insertions(+) create mode 100644 code/spawnerconfig.cpp create mode 100644 code/spawnerconfig.h create mode 100644 manual/changes/spawn-ini-reader.md create mode 100644 tests/spawner/CMakeLists.txt create mode 100644 tests/spawner/spawncontract.cpp diff --git a/code/spawnerconfig.cpp b/code/spawnerconfig.cpp new file mode 100644 index 00000000..ae50b901 --- /dev/null +++ b/code/spawnerconfig.cpp @@ -0,0 +1,354 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#include "spawnerconfig.h" + +#include "crc.h" +#include "ini.h" + +#include +#include +#include +#include +#include + + +namespace { + +/* + * The section a launch file keeps the match's settings in. It doubles as the section the + * machine reading the file describes itself in, so the first seat is read from here. + */ +char const * const SETTINGS = "Settings"; + + +/// +/// Reads a string entry. +/// +/// The launch file being read. +/// The section to read from. +/// The key to read. +/// What an absent key means. +/// The value written, or the fallback. +std::string Read_Text(INIClass const & ini, char const * section, char const * entry, std::string const & fallback) +{ + char buffer[512]; + if (ini.Get_String(section, entry, "", buffer, sizeof(buffer)) == 0) { + return(fallback); + } + return(buffer); +} + + +/// +/// Reads one of the eight numbered entries a section names its seats by. +/// +/// The launch file being read. +/// The section holding the numbered entries. +/// Which seat to read, counted from zero. +/// What an absent entry means. +/// The value written for that seat. +int Read_Slot_Int(INIClass const & ini, char const * section, int slot, int fallback) +{ + std::string entry = "Multi" + std::to_string(slot + 1); + return(ini.Get_Int(section, entry.c_str(), fallback)); +} + +} + + +/// +/// Reads the match's seats. +/// A seat is human because the file wrote a section for it, so an unwritten section is what +/// makes a seat a computer player. The seats are read in the order the file names them, +/// then sorted into the order their houses will be created in -- humans by ascending color, +/// then computer players -- because everything naming a seat by position afterwards means +/// that order. +/// +/// The launch file to read. +void SpawnerConfigClass::Read_Slots(INIClass const & ini) +{ + std::array staging; + + for (int index = 0; index < SLOT_COUNT; index++) { + std::string section = index == 0 ? SETTINGS : "Other" + std::to_string(index); + + SlotType & slot = staging[index]; + if (ini.Section_Present(section.c_str())) { + slot.Occupancy = OccupancyType::Human; + slot.Name = Read_Text(ini, section.c_str(), "Name", ""); + slot.Color = ini.Get_Int(section.c_str(), "Color", -1); + slot.Country = ini.Get_Int(section.c_str(), "Side", -1); + slot.Address = Read_Text(ini, section.c_str(), "Ip", slot.Address); + slot.Port = ini.Get_Int(section.c_str(), "Port", -1); + } else { + slot.Color = Read_Slot_Int(ini, "HouseColors", index, -1); + slot.Country = Read_Slot_Int(ini, "HouseCountries", index, -1); + slot.Handicap = Read_Slot_Int(ini, "HouseHandicaps", index, -1); + } + } + + /* + * The houses are created humans first, in the order their colors fall. Sorting here is + * what makes a seat's index the index of the house it becomes. Every machine writes + * its own file with itself first, so the order must come from what the seats say and + * never from where the file said it: names break a color tie, which a well-formed + * launcher never writes but a hand-edited file otherwise turns into a different match + * on every machine. + */ + std::vector humans; + std::vector rest; + for (int index = 0; index < SLOT_COUNT; index++) { + (staging[index].Occupancy == OccupancyType::Human ? humans : rest).push_back(index); + } + std::stable_sort(humans.begin(), humans.end(), [&staging](int left, int right) { + if (staging[left].Color != staging[right].Color) { + return(staging[left].Color < staging[right].Color); + } + return(_stricmp(staging[left].Name.c_str(), staging[right].Name.c_str()) < 0); + }); + + HumanCount = (int)humans.size(); + LocalSlot = 0; + + int filled = 0; + for (int index : humans) { + if (index == 0) { + LocalSlot = filled; + } + Slots[filled++] = staging[index]; + } + + /* + * What the file wrote for a seat no section claimed describes a computer player, and the + * options say how many of those are playing. + */ + for (int index : rest) { + SlotType & slot = Slots[filled]; + slot = staging[index]; + slot.Occupancy = (filled - HumanCount) < AIPlayers ? OccupancyType::Computer : OccupancyType::Empty; + filled++; + } + + /* + * These name their seats by the sorted order, so they are read once the sorting is done. + */ + static char const * const _ordinals[SLOT_COUNT] = { + "HouseAllyOne", "HouseAllyTwo", "HouseAllyThree", "HouseAllyFour", + "HouseAllyFive", "HouseAllySix", "HouseAllySeven", "HouseAllyEight" + }; + + for (int index = 0; index < SLOT_COUNT; index++) { + SlotType & slot = Slots[index]; + + std::string entry = "Multi" + std::to_string(index + 1); + slot.IsSpectator = ini.Get_Bool("IsSpectator", entry.c_str(), false); + slot.StartingPosition = Read_Slot_Int(ini, "SpawnLocations", index, -1); + + /* + * A start position the map cannot hold is one the game picks instead, which is what + * a file asking for no particular position already means. + */ + if (slot.StartingPosition < -1 || slot.StartingPosition >= SLOT_COUNT) { + slot.StartingPosition = -1; + } + + std::string section = "Multi" + std::to_string(index + 1) + "_Alliances"; + if (!ini.Section_Present(section.c_str())) { + continue; + } + + for (int ally = 0; ally < SLOT_COUNT; ally++) { + slot.Alliances[ally] = ini.Get_Int(section.c_str(), _ordinals[ally], -1); + } + } +} + + +/// +/// What kind of game this file asks for. Resuming a saved game answers the question by +/// itself, because the save carries the type, the options and the houses the game had. +/// +/// The kind of game to launch. +SpawnerConfigClass::LaunchType SpawnerConfigClass::Launch_Type(void) const +{ + if (LoadSaveGame) { + return(LaunchType::Resume); + } + if (IsCampaign) { + return(LaunchType::Campaign); + } + if (HumanCount > 1) { + return(LaunchType::Multiplayer); + } + return(LaunchType::Skirmish); +} + + +/// +/// The identity of the match this file asks for. +/// This gathers every value the course of the match depends upon, and nothing that is only +/// shown to a player, so that two machines handed the same match agree on the number while +/// a difference in what either displays cannot move it. The version leads, since the same +/// file read by two different readings is not the same match. +/// +/// The identity of the configured match. +int SpawnerConfigClass::Session_Identity_CRC(void) const +{ + CRCEngine crc; + + crc(SCHEMA_VERSION); + + crc(ScenarioName.c_str()); + crc(IsCampaign); + crc(CampaignID); + crc(CampaignDifficulty); + crc(CampaignCDifficulty); + + crc(Bases); + crc(Credits); + crc(BridgeDestroy); + crc(Crates); + crc(ShortGame); + crc(BuildOffAlly); + crc(GameSpeed); + crc(MultiEngineer); + crc(UnitCount); + crc(AIPlayers); + crc(AIDifficulty); + crc(AlliesAllowed); + crc(HarvesterTruce); + crc(FogOfWar); + crc(MCVRedeploy); + crc(Seed); + crc(TechLevel); + crc(Firestorm); + crc(AttackNeutralUnits); + crc(ScrapMetal); + + for (bool flag : GlobalFlags) { + crc(flag); + } + + for (SlotType const & slot : Slots) { + crc(static_cast(slot.Occupancy)); + crc(slot.Color); + crc(slot.Country); + crc(slot.Handicap); + crc(slot.IsSpectator); + crc(slot.StartingPosition); + + for (int ally : slot.Alliances) { + crc(ally); + } + } + + return(crc()); +} + + +/// +/// Reads what the CnCNet client asked the game to launch. Reading cannot fail: every key has a +/// settled meaning when absent, a value the reader cannot make sense of keeps that meaning, +/// and a key the game does not know is passed over. +/// +/// The launch file to read. +void SpawnerConfigClass::Read_INI(INIClass const & ini) +{ + IsCampaign = ini.Get_Bool(SETTINGS, "IsSinglePlayer", IsCampaign); + IsHost = ini.Get_Bool(SETTINGS, "Host", IsHost); + CampaignID = ini.Get_Int(SETTINGS, "CampaignID", CampaignID); + Tournament = ini.Get_Int(SETTINGS, "Tournament", Tournament); + GameID = ini.Get_Int(SETTINGS, "GameID", GameID); + + ScenarioName = Read_Text(ini, SETTINGS, "Scenario", ScenarioName); + MapName = Read_Text(ini, SETTINGS, "UIMapName", MapName); + MapHash = Read_Text(ini, SETTINGS, "MapHash", MapHash); + + LoadSaveGame = ini.Get_Bool(SETTINGS, "LoadSaveGame", LoadSaveGame); + + /* + * A saved game is opened by name in the game's own folder, so a name written with a + * path is reduced to its last element. + */ + SaveGameName = std::filesystem::path(Read_Text(ini, SETTINGS, "SaveGameName", SaveGameName)).filename().string(); + + AutoSaveInterval = ini.Get_Int(SETTINGS, "AutoSaveGame", AutoSaveInterval); + + /* + * The client counts its automatic saves from one, while the game numbers them from zero. + */ + NextCampaignAutoSave = ini.Get_Int(SETTINGS, "NextSPAutoSaveId", 1) - 1; + NextSkirmishAutoSave = ini.Get_Int(SETTINGS, "NextSkirmishAutoSaveId", 1) - 1; + + Bases = ini.Get_Bool(SETTINGS, "Bases", Bases); + Credits = ini.Get_Int(SETTINGS, "Credits", Credits); + BridgeDestroy = ini.Get_Bool(SETTINGS, "BridgeDestroy", BridgeDestroy); + Crates = ini.Get_Bool(SETTINGS, "Crates", Crates); + ShortGame = ini.Get_Bool(SETTINGS, "ShortGame", ShortGame); + BuildOffAlly = ini.Get_Bool(SETTINGS, "BuildOffAlly", BuildOffAlly); + GameSpeed = ini.Get_Int(SETTINGS, "GameSpeed", GameSpeed); + MultiEngineer = ini.Get_Bool(SETTINGS, "MultiEngineer", MultiEngineer); + UnitCount = ini.Get_Int(SETTINGS, "UnitCount", UnitCount); + AIPlayers = ini.Get_Int(SETTINGS, "AIPlayers", AIPlayers); + AIDifficulty = ini.Get_Int(SETTINGS, "AIDifficulty", AIDifficulty); + AlliesAllowed = ini.Get_Bool(SETTINGS, "AlliesAllowed", AlliesAllowed); + HarvesterTruce = ini.Get_Bool(SETTINGS, "HarvesterTruce", HarvesterTruce); + FogOfWar = ini.Get_Bool(SETTINGS, "FogOfWar", FogOfWar); + MCVRedeploy = ini.Get_Bool(SETTINGS, "MCVRedeploy", MCVRedeploy); + Seed = ini.Get_Int(SETTINGS, "Seed", Seed); + TechLevel = ini.Get_Int(SETTINGS, "TechLevel", TechLevel); + Firestorm = ini.Get_Bool(SETTINGS, "Firestorm", Firestorm); + CampaignDifficulty = ini.Get_Int(SETTINGS, "DifficultyModeHuman", CampaignDifficulty); + CampaignCDifficulty = ini.Get_Int(SETTINGS, "DifficultyModeComputer", CampaignCDifficulty); + + ReconnectTimeout = ini.Get_Int(SETTINGS, "ReconnectTimeout", ReconnectTimeout); + ConnTimeout = ini.Get_Int(SETTINGS, "ConnTimeout", ConnTimeout); + + /* + * One key carries the port twice: a machine listens on it, and a tunnel names that + * machine by it. They part company only when the key is absent, where a tunnel has no + * name to go by while the game still has a port to listen on. + */ + TunnelId = ini.Get_Int(SETTINGS, "Port", TunnelId); + ListenPort = ini.Get_Int(SETTINGS, "Port", ListenPort); + TunnelAddress = Read_Text(ini, "Tunnel", "Ip", TunnelAddress); + TunnelPort = ini.Get_Int("Tunnel", "Port", TunnelPort); + + QuickMatch = ini.Get_Bool(SETTINGS, "QuickMatch", QuickMatch); + SkipScoreScreen = ini.Get_Bool(SETTINGS, "SkipScoreScreen", SkipScoreScreen); + WriteStatistics = ini.Get_Bool(SETTINGS, "WriteStatistics", WriteStatistics); + AINamesByDifficulty = ini.Get_Bool(SETTINGS, "DifficultyBasedAINames", AINamesByDifficulty); + CoachMode = ini.Get_Bool(SETTINGS, "CoachMode", CoachMode); + AutoSurrender = ini.Get_Bool(SETTINGS, "AutoSurrender", AutoSurrender); + AttackNeutralUnits = ini.Get_Bool(SETTINGS, "AttackNeutralUnits", AttackNeutralUnits); + ScrapMetal = ini.Get_Bool(SETTINGS, "ScrapMetal", ScrapMetal); + ContinueWithoutHumans = ini.Get_Bool(SETTINGS, "ContinueWithoutHumans", ContinueWithoutHumans); + PlayMoviesInMultiplayer = ini.Get_Bool(SETTINGS, "PlayMoviesInMultiplayer", PlayMoviesInMultiplayer); + CustomLoadScreen = Read_Text(ini, SETTINGS, "CustomLoadScreen", CustomLoadScreen); + DifficultyName = Read_Text(ini, SETTINGS, "DifficultyName", DifficultyName); + + std::string position = Read_Text(ini, SETTINGS, "CustomLoadScreenPos", ""); + if (!position.empty()) { + int x = 0; + int y = 0; + if (std::sscanf(position.c_str(), "%d,%d", &x, &y) == 2) { + CustomLoadScreenX = x; + CustomLoadScreenY = y; + } + } + + for (int index = 0; index < GLOBAL_FLAG_COUNT; index++) { + std::string entry = "GlobalFlag" + std::to_string(index); + GlobalFlags[index] = ini.Get_Bool("GlobalFlags", entry.c_str(), false); + } + + Read_Slots(ini); +} diff --git a/code/spawnerconfig.h b/code/spawnerconfig.h new file mode 100644 index 00000000..608d8200 --- /dev/null +++ b/code/spawnerconfig.h @@ -0,0 +1,176 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#pragma once + +#include +#include + +class INIClass; + + +/* + * What a client asked the game to launch. + * + * The file this is read from belongs to the CnCNet client, not to the game: its spelling, its + * defaults and its shape are fixed by what that client already writes. This class is the game's + * own reading of it, so that nothing downstream has to parse anything, and so that the + * values a match's outcome depends upon can be told apart from the ones only shown to a + * player. Reading cannot fail; whether what was read describes a playable game is judged + * where the launch is attempted, against the tables the game has loaded by then. + */ +class SpawnerConfigClass +{ + public: + + /* + * The version of this reading. It counts changes to what the game makes of a launch + * file, never changes to the file's own vocabulary, which belongs to the client. + */ + static constexpr int SCHEMA_VERSION = 1; + + /* + * A match holds this many seats, one per house it may hold. The scenario flags are + * the fifty the engine keeps; a client allowing more writes ones the game passes over. + */ + static constexpr int SLOT_COUNT = 8; + static constexpr int GLOBAL_FLAG_COUNT = 50; + + /* + * What kind of game the file asks for. Resume overrides the rest: a saved game + * carries its own type, options and houses, so nothing else in the file decides them. + */ + enum class LaunchType { + Skirmish, + Campaign, + Multiplayer, + Resume, + }; + + /* + * Who occupies a seat. A launch file marks a seat human by writing a section for it, + * so an unwritten section is what makes a seat a computer player or nothing at all. + */ + enum class OccupancyType { + Empty, + Human, + Computer, + }; + + /* + * One seat of the match. The seats are held in the order the houses are created in -- + * humans first by ascending color, then computer players -- so that a seat's index is + * the index of the house it becomes, which is what alliances and start positions are + * named by. + */ + struct SlotType { + OccupancyType Occupancy = OccupancyType::Empty; + std::string Name; + int Color = -1; + int Country = -1; + int Handicap = -1; + bool IsSpectator = false; + int StartingPosition = -1; + std::array Alliances = {-1, -1, -1, -1, -1, -1, -1, -1}; + std::string Address = "0.0.0.0"; + int Port = -1; + }; + + void Read_INI(INIClass const & ini); + LaunchType Launch_Type(void) const; + int Session_Identity_CRC(void) const; + + /* + * What kind of game to start. + */ + bool IsCampaign = false; + bool IsHost = false; + int CampaignID = -1; + int Tournament = 0; + int GameID = 0; + + /* + * The scenario and the saved game. + */ + std::string ScenarioName = "spawnmap.ini"; + std::string MapName; + std::string MapHash; + bool LoadSaveGame = false; + std::string SaveGameName; + int AutoSaveInterval = 10800; + int NextCampaignAutoSave = 0; + int NextSkirmishAutoSave = 0; + + /* + * The options every house plays under. + */ + bool Bases = true; + int Credits = 10000; + bool BridgeDestroy = true; + bool Crates = false; + bool ShortGame = false; + bool BuildOffAlly = false; + int GameSpeed = 0; + bool MultiEngineer = false; + int UnitCount = 0; + int AIPlayers = 0; + int AIDifficulty = 1; + bool AlliesAllowed = false; + bool HarvesterTruce = false; + bool FogOfWar = false; + bool MCVRedeploy = true; + int Seed = 0; + int TechLevel = 10; + bool Firestorm = true; + int CampaignDifficulty = 1; + int CampaignCDifficulty = 1; + std::array GlobalFlags = {}; + + /* + * Where the machines reach one another. A launcher settles these with the service + * that arranged the match, so they are the one part of the network it decides; the + * timing the machines keep is the game's own and is not read from a launch file. + */ + int ReconnectTimeout = 2400; + int ConnTimeout = 3600; + int TunnelId = 0; + int ListenPort = 1234; + std::string TunnelAddress = "0.0.0.0"; + int TunnelPort = 0; + + /* + * What a player is shown. + */ + bool QuickMatch = false; + bool SkipScoreScreen = false; + bool WriteStatistics = false; + bool AINamesByDifficulty = false; + bool CoachMode = false; + bool AutoSurrender = true; + bool AttackNeutralUnits = false; + bool ScrapMetal = false; + bool ContinueWithoutHumans = false; + bool PlayMoviesInMultiplayer = false; + std::string CustomLoadScreen; + int CustomLoadScreenX = 0; + int CustomLoadScreenY = 0; + std::string DifficultyName; + + /* + * The match's seats, and where in them the machine reading the file sits. + */ + std::array Slots; + int HumanCount = 0; + int LocalSlot = 0; + + private: + + void Read_Slots(INIClass const & ini); +}; diff --git a/manual/changes/spawn-ini-reader.md b/manual/changes/spawn-ini-reader.md new file mode 100644 index 00000000..93a717bf --- /dev/null +++ b/manual/changes/spawn-ini-reader.md @@ -0,0 +1,12 @@ +--- +title: Add a headless reader for client launch files +category: internal +release: 0.2.0 +targets: [] +credit: [ZivDero] +--- + +The engine gains a tested reading of the SPAWN.INI launch file the CnCNet +client writes, with the vocabulary and defaults that client already uses. +Nothing launches from it yet, so no player- or modder-visible behavior +changes. diff --git a/manual/data/ini-read-exclusions.yaml b/manual/data/ini-read-exclusions.yaml index 4467c34d..2a330512 100644 --- a/manual/data/ini-read-exclusions.yaml +++ b/manual/data/ini-read-exclusions.yaml @@ -140,3 +140,13 @@ site_exclusions: keys: [Name] classification: excluded reason: This dynamic region lookup reads a legacy online-service endpoint record, not a mod setting. + - path: code/spawnerconfig.cpp + function: SpawnerConfigClass::Read_Slots + keys: [Color, Side, Port] + classification: excluded + reason: Each names one seat of a match inside a section the CnCNet client numbers per seat, not an authored game-data setting. + - path: code/spawnerconfig.cpp + function: SpawnerConfigClass::Read_INI + keys: [AIDifficulty, AIPlayers, AlliesAllowed, AttackNeutralUnits, AutoSaveGame, AutoSurrender, Bases, BridgeDestroy, BuildOffAlly, CampaignID, CoachMode, ConnTimeout, ContinueWithoutHumans, Crates, Credits, DifficultyBasedAINames, DifficultyModeComputer, DifficultyModeHuman, Firestorm, FogOfWar, GameID, GameSpeed, HarvesterTruce, Host, IsSinglePlayer, LoadSaveGame, MCVRedeploy, MultiEngineer, NextSPAutoSaveId, NextSkirmishAutoSaveId, PlayMoviesInMultiplayer, Port, QuickMatch, ReconnectTimeout, ScrapMetal, Seed, ShortGame, SkipScoreScreen, TechLevel, Tournament, UnitCount, WriteStatistics] + classification: excluded + reason: The CnCNet client writes these per launch to describe one match, so they are not part of the authored game-data key catalog. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7e8311bd..3d167acb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1 +1,2 @@ add_subdirectory(logstress) +add_subdirectory(spawner) diff --git a/tests/spawner/CMakeLists.txt b/tests/spawner/CMakeLists.txt new file mode 100644 index 00000000..f635058f --- /dev/null +++ b/tests/spawner/CMakeLists.txt @@ -0,0 +1,42 @@ +# The launch file reader is compiled straight into the harness along with the INI reader it +# is written against. It lives outside code/ so that the recursive glob building the engine +# cannot pick this target's entry point up. +add_executable(SpawnContract + "${CMAKE_CURRENT_SOURCE_DIR}/spawncontract.cpp" + "${CMAKE_SOURCE_DIR}/code/spawnerconfig.cpp" + "${CMAKE_SOURCE_DIR}/code/ini.cpp" + "${CMAKE_SOURCE_DIR}/code/readline.cpp" + "${CMAKE_SOURCE_DIR}/code/trim.cpp" + "${CMAKE_SOURCE_DIR}/code/buff.cpp" + "${CMAKE_SOURCE_DIR}/code/straw.cpp" + "${CMAKE_SOURCE_DIR}/code/xstraw.cpp" + "${CMAKE_SOURCE_DIR}/code/cstraw.cpp" + "${CMAKE_SOURCE_DIR}/code/pipe.cpp" + "${CMAKE_SOURCE_DIR}/code/xpipe.cpp" + "${CMAKE_SOURCE_DIR}/code/b64straw.cpp" + "${CMAKE_SOURCE_DIR}/code/b64pipe.cpp" + "${CMAKE_SOURCE_DIR}/code/base64.cpp" + "${CMAKE_SOURCE_DIR}/code/crc.cpp" + "${CMAKE_SOURCE_DIR}/code/pk.cpp" + "${CMAKE_SOURCE_DIR}/code/int.cpp" + "${CMAKE_SOURCE_DIR}/code/mpmath.cpp" +) + +target_compile_features(SpawnContract PRIVATE cxx_std_20) + +target_include_directories(SpawnContract PRIVATE "${CMAKE_SOURCE_DIR}/code") + +target_compile_definitions(SpawnContract PRIVATE WIN32 _WINDOWS _MBCS) + +target_compile_options(SpawnContract PRIVATE + $<$:/MTd /EHsc /Zc:__cplusplus> + $<$:/MT /EHsc /Zc:__cplusplus> +) + +target_link_libraries(SpawnContract PRIVATE kernel32 user32 shell32) + +set_target_properties(SpawnContract PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" +) + +add_test(NAME spawncontract COMMAND SpawnContract) diff --git a/tests/spawner/spawncontract.cpp b/tests/spawner/spawncontract.cpp new file mode 100644 index 00000000..1029ec8c --- /dev/null +++ b/tests/spawner/spawncontract.cpp @@ -0,0 +1,423 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// Pins the launch file the CnCNet client writes to start a game, without the engine or any game +// data: what an unwritten key means, the order the seats end up in, the repairs the reader +// makes silently, and which values two machines have to agree on. Whether a reading +// describes a playable game is judged at launch, so nothing here refuses anything. + +#include +#include + +#include "ini.h" +#include "spawnerconfig.h" +#include "xstraw.h" + +namespace { + +using LaunchType = SpawnerConfigClass::LaunchType; +using OccupancyType = SpawnerConfigClass::OccupancyType; + +int Failures = 0; + + +void Check(bool condition, char const * what) +{ + std::printf("%-64s %s\n", what, condition ? "ok" : "FAILED"); + + if (!condition) { + Failures++; + } +} + + +// What the client writes to resume a saved campaign. It names almost nothing: the saved game +// carries the houses, the options and the kind of game they were played as. +char const _Resume[] = + "[Settings]\n" + "Scenario=spawnmap.ini\n" + "SaveGameName=SAVEGAME.001\n" + "LoadSaveGame=Yes\n" + "SidebarHack=True\n" + "CustomLoadScreen=Resources/l600s01.pcx\n" + "Firestorm=No\n" + "GameSpeed=1\n"; + + +// What the client writes to start a campaign mission. It names no player and no color, since +// the map says who is playing. +char const _Campaign[] = + "[Settings]\n" + "Scenario=spawnmap.ini\n" + "CampaignID=-1\n" + "GameSpeed=1\n" + "Firestorm=False\n" + "CustomLoadScreen=Resources/l600s01.pcx\n" + "IsSinglePlayer=Yes\n" + "SidebarHack=True\n" + "Side=0\n" + "BuildOffAlly=False\n" + "DifficultyModeHuman=0\n" + "DifficultyModeComputer=2\n"; + + +// What the client writes to start a game against computer players. The player describes +// himself in the settings, and the computer players are named by position. +char const _Skirmish[] = + "[Settings]\n" + "Scenario=spawnmap.ini\n" + "Name=Commander\n" + "Side=1\n" + "Color=4\n" + "Port=1234\n" + "AIPlayers=2\n" + "Credits=5000\n" + "ShortGame=Yes\n" + "Protocol=0\n" + "NextSkirmishAutoSaveId=5\n" + "\n" + "[HouseColors]\n" + "Multi2=2\n" + "Multi3=7\n" + "\n" + "[HouseCountries]\n" + "Multi2=0\n" + "Multi3=1\n" + "\n" + "[HouseHandicaps]\n" + "Multi2=2\n" + "Multi3=0\n" + "\n" + "[SpawnLocations]\n" + "Multi1=3\n" + "Multi2=9\n" + "Multi3=-2\n" + "\n" + "[Multi1_Alliances]\n" + "HouseAllyOne=1\n"; + + +// What the client writes for a game against other machines. The player who wrote it holds the +// higher color, so the sorting puts him second. +char const _Network[] = + "[Settings]\n" + "Scenario=spawnmap.ini\n" + "Name=Second\n" + "Side=1\n" + "Color=5\n" + "Port=50000\n" + "Host=No\n" + "\n" + "[Other1]\n" + "Name=First\n" + "Side=0\n" + "Color=1\n" + "Ip=10.0.0.7\n" + "Port=50001\n" + "\n" + "[Tunnel]\n" + "Ip=88.99.11.22\n" + "Port=50010\n" + "\n" + "[IsSpectator]\n" + "Multi1=Yes\n" + "\n" + "[Multi2_Alliances]\n" + "HouseAllyOne=0\n"; + + +SpawnerConfigClass Read(char const * text, int length) +{ + INIClass ini; + BufferStraw straw(text, length); + ini.Load(straw); + + SpawnerConfigClass config; + config.Read_INI(ini); + return(config); +} + +} + + +int main(void) +{ + /* + * A file naming nothing at all still describes a playable game, because every key the + * client leaves out has a settled meaning. + */ + { + char const empty[] = "[Settings]\n"; + SpawnerConfigClass config = Read(empty, sizeof(empty) - 1); + + Check(config.Bases && config.Credits == 10000 && config.MCVRedeploy, + "the unwritten keys keep the meaning clients expect"); + Check(config.TunnelId == 0 && config.ListenPort == 1234, + "one absent port names no tunnel and still listens"); + Check(config.ScenarioName == "spawnmap.ini", + "the scenario a client always writes is also the default"); + Check(config.NextCampaignAutoSave == 0 && config.NextSkirmishAutoSave == 0, + "the first automatic save is numbered from zero"); + + bool any = false; + for (bool flag : config.GlobalFlags) { + any = any || flag; + } + Check(!any, "no scenario flag is set unasked"); + } + + /* + * Resuming a saved game asks for almost nothing; the save answers for the rest, so the + * reading alone decides the kind of launch. + */ + { + SpawnerConfigClass config = Read(_Resume, sizeof(_Resume) - 1); + + Check(config.Launch_Type() == LaunchType::Resume, "resuming a save is what the file asks for"); + Check(config.SaveGameName == "SAVEGAME.001", "the saved game is named"); + Check(!config.Firestorm, "the expansion is left out when the file says so"); + } + + /* + * A saved game is opened by name in the game's own folder, so a name written with a path + * is reduced to the name without ceremony. + */ + { + char const traversal[] = + "[Settings]\n" + "LoadSaveGame=Yes\n" + "SaveGameName=..\\..\\Windows\\SAVEGAME.001\n"; + SpawnerConfigClass config = Read(traversal, sizeof(traversal) - 1); + + Check(config.SaveGameName == "SAVEGAME.001", "only the name of the saved game is read"); + + char const forward[] = + "[Settings]\n" + "SaveGameName=saves/deep/SAVEGAME.002\n"; + config = Read(forward, sizeof(forward) - 1); + + Check(config.SaveGameName == "SAVEGAME.002", "a forward slash hides nothing either"); + + char const drive[] = + "[Settings]\n" + "SaveGameName=C:SAVEGAME.003\n"; + config = Read(drive, sizeof(drive) - 1); + + Check(config.SaveGameName == "SAVEGAME.003", "a drive letter is not part of the name"); + } + + /* + * A campaign takes its houses from the map, so a file naming no player is complete. + */ + { + SpawnerConfigClass config = Read(_Campaign, sizeof(_Campaign) - 1); + + Check(config.Launch_Type() == LaunchType::Campaign, "a single player game is a campaign"); + Check(config.CampaignDifficulty == 0 && config.CampaignCDifficulty == 2, + "the two difficulties are read apart"); + Check(config.CampaignID == -1, "a mission outside any campaign says so"); + } + + /* + * The seats end up in the order the houses are created in, which is what everything + * naming a seat by position afterwards means. + */ + { + SpawnerConfigClass config = Read(_Skirmish, sizeof(_Skirmish) - 1); + + Check(config.Launch_Type() == LaunchType::Skirmish, "one player and computers is a skirmish"); + Check(config.HumanCount == 1 && config.LocalSlot == 0, "the only player holds the first seat"); + Check(config.Slots[0].Occupancy == OccupancyType::Human && + config.Slots[0].Name == "Commander" && + config.Slots[0].Color == 4 && config.Slots[0].Country == 1, + "the player is read from the settings themselves"); + Check(config.Slots[1].Occupancy == OccupancyType::Computer && config.Slots[1].Color == 2 && + config.Slots[1].Country == 0 && config.Slots[1].Handicap == 2, + "a seat no section claimed is a computer player named by position"); + Check(config.Slots[2].Occupancy == OccupancyType::Computer && config.Slots[2].Color == 7, + "the second computer player follows the first"); + Check(config.Slots[3].Occupancy == OccupancyType::Empty, "no more seats are filled than are asked for"); + Check(config.Slots[0].StartingPosition == 3, "a start position is read for the seat that asked"); + Check(config.Slots[1].StartingPosition == -1, + "a start position the map cannot hold becomes the game's own choice"); + Check(config.Slots[2].StartingPosition == -1, + "a position below the game's own choice is that choice"); + Check(config.Slots[0].Alliances[0] == 1, "an alliance is read as the seat it names"); + Check(config.NextSkirmishAutoSave == 4, "a client's save numbering is shifted to the game's"); + } + + /* + * The player who wrote the file is not always the first house, and the game has to know + * which seat is his once the sorting is done. + */ + { + SpawnerConfigClass config = Read(_Network, sizeof(_Network) - 1); + + Check(config.HumanCount == 2, "a written section is what makes a seat a player"); + Check(config.Slots[0].Name == "First" && config.Slots[1].Name == "Second", + "the players are sorted into the order their houses are created in"); + Check(config.LocalSlot == 1, "this machine knows which of the seats is its own"); + Check(config.Slots[0].Address == "10.0.0.7" && config.Slots[0].Port == 50001, + "the other machine is read with the address it answers on"); + Check(config.TunnelId == 50000 && config.ListenPort == 50000, + "one written port both listens and names this machine to a tunnel"); + Check(config.TunnelAddress == "88.99.11.22" && config.TunnelPort == 50010, + "the tunnel is read from its own section"); + Check(config.Launch_Type() == LaunchType::Multiplayer, + "two players is a game against another machine"); + Check(config.Slots[0].IsSpectator && !config.Slots[1].IsSpectator, + "a watcher is named by the seat order the houses take"); + Check(config.Slots[1].Alliances[0] == 0, + "an alliance section names the sorted seat too"); + } + + /* + * Every machine writes its own file with itself first, so a color tie must be broken + * by what the seats say rather than where the file said it, or two machines would + * assemble two different matches. + */ + { + char const view_a[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=3\n" + "Ip=10.0.0.9\n" + "Port=50002\n"; + char const view_b[] = + "[Settings]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "Ip=10.0.0.8\n" + "Port=50001\n"; + + SpawnerConfigClass a = Read(view_a, sizeof(view_a) - 1); + SpawnerConfigClass b = Read(view_b, sizeof(view_b) - 1); + Check(a.Slots[0].Name == "Alpha" && b.Slots[0].Name == "Alpha", + "a color tie seats the match identically on every machine"); + Check(a.LocalSlot == 0 && b.LocalSlot == 1, + "each machine still knows which of the tied seats is its own"); + Check(a.Session_Identity_CRC() == b.Session_Identity_CRC(), + "the tied match carries one identity on both machines"); + } + + /* + * Two machines handed the same match agree on its identity, and a difference in what + * either of them merely displays cannot move it. + */ + { + SpawnerConfigClass one = Read(_Skirmish, sizeof(_Skirmish) - 1); + SpawnerConfigClass two = Read(_Skirmish, sizeof(_Skirmish) - 1); + + Check(one.Session_Identity_CRC() == two.Session_Identity_CRC(), + "the same match is given the same identity twice"); + + two.MapName = "A Map By Another Name"; + two.DifficultyName = "Gentle"; + two.Slots[0].Name = "Somebody Else"; + Check(one.Session_Identity_CRC() == two.Session_Identity_CRC(), + "what a player is shown is left out of the identity"); + + two.Credits = one.Credits + 1; + Check(one.Session_Identity_CRC() != two.Session_Identity_CRC(), + "a value the match is played by moves the identity"); + + SpawnerConfigClass three = Read(_Skirmish, sizeof(_Skirmish) - 1); + three.Slots[1].Country = one.Slots[1].Country + 1; + Check(one.Session_Identity_CRC() != three.Session_Identity_CRC(), + "a computer player's country moves the identity"); + + SpawnerConfigClass four = Read(_Skirmish, sizeof(_Skirmish) - 1); + four.GlobalFlags[49] = !four.GlobalFlags[49]; + Check(one.Session_Identity_CRC() != four.Session_Identity_CRC(), + "a scenario flag moves the identity"); + } + + /* + * The timing the machines keep is the game's own, and a key the game does not know is + * passed over, so neither can move a match's identity. + */ + { + char const plain[] = + "[Settings]\n" + "Credits=7000\n" + "Seed=42\n"; + char const noisy[] = + "[Settings]\n" + "Credits=7000\n" + "Seed=42\n" + "Protocol=2\n" + "FrameSendRate=3\n" + "MaxAhead=100\n" + "PreCalcMaxAhead=1\n" + "MaxLatencyLevel=2\n" + "SomeFutureClientKey=1\n"; + + SpawnerConfigClass a = Read(plain, sizeof(plain) - 1); + SpawnerConfigClass b = Read(noisy, sizeof(noisy) - 1); + Check(a.Session_Identity_CRC() == b.Session_Identity_CRC(), + "timing and unknown keys cannot move a match's identity"); + } + + /* + * The scenario flags are named by their number, and a load screen position is taken + * whole or not at all. + */ + { + char const flags[] = + "[Settings]\n" + "CustomLoadScreenPos=317,401\n" + "\n" + "[GlobalFlags]\n" + "GlobalFlag0=yes\n" + "GlobalFlag49=yes\n"; + SpawnerConfigClass config = Read(flags, sizeof(flags) - 1); + + Check(config.GlobalFlags[0] && config.GlobalFlags[49], + "a scenario flag is read by its number"); + + bool between = false; + for (int index = 1; index < 49; index++) { + between = between || config.GlobalFlags[index]; + } + Check(!between, "no flag is set by a neighbor's spelling"); + Check(config.CustomLoadScreenX == 317 && config.CustomLoadScreenY == 401, + "the load screen position is read whole"); + + char const malformed[] = + "[Settings]\n" + "CustomLoadScreenPos=oops\n"; + config = Read(malformed, sizeof(malformed) - 1); + + Check(config.CustomLoadScreenX == 0 && config.CustomLoadScreenY == 0, + "a position the reader cannot make sense of is no position"); + + char const half[] = + "[Settings]\n" + "CustomLoadScreenPos=12\n"; + config = Read(half, sizeof(half) - 1); + + Check(config.CustomLoadScreenX == 0 && config.CustomLoadScreenY == 0, + "half a position is no position either"); + } + + std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); + return(Failures == 0 ? 0 : 1); +} From 5eec22b4337be681d30be5853d846734ea374138 Mon Sep 17 00:00:00 2001 From: Kirill Andriiashin Date: Sun, 30 Aug 2026 00:35:31 +0300 Subject: [PATCH 02/35] Client launch (#75) * Read the side roster through one routine * Start a session node with nothing asked for * Record the start position a house was placed at * Seat a house at the start position it asked for * Let a session source seat the computer players * Apply a seat's alliances at house assignment * Judge whether a launch file can be played * Launch a client-driven skirmish from SPAWN.INI * Sit new comments beside the ones already there * Document launching a game from a client's file * Name the easiest difficulty what the game calls it * Fill a shortfall of start positions in one place * Bound the start positions by the players there can be --- code/house.cpp | 2 + code/house.h | 1 + code/init.cpp | 61 +++- code/init.h | 2 + code/netdlg2.cpp | 4 - code/scenario.cpp | 192 +++++++++-- code/session.cpp | 7 +- code/session.h | 23 ++ code/skirmish.cpp | 7 +- code/spawner.cpp | 385 +++++++++++++++++++++++ code/spawner.h | 16 + code/spawnerconfig.cpp | 106 +++++++ code/spawnerconfig.h | 9 + code/startup.cpp | 5 +- manual/changes/chosen-start-positions.md | 23 ++ manual/changes/client-driven-launch.md | 30 ++ manual/content/formats/spawn-ini.md | 112 +++++++ manual/data/command-adapters.yaml | 8 + manual/data/commands.yaml | 11 + tests/spawner/spawncontract.cpp | 151 +++++++++ 20 files changed, 1104 insertions(+), 51 deletions(-) create mode 100644 code/spawner.cpp create mode 100644 code/spawner.h create mode 100644 manual/changes/chosen-start-positions.md create mode 100644 manual/changes/client-driven-launch.md create mode 100644 manual/content/formats/spawn-ini.md diff --git a/code/house.cpp b/code/house.cpp index 9f259989..f10f5de6 100644 --- a/code/house.cpp +++ b/code/house.cpp @@ -311,6 +311,7 @@ HouseClass::HouseClass(HouseTypeClass const * type) : BuildingsLost(0), WhoLastHurtMe(HOUSE_NONE), Center(0,0,0), + SpawnWaypoint(-1), Radius(0), LATime(0), LAEnemy(HOUSE_NONE), @@ -6491,6 +6492,7 @@ void HouseClass::Serialize(SaveStreamClass & stream) stream.Serialize(EnemyAirForcePrediction); stream.Serialize(EnemyInfantryForcePrediction); stream.Serialize(PowerSurplus); + stream.Serialize(SpawnWaypoint); } diff --git a/code/house.h b/code/house.h index 604f5f20..aac1c0a9 100644 --- a/code/house.h +++ b/code/house.h @@ -565,6 +565,7 @@ class HouseClass : public AbstractClass ** the base. */ Coord Center; // Center of the base. + int SpawnWaypoint; // starting waypoint this house was placed at; -1 = never placed int Radius; // Average building distance from center (leptons). struct { int AirDefense; diff --git a/code/init.cpp b/code/init.cpp index ed66b0c6..137d6490 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -152,6 +152,7 @@ #include "scheme.h" #include "script.h" #include "session.h" +#include "spawner.h" #include "side.h" #include "skirmish.h" #include "smudtype.h" @@ -383,18 +384,20 @@ int Init_Game(int , char * []) /* ** Play the startup animation. */ - if (Special.IsFromInstall == true) { - DebugString("Playing first time intro sequence.\n"); - Play_Movie("EVA.VQA", THEME_NONE, false); - } + if (!Spawner_Is_Requested()) { + if (Special.IsFromInstall == true) { + DebugString("Playing first time intro sequence.\n"); + Play_Movie("EVA.VQA", THEME_NONE, false); + } - DebugString("Playing startup movies.\n"); - Play_Movie("WWLOGO.VQA", THEME_NONE); - if (!Get_New_Menu()->MixFile) { - if (CCFileClass("FS_TITLE.VQA").Is_Available() == true) { - Play_Movie("FS_TITLE.VQA", THEME_NONE, false); - } else { - Play_Movie("STARTUP.VQA", THEME_NONE, false); + DebugString("Playing startup movies.\n"); + Play_Movie("WWLOGO.VQA", THEME_NONE); + if (!Get_New_Menu()->MixFile) { + if (CCFileClass("FS_TITLE.VQA").Is_Available() == true) { + Play_Movie("FS_TITLE.VQA", THEME_NONE, false); + } else { + Play_Movie("STARTUP.VQA", THEME_NONE, false); + } } } @@ -661,6 +664,23 @@ void Init_Campaigns(void) } +/// +/// Reads the countries and the sides they belong to from the rules. +/// This runs before a game is set up, so that a house's side is known before anything asks +/// for it. The side roster is only established by reading it: a country carries the name of +/// its side, and the rules' own side list decides what order the sides are registered in. +/// +void Prepare_Side_Roster(void) +{ + Rule->Do_HouseTypes(*RuleINI); + Rule->Do_Sides(*RuleINI); + + for (int index = 0; index < HouseTypes.Count(); index++) { + HouseTypes[index]->Read_INI(*RuleINI); + } +} + + /// /// Can this campaign be played with the addons that are enabled? /// A base game campaign is offered only when no addon is running, and an addon's own @@ -1105,6 +1125,19 @@ bool Select_Game(bool ) } } + /* + * A client-requested launch takes the place of the menu, once. A spawned match that + * has ended, or a launch that was refused, answers false so the process leaves and + * the client sees it go. + */ + if (Spawner_Is_Requested()) { + if (!Spawner_Prepare(gameloaded)) { + return(false); + } + process = false; + Theme.Stop(true); + } + while (process) { /* @@ -1802,6 +1835,12 @@ bool Parse_Command_Line(int argc, char * argv[]) continue; } + // A client asking the game to launch what SPAWN.INI describes. + if (stricmp(string, "-SPAWN") == 0) { + Spawner_Request(); + continue; + } + if (memcmp(string, "-TIME=", 6) == 0) { sscanf(&string[6], "%d", &TournamentTime); } diff --git a/code/init.h b/code/init.h index 5d57aba1..7b80b5ff 100644 --- a/code/init.h +++ b/code/init.h @@ -42,6 +42,8 @@ void Title_Screen_Restore(bool force=false); void Init_Campaigns(void); +void Prepare_Side_Roster(void); + void Delete_All_Objects(void); void Init_Theater(TheaterType theater); diff --git a/code/netdlg2.cpp b/code/netdlg2.cpp index dcef0e2c..80200886 100644 --- a/code/netdlg2.cpp +++ b/code/netdlg2.cpp @@ -852,7 +852,6 @@ bool Net2Remote_Connect(void) // Add myself to the list, and to the Players vector. //------------------------------------------------------------------------ NodeNameType * who = new NodeNameType; - memset(who, 0, sizeof(*who)); strcpy(who->Name, Session.Handle); strcpy(who->Player.Serial, SerialNumber); who->Player.House = Session.House; @@ -2474,7 +2473,6 @@ static void Get_Join_Responses(void) // Create & add a node to the Vector //.................................................................. who = new NodeNameType; - memset(who, 0, sizeof(*who)); strcpy(who->Name, Session.GPacket.Name); strcpy(who->Player.Serial, Session.GPacket.Serial); who->Address = Session.GAddress; @@ -2531,7 +2529,6 @@ static void Get_Join_Responses(void) Clear_Vector(&Session.Players); who = new NodeNameType; - memset(who, 0, sizeof(*who)); strcpy(who->Name, Session.Handle); who->Player.House = Session.House; who->Player.Color = Session.ColorIdx; @@ -3105,7 +3102,6 @@ static void Get_Join_Responses(void) // Add node to the Vector list //.................................................................. who = new NodeNameType; - memset(who, 0, sizeof(*who)); strcpy(who->Name, Session.GPacket.Name); who->Address = Session.GAddress; who->Player.House = Session.GPacket.PlayerInfo.House; diff --git a/code/scenario.cpp b/code/scenario.cpp index f8e5603a..7b43b317 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -2062,6 +2062,27 @@ void Write_Scenario_INI(char const * fname, bool mplayer) } +/// +/// Fetches the node a seat of the match was described by. +/// The seats are numbered in the order their houses are created: the players first, then the +/// computer players a session source seated. +/// +/// The seat to fetch, counted from zero. +/// The node describing that seat, or NULL if the match does not hold it. +static NodeNameType * Seated_Node(int seat) +{ + if (seat < 0) { + return(NULL); + } + if (seat < Session.Players.Count()) { + return(Session.Players[seat]); + } + + seat -= Session.Players.Count(); + return(seat < Session.Computers.Count() ? Session.Computers[seat] : NULL); +} + + /*********************************************************************************************** * Assign_Houses -- Assigns multiplayer houses to various players * * * @@ -2160,6 +2181,8 @@ void Assign_Houses(void) housep->Assign_Handicap(DIFF_NORMAL); + housep->SpawnWaypoint = player->Player.SpawnChoice; + //..................................................................... // Record where we placed this player //..................................................................... @@ -2172,7 +2195,18 @@ void Assign_Houses(void) // Now assign computer players to the remaining houses. //------------------------------------------------------------------------ for (i = Session.Players.Count(); i < Session.Players.Count() + Session.Options.AIPlayers; i++) { + + /* + * A session source may have seated this computer player itself. What it left + * unnamed the game draws, exactly as it does for a game set up from the menu. + */ + int seatnum = i - Session.Players.Count(); + NodeNameType * seat = seatnum < Session.Computers.Count() ? Session.Computers[seatnum] : NULL; + pref_house = (HousesType)Random_Pick(0, 1); + if (seat != NULL && seat->Player.House != -1) { + pref_house = (HousesType)seat->Player.House; + } // Pick a color for this house; keep looping until we find one. int color = -1; @@ -2182,6 +2216,14 @@ void Assign_Houses(void) break; } } + + /* + * A seated color is taken as written, repeats included, since a cooperative team + * shares one. + */ + if (seat != NULL && seat->Player.Color != -1) { + color = seat->Player.Color; + } color_used[color] = true; /* @@ -2195,7 +2237,7 @@ void Assign_Houses(void) housep->Init_Data(color, pref_house, Session.Options.Credits); housep->Scheme = Session.Color_Index_To_Scheme(color); housep->Initialize_Radar_Color(); - housep->IniName = Fetch_String(TXT_COMPUTER); + housep->IniName = (seat != NULL && seat->Name[0] != '\0') ? seat->Name : Fetch_String(TXT_COMPUTER); if (Session.Type != GAME_NORMAL) { housep->IQ = Rule->MaxIQ; @@ -2205,7 +2247,40 @@ void Assign_Houses(void) if (Session.Players.Count() > 1 && Rule->IsCompEasyBonus && difficulty > DIFF_EASY) { difficulty = (DiffType)(difficulty - 1); } + if (seat != NULL && seat->Player.Handicap >= 0) { + difficulty = (DiffType)seat->Player.Handicap; + } housep->Assign_Handicap(difficulty); + + if (seat != NULL) { + housep->SpawnWaypoint = seat->Player.SpawnChoice; + seat->Player.ID = housep->HeapID; + } + } + + /* + * The alliance table names seats, in the order the houses above were created. While the + * scenario is still assembling, a pact is always permitted and is made quietly, so the + * table stands before the first frame is played. The seats are walked before the neutral + * and special houses exist, since neither is a seat of the match. + */ + int seated = Session.Players.Count() + Session.Computers.Count(); + for (int seatnum = 0; seatnum < seated; seatnum++) { + NodeNameType * node = Seated_Node(seatnum); + if (node == NULL || node->Player.AlliesMask == 0) { + continue; + } + + for (int target = 0; target < seated; target++) { + if (target == seatnum || (node->Player.AlliesMask & (1u << target)) == 0) { + continue; + } + + NodeNameType * other = Seated_Node(target); + if (other != NULL) { + Houses[node->Player.ID]->Make_Ally(Houses[other->Player.ID]); + } + } } HouseClass * neutral_house = new HouseClass(HouseTypes[HouseTypeClass::From_Name("Neutral")]); @@ -2248,19 +2323,72 @@ static void Remove_AI_Players(void) } +/// +/// Makes up a shortfall of starting locations with open ground. +/// A map is not obliged to declare a start position for everybody playing, so spots are +/// drawn until there are enough of them to go round. +/// +/// The list of starting locations to append to. +/// How many of the locations may actually be started from; raised as +/// spots are appended. +/// How many are needed. +static void Append_Open_Start_Positions(DynamicVectorClass & waypts, int & usable, int wanted) +{ + if (usable >= wanted) { + return; + } + + DebugString("Multiplayer start waypoint deficiency - looking for more start positions\n"); + + while (usable < wanted) { + Cell trycell = Cell(Map.MapRect.X + Random_Pick(10, Map.MapRect.Width - 10), Map.MapRect.Y + 10 + Random_Pick(0, Map.MapRect.Height - 10)); + + trycell = Map.Nearby_Location(trycell, SPEED_TRACK, -1, MZONE_NORMAL, false, Point2D(8, 8)); + if (trycell != CELL_NONE) { + waypts.Add(trycell); + usable++; + DebugString("Random multiplayer start waypoint added at cell %d,%d\n", trycell.X, trycell.Y); + } + } +} + + /// /// Fetches the starting locations available to a multiplayer game. /// The scenario's own waypoints are preferred, but a map that does not supply enough of /// them for everyone playing has the shortfall made up with random spots on open ground. +/// When a house has asked for a position by number, the list is built so that an entry's +/// place in it is the waypoint of that number, and a waypoint the map does not declare +/// keeps its place as a hole rather than letting the ones after it slide down. /// /// Is this one of the maps that shipped with the game? +/// Must an entry's place in the list be its waypoint number? /// Returns with the list of cells that players may be started from. -static DynamicVectorClass Build_Start_Waypoint_List(bool official) +static DynamicVectorClass Build_Start_Waypoint_List(bool official, bool keep_identity) { DynamicVectorClass waypts; + if (keep_identity) { + int usable = 0; + for (int waycount = 0; waycount < MAX_PLAYERS; waycount++) { + bool declared = Scen->Is_Valid_Waypoint(waycount); + waypts.Add(declared ? Scen->Get_Waypoint_Cell(waycount) : CELL_NONE); + if (declared) { + usable++; + } + } + + /* + * The spots making up a shortfall are appended past the numbered ones, so that no + * number comes to mean a place the map never named. + */ + Append_Open_Start_Positions(waypts, usable, Session.Players.Count() + Session.Options.AIPlayers); + + return(waypts); + } + int num_waypts = 0; - for (int i = 0; i < 8; i++) { + for (int i = 0; i < MAX_PLAYERS; i++) { if (Scen->Is_Valid_Waypoint(i)) { num_waypts++; } else { @@ -2276,7 +2404,7 @@ static DynamicVectorClass Build_Start_Waypoint_List(bool official) */ int look_for = std::max(num_waypts, Session.Players.Count()+Session.Options.AIPlayers); if (!official) { - look_for = 8; + look_for = MAX_PLAYERS; } for (int waycount = 0; waycount < look_for; waycount++) { @@ -2286,24 +2414,8 @@ static DynamicVectorClass Build_Start_Waypoint_List(bool official) } } - /* - ** If there are insufficient waypoints to account for all players, then randomly assign - ** starting points until there is enough. - */ - int deficiency = look_for - waypts.Count(); - if (deficiency > 0) { - DebugString("Multiplayer start waypoint deficiency - looking for more start positions\n"); - - while (waypts.Count() < look_for) { - Cell trycell = Cell(Map.MapRect.X + Random_Pick(10, Map.MapRect.Width - 10), Map.MapRect.Y + 10 + Random_Pick(0, Map.MapRect.Height - 10)); - - trycell = Map.Nearby_Location(trycell, SPEED_TRACK, -1, MZONE_NORMAL, false, Point2D(8, 8)); - if (trycell != CELL_NONE) { - waypts.Add(trycell); - DebugString("Random multiplayer start waypoint added at cell %d,%d\n", trycell.X, trycell.Y); - } - } - } + int usable = waypts.Count(); + Append_Open_Start_Positions(waypts, usable, look_for); return(waypts); } @@ -2366,16 +2478,28 @@ static void Create_Units(bool official) int average_cost = total_cost / total_objs; int max_value = unit_count * average_cost; + /* + * A house only asks for a position by number when a session source chose one for it, + * and that is what decides whether the numbers have to keep their identity. + */ + bool choices = false; + for (int index = 0; index < Houses.Count(); index++) { + if (Houses[index] != NULL && Houses[index]->SpawnWaypoint >= 0) { + choices = true; + break; + } + } + /* ** Build a list of the valid waypoints. This normally shouldn't be ** necessary because the scenario level designer should have assigned ** valid locations to the first N waypoints, but just in case, this ** loop verifies that. */ - DynamicVectorClass waypts = Build_Start_Waypoint_List(official); - bool taken[16]; + DynamicVectorClass waypts = Build_Start_Waypoint_List(official, choices); + bool taken[MAX_PLAYERS * 2]; for (int index = 0; index < ARRAY_SIZE(taken); index++) { - taken[index] = false; + taken[index] = choices && index < waypts.Count() && waypts[index] == CELL_NONE; } /* @@ -2425,10 +2549,18 @@ static void Create_Units(bool official) ** one of the valid locations at random. The other houses pick the furthest ** wapoint from the existing houses. */ - if (numtaken == 0) { - int pick = Random_Pick(0, waypts.Count() - 1); + if (choices && hptr->SpawnWaypoint >= 0 && hptr->SpawnWaypoint < waypts.Count() && !taken[hptr->SpawnWaypoint]) { + centroid = waypts[hptr->SpawnWaypoint]; + taken[hptr->SpawnWaypoint] = true; + numtaken++; + } else if (numtaken == 0) { + int pick; + do { + pick = Random_Pick(0, waypts.Count() - 1); + } while (taken[pick]); centroid = waypts[pick]; taken[pick] = true; + hptr->SpawnWaypoint = pick; numtaken++; } else { @@ -2453,7 +2585,7 @@ static void Create_Units(bool official) if (!taken[index]) { for (int trypoint = 0; trypoint < waypts.Count(); trypoint++) { - if (taken[trypoint]) { + if (taken[trypoint] && waypts[trypoint] != CELL_NONE) { score[index] += Distance(waypts[index], waypts[trypoint]); } } @@ -2467,6 +2599,9 @@ static void Create_Units(bool official) int best = 0; int bestvalue = 0; for (int searchindex = 0; searchindex < waypts.Count(); searchindex++) { + if (waypts[searchindex] == CELL_NONE) { + continue; + } if (score[searchindex] > bestvalue || bestvalue == 0) { bestvalue = score[searchindex]; best = searchindex; @@ -2478,6 +2613,7 @@ static void Create_Units(bool official) */ centroid = waypts[best]; taken[best] = true; + hptr->SpawnWaypoint = best; numtaken++; } diff --git a/code/session.cpp b/code/session.cpp index 550f16f3..4ccdae36 100644 --- a/code/session.cpp +++ b/code/session.cpp @@ -65,6 +65,7 @@ #include "rules.h" #include "savestream.h" #include "scenario.h" +#include "spawner.h" #include "special.h" #include "wonline.h" #include "xstraw.h" @@ -284,7 +285,11 @@ SessionClass::~SessionClass(void) void SessionClass::One_Time(void) { //Read_MultiPlayer_Settings(); - Read_Scenario_Descriptions(); + + // A client-launched game names its scenario outright and never shows the map list. + if (!Spawner_Is_Requested()) { + Read_Scenario_Descriptions(); + } UniqueID = Compute_Unique_ID(); DebugString("Session one time init. UniqueID is %08x\n", UniqueID); diff --git a/code/session.h b/code/session.h index b8951230..52304b4e 100644 --- a/code/session.h +++ b/code/session.h @@ -44,6 +44,8 @@ #include "version.h" #include "win.h" +#include + #include "dialog.hh" #include "diff.hh" @@ -217,6 +219,9 @@ struct NodeNameType { int ProcessTime; // Length of time to process players main loop int Status; // int SquadID; // + int SpawnChoice; // starting waypoint asked for; -1 = the engine picks + int Handicap; // difficulty asked for; -1 = the session default + unsigned AlliesMask; // seats allied with, one bit per seat index } Player; struct { unsigned int LastTime; // last time we heard from this guy @@ -224,6 +229,17 @@ struct NodeNameType { int Color; // chat player's color } Chat; }; + + /* + * A node starts with nothing asked for, so that a session source which names none of + * these leaves the game its own choice of start position and difficulty. + */ + NodeNameType(void) + { + memset(this, 0, sizeof(*this)); + Player.SpawnChoice = -1; + Player.Handicap = -1; + } }; @@ -715,6 +731,13 @@ class SessionClass DynamicVectorClass Games; // list of games DynamicVectorClass Players; // list of players DynamicVectorClass Chat; // list of chat nodes + + /* + * The computer players a session source seated, in the order their houses are + * created, after the human ones. The menu leaves this empty and lets the game draw + * its own computer players. + */ + DynamicVectorClass Computers; int Suspended; /* diff --git a/code/skirmish.cpp b/code/skirmish.cpp index 1f3d5f7e..83073897 100644 --- a/code/skirmish.cpp +++ b/code/skirmish.cpp @@ -220,11 +220,7 @@ bool Skirmish_Mode_Dialog(void) { int rc = -1; - Rule->Do_HouseTypes(*RuleINI); - Rule->Do_Sides(*RuleINI); - for (int i = 0; i < HouseTypes.Count(); i++) { - HouseTypes[i]->Read_INI(*RuleINI); - } + Prepare_Side_Roster(); Hide_Mouse(); Draw_Menu_Background(); @@ -417,6 +413,7 @@ BOOL Skirmish_On_WM_INITDIALOG(HWND window, WPARAM wparam, LPARAM lparam) Session.Options.ScenarioIndex = 0; SendDlgItemMessage(window, IDC_SCENARIONAME, WM_SETTEXT, 0, (LPARAM)Session.Options.ScenarioDescription); Clear_Vector(&Session.Players); + Clear_Vector(&Session.Computers); handle = GetDlgItem(window, IDC_SKIRMISH_BASES); if (handle) Button_SetCheck(handle, Session.Options.Bases ? BST_CHECKED : BST_UNCHECKED); diff --git a/code/spawner.cpp b/code/spawner.cpp new file mode 100644 index 00000000..68e743fb --- /dev/null +++ b/code/spawner.cpp @@ -0,0 +1,385 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#include "always.h" + +#include "spawner.h" + +#include "spawnerconfig.h" + +#include "addon.h" +#include "ccfile.h" +#include "ccini.h" +#include "dbgprint.h" +#include "globals.h" +#include "goptions.h" +#include "houstype.h" +#include "init.h" +#include "language\language.h" +#include "mplayer.h" +#include "msgbox.h" +#include "scenario.h" +#include "session.h" + +#include +#include +#include +#include + + +/* + * A spawned launch happens at most once for the life of the process: the client that asked + * for it watches for the process to exit, so a finished or refused spawn ends the program + * rather than falling into the menu. + */ +static bool SpawnRequested = false; +static bool SpawnConsumed = false; +static SpawnerConfigClass SpawnConfig; + + +/// +/// Refuses the launch, telling the player why and leaving the reason in the log. +/// +/// A printf style description of the fault. +/// false, so a caller can refuse and return in one statement. +static bool Spawner_Refuse(char const * fault, ...) +{ + char buffer[256]; + + va_list args; + va_start(args, fault); + std::vsnprintf(buffer, sizeof(buffer), fault, args); + va_end(args); + + DebugString("[Spawner] Refusing to launch: %s\n", buffer); + WWMessageBox().Process(buffer, TXT_OK); + + return(false); +} + + +/// +/// Folds a seat's alliance list into the bitfield the houses are allied by. +/// +/// The seat whose alliances are wanted. +/// One bit set per seat this one is allied with. +static unsigned Spawner_Allies_Mask(SpawnerConfigClass::SlotType const & seat) +{ + unsigned mask = 0; + + for (int ally : seat.Alliances) { + if (ally >= 0 && ally < SpawnerConfigClass::SLOT_COUNT) { + mask |= 1u << ally; + } + } + + return(mask); +} + + +/// +/// The difficulty one seat is played at, saying so when it is not the one asked for. +/// +/// Which seat, counted from zero. +/// The difficulty the launch file asked for. +/// The difficulty to play the seat at, or -1 for the session default. +static int Spawner_Seat_Handicap(int index, int asked) +{ + int played = SpawnerConfigClass::Playable_Handicap(asked); + + if (played != asked) { + DebugString("[Spawner] Seat %d asked for difficulty %d and is played at %d.\n", + index + 1, asked, played); + } + + return(played); +} + + +/// +/// Tells the session who is playing at this machine. +/// +static void Spawner_Seat_Local(void) +{ + SpawnerConfigClass::SlotType const & local = SpawnConfig.Slots[SpawnConfig.LocalSlot]; + + std::snprintf(Session.Handle, sizeof(Session.Handle), "%s", + local.Name.empty() ? "Player" : local.Name.c_str()); + Session.House = local.Country; + Session.ColorIdx = local.Color; + Session.PrefColor = Session.ColorIdx; +} + + +/// +/// Puts the people playing into the list the houses are created from. +/// +static void Spawner_Seat_Humans(void) +{ + for (int index = 0; index < SpawnConfig.HumanCount; index++) { + SpawnerConfigClass::SlotType const & seat = SpawnConfig.Slots[index]; + + NodeNameType * node = new NodeNameType; + std::snprintf(node->Name, sizeof(node->Name), "%s", + seat.Name.empty() ? "Player" : seat.Name.c_str()); + node->Player.House = seat.Country; + node->Player.Color = seat.Color; + node->Player.ProcessTime = -1; + node->Player.SpawnChoice = seat.StartingPosition; + node->Player.AlliesMask = Spawner_Allies_Mask(seat); + Session.Players.Add(node); + } + + Session.NumPlayers = SpawnConfig.HumanCount; +} + + +/// +/// Puts the computer players the client seated into the list the houses are created from. +/// +static void Spawner_Seat_Computers(void) +{ + static char const * const _ai_names[DIFF_COUNT] = { "Easy AI", "Medium AI", "Hard AI" }; + + for (int index = SpawnConfig.HumanCount; index < SpawnerConfigClass::SLOT_COUNT; index++) { + SpawnerConfigClass::SlotType const & seat = SpawnConfig.Slots[index]; + if (seat.Occupancy != SpawnerConfigClass::OccupancyType::Computer) { + continue; + } + + NodeNameType * node = new NodeNameType; + node->Player.House = seat.Country; + node->Player.Color = seat.Color; + node->Player.Handicap = Spawner_Seat_Handicap(index, seat.Handicap); + node->Player.SpawnChoice = seat.StartingPosition; + node->Player.AlliesMask = Spawner_Allies_Mask(seat); + + /* + * A computer player is named for the difficulty it is actually played at, which is + * the one its seat asked for, or else the one the session gives every computer. + */ + if (SpawnConfig.AINamesByDifficulty) { + int played = node->Player.Handicap >= 0 + ? node->Player.Handicap + : (DIFF_COUNT - 1 - SpawnConfig.AIDifficulty); + std::snprintf(node->Name, sizeof(node->Name), "%s", + _ai_names[std::clamp(played, 0, DIFF_COUNT - 1)]); + } + + Session.Computers.Add(node); + } +} + + +/// +/// Tells the session what every house plays under. +/// The order below follows the launch file's own, so that what the game takes from a launch +/// can be read against what the file carries. +/// +static void Spawner_Bind_Options(void) +{ + Session.Options.Bases = SpawnConfig.Bases; + Session.Options.Credits = SpawnConfig.Credits; + Session.Options.BridgeDestruction = SpawnConfig.BridgeDestroy; + Session.Options.Goodies = SpawnConfig.Crates; + Session.Options.ShortGame = SpawnConfig.ShortGame; + Session.Options.GameSpeed = SpawnConfig.GameSpeed; + Session.Options.CrapEngineers = SpawnConfig.MultiEngineer; + Session.Options.UnitCount = SpawnConfig.UnitCount; + Session.Options.AIPlayers = SpawnConfig.AIPlayers; + Session.Options.AIDifficulty = (DiffType)SpawnConfig.AIDifficulty; + Session.Options.AlliesAllowed = SpawnConfig.AlliesAllowed; + Session.Options.FogOfWar = SpawnConfig.FogOfWar; + Session.Options.MCVRedeploy = SpawnConfig.MCVRedeploy; + + /* + * A skirmish never reaches the pregame setup that hands this to the simulation, so the + * session records what was asked for while the map's own setting still decides it. + */ + Session.Options.HarvTruce = SpawnConfig.HarvesterTruce; + + /* + * These two are options as much as anything above, but they live outside the block the + * session keeps them in; the menu's own commit sets both the same way. + */ + Options.GameSpeed = SpawnConfig.GameSpeed; + BuildLevel = SpawnConfig.TechLevel; + + /* + * The seed is left where a launch option leaves it, since the random numbers are only + * settled once the session type is known. A file naming no seed leaves it to the clock. + */ + CustomSeed = SpawnConfig.Seed; + + /* + * Read, not honored. Every field the reader carries is either bound above, consumed to + * refuse a launch, or named here with the reason, so that adding a field to the reader + * forces a decision rather than a silent omission. A field named here is not a defect: + * the launch file is the client's vocabulary, and much of it describes machinery this + * game does not have yet. + * + * IsHost, Tournament, GameID - the client's own bookkeeping of the match. + * MapName - shown while loading; bound with the scenario below. + * MapHash - the client checks that the machines hold one map. + * AutoSaveInterval, + * NextCampaignAutoSave, + * NextSkirmishAutoSave - saving by itself is not wired up. + * BuildOffAlly - the game has no such option to give it to. + * GlobalFlags - scenario flags are a campaign's, and a campaign is + * refused here. + * ReconnectTimeout, ConnTimeout, + * TunnelId, ListenPort, + * TunnelAddress, TunnelPort, + * Slots[].Address, Slots[].Port - where machines reach one another; a skirmish reaches + * none of them. + * QuickMatch, SkipScoreScreen, + * WriteStatistics, CoachMode, + * AutoSurrender, AttackNeutralUnits, + * ScrapMetal, ContinueWithoutHumans, + * PlayMoviesInMultiplayer - behaviors no part of this game offers yet. + * CustomLoadScreen, + * CustomLoadScreenX/Y - the loading backdrop belongs to the campaign path. + * DifficultyName - shown, never played by. + * IsCampaign, CampaignID, + * CampaignDifficulty, + * CampaignCDifficulty, + * LoadSaveGame, SaveGameName - read to decide what kind of launch this is, which is + * how the three this game cannot start are refused. + * Slots[].IsSpectator - read to refuse a launch. + * + * The timing keys a client writes are not read at all: the game keeps its own. + */ +} + + +/// +/// Tells the session which scenario is being played, in place of the map list the menu picks +/// from, which a client-launched game never shows. +/// +static void Spawner_Bind_Scenario(void) +{ + std::snprintf(Scen->ScenarioName, sizeof(Scen->ScenarioName), "%s", SpawnConfig.ScenarioName.c_str()); + std::snprintf(Session.ScenarioFileName, sizeof(Session.ScenarioFileName), "%s", SpawnConfig.ScenarioName.c_str()); + std::snprintf(Session.Options.ScenarioDescription, sizeof(Session.Options.ScenarioDescription), + "%s", SpawnConfig.MapName.c_str()); + + Session.Options.ScenarioIndex = -1; + Session.ScenarioFileLength = CCFileClass(Scen->ScenarioName).Size(); + Session.ScenarioIsOfficial = false; + Session.ScenarioDigest[0] = '\0'; +} + + +/// +/// Assembles the session a skirmish launch asks for, in place of what the skirmish dialog +/// commits when a player presses OK. +/// +static void Spawner_Setup_Skirmish(void) +{ + Session.Type = GAME_SKIRMISH; + + Clear_Vector(&Session.Players); + Clear_Vector(&Session.Computers); + + Spawner_Bind_Options(); + Spawner_Seat_Local(); + Spawner_Seat_Humans(); + Spawner_Seat_Computers(); + Spawner_Bind_Scenario(); +} + + +/// +/// Notes that a client asked the game to launch what its file describes. +/// +void Spawner_Request(void) +{ + SpawnRequested = true; +} + + +/// +/// Did a client ask the game to launch what its file describes? +/// +/// bool; Was a launch requested on the command line? +bool Spawner_Is_Requested(void) +{ + return(SpawnRequested); +} + + +/// +/// Reads the launch file and assembles the game it describes. +/// This stands in place of the menu, and answers false once the game it launched has ended, +/// so that the process leaves rather than showing a menu the client never meant to show. +/// +/// Set when the launch resumed a saved game. +/// bool; Is a game ready to start? +bool Spawner_Prepare(bool & gameloaded) +{ + (void)gameloaded; + + if (SpawnConsumed) { + return(false); + } + + CCFileClass file("SPAWN.INI"); + if (!file.Is_Available()) { + return(Spawner_Refuse("SPAWN.INI is missing, and it says what to launch.")); + } + + CCINIClass ini; + ini.Load(file, false); + SpawnConfig.Read_INI(ini); + + /* + * A launch is spent as soon as it is read, so that a refusal ends the process the same + * way a finished game does. + */ + SpawnConsumed = true; + + /* + * The countries a seat may name are the rules', so the roster is read before the seats + * are judged, as the menu paths setting up a game do. + */ + Prepare_Side_Roster(); + + switch (SpawnConfig.Launch_Type()) { + case SpawnerConfigClass::LaunchType::Resume: + return(Spawner_Refuse("Resuming a saved game from a launch file is not supported yet.")); + + case SpawnerConfigClass::LaunchType::Campaign: + return(Spawner_Refuse("Launching a campaign mission from a launch file is not supported yet.")); + + case SpawnerConfigClass::LaunchType::Multiplayer: + return(Spawner_Refuse("Launching a game against other machines is not supported yet.")); + + case SpawnerConfigClass::LaunchType::Skirmish: + break; + } + + std::string fault; + if (!SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)) { + return(Spawner_Refuse("%s", fault.c_str())); + } + + Disable_Addon(ADDON_ANY); + if (SpawnConfig.Firestorm) { + Enable_Addon(ADDON_FIRESTORM); + Set_Required_Addon(ADDON_FIRESTORM); + } + + Spawner_Setup_Skirmish(); + + DebugString("[Spawner] Launching %s with session identity %08x.\n", + Scen->ScenarioName, SpawnConfig.Session_Identity_CRC()); + + return(true); +} diff --git a/code/spawner.h b/code/spawner.h new file mode 100644 index 00000000..fa09dc7c --- /dev/null +++ b/code/spawner.h @@ -0,0 +1,16 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#pragma once + + +void Spawner_Request(void); +bool Spawner_Is_Requested(void); +bool Spawner_Prepare(bool & gameloaded); diff --git a/code/spawnerconfig.cpp b/code/spawnerconfig.cpp index ae50b901..c3161293 100644 --- a/code/spawnerconfig.cpp +++ b/code/spawnerconfig.cpp @@ -11,9 +11,11 @@ #include "spawnerconfig.h" #include "crc.h" +#include "diff.hh" #include "ini.h" #include +#include #include #include #include @@ -61,6 +63,26 @@ int Read_Slot_Int(INIClass const & ini, char const * section, int slot, int fall return(ini.Get_Int(section, entry.c_str(), fallback)); } + +/// +/// Names the fault that refuses a launch. +/// +/// Where to leave the sentence describing the fault. +/// A printf style description of the fault. +/// false, so a caller can name a fault and refuse in one statement. +bool Fault(std::string & fault, char const * format, ...) +{ + char buffer[256]; + + va_list args; + va_start(args, format); + std::vsnprintf(buffer, sizeof(buffer), format, args); + va_end(args); + + fault = buffer; + return(false); +} + } @@ -254,6 +276,90 @@ int SpawnerConfigClass::Session_Identity_CRC(void) const } +/// +/// The difficulty a seat is played at, given the one it asked for. +/// A client may offer more easy settings than the game holds; the easiest one the game has +/// is what any easier request comes to. A seat asking for nothing keeps the session default. +/// +/// The difficulty the launch file asked for. +/// The difficulty to play the seat at, or -1 for the session default. +int SpawnerConfigClass::Playable_Handicap(int asked) +{ + if (asked < 0) { + return(-1); + } + if (asked > DIFF_HARD) { + return(DIFF_EASY); + } + return(asked); +} + + +/// +/// Judges whether this reading describes a game that can be played. +/// The countries and colors are handed in because they are the rules', settled only once the +/// game has loaded them, while everything else a launch is refused for is here in the file. +/// +/// How many countries the rules declared. +/// How many colors a house may be given. +/// Where to leave the sentence describing the first fault found. +/// bool; Can the game this file describes be played? +bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fault) const +{ + int free_seats = SLOT_COUNT - HumanCount; + if (AIPlayers < 0 || AIPlayers > free_seats) { + return(Fault(fault, "The file asks for %d computer players, and %d seats are left.", + AIPlayers, free_seats)); + } + + for (int index = 0; index < SLOT_COUNT; index++) { + SlotType const & slot = Slots[index]; + if (slot.Occupancy == OccupancyType::Empty) { + continue; + } + + bool human = slot.Occupancy == OccupancyType::Human; + + /* + * A computer seat may leave its country and its color to the game, as a game set up + * from the menu does. A person's seat names both. + */ + if ((human || slot.Country != -1) && (slot.Country < 0 || slot.Country >= countries)) { + return(Fault(fault, "Seat %d is given country %d, and there are %d to choose from.", + index + 1, slot.Country, countries)); + } + + /* + * A shared color is not a fault: co-op matches deliberately give one team's houses + * the same color. Only a color the game has no scheme for refuses. + */ + if ((human || slot.Color != -1) && (slot.Color < 0 || slot.Color >= colors)) { + return(Fault(fault, "Seat %d is given color %d, and there are %d to choose from.", + index + 1, slot.Color, colors)); + } + + if (slot.Handicap < -1 || slot.Handicap > 6) { + return(Fault(fault, "Seat %d is given difficulty %d, which names none.", + index + 1, slot.Handicap)); + } + + for (int ally : slot.Alliances) { + if (ally < -1 || ally >= SLOT_COUNT) { + return(Fault(fault, "Seat %d is allied to seat %d, which the match does not hold.", + index + 1, ally + 1)); + } + } + + if (slot.IsSpectator) { + return(Fault(fault, "Seat %d watches rather than plays, which this game cannot yet do.", + index + 1)); + } + } + + return(true); +} + + /// /// Reads what the CnCNet client asked the game to launch. Reading cannot fail: every key has a /// settled meaning when absent, a value the reader cannot make sense of keeps that meaning, diff --git a/code/spawnerconfig.h b/code/spawnerconfig.h index 608d8200..ec68357f 100644 --- a/code/spawnerconfig.h +++ b/code/spawnerconfig.h @@ -87,6 +87,15 @@ class SpawnerConfigClass LaunchType Launch_Type(void) const; int Session_Identity_CRC(void) const; + /* + * Reading cannot fail, so what was read is judged separately, against the tables + * the game has loaded by the time a launch is attempted. Those are handed in rather + * than reached for, so that a reading can be judged without the game running. + */ + bool Is_Playable(int countries, int colors, std::string & fault) const; + + static int Playable_Handicap(int asked); + /* * What kind of game to start. */ diff --git a/code/startup.cpp b/code/startup.cpp index b4671501..a8d03f38 100644 --- a/code/startup.cpp +++ b/code/startup.cpp @@ -114,6 +114,7 @@ #include "shapeset.h" #include "side.h" #include "sidebar.h" +#include "spawner.h" #include "smudge.h" #include "smudtype.h" #include "sun.h" @@ -612,7 +613,7 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * command_line , in ** Check for forced intro movie run disabling. If the conquer ** configuration file says "no", then don't run the intro. */ - if (!Special.IsFromInstall) { + if (!Special.IsFromInstall && !Spawner_Is_Requested()) { Special.IsFromInstall = ConfigINI.Get_Bool("Intro", "PlayIntro", true); } @@ -620,7 +621,7 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * command_line , in ** Regardless of whether we should run it or not, here we're ** gonna change it to say "no" in the future. */ - if (Special.IsFromInstall == true) { + if (Special.IsFromInstall == true && !Spawner_Is_Requested()) { ConfigINI.Put_Bool("Intro", "PlayIntro", false); cfile->Close(); cfile->Open(); diff --git a/manual/changes/chosen-start-positions.md b/manual/changes/chosen-start-positions.md new file mode 100644 index 00000000..108f8cd9 --- /dev/null +++ b/manual/changes/chosen-start-positions.md @@ -0,0 +1,23 @@ +--- +title: Seat a house at the start position it asked for +category: feature +release: 0.2.0 +targets: +- type: format + id: save-games + effect: changed +credit: [ZivDero] +--- + +A house may now be placed at a named map start position rather than one the game picks. A +position is named by the map's own waypoint number, so a map that declares some of its +first eight waypoints and not others keeps the numbering it wrote: an undeclared waypoint +stays a gap instead of shifting the positions after it. A position the map does not declare, +or one another house has already taken, falls back to the game's own choice. + +A game that names no positions is placed exactly as before, drawing the same random numbers +in the same order. + +The house record in a saved game gained the start position the house was placed at. Saved +games from other versions were already refused; within this unreleased development cycle, +saves made before this change do not interoperate with builds made after it. diff --git a/manual/changes/client-driven-launch.md b/manual/changes/client-driven-launch.md new file mode 100644 index 00000000..6a17c370 --- /dev/null +++ b/manual/changes/client-driven-launch.md @@ -0,0 +1,30 @@ +--- +title: Launch a game from a client's launch file +category: feature +release: 0.2.0 +targets: +- type: command + id: launch:spawn + effect: added +- type: format + id: spawn-ini + effect: added +credit: [ZivDero] +--- + +Starting the game with `-SPAWN` now plays the skirmish that `SPAWN.INI` describes. The +startup movies and the main menu are both skipped, the map list the menu would scan is not +read, and the game exits when the match ends rather than returning to a menu the client +never meant to show. The settings file a client manages is no longer written back to while +a launch is in progress. + +The file names the options every house plays under, who is playing, each seat's country, +color, difficulty and start position, and the alliances between them. A file describing a +campaign mission, a saved game, or a game against other machines is refused with the reason +shown; those launches arrive separately. Anything the file asks for that the game cannot +honor is listed on the launch file's own page. + +The node the player and lobby lists are made of now initializes itself rather than starting +as whatever the heap last held, and carries the start position, difficulty and alliances a +seat asked for. No packet the game sends and no save it writes carries that node, so +neither format changes, and a game set up from the menu is assembled exactly as before. diff --git a/manual/content/formats/spawn-ini.md b/manual/content/formats/spawn-ini.md new file mode 100644 index 00000000..5c54c8d2 --- /dev/null +++ b/manual/content/formats/spawn-ini.md @@ -0,0 +1,112 @@ +--- +format_id: spawn-ini +title: Client launch file +summary: Describes the match a client asks the game to launch when it starts the game with -SPAWN. +kind: file +source_files: +- code/spawnerconfig.cpp +- code/spawnerconfig.h +- code/spawner.cpp +filenames: +- SPAWN.INI +related: +- type: format + id: ini-syntax +- type: command + id: launch:spawn +--- + +A client that sets up matches outside the game writes this file beside the game and starts +the game with [`-SPAWN`](/using/command-line/spawn). The game then plays the match the file +describes instead of showing its own menu, and exits when that match ends. + +The vocabulary below is the client's, not the game's: the spelling of every key, and what +each means when it is left out, are settled by what clients already write. Reading the file +never fails. A key the game does not know is passed over, a value it cannot make sense of +keeps the meaning an absent key would have, and whether the result describes a game that +can be played is judged once, at the moment the launch is attempted. + +## What the file asks for + +The `[Settings]` section says what kind of game to start. + +| Key | Meaning | +| --- | --- | +| `Scenario` | The scenario file to play. Defaults to `spawnmap.ini`. | +| `IsSinglePlayer` | Play a campaign mission rather than a match. | +| `LoadSaveGame`, `SaveGameName` | Resume the named saved game. | + +Only a skirmish is currently launched this way. A file asking for a campaign mission, a +saved game, or a game against other machines is refused with the reason shown, and the game +exits. + +## The options every house plays under + +Read from `[Settings]`: `Bases`, `Credits`, `BridgeDestroy`, `Crates`, `ShortGame`, +`GameSpeed`, `MultiEngineer`, `UnitCount`, `AIPlayers`, `AIDifficulty`, `AlliesAllowed`, +`FogOfWar`, `MCVRedeploy`, `TechLevel`, `Firestorm`, and `Seed`. + +A written `Seed` makes a launch repeatable: the same file played twice places every house +the same way. A seed of `0` leaves the placement to chance, which is also what an absent +`Seed` means. + +`HarvesterTruce` is read and recorded with the rest of the match's options, but a skirmish +takes harvester immunity from the scenario's own `[SPECIAL]` section, so the key does not +change how a skirmish is played. + +## Who is playing + +A seat is a person's because the file writes a section for it: `[Settings]` describes the +player at this machine, and `[Other1]` through `[Other7]` describe the others. Each names +`Name`, `Side` (the country), and `Color`. + +A seat no section claims is a computer player, described by position instead: + +| Section | Entry | Meaning | +| --- | --- | --- | +| `[HouseColors]` | `Multi1`–`Multi8` | The color that seat plays. | +| `[HouseCountries]` | `Multi1`–`Multi8` | The country that seat plays. | +| `[HouseHandicaps]` | `Multi1`–`Multi8` | The difficulty that seat plays at. | + +A computer seat may write `-1` for its country or color and leave the choice to the game, +as a game set up from the menu does. A person's seat names both. `AIPlayers` says how many +of the unclaimed seats are actually played by a computer. + +The seats are then ordered the way the game creates houses — the people first, by ascending +color — and everything below that names a seat by number means that order. + +| Section | Entry | Meaning | +| --- | --- | --- | +| `[SpawnLocations]` | `Multi1`–`Multi8` | The map start position that seat begins at. | +| `[Multi1_Alliances]`–`[Multi8_Alliances]` | `HouseAllyOne`–`HouseAllyEight` | The seats that seat is allied with. | + +A start position the map does not declare, or one another seat has already taken, is left +to the game to choose, which is also what writing no position means. Alliances are made +exactly as written, before the first frame is played, and quietly: a match whose file +forbids new pacts still starts with the ones it wrote. + +Two seats may share a color deliberately — a cooperative team does — and the game does not +refuse it. + +## When something is wrong + +A file describing a game that cannot be played is refused: the reason is shown and written +to the log, and the game exits rather than falling back to its menu. A launch is refused +when it asks for more computer players than there are seats, names a country or color the +loaded rules do not have, names a difficulty that is not one, allies a seat with one the +match does not hold, or asks for a seat that watches rather than plays. + +A difficulty easier than the three the game has is not refused: the seat is played at the +easiest one the game does have. + +## What the game does not take from a launch file + +The file's timing keys are not read at all. How far ahead the machines run and how often +they exchange their orders is the game's own business, and no launch file changes it. + +These keys are read but do not yet change anything, because the game has no such behavior +to give them to: `Tournament`, `GameID`, `MapHash`, `BuildOffAlly`, the automatic-save +keys, `QuickMatch`, `SkipScoreScreen`, `WriteStatistics`, `CoachMode`, `AutoSurrender`, +`AttackNeutralUnits`, `ScrapMetal`, `ContinueWithoutHumans`, `PlayMoviesInMultiplayer`, +`CustomLoadScreen`, `CustomLoadScreenPos`, and `DifficultyName`. The tunnel and timeout +keys describe how machines reach one another, which a skirmish never needs. diff --git a/manual/data/command-adapters.yaml b/manual/data/command-adapters.yaml index 78c1cbe8..3e30e40d 100644 --- a/manual/data/command-adapters.yaml +++ b/manual/data/command-adapters.yaml @@ -474,6 +474,14 @@ launch_options: availability: *all sites: - { file: code/init.cpp, function: Parse_Command_Line, expression: 'literal:-CD' } + - id: launch:spawn + title: Client launch + syntax: -SPAWN + description: Launches the game SPAWN.INI describes, in place of the startup movies and the menu. + audience: player + availability: *all + sites: + - { file: code/init.cpp, function: Parse_Command_Line, expression: 'literal:-SPAWN' } - id: launch:tournament-time title: Tournament time limit syntax: -TIME= diff --git a/manual/data/commands.yaml b/manual/data/commands.yaml index e3cb9916..c516a1e4 100644 --- a/manual/data/commands.yaml +++ b/manual/data/commands.yaml @@ -1775,6 +1775,17 @@ launch_options: _provenance: source: code/init.cpp guard: null +- id: launch:spawn + route_id: spawn + kind: launch + title: Client launch + description: Launches the game SPAWN.INI describes, in place of the startup movies and the menu. + audience: player + availability: *id001 + syntax: -SPAWN + _provenance: + source: code/init.cpp + guard: null - id: launch:tournament-time route_id: tournament-time kind: launch diff --git a/tests/spawner/spawncontract.cpp b/tests/spawner/spawncontract.cpp index 1029ec8c..9db99b10 100644 --- a/tests/spawner/spawncontract.cpp +++ b/tests/spawner/spawncontract.cpp @@ -14,6 +14,7 @@ #include #include +#include #include "ini.h" #include "spawnerconfig.h" @@ -143,6 +144,14 @@ SpawnerConfigClass Read(char const * text, int length) return(config); } + +bool Judge(char const * text, int length, int countries, int colors, std::string & fault) +{ + SpawnerConfigClass config = Read(text, length); + fault.clear(); + return(config.Is_Playable(countries, colors, fault)); +} + } @@ -418,6 +427,148 @@ int main(void) "half a position is no position either"); } + /* + * Reading a launch file cannot fail, so whether what it describes can be played is + * judged separately, against the tables the game has loaded by the time it launches. + */ + { + std::string fault; + + Check(Judge(_Skirmish, sizeof(_Skirmish) - 1, 2, 8, fault), + "a match the loaded rules can hold is played"); + + char const crowded[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "AIPlayers=8\n"; + Check(!Judge(crowded, sizeof(crowded) - 1, 2, 8, fault) && + fault.find("8") != std::string::npos && fault.find("7") != std::string::npos, + "more computer players than seats names both counts"); + + char const negative[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "AIPlayers=-1\n"; + Check(!Judge(negative, sizeof(negative) - 1, 2, 8, fault), + "fewer than no computer players is refused"); + + char const nameless_country[] = + "[Settings]\n" + "Name=Commander\n" + "Color=0\n"; + Check(!Judge(nameless_country, sizeof(nameless_country) - 1, 2, 8, fault), + "a person's country is never the game's to draw"); + + char const nameless_color[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n"; + Check(!Judge(nameless_color, sizeof(nameless_color) - 1, 2, 8, fault), + "a person's color is never the game's to draw either"); + + char const drawn_computer[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "AIPlayers=1\n" + "\n" + "[HouseColors]\n" + "Multi2=-1\n" + "\n" + "[HouseCountries]\n" + "Multi2=-1\n"; + Check(Judge(drawn_computer, sizeof(drawn_computer) - 1, 2, 8, fault), + "a computer seat may leave its country and color to the game"); + + char const past_countries[] = + "[Settings]\n" + "Name=Commander\n" + "Side=2\n" + "Color=0\n"; + Check(!Judge(past_countries, sizeof(past_countries) - 1, 2, 8, fault), + "a country the rules did not declare is refused"); + + char const past_colors[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=8\n"; + Check(!Judge(past_colors, sizeof(past_colors) - 1, 2, 8, fault), + "a color the game has no scheme for is refused"); + + char const shared_color[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=3\n"; + Check(Judge(shared_color, sizeof(shared_color) - 1, 2, 8, fault), + "a cooperative team shares one color on purpose"); + + char const past_difficulty[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "AIPlayers=1\n" + "\n" + "[HouseHandicaps]\n" + "Multi2=7\n"; + Check(!Judge(past_difficulty, sizeof(past_difficulty) - 1, 2, 8, fault), + "a difficulty naming none is refused"); + + char const easy_difficulty[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "AIPlayers=1\n" + "\n" + "[HouseHandicaps]\n" + "Multi2=6\n"; + Check(Judge(easy_difficulty, sizeof(easy_difficulty) - 1, 2, 8, fault), + "a difficulty easier than the game holds is played, not refused"); + + Check(SpawnerConfigClass::Playable_Handicap(-1) == -1 && SpawnerConfigClass::Playable_Handicap(0) == 0 && + SpawnerConfigClass::Playable_Handicap(2) == 2 && SpawnerConfigClass::Playable_Handicap(3) == 0 && + SpawnerConfigClass::Playable_Handicap(6) == 0, + "an easier setting than the game has comes to the easiest it has"); + + char const past_seats[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "\n" + "[Multi1_Alliances]\n" + "HouseAllyOne=8\n"; + Check(!Judge(past_seats, sizeof(past_seats) - 1, 2, 8, fault), + "an alliance with a seat the match does not hold is refused"); + + char const watcher[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "\n" + "[IsSpectator]\n" + "Multi1=Yes\n"; + Check(!Judge(watcher, sizeof(watcher) - 1, 2, 8, fault), + "a seat that watches rather than plays is refused"); + + Check(!Judge(_Skirmish, sizeof(_Skirmish) - 1, 0, 8, fault), + "a match is refused rather than read against countries the rules never declared"); + } + std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); return(Failures == 0 ? 0 : 1); } From bc471c85ac74d6aa1f1791e99d3eb72148c2d775 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 01:46:03 +0300 Subject: [PATCH 03/35] Pin what the client launch path promises --- code/spawner.cpp | 3 +- .../documentation-source-contract.test.mjs | 80 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/code/spawner.cpp b/code/spawner.cpp index 68e743fb..8ecde11c 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -245,7 +245,8 @@ static void Spawner_Bind_Options(void) * ScrapMetal, ContinueWithoutHumans, * PlayMoviesInMultiplayer - behaviors no part of this game offers yet. * CustomLoadScreen, - * CustomLoadScreenX/Y - the loading backdrop belongs to the campaign path. + * CustomLoadScreenX, + * CustomLoadScreenY - the loading backdrop belongs to the campaign path. * DifficultyName - shown, never played by. * IsCampaign, CampaignID, * CampaignDifficulty, diff --git a/manual/site/tests/documentation-source-contract.test.mjs b/manual/site/tests/documentation-source-contract.test.mjs index 374d7b37..ac157016 100644 --- a/manual/site/tests/documentation-source-contract.test.mjs +++ b/manual/site/tests/documentation-source-contract.test.mjs @@ -192,3 +192,83 @@ test('Building main-shape Image is additive to the inherited ObjectType Image re ], 'Building main-shape selection'); assert.doesNotMatch(fetchImage, /\bGraphicName\s*=/); }); + +test('Every field the launch file reader carries is bound or named as unhonored', () => { + const header = source('code/spawnerconfig.h'); + const spawner = source('code/spawner.cpp'); + + assert.match( + spawner, + /Read, not honored/, + 'the binding step keeps its ledger of fields it deliberately leaves alone', + ); + + const fields = []; + for (const line of header.split('\n')) { + const declaration = /^\t{2,3}(?!static |enum |struct |\/)[A-Za-z_][^;(]*?[\s>*&]([A-Za-z_]\w*)\s*(?:=[^;]*)?;\s*$/.exec(line); + if (declaration) fields.push(declaration[1]); + } + assert.ok(fields.length > 30, `expected the reader to carry many fields, found ${fields.length}`); + + for (const field of fields) { + assert.match( + spawner, + new RegExp(String.raw`\b${field}\b`), + `${field} is read from a launch file but code/spawner.cpp neither binds it nor names it in the "Read, not honored" ledger`, + ); + } +}); + +test('A session node is left to its own constructor rather than zeroed by hand', () => { + assert.doesNotMatch( + source('code/netdlg2.cpp'), + /memset\(who, 0, sizeof\(\*who\)\)/, + 'zeroing a node by hand would wipe the defaults its constructor sets', + ); +}); + +test('House assignment takes each seat as written before the neutral houses exist', () => { + const assign = functionBody(source('code/scenario.cpp'), 'void Assign_Houses(void)'); + + assertOrdered(assign, [ + 'housep->SpawnWaypoint = player->Player.SpawnChoice;', + 'seat->Player.House != -1', + 'seat->Player.Color != -1', + 'seat->Player.Handicap >= 0', + 'housep->SpawnWaypoint = seat->Player.SpawnChoice;', + 'seat->Player.ID = housep->HeapID;', + ], 'a seated house takes its country, color, difficulty and start position'); + + assertOrdered(assign, [ + 'Seated_Node(seatnum)', + 'Make_Ally', + 'HouseTypeClass::From_Name("Neutral")', + ], 'the alliance table names seats, so it is applied before any house that is not one'); +}); + +test('A chosen start position keeps its number and is claimed before the game picks', () => { + const scenario = source('code/scenario.cpp'); + + const build = functionBody( + scenario, + 'static DynamicVectorClass Build_Start_Waypoint_List(bool official, bool keep_identity)', + ); + assertOrdered(build, [ + 'if (keep_identity) {', + 'waypts.Add(declared ? Scen->Get_Waypoint_Cell(waycount) : CELL_NONE);', + 'Append_Open_Start_Positions(', + 'return(waypts);', + ], 'the numbered list keeps an undeclared position as a hole and appends any shortfall past it'); + + const create = functionBody( + scenario.slice(scenario.search(/static void Create_Units\(bool official\)\s*\{/)), + 'static void Create_Units(bool official)', + ); + assertOrdered(create, [ + 'Houses[index]->SpawnWaypoint >= 0', + 'Build_Start_Waypoint_List(official, choices)', + 'taken[index] = choices && index < waypts.Count() && waypts[index] == CELL_NONE;', + 'if (choices && hptr->SpawnWaypoint >= 0', + '} else if (numtaken == 0) {', + ], 'holes are spoken for before the claim, and the claim comes before the game picks'); +}); From e3226f034669cd999dc0c22db9f258fe17411b8a Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 01:48:01 +0300 Subject: [PATCH 04/35] Hold the campaign difficulty to the settings the game has --- code/options.cpp | 2 +- manual/changes/campaign-difficulty-range.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 manual/changes/campaign-difficulty-range.md diff --git a/code/options.cpp b/code/options.cpp index de479fdd..f69efe1c 100644 --- a/code/options.cpp +++ b/code/options.cpp @@ -359,7 +359,7 @@ void OptionsClass::Load_Settings(void) DebugString("GameSpeed = %d\n", GameSpeed); Difficulty = ConfigINI.Get_Int("Options", "Difficulty", Difficulty); - Difficulty = std::min(Difficulty, 4); + Difficulty = std::min(Difficulty, (int)DIFF_COUNT - 1); Difficulty = std::max(Difficulty, 0); DebugString("Difficulty = %d\n", Difficulty); diff --git a/manual/changes/campaign-difficulty-range.md b/manual/changes/campaign-difficulty-range.md new file mode 100644 index 00000000..339ad8f7 --- /dev/null +++ b/manual/changes/campaign-difficulty-range.md @@ -0,0 +1,14 @@ +--- +title: Hold the campaign difficulty setting to the settings it names +category: fix +release: 0.2.0 +targets: [] +credit: [ZivDero] +--- + +The campaign difficulty read from the settings file is now held to the three +difficulties the game has, rather than to five. The game has offered three +since its sliders were built that way, so a setting chosen in the game is +unaffected; a file edited by hand to name a fourth or fifth could previously +start a mission whose computer difficulty fell below the easiest one and read +the difficulty table from outside itself. From 28cb38114e7e47b30b4252909627441f2717040e Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 01:52:33 +0300 Subject: [PATCH 05/35] Carry the campaign handicap pair on the session --- code/init.cpp | 36 +++++-------------- code/scenario.cpp | 4 +-- code/session.cpp | 2 ++ code/session.h | 8 +++++ code/spawner.cpp | 12 +++++++ code/spawner.h | 1 + .../documentation-source-contract.test.mjs | 27 ++++++++++++++ 7 files changed, 61 insertions(+), 29 deletions(-) diff --git a/code/init.cpp b/code/init.cpp index 13b3ba17..d7bcd91a 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -1156,33 +1156,6 @@ bool Select_Game(bool ) break; } - switch (Options.Difficulty) { - case 0: - Scen->CDifficulty = DIFF_HARD; - Scen->Difficulty = DIFF_EASY; - break; - - case 1: - Scen->CDifficulty = DIFF_HARD; - Scen->Difficulty = DIFF_NORMAL; - break; - - case 2: - Scen->CDifficulty = DIFF_NORMAL; - Scen->Difficulty = DIFF_NORMAL; - break; - - case 3: - Scen->CDifficulty = DIFF_EASY; - Scen->Difficulty = DIFF_NORMAL; - break; - - case 4: - Scen->CDifficulty = DIFF_EASY; - Scen->Difficulty = DIFF_HARD; - break; - } - Theme.Stop(true); int timeout = (TickCount + 5 * TIMER_SECOND); @@ -1545,6 +1518,15 @@ bool Select_Game(bool ) Session.PlayerIsGDI = stricmp(HouseTypes[Session.Players[0]->Player.House]->Name(), "GDI") == 0; } + /* + * The campaign handicap pair follows the menu's difficulty setting on every path but + * a client launch, which chose the pair itself. + */ + if (!Spawner_Is_Active()) { + Session.CampaignDifficulty = (DiffType)Options.Difficulty; + Session.CampaignCDifficulty = (DiffType)(DIFF_COUNT - 1 - Options.Difficulty); + } + if (Session.Type != GAME_NORMAL || Debug_ForceScenario || Session.Play) { if (!Start_Scenario(Scen->ScenarioName, true, CAMPAIGN_NONE)) { if (Debug_Map) { diff --git a/code/scenario.cpp b/code/scenario.cpp index 7b43b317..697c7402 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -1582,8 +1582,8 @@ bool Read_Scenario_INI(CCINIClass const & ini, bool is_mapgen) Clear_Scenario(); if (Session.Type == GAME_NORMAL) { - Scen->Difficulty = (DiffType)Options.Difficulty; - Scen->CDifficulty = (DiffType)(DIFF_COUNT - 1 - Options.Difficulty); + Scen->Difficulty = Session.CampaignDifficulty; + Scen->CDifficulty = Session.CampaignCDifficulty; Scen->Special.IsFogOfWar = false; Special.IsFogOfWar = false; } else { diff --git a/code/session.cpp b/code/session.cpp index 0e071270..376e0f0e 100644 --- a/code/session.cpp +++ b/code/session.cpp @@ -175,6 +175,8 @@ SessionClass::SessionClass(void) ObiWan = 0; Solo = 0; + CampaignDifficulty = DIFF_NORMAL; + CampaignCDifficulty = DIFF_NORMAL; PreferredServer = NULL; diff --git a/code/session.h b/code/session.h index 52304b4e..495e3f34 100644 --- a/code/session.h +++ b/code/session.h @@ -526,6 +526,14 @@ class SessionClass int ObiWan; // 1 = player can see all int Solo; // 1 = player can play alone + /* + * The handicap pair a campaign mission is played at, set by whichever path starts + * the campaign. It lives here because the scenario's own copy is wiped before every + * mission is read, while a restart or the next mission must keep the pair chosen. + */ + DiffType CampaignDifficulty; + DiffType CampaignCDifficulty; + /* * This is the name of the Westwood Online server the player would rather log on to, * remembered between runs. If NULL, then the service is left to pick one for him. diff --git a/code/spawner.cpp b/code/spawner.cpp index 8ecde11c..da8cf0c9 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -316,6 +316,18 @@ bool Spawner_Is_Requested(void) } +/// +/// Is the game being played the one a launch file described? +/// This answers for the game itself rather than for the command line, so a path that must +/// leave a client's choices alone can tell that a launch file made them. +/// +/// bool; Was the game assembled from a launch file? +bool Spawner_Is_Active(void) +{ + return(SpawnConsumed); +} + + /// /// Reads the launch file and assembles the game it describes. /// This stands in place of the menu, and answers false once the game it launched has ended, diff --git a/code/spawner.h b/code/spawner.h index fa09dc7c..0dc9bcca 100644 --- a/code/spawner.h +++ b/code/spawner.h @@ -13,4 +13,5 @@ void Spawner_Request(void); bool Spawner_Is_Requested(void); +bool Spawner_Is_Active(void); bool Spawner_Prepare(bool & gameloaded); diff --git a/manual/site/tests/documentation-source-contract.test.mjs b/manual/site/tests/documentation-source-contract.test.mjs index ac157016..6b68a13b 100644 --- a/manual/site/tests/documentation-source-contract.test.mjs +++ b/manual/site/tests/documentation-source-contract.test.mjs @@ -272,3 +272,30 @@ test('A chosen start position keeps its number and is claimed before the game pi '} else if (numtaken == 0) {', ], 'holes are spoken for before the claim, and the claim comes before the game picks'); }); + +test('The campaign handicap pair lives on the session, and the mission reader never asks the spawner', () => { + const scenario = source('code/scenario.cpp'); + + assertOrdered( + functionBody(scenario, 'bool Read_Scenario_INI(CCINIClass const & ini, bool is_mapgen)'), + [ + 'Scen->Difficulty = Session.CampaignDifficulty;', + 'Scen->CDifficulty = Session.CampaignCDifficulty;', + ], + 'the mission takes the pair the session carries', + ); + assert.doesNotMatch( + scenario, + /#include "spawner\.h"/, + 'the mission reader has no line to the spawner', + ); + + assertOrdered( + functionBody(source('code/init.cpp'), 'bool Select_Game(bool )'), + [ + 'Session.CampaignDifficulty = (DiffType)Options.Difficulty;', + 'Session.CampaignCDifficulty = (DiffType)(DIFF_COUNT - 1 - Options.Difficulty);', + ], + 'the menu derives the pair the mission reader used to compute, ahead of the start', + ); +}); From dc8c57e56d19b7425d8d8b3924dfbd49cafd4126 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 01:59:53 +0300 Subject: [PATCH 06/35] Launch a campaign mission from a client's file --- code/init.cpp | 16 ++++- code/spawner.cpp | 72 ++++++++++++++----- manual/changes/campaign-spawn-launch.md | 25 +++++++ manual/changes/client-driven-launch.md | 4 +- manual/content/formats/spawn-ini.md | 24 ++++++- .../documentation-source-contract.test.mjs | 18 +++++ 6 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 manual/changes/campaign-spawn-launch.md diff --git a/code/init.cpp b/code/init.cpp index d7bcd91a..39c2905c 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -1527,8 +1527,8 @@ bool Select_Game(bool ) Session.CampaignCDifficulty = (DiffType)(DIFF_COUNT - 1 - Options.Difficulty); } - if (Session.Type != GAME_NORMAL || Debug_ForceScenario || Session.Play) { - if (!Start_Scenario(Scen->ScenarioName, true, CAMPAIGN_NONE)) { + if (Session.Type != GAME_NORMAL || Debug_ForceScenario || Session.Play || Spawner_Is_Active()) { + if (!Start_Scenario(Scen->ScenarioName, true, Spawner_Is_Active() ? Scen->Campaign : CAMPAIGN_NONE)) { if (Debug_Map) { return(false); } else { @@ -1545,6 +1545,18 @@ bool Select_Game(bool ) } } + /* + * A mission reached through a client's launch file starts with the scenario flags + * that file carried over, which the mission read has just cleared. The flags a + * mission chain carries between its own missions arrive the same way, once the + * mission after this one is started. + */ + if (Spawner_Is_Active() && Session.Type == GAME_NORMAL) { + for (int index = 0; index < ARRAY_SIZE(Environment.Globals); index++) { + Scen->Set_Global_To(index, Environment.Globals[index]); + } + } + /* ** Save initialization values if we're recording this game. */ diff --git a/code/spawner.cpp b/code/spawner.cpp index da8cf0c9..339d818d 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -15,9 +15,11 @@ #include "spawnerconfig.h" #include "addon.h" +#include "campaign.h" #include "ccfile.h" #include "ccini.h" #include "dbgprint.h" +#include "enviro.h" #include "globals.h" #include "goptions.h" #include "houstype.h" @@ -232,8 +234,6 @@ static void Spawner_Bind_Options(void) * NextCampaignAutoSave, * NextSkirmishAutoSave - saving by itself is not wired up. * BuildOffAlly - the game has no such option to give it to. - * GlobalFlags - scenario flags are a campaign's, and a campaign is - * refused here. * ReconnectTimeout, ConnTimeout, * TunnelId, ListenPort, * TunnelAddress, TunnelPort, @@ -248,11 +248,9 @@ static void Spawner_Bind_Options(void) * CustomLoadScreenX, * CustomLoadScreenY - the loading backdrop belongs to the campaign path. * DifficultyName - shown, never played by. - * IsCampaign, CampaignID, - * CampaignDifficulty, - * CampaignCDifficulty, - * LoadSaveGame, SaveGameName - read to decide what kind of launch this is, which is - * how the three this game cannot start are refused. + * IsCampaign, LoadSaveGame, + * SaveGameName - read to decide what kind of launch this is, which is + * how the two this game cannot start are refused. * Slots[].IsSpectator - read to refuse a launch. * * The timing keys a client writes are not read at all: the game keeps its own. @@ -278,6 +276,44 @@ static void Spawner_Bind_Scenario(void) } +/// +/// Assembles the campaign mission a launch asks for: the mission, the handicap pair, and the +/// scenario flags a client carries over from an earlier mission. +/// +/// bool; Can the campaign the file describes be played? +static bool Spawner_Setup_Campaign(void) +{ + if (SpawnConfig.CampaignDifficulty < 0 || SpawnConfig.CampaignDifficulty >= DIFF_COUNT || + SpawnConfig.CampaignCDifficulty < 0 || SpawnConfig.CampaignCDifficulty >= DIFF_COUNT) { + return(Spawner_Refuse("A campaign is played at difficulty 0, 1 or 2, and the file says %d and %d.", + SpawnConfig.CampaignDifficulty, SpawnConfig.CampaignCDifficulty)); + } + + if (SpawnConfig.CampaignID < -1 || SpawnConfig.CampaignID >= Campaigns.Count()) { + return(Spawner_Refuse("The file names campaign %d, and there are %d.", + SpawnConfig.CampaignID, Campaigns.Count())); + } + + Session.Type = GAME_NORMAL; + Session.CampaignDifficulty = (DiffType)SpawnConfig.CampaignDifficulty; + Session.CampaignCDifficulty = (DiffType)SpawnConfig.CampaignCDifficulty; + Scen->Campaign = (CampaignType)SpawnConfig.CampaignID; + + /* + * The flags are left where a mission carries them over from the one before it, since a + * fresh launch has nothing else of its own to carry. + */ + new (&Environment) EnvironmentClass; + for (int index = 0; index < SpawnerConfigClass::GLOBAL_FLAG_COUNT; index++) { + Environment.Globals[index] = SpawnConfig.GlobalFlags[index]; + } + + std::snprintf(Scen->ScenarioName, sizeof(Scen->ScenarioName), "%s", SpawnConfig.ScenarioName.c_str()); + + return(true); +} + + /// /// Assembles the session a skirmish launch asks for, in place of what the skirmish dialog /// commits when a player presses OK. @@ -368,28 +404,32 @@ bool Spawner_Prepare(bool & gameloaded) case SpawnerConfigClass::LaunchType::Resume: return(Spawner_Refuse("Resuming a saved game from a launch file is not supported yet.")); - case SpawnerConfigClass::LaunchType::Campaign: - return(Spawner_Refuse("Launching a campaign mission from a launch file is not supported yet.")); - case SpawnerConfigClass::LaunchType::Multiplayer: return(Spawner_Refuse("Launching a game against other machines is not supported yet.")); + case SpawnerConfigClass::LaunchType::Campaign: case SpawnerConfigClass::LaunchType::Skirmish: break; } - std::string fault; - if (!SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)) { - return(Spawner_Refuse("%s", fault.c_str())); - } - Disable_Addon(ADDON_ANY); if (SpawnConfig.Firestorm) { Enable_Addon(ADDON_FIRESTORM); Set_Required_Addon(ADDON_FIRESTORM); } - Spawner_Setup_Skirmish(); + if (SpawnConfig.Launch_Type() == SpawnerConfigClass::LaunchType::Campaign) { + if (!Spawner_Setup_Campaign()) { + return(false); + } + } else { + std::string fault; + if (!SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)) { + return(Spawner_Refuse("%s", fault.c_str())); + } + + Spawner_Setup_Skirmish(); + } DebugString("[Spawner] Launching %s with session identity %08x.\n", Scen->ScenarioName, SpawnConfig.Session_Identity_CRC()); diff --git a/manual/changes/campaign-spawn-launch.md b/manual/changes/campaign-spawn-launch.md new file mode 100644 index 00000000..eda1f37b --- /dev/null +++ b/manual/changes/campaign-spawn-launch.md @@ -0,0 +1,25 @@ +--- +title: Launch a campaign mission from a client's launch file +category: feature +release: 0.2.0 +targets: +- type: format + id: spawn-ini + effect: changed +credit: [ZivDero] +--- + +A launch file marked as a single-player game now starts the mission it names. The campaign +the mission belongs to, the two difficulties, and the scenario flags a client carries over +from an earlier mission all reach the game's own state before the mission is read, so a +mission launched partway through a chain begins in the state the missions before it left. + +The two difficulties are named apart, so a client may combine all nine pairings where the +menu offers its three coupled ones. To make that possible the pair became session state: +whichever path starts a campaign sets it, the menu deriving the same pair the mission +reader used to compute for itself, and the mission reader now takes the pair from the +session. A restart or the next mission keeps it, as before. + +A dead difficulty table fell out of the campaign menu on the way. It was overwritten by the +mission reader on every start, and two of its five cases answered settings the game's +three-setting slider cannot reach. Live behavior is unchanged. diff --git a/manual/changes/client-driven-launch.md b/manual/changes/client-driven-launch.md index 6a17c370..aed3dc55 100644 --- a/manual/changes/client-driven-launch.md +++ b/manual/changes/client-driven-launch.md @@ -20,8 +20,8 @@ a launch is in progress. The file names the options every house plays under, who is playing, each seat's country, color, difficulty and start position, and the alliances between them. A file describing a -campaign mission, a saved game, or a game against other machines is refused with the reason -shown; those launches arrive separately. Anything the file asks for that the game cannot +saved game or a game against other machines is refused with the reason shown; those +launches arrive separately. Anything the file asks for that the game cannot honor is listed on the launch file's own page. The node the player and lobby lists are made of now initializes itself rather than starting diff --git a/manual/content/formats/spawn-ini.md b/manual/content/formats/spawn-ini.md index 5c54c8d2..7f06ed06 100644 --- a/manual/content/formats/spawn-ini.md +++ b/manual/content/formats/spawn-ini.md @@ -36,9 +36,27 @@ The `[Settings]` section says what kind of game to start. | `IsSinglePlayer` | Play a campaign mission rather than a match. | | `LoadSaveGame`, `SaveGameName` | Resume the named saved game. | -Only a skirmish is currently launched this way. A file asking for a campaign mission, a -saved game, or a game against other machines is refused with the reason shown, and the game -exits. +A file asking for a saved game or a game against other machines is refused with the reason +shown, and the game exits. + +## A campaign mission + +`IsSinglePlayer=yes` plays the mission `Scenario` names. `CampaignID` says which campaign +the mission belongs to, counted from zero in the order the battle files declare them, or +`-1` for a mission outside any campaign. The campaign decides what the mission leads on to +and which ending it plays, and it is what the game's own introduction is gated on, exactly +as when a campaign is chosen from the menu. + +`DifficultyModeHuman` and `DifficultyModeComputer` each name a difficulty from 0 to 2 — the +player's houses and the computer's, applied independently, so all nine pairings can be +played where the menu offers only its three. A restart or the next mission keeps the pair. + +`[GlobalFlags]` seeds the scenario flags a mission chain carries forward: entries +`GlobalFlag0` through `GlobalFlag49` are set on the mission as it starts, so a mission +launched partway through a chain begins in the state the missions before it left. + +The mission's own briefing and opening movies play as they do from the menu; only the +game's startup movies are skipped. ## The options every house plays under diff --git a/manual/site/tests/documentation-source-contract.test.mjs b/manual/site/tests/documentation-source-contract.test.mjs index 6b68a13b..4de31acd 100644 --- a/manual/site/tests/documentation-source-contract.test.mjs +++ b/manual/site/tests/documentation-source-contract.test.mjs @@ -299,3 +299,21 @@ test('The campaign handicap pair lives on the session, and the mission reader ne 'the menu derives the pair the mission reader used to compute, ahead of the start', ); }); + +test('A campaign spawn writes the game its own state and nothing more', () => { + const spawner = source('code/spawner.cpp'); + + assertOrdered(functionBody(spawner, 'static bool Spawner_Setup_Campaign(void)'), [ + 'Session.Type = GAME_NORMAL;', + 'Session.CampaignDifficulty = (DiffType)SpawnConfig.CampaignDifficulty;', + 'Session.CampaignCDifficulty = (DiffType)SpawnConfig.CampaignCDifficulty;', + 'Scen->Campaign = (CampaignType)SpawnConfig.CampaignID;', + 'new (&Environment) EnvironmentClass;', + 'Environment.Globals[index] = SpawnConfig.GlobalFlags[index];', + ], 'a campaign launch lands in the game’s own state'); + + assertOrdered(functionBody(source('code/init.cpp'), 'bool Select_Game(bool )'), [ + 'Spawner_Is_Active() ? Scen->Campaign : CAMPAIGN_NONE', + 'Scen->Set_Global_To(index, Environment.Globals[index]);', + ], 'a spawned mission is named by the file and starts with the flags it carried'); +}); From d226c5d7c005c1294164504bc2a2439a16842836 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 05:50:05 +0300 Subject: [PATCH 07/35] Resume a saved game from a spawn request --- code/gamedirs.cpp | 24 ++++ code/gamedirs.h | 6 + code/loaddlg.cpp | 115 +++++++++--------- code/saveload.cpp | 14 ++- code/spawner.cpp | 52 +++++++- code/spawnerconfig.cpp | 2 + manual/changes/client-driven-launch.md | 6 +- manual/changes/resume-spawn-launch.md | 22 ++++ manual/changes/saved-games-folder.md | 25 ++++ manual/changes/user-data-directory.md | 2 +- manual/content/formats/opents-ini.md | 6 +- manual/content/formats/save-games.md | 13 +- manual/content/formats/spawn-ini.md | 18 ++- manual/content/using/game-data.md | 2 + .../documentation-source-contract.test.mjs | 49 ++++++++ tests/gamedirs/gamedirscontract.cpp | 44 +++++++ tests/spawner/spawncontract.cpp | 15 +++ 17 files changed, 332 insertions(+), 83 deletions(-) create mode 100644 manual/changes/resume-spawn-launch.md create mode 100644 manual/changes/saved-games-folder.md diff --git a/code/gamedirs.cpp b/code/gamedirs.cpp index ae3aeaf2..07c168bf 100644 --- a/code/gamedirs.cpp +++ b/code/gamedirs.cpp @@ -33,6 +33,11 @@ static char const * const DefaultSearchFolders = "INI,MIX,Maps"; static char const * const ConfigName = "OPENTS.INI"; +/* + * The folder saved games are kept in, under whichever directory the player's own files go. + */ +static char const * const SavedGamesFolder = "Saved Games"; + /* * The folders the configuration itself is looked for in, relative to the data directory. */ @@ -314,6 +319,25 @@ std::string User_File_Write_Name(char const * filename) } +/// +/// Names a saved game inside the folder they are kept in, which sits with the rest of the +/// player's own files. Saved games are written, so the folder is deliberately not one of the +/// searched ones: every save, load, listing and deletion names it. It is created the first +/// time the game asks for a saved game, so a launcher browsing for them finds it whether or +/// not one has been written yet. +/// +/// The name of a saved game, or a pattern matching several. +/// The name to open, delete or scan for. +std::string Saved_Game_Name(char const * filename) +{ + std::string const folder = UserDirectory + SavedGamesFolder; + + CreateDirectory(folder.c_str(), NULL); + + return(folder + '\\' + filename); +} + + static void Scan_Folder(char const * prefix, char const * pattern, std::vector & names) { std::string const search = std::string(prefix) + pattern; diff --git a/code/gamedirs.h b/code/gamedirs.h index 40068f9b..8a569645 100644 --- a/code/gamedirs.h +++ b/code/gamedirs.h @@ -37,5 +37,11 @@ char const * Game_Directory_Error(void); */ std::string User_File_Write_Name(char const * filename); +/* + * Where the player's saved games are. They are written, so they are never searched for: the + * folder is named outright wherever a saved game is opened, listed or removed. + */ +std::string Saved_Game_Name(char const * filename); + std::vector Parse_Search_Folders(char const * list); std::vector Search_Files(char const * pattern); diff --git a/code/loaddlg.cpp b/code/loaddlg.cpp index 9988e7d8..5b540be8 100644 --- a/code/loaddlg.cpp +++ b/code/loaddlg.cpp @@ -358,6 +358,19 @@ LRESULT CALLBACK LoadOptionsClass::Delete_Dialog_Proc(HWND window, UINT message, } +/// +/// Is a saved game of this name already there? +/// Asked before one is written, since a name the folder already holds is written over +/// rather than added to. +/// +/// The name of the saved game to look for. +/// bool; Does the saved games folder already hold this name? +static bool Saved_Game_Exists(char const * name) +{ + return(GetFileAttributes(Saved_Game_Name(name).c_str()) != INVALID_FILE_ATTRIBUTES); +} + + /*********************************************************************************************** * LoadOptionsClass::Process -- main processing routine * * * @@ -490,25 +503,17 @@ bool LoadOptionsClass::Dialog(void) } const char * filename = NULL; + char test_filename[256]; if (entry && entry->Valid) { filename = entry->Filename; } else { - char test_filename[256]; - - { /// the scope is important to make it match - temp_file nedes to be destroyed before assigning the string - CCFileClass temp_file; - do { - sprintf(test_filename, "SAVE%04lX.%3s", rand(), Extension); - temp_file.Set_Name(test_filename); - } while (temp_file.Is_Available() == true); - } - + Pick_Filename(test_filename); filename = test_filename; } if (filename != NULL) { - bool exists = CDFileClass(filename).Is_Available() == true; + bool exists = Saved_Game_Exists(filename); if (exists && WWMessageBox()._Process(TXT_CONFIRM_SAVE, 1, TXT_YES, TXT_NO, TXT_NONE)) State = STATE_PENDING; else { @@ -569,11 +574,9 @@ bool LoadOptionsClass::Dialog(void) /// Be sure the buffer is big enough to hold a complete filename. void LoadOptionsClass::Pick_Filename(char *name) { - CCFileClass file; do { sprintf(name, "SAVE%04lX.%3s", rand(), Extension); - file.Set_Name(name); - } while (file.Is_Available() == true); + } while (Saved_Game_Exists(name)); } @@ -606,25 +609,6 @@ void LoadOptionsClass::Clear_List(void) } -/* - * Recovers the directory entry for a saved game the scan turned up. The scan reports bare - * names, so the file is located the way an open would locate it and then asked about by the - * name it actually has. The entry names the file alone, without the directory it sits in. - */ -static bool Find_Saved_Game(char const * name, WIN32_FIND_DATAA * entry) -{ - CDFileClass located(name); - - HANDLE handle = FindFirstFile(located.File_Name(), entry); - if (handle == INVALID_HANDLE_VALUE) { - return(false); - } - - FindClose(handle); - return(true); -} - - /*********************************************************************************************** * LoadOptionsClass::Fill_List -- fills the list box & GameNum arrays * * * @@ -685,22 +669,28 @@ void LoadOptionsClass::Fill_List(HWND window) */ fdata = NULL; - for (std::string const & name : Search_Files(buffer)) { - if (!Find_Saved_Game(name.c_str(), &ff)) { - continue; - } + HANDLE hFind = FindFirstFile(Saved_Game_Name(buffer).c_str(), &ff); - if (fdata == NULL) { - fdata = new FileEntryClass; - } + if (hFind != INVALID_HANDLE_VALUE) { + do { + if ((ff.dwFileAttributes & (FILE_ATTRIBUTE_TEMPORARY|FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_HIDDEN)) != 0) { + continue; + } - /* - ** get the game's info; if success, add it to the list - */ - if (Read_File(fdata, &ff) == true) { - Files.Add(fdata); - fdata = NULL; - } + if (fdata == NULL) { + fdata = new FileEntryClass; + } + + /* + ** get the game's info; if success, add it to the list + */ + if (Read_File(fdata, &ff) == true) { + Files.Add(fdata); + fdata = NULL; + } + } while (FindNextFile(hFind, &ff)); + + FindClose(hFind); } if (fdata != NULL) { @@ -786,21 +776,26 @@ bool LoadOptionsClass::Files_Present(void) sprintf(pattern, "*.%3s", Extension); WIN32_FIND_DATAA find_data; + HANDLE hFind = FindFirstFile(Saved_Game_Name(pattern).c_str(), &find_data); - for (std::string const & name : Search_Files(pattern)) { - if (_stricmp(name.c_str(), NET_SAVE_FILE_NAME) == 0) { - continue; - } + if (hFind != INVALID_HANDLE_VALUE) { + do { + if ((find_data.dwFileAttributes & (FILE_ATTRIBUTE_TEMPORARY|FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_HIDDEN)) != 0) { + continue; + } - if (!Find_Saved_Game(name.c_str(), &find_data)) { - continue; - } + if (_stricmp(find_data.cFileName, NET_SAVE_FILE_NAME) == 0) { + continue; + } - FileEntryClass entry; - if (Read_File(&entry, &find_data) == true) { - files_found = true; - break; - } + FileEntryClass entry; + if (Read_File(&entry, &find_data) == true) { + files_found = true; + break; + } + } while (FindNextFile(hFind, &find_data)); + + FindClose(hFind); } return(files_found); @@ -883,7 +878,7 @@ bool LoadOptionsClass::Save_File(const char * file_name, const char * descr) /// bool; Was the file deleted? bool LoadOptionsClass::Delete_File(const char * file_name) { - if (DeleteFile(User_File_Write_Name(file_name).c_str()) == TRUE) { + if (DeleteFile(Saved_Game_Name(file_name).c_str()) == TRUE) { return(true); } return(false); diff --git a/code/saveload.cpp b/code/saveload.cpp index 51c1bcc0..8f952579 100644 --- a/code/saveload.cpp +++ b/code/saveload.cpp @@ -928,7 +928,7 @@ static bool Save_Game(const char *file_name, char const * descr) DebugString("\nSAVING GAME [%s - %s]\n", file_name, descr); - MultiByteToWideChar(0,0, User_File_Write_Name(file_name).c_str(), -1, name, sizeof(name)/sizeof(WCHAR)); + MultiByteToWideChar(0,0, Saved_Game_Name(file_name).c_str(), -1, name, sizeof(name)/sizeof(WCHAR)); /* ** Open the file @@ -1181,8 +1181,8 @@ bool Load_Game(const char *file_name) */ IStoragePtr storage; - // Structured storage goes straight to Windows, so the file layer locates the save first. - MultiByteToWideChar(0,0,CDFileClass(file_name).File_Name(), -1, name, (sizeof(name)/sizeof(WCHAR))); + // Structured storage goes straight to Windows, so the saved game is named in full first. + MultiByteToWideChar(0,0,Saved_Game_Name(file_name).c_str(), -1, name, (sizeof(name)/sizeof(WCHAR))); if (FAILED(StgOpenStorage(name, 0, STGM_SHARE_DENY_WRITE, 0, 0, &storage))) { return(false); @@ -1217,6 +1217,10 @@ bool Load_Game(const char *file_name) */ Post_Load_Game(); + // The next mission of a resumed campaign is played at the pair the save carries. + Session.CampaignDifficulty = Scen->Difficulty; + Session.CampaignCDifficulty = Scen->CDifficulty; + Map.Init_IO(); Map.Activate(1); Map.Reposition_Sidebar(); @@ -1328,8 +1332,8 @@ bool Get_Savefile_Info(char const * name, SaveVersionInfo * info) IStoragePtr storage; WCHAR wname[MAX_PATH]; - // Structured storage goes straight to Windows, so the file layer locates the save first. - MultiByteToWideChar(0, 0, CDFileClass(name).File_Name(), -1, wname, sizeof(wname) / sizeof(WCHAR)); + // Structured storage goes straight to Windows, so the saved game is named in full first. + MultiByteToWideChar(0, 0, Saved_Game_Name(name).c_str(), -1, wname, sizeof(wname) / sizeof(WCHAR)); HRESULT result = StgOpenStorage(wname, NULL, STGM_SHARE_EXCLUSIVE|STGM_READWRITE, NULL, 0, &storage); if (FAILED(result)) { diff --git a/code/spawner.cpp b/code/spawner.cpp index 339d818d..eb722d16 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -25,8 +25,11 @@ #include "houstype.h" #include "init.h" #include "language\language.h" +#include "loaddlg.h" #include "mplayer.h" #include "msgbox.h" +#include "saveload.h" +#include "savever.h" #include "scenario.h" #include "session.h" @@ -249,8 +252,8 @@ static void Spawner_Bind_Options(void) * CustomLoadScreenY - the loading backdrop belongs to the campaign path. * DifficultyName - shown, never played by. * IsCampaign, LoadSaveGame, - * SaveGameName - read to decide what kind of launch this is, which is - * how the two this game cannot start are refused. + * SaveGameName - read to decide what kind of launch this is, and to + * name the saved game a resume restores. * Slots[].IsSpectator - read to refuse a launch. * * The timing keys a client writes are not read at all: the game keeps its own. @@ -276,6 +279,47 @@ static void Spawner_Bind_Scenario(void) } +/// +/// Resumes the saved game a launch file names. The save carries the kind of game, the options +/// and the houses, so nothing else in the file is consulted, and the expansion comes back with +/// the save rather than from the file. +/// +/// Set when the save loads, so the caller starts no scenario. +/// bool; Is the saved game running? +static bool Spawner_Resume(bool & gameloaded) +{ + if (SpawnConfig.SaveGameName.empty()) { + return(Spawner_Refuse("The file asks to resume a saved game without naming one.")); + } + + SaveVersionInfo info; + if (!Get_Savefile_Info(SpawnConfig.SaveGameName.c_str(), &info)) { + return(Spawner_Refuse("The saved game %s is missing or unreadable.", SpawnConfig.SaveGameName.c_str())); + } + + if (info.Get_Internal_Version() != ExpectedGameVersion) { + return(Spawner_Refuse("The saved game was made by another version of the game.")); + } + + /* + * A save made against other machines restores the connections along with everything else, + * and there are none to restore it into until the network is wired up. + */ + GameType type = (GameType)info.Get_Game_Type(); + if (type != GAME_NORMAL && type != GAME_SKIRMISH) { + return(Spawner_Refuse("Resuming a game against other machines is not supported yet.")); + } + + if (!LoadOptionsClass().Load_File(SpawnConfig.SaveGameName.c_str())) { + return(Spawner_Refuse("The saved game %s could not be loaded.", SpawnConfig.SaveGameName.c_str())); + } + + gameloaded = true; + + return(true); +} + + /// /// Assembles the campaign mission a launch asks for: the mission, the handicap pair, and the /// scenario flags a client carries over from an earlier mission. @@ -373,8 +417,6 @@ bool Spawner_Is_Active(void) /// bool; Is a game ready to start? bool Spawner_Prepare(bool & gameloaded) { - (void)gameloaded; - if (SpawnConsumed) { return(false); } @@ -402,7 +444,7 @@ bool Spawner_Prepare(bool & gameloaded) switch (SpawnConfig.Launch_Type()) { case SpawnerConfigClass::LaunchType::Resume: - return(Spawner_Refuse("Resuming a saved game from a launch file is not supported yet.")); + return(Spawner_Resume(gameloaded)); case SpawnerConfigClass::LaunchType::Multiplayer: return(Spawner_Refuse("Launching a game against other machines is not supported yet.")); diff --git a/code/spawnerconfig.cpp b/code/spawnerconfig.cpp index c3161293..8fdb4a5d 100644 --- a/code/spawnerconfig.cpp +++ b/code/spawnerconfig.cpp @@ -233,6 +233,8 @@ int SpawnerConfigClass::Session_Identity_CRC(void) const crc(CampaignID); crc(CampaignDifficulty); crc(CampaignCDifficulty); + crc(LoadSaveGame); + crc(SaveGameName.c_str()); crc(Bases); crc(Credits); diff --git a/manual/changes/client-driven-launch.md b/manual/changes/client-driven-launch.md index aed3dc55..d920d137 100644 --- a/manual/changes/client-driven-launch.md +++ b/manual/changes/client-driven-launch.md @@ -20,9 +20,9 @@ a launch is in progress. The file names the options every house plays under, who is playing, each seat's country, color, difficulty and start position, and the alliances between them. A file describing a -saved game or a game against other machines is refused with the reason shown; those -launches arrive separately. Anything the file asks for that the game cannot -honor is listed on the launch file's own page. +game against other machines is refused with the reason shown; that launch arrives +separately. Anything the file asks for that the game cannot honor is listed on the launch +file's own page. The node the player and lobby lists are made of now initializes itself rather than starting as whatever the heap last held, and carries the start position, difficulty and alliances a diff --git a/manual/changes/resume-spawn-launch.md b/manual/changes/resume-spawn-launch.md new file mode 100644 index 00000000..fe742e05 --- /dev/null +++ b/manual/changes/resume-spawn-launch.md @@ -0,0 +1,22 @@ +--- +title: Resume a saved game from a client's launch file +category: feature +release: 0.2.0 +targets: +- type: format + id: spawn-ini + effect: changed +credit: [ZivDero] +--- + +A launch file with `LoadSaveGame=yes` now resumes the saved game it names. The save carries +the kind of game it was, the options it was played under and the houses that played it, so +nothing else in the file is consulted — which is what clients already write, their resume +files naming little beyond the save. + +A save the folder does not hold, one made by another version of the game, and one made in a +game against other machines each refuse the launch with the reason shown; resuming a game +against other machines arrives with the network work it needs. + +Which saved game a launch file names is now part of the identity two machines compare a +match by, since a resume is a match of its own. diff --git a/manual/changes/saved-games-folder.md b/manual/changes/saved-games-folder.md new file mode 100644 index 00000000..b059855e --- /dev/null +++ b/manual/changes/saved-games-folder.md @@ -0,0 +1,25 @@ +--- +title: Keep saved games in a folder of their own +category: feature +release: 0.2.0 +targets: +- type: format + id: save-games + effect: changed +credit: [ZivDero] +--- + +Saved games now live in a `Saved Games` folder — beside the game, or inside the user data +directory when one is named — created the first time the game asks for a saved game. Every +save, load, listing and deletion names that folder, and it is deliberately not one of the +folders the game searches: a saved game is written, so it is named rather than found. That +is where the launchers which browse saved games already look, which is what makes resuming +one from a launch file possible. + +Saves made by earlier builds sit beside the game and are no longer listed; moving the `.SAV` +files into `Saved Games` restores them. + +Following a load, the campaign difficulty pair now comes from the save rather than from the +menu's difficulty setting, so the next mission of a resumed campaign is played at the +difficulty the campaign was saved at. Before, it silently took whatever the setting happened +to say at the time. diff --git a/manual/changes/user-data-directory.md b/manual/changes/user-data-directory.md index 542fd7dc..6edc42f8 100644 --- a/manual/changes/user-data-directory.md +++ b/manual/changes/user-data-directory.md @@ -13,7 +13,7 @@ credit: [ZivDero] game writes, creates or deletes goes there — the settings file, hotkeys, saved games, the hall of fame, recordings, saved random maps, screenshots and the files a multiplayer game downloads — and the directory is created when it is not -there yet. +there yet. Saved games take a `Saved Games` folder of their own inside it. It is read from before anywhere else, so a player's own copy of a file is the one the game uses, whatever a deployment ships under the same name. Files a player diff --git a/manual/content/formats/opents-ini.md b/manual/content/formats/opents-ini.md index a5ac33db..902f521f 100644 --- a/manual/content/formats/opents-ini.md +++ b/manual/content/formats/opents-ini.md @@ -44,9 +44,11 @@ The game data directory is what [`-DATADIR`](/using/command-line/data-directory/ Everything the game opens follows that order: archives, rules, artwork, scenarios and launch files alike. A loose file still stands in for an archived one, so a copy found in any of these folders is used ahead of an archived copy of the same name. -A player's own copy is therefore the one the game reads, whatever a deployment ships under the same name. That is what makes a shared installation work: the settings, hotkeys and saved games a player has are theirs, and the rest is read from the copy everyone shares. +A player's own copy is therefore the one the game reads, whatever a deployment ships under the same name. That is what makes a shared installation work: the settings and hotkeys a player has are theirs, and the rest is read from the copy everyone shares. -Wildcard searches — for rules, battle files, map packs, saved games, map archives and movie archives — cover every directory in the list rather than stopping at the first that holds a match. A name held by more than one is used once, from the one that comes first, which is the same copy an ordinary open of that name would land on. +Wildcard searches — for rules, battle files, map packs, map archives and movie archives — cover every directory in the list rather than stopping at the first that holds a match. A name held by more than one is used once, from the one that comes first, which is the same copy an ordinary open of that name would land on. + +[Saved games](/formats/save-games/) are the exception to all of this. They keep to a `Saved Games` folder inside the user data directory, and are named there outright rather than searched for, so that a launcher browsing them finds them in one place. :::caution[Files the game writes are not searched for] Settings, saved games, recordings and everything else the game writes go to the user data directory, or to the game's own directory when there is none. A file the game deletes is its own copy, so throwing away a player's hotkeys falls back to the ones a deployment shipped rather than removing them. Nothing listed here is ever written to or deleted from. diff --git a/manual/content/formats/save-games.md b/manual/content/formats/save-games.md index 8ad78e4c..56500b84 100644 --- a/manual/content/formats/save-games.md +++ b/manual/content/formats/save-games.md @@ -23,6 +23,10 @@ source_files: The save dialog creates `.SAV` files. Each file is an OLE compound document: the listing details live in the document's own property set, and the game state goes into a single `CONTENTS` stream that is compressed as it is written. +## Where the files are + +Saved games keep to a `Saved Games` folder of their own, beside the game or inside the [user directory](/using/game-data/) when one is named, created the first time the game asks for a saved game. Every save, load, listing and deletion names that folder outright: unlike the files the game reads, a saved game is never looked for anywhere else. A client that browses saved games therefore finds them in one place, whichever layout the game was installed in. + The dialog names a new save `SAVE` followed by four hexadecimal digits, drawing again until it finds a name no existing file answers to; saving over a listed game reuses that game's name. A multiplayer save is written under one fixed name instead and is never offered in the list. ## When the file is written @@ -41,10 +45,11 @@ The `CONTENTS` stream is a fixed sequence of records — the scenario, the envir The project-version stamp decides whether a file is offered at all, and only the running version's stamp is accepted. The load dialog reads the property set -of every `.SAV` in the game directory and skips every file stamped by anything -else, including the Tiberian Sun release and another OpenTS release-cycle -version. A save that reaches the engine without passing through the dialog, as -a network save does, is checked the same way and refused. Development snapshots +of every `.SAV` in the saved-games folder and skips every file stamped by +anything else, including the Tiberian Sun release and another OpenTS +release-cycle version. A save that reaches the engine without passing through +the dialog, as one resumed from a [launch file](/formats/spawn-ini/) does, is +checked the same way and refused. Development snapshots within one cycle share the stamp; that mechanical match is not a promise that their save layouts or simulation state interoperate. A listed save that was not made in a campaign is marked with a leading `*`. diff --git a/manual/content/formats/spawn-ini.md b/manual/content/formats/spawn-ini.md index 7f06ed06..92ca7a06 100644 --- a/manual/content/formats/spawn-ini.md +++ b/manual/content/formats/spawn-ini.md @@ -36,8 +36,20 @@ The `[Settings]` section says what kind of game to start. | `IsSinglePlayer` | Play a campaign mission rather than a match. | | `LoadSaveGame`, `SaveGameName` | Resume the named saved game. | -A file asking for a saved game or a game against other machines is refused with the reason -shown, and the game exits. +A file asking for a game against other machines is refused with the reason shown, and the +game exits. + +## Resuming a saved game + +`LoadSaveGame=yes` resumes the saved game `SaveGameName` names, and settles the question by +itself: a saved game carries the kind of game it was, the options it was played under and +the houses that played it, so nothing else in the file decides those. A client resuming a +campaign writes little more than the name of the save. + +The name is a file inside the game's saved-games folder, and a name written with a path of +its own is reduced to its last part. A save the folder does not hold, one made by another +version of the game, and one made in a game against other machines each refuse the launch +with the reason shown. ## A campaign mission @@ -124,7 +136,7 @@ they exchange their orders is the game's own business, and no launch file change These keys are read but do not yet change anything, because the game has no such behavior to give them to: `Tournament`, `GameID`, `MapHash`, `BuildOffAlly`, the automatic-save -keys, `QuickMatch`, `SkipScoreScreen`, `WriteStatistics`, `CoachMode`, `AutoSurrender`, +scheduling keys, `QuickMatch`, `SkipScoreScreen`, `WriteStatistics`, `CoachMode`, `AutoSurrender`, `AttackNeutralUnits`, `ScrapMetal`, `ContinueWithoutHumans`, `PlayMoviesInMultiplayer`, `CustomLoadScreen`, `CustomLoadScreenPos`, and `DifficultyName`. The tunnel and timeout keys describe how machines reach one another, which a skirmish never needs. diff --git a/manual/content/using/game-data.md b/manual/content/using/game-data.md index 8c8a5e58..90e3c32a 100644 --- a/manual/content/using/game-data.md +++ b/manual/content/using/game-data.md @@ -32,4 +32,6 @@ Do not place game data in the CMake build directory. The build copies OpenTS exe `-DATADIR=` reads the game's data from the directory named instead of requiring it beside the executable, and `-USERDIR=` keeps what the game writes — settings, saved games, recordings and downloaded maps — in a directory of its own. Together they let one copy of the data serve several people, each writing only to their own directory and reading their own files ahead of the shared ones. +[Saved games](/formats/save-games/) go one step further, into a `Saved Games` folder of their own inside that directory. They are the one thing the game both writes and browses, so they are named there outright rather than looked for among the folders the game reads from. + The data may be sorted into folders rather than left in one directory. Without any configuration the game also searches `INI`, `MIX` and `Maps`; [`OPENTS.INI`](/formats/opents-ini/) names other folders and the order they are searched in. diff --git a/manual/site/tests/documentation-source-contract.test.mjs b/manual/site/tests/documentation-source-contract.test.mjs index 4de31acd..53ae4c11 100644 --- a/manual/site/tests/documentation-source-contract.test.mjs +++ b/manual/site/tests/documentation-source-contract.test.mjs @@ -317,3 +317,52 @@ test('A campaign spawn writes the game its own state and nothing more', () => { 'Scen->Set_Global_To(index, Environment.Globals[index]);', ], 'a spawned mission is named by the file and starts with the flags it carried'); }); + +test('A resume is judged before it is loaded, and the save answers for the rest', () => { + assertOrdered(functionBody(source('code/spawner.cpp'), 'static bool Spawner_Resume(bool & gameloaded)'), [ + 'SpawnConfig.SaveGameName.empty()', + 'Get_Savefile_Info(SpawnConfig.SaveGameName.c_str(), &info)', + 'info.Get_Internal_Version() != ExpectedGameVersion', + 'type != GAME_NORMAL && type != GAME_SKIRMISH', + 'LoadOptionsClass().Load_File(SpawnConfig.SaveGameName.c_str())', + 'gameloaded = true;', + ], 'a save is named, found, stamped and of a kind that can be resumed before it is read'); + + assertOrdered(functionBody(source('code/saveload.cpp'), 'bool Load_Game(const char *file_name)'), [ + 'Session.Type = (GameType)info.Get_Game_Type();', + 'Post_Load_Game();', + 'Session.CampaignDifficulty = Scen->Difficulty;', + 'Session.CampaignCDifficulty = Scen->CDifficulty;', + ], 'a load takes the kind of game and the campaign pair from the save'); +}); + +test('Saved games are named in one folder rather than searched for', () => { + const gamedirs = source('code/gamedirs.cpp'); + + assertOrdered(functionBody(gamedirs, 'std::string Saved_Game_Name(char const * filename)'), [ + 'UserDirectory + SavedGamesFolder', + 'CreateDirectory(folder.c_str(), NULL);', + ], 'a saved game is named inside the user directory, and the folder is made on the way'); + + for (const [file, signature] of [ + ['code/saveload.cpp', 'static bool Save_Game(const char *file_name, char const * descr)'], + ['code/saveload.cpp', 'bool Load_Game(const char *file_name)'], + ['code/saveload.cpp', 'bool Get_Savefile_Info(char const * name, SaveVersionInfo * info)'], + ['code/loaddlg.cpp', 'void LoadOptionsClass::Fill_List(HWND window)'], + ['code/loaddlg.cpp', 'bool LoadOptionsClass::Files_Present(void)'], + ['code/loaddlg.cpp', 'bool LoadOptionsClass::Delete_File(const char * file_name)'], + ]) { + assert.match( + functionBody(source(file), signature), + /Saved_Game_Name\(/, + `${signature} names the folder saved games are kept in`, + ); + } + + assert.doesNotMatch( + functionBody(source('code/loaddlg.cpp'), 'void LoadOptionsClass::Fill_List(HWND window)') + + functionBody(source('code/loaddlg.cpp'), 'bool LoadOptionsClass::Files_Present(void)'), + /Search_Files\(/, + 'the listing no longer scans the folders the game reads from', + ); +}); diff --git a/tests/gamedirs/gamedirscontract.cpp b/tests/gamedirs/gamedirscontract.cpp index d2f9a6a0..e8f92452 100644 --- a/tests/gamedirs/gamedirscontract.cpp +++ b/tests/gamedirs/gamedirscontract.cpp @@ -506,6 +506,49 @@ void Test_Without_A_User_Directory_Nothing_Moves(void) } +/* + * Saved games are the one thing the game both writes and browses, so they keep to a folder of + * their own that is named outright rather than searched for. + */ +void Test_Saved_Games_Folder(void) +{ + Reset(); + Init_Search_Folders(); + + Check(Saved_Game_Name("SAVE0001.SAV") == "Saved Games\\SAVE0001.SAV", + "a saved game is named inside the folder saved games are kept in"); + Check(File_Exists(Root + "\\Saved Games"), + "asking for a saved game makes the folder to keep it in"); + + for (int index = 0; ; index++) { + char const * path = CDFileClass::Search_Path(index); + if (path == NULL) { + break; + } + + Check(std::string(path).find("Saved Games") == std::string::npos, + "the folder saved games are kept in is not one of the searched folders"); + } + + Reset(); + Set_User_Directory((Root + "\\User\\Saves").c_str()); + Apply_Game_Directories(); + + std::string const expected = Root + "\\User\\Saves\\Saved Games"; + Check(Saved_Game_Name("SAVE0002.SAV") == expected + "\\SAVE0002.SAV", + "a user directory takes the saved games with it"); + Check(File_Exists(expected), + "the folder is made inside the user directory"); + + /* + * A pattern is named the same way a file is, since the listing scans the one folder rather + * than every folder the game reads from. + */ + Check(Saved_Game_Name("*.SAV") == expected + "\\*.SAV", + "a pattern is named in the same folder the saved games are"); +} + + bool Make_Root(void) { char temp[MAX_PATH]; @@ -566,6 +609,7 @@ int main(void) Test_A_Name_With_A_Directory_Is_Left_Alone(); Test_Placing_A_File_Is_Repeatable(); Test_Without_A_User_Directory_Nothing_Moves(); + Test_Saved_Games_Folder(); Reset(); Remove_Root(); diff --git a/tests/spawner/spawncontract.cpp b/tests/spawner/spawncontract.cpp index 9db99b10..84abf137 100644 --- a/tests/spawner/spawncontract.cpp +++ b/tests/spawner/spawncontract.cpp @@ -357,6 +357,21 @@ int main(void) four.GlobalFlags[49] = !four.GlobalFlags[49]; Check(one.Session_Identity_CRC() != four.Session_Identity_CRC(), "a scenario flag moves the identity"); + + /* + * A resume is a match of its own: the saved game decides everything the fields above + * would otherwise have decided, so which save is being resumed is part of the identity. + */ + SpawnerConfigClass five = Read(_Skirmish, sizeof(_Skirmish) - 1); + five.LoadSaveGame = !five.LoadSaveGame; + Check(one.Session_Identity_CRC() != five.Session_Identity_CRC(), + "resuming a save rather than starting one moves the identity"); + + SpawnerConfigClass six = Read(_Resume, sizeof(_Resume) - 1); + SpawnerConfigClass seven = Read(_Resume, sizeof(_Resume) - 1); + seven.SaveGameName = "SAVEGAME.002"; + Check(six.Session_Identity_CRC() != seven.Session_Identity_CRC(), + "resuming another saved game moves the identity"); } /* From 7960033a64f7513ed4b365a6b1f12b930ee7c5b2 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 06:32:08 +0300 Subject: [PATCH 08/35] Do not repeat a score the game does not have --- code/theme.cpp | 22 ++++++++++++++++-- .../changes/repeating-score-availability.md | 23 +++++++++++++++++++ manual/content/keys/repeat.md | 2 ++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 manual/changes/repeating-score-availability.md diff --git a/code/theme.cpp b/code/theme.cpp index 9cddfdaf..07c32ad5 100644 --- a/code/theme.cpp +++ b/code/theme.cpp @@ -316,7 +316,12 @@ ThemeType ThemeClass::Next_Song(ThemeType theme) const { int i; - if (theme > THEME_NONE && (!Themes[theme]->Repeat && !IsRepeat) || theme < THEME_FIRST) { + /* + * A score that repeats is played again, but only while the game actually holds it. One + * it does not would otherwise answer this forever, and nothing else would ever be picked. + */ + if ((unsigned)theme >= (unsigned)Themes.Count() || !Themes[theme]->Available || + (!Themes[theme]->Repeat && !IsRepeat)) { if (IsShuffle == true) { /* @@ -427,10 +432,23 @@ int ThemeClass::Play_Song(ThemeType theme) Stop(false); if (theme != THEME_NONE && theme != THEME_QUIET) { if (theme > THEME_NONE && Volume > 0) { - Score = theme; Audio.StreamLowImpact = true; Current = Audio.File_Stream_Sample_Vol(Theme_File_Name(theme), Volume, true); Audio.StreamLowImpact = false; + + /* + * A score that would not start is not the one playing. Recording it as the + * current one silences the game for good: stopping a score that never + * started does nothing, so the one that failed would stay current. + */ + if (Current == -1) { + DebugString("Theme::PlaySong(%d) - Unavailable\n", theme); + Score = THEME_NONE; + Pending = THEME_NONE; + return(Current); + } + + Score = theme; DebugString("Theme::PlaySong(%d) - %s\n", Score, IsRepeat == true || Themes[theme]->Repeat == true ? "Repeating" : "Playing"); if (IsRepeat == true || Themes[theme]->Repeat == true) { Pending = theme; diff --git a/manual/changes/repeating-score-availability.md b/manual/changes/repeating-score-availability.md new file mode 100644 index 00000000..623e790d --- /dev/null +++ b/manual/changes/repeating-score-availability.md @@ -0,0 +1,23 @@ +--- +title: Do not repeat a score the game does not have +category: fix +release: 0.2.0 +targets: +- type: key + id: Repeat + effect: changed +credit: [ZivDero] +--- + +A score marked to repeat is no longer handed back by the playlist when its audio file is +missing from the mixfiles. Before, such a score was answered with forever: nothing else was +ever picked, and the game played no music at all until a track was chosen by hand. + +Starting a score that will not play no longer records it as the one playing either. A score +that never started could not be stopped, so it stayed current and was tried again on every +frame. + +Together these matter to any installation that ships a reduced set of scores. A deployment +whose launcher plays its own menu music, and which therefore leaves the game's menu and map +selection tracks out, would reach the first mission with its music already wedged on a track +that does not exist. diff --git a/manual/content/keys/repeat.md b/manual/content/keys/repeat.md index 08892420..475b326d 100644 --- a/manual/content/keys/repeat.md +++ b/manual/content/keys/repeat.md @@ -15,4 +15,6 @@ Repeat=yes Two places read it, and between them they close the loop. Starting the score queues the same score as the one to play next, and the routine that would otherwise advance the playlist hands back the score it was given rather than choosing another. The score therefore plays until something else replaces it, and neither the sequential order nor the shuffle ever moves off it. +The loop closes only around a score the game actually holds. A score whose audio file is missing from the mixfiles is advanced past like any other, rather than being handed back and asked for again. + The repeat button on the sound options screen does the same thing for every score at once. Either is enough on its own: a score marked this way repeats whether or not the button is on. From 4bcb2086405e8e05fe84d7c54f45b2341fda1d86 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 14:45:38 +0300 Subject: [PATCH 09/35] Assemble a game against other machines up to its sockets --- code/spawner.cpp | 39 +++++++++--- code/spawnerconfig.cpp | 22 +++++++ manual/changes/client-driven-launch.md | 6 +- manual/changes/network-spawn-assembly.md | 21 +++++++ manual/content/formats/spawn-ini.md | 28 +++++++-- .../documentation-source-contract.test.mjs | 37 +++++++++++ tests/spawner/spawncontract.cpp | 62 +++++++++++++++++++ 7 files changed, 196 insertions(+), 19 deletions(-) create mode 100644 manual/changes/network-spawn-assembly.md diff --git a/code/spawner.cpp b/code/spawner.cpp index eb722d16..47f9c2fa 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -37,6 +37,7 @@ #include #include #include +#include /* @@ -124,7 +125,8 @@ static void Spawner_Seat_Local(void) /// -/// Puts the people playing into the list the houses are created from. +/// Puts the people playing into the list the houses are created from, each with the address +/// its machine is reached on when the match is against other machines. /// static void Spawner_Seat_Humans(void) { @@ -139,6 +141,17 @@ static void Spawner_Seat_Humans(void) node->Player.ProcessTime = -1; node->Player.SpawnChoice = seat.StartingPosition; node->Player.AlliesMask = Spawner_Allies_Mask(seat); + + /* + * Through a tunnel a machine is named by its tunnel number alone, carried where a port + * would go; reached directly, it is named by the address it answers on. + */ + if (SpawnConfig.TunnelPort != 0) { + node->Address.Set_Address(0, htons((unsigned short)seat.Port)); + } else if (seat.Port > 0) { + node->Address.Set_Address(inet_addr(seat.Address.c_str()), htons((unsigned short)seat.Port)); + } + Session.Players.Add(node); } @@ -239,9 +252,8 @@ static void Spawner_Bind_Options(void) * BuildOffAlly - the game has no such option to give it to. * ReconnectTimeout, ConnTimeout, * TunnelId, ListenPort, - * TunnelAddress, TunnelPort, - * Slots[].Address, Slots[].Port - where machines reach one another; a skirmish reaches - * none of them. + * TunnelAddress - the socket this machine opens and the tunnel it joins + * through, neither of which anything opens yet. * QuickMatch, SkipScoreScreen, * WriteStatistics, CoachMode, * AutoSurrender, AttackNeutralUnits, @@ -359,12 +371,13 @@ static bool Spawner_Setup_Campaign(void) /// -/// Assembles the session a skirmish launch asks for, in place of what the skirmish dialog +/// Assembles the session a launch asks for, in place of what the skirmish or the lobby dialog /// commits when a player presses OK. /// -static void Spawner_Setup_Skirmish(void) +static void Spawner_Setup_Session(void) { - Session.Type = GAME_SKIRMISH; + Session.Type = SpawnConfig.Launch_Type() == SpawnerConfigClass::LaunchType::Multiplayer + ? GAME_INTERNET : GAME_SKIRMISH; Clear_Vector(&Session.Players); Clear_Vector(&Session.Computers); @@ -447,8 +460,6 @@ bool Spawner_Prepare(bool & gameloaded) return(Spawner_Resume(gameloaded)); case SpawnerConfigClass::LaunchType::Multiplayer: - return(Spawner_Refuse("Launching a game against other machines is not supported yet.")); - case SpawnerConfigClass::LaunchType::Campaign: case SpawnerConfigClass::LaunchType::Skirmish: break; @@ -470,11 +481,19 @@ bool Spawner_Prepare(bool & gameloaded) return(Spawner_Refuse("%s", fault.c_str())); } - Spawner_Setup_Skirmish(); + Spawner_Setup_Session(); } DebugString("[Spawner] Launching %s with session identity %08x.\n", Scen->ScenarioName, SpawnConfig.Session_Identity_CRC()); + /* + * The session is assembled whole, so that wiring the network is all that remains; the + * sockets themselves are the one thing this game does not reach yet. + */ + if (Session.Type == GAME_INTERNET) { + return(Spawner_Refuse("A game against other machines needs its network, which is not wired up yet.")); + } + return(true); } diff --git a/code/spawnerconfig.cpp b/code/spawnerconfig.cpp index 8fdb4a5d..8a920f9b 100644 --- a/code/spawnerconfig.cpp +++ b/code/spawnerconfig.cpp @@ -308,6 +308,8 @@ int SpawnerConfigClass::Playable_Handicap(int asked) /// bool; Can the game this file describes be played? bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fault) const { + bool multiplayer = Launch_Type() == LaunchType::Multiplayer; + int free_seats = SLOT_COUNT - HumanCount; if (AIPlayers < 0 || AIPlayers > free_seats) { return(Fault(fault, "The file asks for %d computer players, and %d seats are left.", @@ -356,6 +358,26 @@ bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fa return(Fault(fault, "Seat %d watches rather than plays, which this game cannot yet do.", index + 1)); } + + /* + * Against other machines a person's name is what breaks a tie between two seats of one + * color, and what tells one seat from another at the connection it is reached on. An + * unnamed person, or two under one name, leaves the match without the single seat order + * every machine has to arrive at from its own file. + */ + if (human && multiplayer) { + if (slot.Name.empty()) { + return(Fault(fault, "Seat %d is played by somebody the file does not name.", index + 1)); + } + + for (int other = 0; other < index; other++) { + if (Slots[other].Occupancy == OccupancyType::Human && + _stricmp(Slots[other].Name.c_str(), slot.Name.c_str()) == 0) { + return(Fault(fault, "Seats %d and %d are both played by %s.", + other + 1, index + 1, slot.Name.c_str())); + } + } + } } return(true); diff --git a/manual/changes/client-driven-launch.md b/manual/changes/client-driven-launch.md index d920d137..ff1b501c 100644 --- a/manual/changes/client-driven-launch.md +++ b/manual/changes/client-driven-launch.md @@ -19,10 +19,8 @@ never meant to show. The settings file a client manages is no longer written bac a launch is in progress. The file names the options every house plays under, who is playing, each seat's country, -color, difficulty and start position, and the alliances between them. A file describing a -game against other machines is refused with the reason shown; that launch arrives -separately. Anything the file asks for that the game cannot honor is listed on the launch -file's own page. +color, difficulty and start position, and the alliances between them. Anything the file asks +for that the game cannot honor is listed on the launch file's own page. The node the player and lobby lists are made of now initializes itself rather than starting as whatever the heap last held, and carries the start position, difficulty and alliances a diff --git a/manual/changes/network-spawn-assembly.md b/manual/changes/network-spawn-assembly.md new file mode 100644 index 00000000..1bb2ccc0 --- /dev/null +++ b/manual/changes/network-spawn-assembly.md @@ -0,0 +1,21 @@ +--- +title: Assemble a launch against other machines up to its network +category: feature +release: 0.2.0 +targets: +- type: format + id: spawn-ini + effect: changed +credit: [ZivDero] +--- + +A launch file describing a game against other machines is now read and judged like any +other, and the match it asks for is assembled whole — the options, the seats, the alliances, +the start positions, and the address each machine is reached on, whether directly or through +a tunnel. The launch is then refused at the network itself, which the game does not yet +open, with the reason shown. + +Such a match is held to two rules a skirmish is not: every person must be named, and no two +may be named the same. The seat order the machines have to agree on is settled by color and +then by name, so a match missing those names is not one match. Sharing a color is still +allowed. diff --git a/manual/content/formats/spawn-ini.md b/manual/content/formats/spawn-ini.md index 92ca7a06..3a94f011 100644 --- a/manual/content/formats/spawn-ini.md +++ b/manual/content/formats/spawn-ini.md @@ -36,8 +36,7 @@ The `[Settings]` section says what kind of game to start. | `IsSinglePlayer` | Play a campaign mission rather than a match. | | `LoadSaveGame`, `SaveGameName` | Resume the named saved game. | -A file asking for a game against other machines is refused with the reason shown, and the -game exits. +A file that seats more than one person asks for a game against other machines. ## Resuming a saved game @@ -118,13 +117,31 @@ forbids new pacts still starts with the ones it wrote. Two seats may share a color deliberately — a cooperative team does — and the game does not refuse it. +## A game against other machines + +Each machine writes its own file, with itself in `[Settings]` and everybody else in the +`[OtherN]` sections. Those sections carry `Ip` and `Port` as well, naming the address a +machine answers on. A `[Tunnel]` section with its own `Ip` and `Port` routes the match +through a tunnel instead, and each machine is then named by the tunnel number its own `Port` +key carries rather than by its address. + +Every person must be named, and no two may be named the same, whatever the letters' case. +The seats are ordered by color, and a name is what breaks a tie between two of one color, so +a match without those names is not the same match on every machine. Colors themselves may +still be shared. + +The game reads the file, judges it, and assembles the whole match — the options, the seats, +the alliances, the start positions and the addresses — before it is refused at the network, +which this game does not yet open. The reason is shown and the game exits. + ## When something is wrong A file describing a game that cannot be played is refused: the reason is shown and written to the log, and the game exits rather than falling back to its menu. A launch is refused when it asks for more computer players than there are seats, names a country or color the loaded rules do not have, names a difficulty that is not one, allies a seat with one the -match does not hold, or asks for a seat that watches rather than plays. +match does not hold, or asks for a seat that watches rather than plays. A match against +other machines is refused as well when a person is left unnamed or two are named the same. A difficulty easier than the three the game has is not refused: the seat is played at the easiest one the game does have. @@ -138,5 +155,6 @@ These keys are read but do not yet change anything, because the game has no such to give them to: `Tournament`, `GameID`, `MapHash`, `BuildOffAlly`, the automatic-save scheduling keys, `QuickMatch`, `SkipScoreScreen`, `WriteStatistics`, `CoachMode`, `AutoSurrender`, `AttackNeutralUnits`, `ScrapMetal`, `ContinueWithoutHumans`, `PlayMoviesInMultiplayer`, -`CustomLoadScreen`, `CustomLoadScreenPos`, and `DifficultyName`. The tunnel and timeout -keys describe how machines reach one another, which a skirmish never needs. +`CustomLoadScreen`, `CustomLoadScreenPos`, and `DifficultyName`. The timeout keys, the port +this machine listens on, and the tunnel's own address wait on the network the game does not +yet open. diff --git a/manual/site/tests/documentation-source-contract.test.mjs b/manual/site/tests/documentation-source-contract.test.mjs index 53ae4c11..e68b9ad4 100644 --- a/manual/site/tests/documentation-source-contract.test.mjs +++ b/manual/site/tests/documentation-source-contract.test.mjs @@ -366,3 +366,40 @@ test('Saved games are named in one folder rather than searched for', () => { 'the listing no longer scans the folders the game reads from', ); }); + +test('A match against other machines is assembled whole and refused at the network', () => { + const spawner = source('code/spawner.cpp'); + + assertOrdered(functionBody(spawner, 'bool Spawner_Prepare(bool & gameloaded)'), [ + 'SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)', + 'Spawner_Setup_Session();', + 'SpawnConfig.Session_Identity_CRC()', + 'if (Session.Type == GAME_INTERNET) {', + ], 'the match is judged, assembled and named before the missing network refuses it'); + + assert.match( + functionBody(spawner, 'static void Spawner_Setup_Session(void)'), + /LaunchType::Multiplayer\s*\n?\s*\?\s*GAME_INTERNET : GAME_SKIRMISH;/, + 'one assembly serves both kinds of match', + ); + + assertOrdered(functionBody(spawner, 'static void Spawner_Seat_Humans(void)'), [ + 'if (SpawnConfig.TunnelPort != 0) {', + 'node->Address.Set_Address(0, htons((unsigned short)seat.Port));', + 'inet_addr(seat.Address.c_str())', + ], 'a tunnelled machine is named by its tunnel number before an address is read'); + + assertOrdered( + functionBody( + source('code/spawnerconfig.cpp'), + 'bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fault) const', + ), + [ + 'bool multiplayer = Launch_Type() == LaunchType::Multiplayer;', + 'if (human && multiplayer) {', + 'slot.Name.empty()', + '_stricmp(Slots[other].Name.c_str(), slot.Name.c_str()) == 0', + ], + 'the seat order the machines share is what the name rules are held for', + ); +}); diff --git a/tests/spawner/spawncontract.cpp b/tests/spawner/spawncontract.cpp index 84abf137..9d220248 100644 --- a/tests/spawner/spawncontract.cpp +++ b/tests/spawner/spawncontract.cpp @@ -372,6 +372,20 @@ int main(void) seven.SaveGameName = "SAVEGAME.002"; Check(six.Session_Identity_CRC() != seven.Session_Identity_CRC(), "resuming another saved game moves the identity"); + + /* + * Where the machines reach one another is how a match is carried rather than what it + * plays out as, and each machine writes its own view of it, so it is left out as well. + */ + SpawnerConfigClass eight = Read(_Network, sizeof(_Network) - 1); + SpawnerConfigClass nine = Read(_Network, sizeof(_Network) - 1); + nine.TunnelAddress = "203.0.113.9"; + nine.TunnelPort = 50010; + nine.ListenPort = 60000; + nine.Slots[0].Address = "10.0.0.8"; + nine.Slots[0].Port = 50003; + Check(eight.Session_Identity_CRC() == nine.Session_Identity_CRC(), + "where the machines reach one another is left out of the identity"); } /* @@ -558,6 +572,54 @@ int main(void) SpawnerConfigClass::Playable_Handicap(6) == 0, "an easier setting than the game has comes to the easiest it has"); + char const two_machines[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=5\n" + "Ip=10.0.0.9\n" + "Port=50002\n"; + Check(Judge(two_machines, sizeof(two_machines) - 1, 2, 8, fault), + "a match against another machine with everybody named is played"); + + char const nameless_machine[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Side=1\n" + "Color=5\n"; + Check(!Judge(nameless_machine, sizeof(nameless_machine) - 1, 2, 8, fault), + "a person the file leaves unnamed is refused against other machines"); + + char const one_name[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=alpha\n" + "Side=1\n" + "Color=5\n"; + Check(!Judge(one_name, sizeof(one_name) - 1, 2, 8, fault) && + fault.find("1") != std::string::npos && fault.find("2") != std::string::npos, + "two people under one name are refused however either is spelled"); + + char const alone[] = + "[Settings]\n" + "Side=0\n" + "Color=0\n"; + Check(Judge(alone, sizeof(alone) - 1, 2, 8, fault), + "somebody playing alone need not be named"); + char const past_seats[] = "[Settings]\n" "Name=Commander\n" From fdcdb3946cb88414f289757efe0236fe86132eda Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 16:12:28 +0300 Subject: [PATCH 10/35] Wire a spawned game against other machines to its network --- code/scenario.cpp | 35 ++++-- code/spawner.cpp | 114 +++++++++++++----- manual/changes/network-spawn-assembly.md | 20 +-- manual/content/formats/spawn-ini.md | 15 ++- .../documentation-source-contract.test.mjs | 36 +++++- 5 files changed, 159 insertions(+), 61 deletions(-) diff --git a/code/scenario.cpp b/code/scenario.cpp index a5bebb53..b7d71a7a 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -2065,21 +2065,26 @@ void Write_Scenario_INI(char const * fname, bool mplayer) /// /// Fetches the node a seat of the match was described by. /// The seats are numbered in the order their houses are created: the players first, then the -/// computer players a session source seated. +/// computer players a session source seated. The player list is held in each machine's own +/// order, so a seat is found by the house it was assigned rather than by list position. /// /// The seat to fetch, counted from zero. /// The node describing that seat, or NULL if the match does not hold it. static NodeNameType * Seated_Node(int seat) { - if (seat < 0) { - return(NULL); + for (int i = 0; i < Session.Players.Count(); i++) { + if (Session.Players[i]->Player.ID == seat) { + return(Session.Players[i]); + } } - if (seat < Session.Players.Count()) { - return(Session.Players[seat]); + + for (int i = 0; i < Session.Computers.Count(); i++) { + if (Session.Computers[i]->Player.ID == seat) { + return(Session.Computers[i]); + } } - seat -= Session.Players.Count(); - return(seat < Session.Computers.Count() ? Session.Computers[seat] : NULL); + return(NULL); } @@ -2127,8 +2132,8 @@ void Assign_Houses(void) // DebugString( "Assign_Houses()\n" ); //------------------------------------------------------------------------ // Assign each player in 'Players' to a multiplayer house. Players will - // be sorted by their chosen color value (this value must be unique among - // all the players). + // be sorted by their chosen color value (a tie between colors is + // settled by the players' names). //------------------------------------------------------------------------ for (i = 0; i < Session.Players.Count(); i++) { @@ -2141,7 +2146,17 @@ void Assign_Houses(void) //.................................................................. // If we've already assigned this house, skip it. //.................................................................. - if (!assigned[j] && (lowest_color == -1 || Session.Players[j]->Player.Color < lowest_color)) { + if (assigned[j]) { + continue; + } + + /* + * Each machine holds this list in its own order, with itself first, so a color + * tie is settled by name to keep the houses created in one order everywhere. + */ + if (index == -1 || Session.Players[j]->Player.Color < lowest_color || + (Session.Players[j]->Player.Color == lowest_color && + stricmp(Session.Players[j]->Name, Session.Players[index]->Name) < 0)) { lowest_color = Session.Players[j]->Player.Color; index = j; } diff --git a/code/spawner.cpp b/code/spawner.cpp index 47f9c2fa..6fc3a1da 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -24,6 +24,7 @@ #include "goptions.h" #include "houstype.h" #include "init.h" +#include "ipxmgr.h" #include "language\language.h" #include "loaddlg.h" #include "mplayer.h" @@ -125,34 +126,51 @@ static void Spawner_Seat_Local(void) /// -/// Puts the people playing into the list the houses are created from, each with the address -/// its machine is reached on when the match is against other machines. +/// Puts one person's seat into the list the houses are created from, with the address the +/// machine playing it is reached on when the match is against other machines. /// -static void Spawner_Seat_Humans(void) +/// Which seat, counted from zero. +static void Spawner_Seat_Human(int index) { - for (int index = 0; index < SpawnConfig.HumanCount; index++) { - SpawnerConfigClass::SlotType const & seat = SpawnConfig.Slots[index]; + SpawnerConfigClass::SlotType const & seat = SpawnConfig.Slots[index]; - NodeNameType * node = new NodeNameType; - std::snprintf(node->Name, sizeof(node->Name), "%s", - seat.Name.empty() ? "Player" : seat.Name.c_str()); - node->Player.House = seat.Country; - node->Player.Color = seat.Color; - node->Player.ProcessTime = -1; - node->Player.SpawnChoice = seat.StartingPosition; - node->Player.AlliesMask = Spawner_Allies_Mask(seat); + NodeNameType * node = new NodeNameType; + std::snprintf(node->Name, sizeof(node->Name), "%s", + seat.Name.empty() ? "Player" : seat.Name.c_str()); + node->Player.House = seat.Country; + node->Player.Color = seat.Color; + node->Player.ProcessTime = -1; + node->Player.SpawnChoice = seat.StartingPosition; + node->Player.AlliesMask = Spawner_Allies_Mask(seat); - /* - * Through a tunnel a machine is named by its tunnel number alone, carried where a port - * would go; reached directly, it is named by the address it answers on. - */ - if (SpawnConfig.TunnelPort != 0) { - node->Address.Set_Address(0, htons((unsigned short)seat.Port)); - } else if (seat.Port > 0) { - node->Address.Set_Address(inet_addr(seat.Address.c_str()), htons((unsigned short)seat.Port)); - } + /* + * Through a tunnel a machine is named by its tunnel number alone, carried where a port + * would go; reached directly, it is named by the address it answers on. + */ + if (SpawnConfig.TunnelPort != 0) { + node->Address.Set_Address(0, htons((unsigned short)seat.Port)); + } else if (seat.Port > 0) { + node->Address.Set_Address(inet_addr(seat.Address.c_str()), htons((unsigned short)seat.Port)); + } + + Session.Players.Add(node); +} + + +/// +/// Puts the people playing into the list the houses are created from. The game takes the +/// first entry of this list to be the player at this machine, so the local seat leads and +/// the rest follow in seat order; the houses take their own order from what the seats say +/// rather than from this list. +/// +static void Spawner_Seat_Humans(void) +{ + Spawner_Seat_Human(SpawnConfig.LocalSlot); - Session.Players.Add(node); + for (int index = 0; index < SpawnConfig.HumanCount; index++) { + if (index != SpawnConfig.LocalSlot) { + Spawner_Seat_Human(index); + } } Session.NumPlayers = SpawnConfig.HumanCount; @@ -250,10 +268,8 @@ static void Spawner_Bind_Options(void) * NextCampaignAutoSave, * NextSkirmishAutoSave - saving by itself is not wired up. * BuildOffAlly - the game has no such option to give it to. - * ReconnectTimeout, ConnTimeout, - * TunnelId, ListenPort, - * TunnelAddress - the socket this machine opens and the tunnel it joins - * through, neither of which anything opens yet. + * ReconnectTimeout, ConnTimeout - how patiently to wait for a machine that has gone + * quiet is part of the timing the game keeps for itself. * QuickMatch, SkipScoreScreen, * WriteStatistics, CoachMode, * AutoSurrender, AttackNeutralUnits, @@ -379,6 +395,14 @@ static void Spawner_Setup_Session(void) Session.Type = SpawnConfig.Launch_Type() == SpawnerConfigClass::LaunchType::Multiplayer ? GAME_INTERNET : GAME_SKIRMISH; + /* + * Against other machines the random numbers must fall the same way everywhere, and no + * lobby is there to hand a seed around, so the file's own is taken exactly as written. + */ + if (Session.Type == GAME_INTERNET) { + Seed = SpawnConfig.Seed; + } + Clear_Vector(&Session.Players); Clear_Vector(&Session.Computers); @@ -390,6 +414,35 @@ static void Spawner_Setup_Session(void) } +/// +/// Opens the network a game against other machines is played over. Through a tunnel every +/// machine is named by its tunnel number; otherwise each is reached at its own address, +/// and this machine listens where the file told the others to find it. The other players' +/// seats become the addresses a broadcast fans out to. +/// +/// bool; Is the network ready to carry the match? +static bool Spawner_Wire_Network(void) +{ + if (SpawnConfig.TunnelPort != 0) { + Ipx.Configure_Tunnel(htons((unsigned short)SpawnConfig.TunnelId), + inet_addr(SpawnConfig.TunnelAddress.c_str()), htons((unsigned short)SpawnConfig.TunnelPort)); + } else { + Ipx.Configure_Direct_Peers((unsigned short)SpawnConfig.ListenPort); + } + + // The local seat leads the player list, so everybody after it is another machine. + for (int index = 1; index < Session.Players.Count(); index++) { + Ipx.Add_Peer(Session.Players[index]->Address); + } + + if (!Ipx.Init()) { + return(Spawner_Refuse("The network could not be opened.")); + } + + return(true); +} + + /// /// Notes that a client asked the game to launch what its file describes. /// @@ -488,11 +541,10 @@ bool Spawner_Prepare(bool & gameloaded) Scen->ScenarioName, SpawnConfig.Session_Identity_CRC()); /* - * The session is assembled whole, so that wiring the network is all that remains; the - * sockets themselves are the one thing this game does not reach yet. + * The network comes last, once the session it will carry is assembled whole. */ - if (Session.Type == GAME_INTERNET) { - return(Spawner_Refuse("A game against other machines needs its network, which is not wired up yet.")); + if (Session.Type == GAME_INTERNET && !Spawner_Wire_Network()) { + return(false); } return(true); diff --git a/manual/changes/network-spawn-assembly.md b/manual/changes/network-spawn-assembly.md index 1bb2ccc0..a01ec91b 100644 --- a/manual/changes/network-spawn-assembly.md +++ b/manual/changes/network-spawn-assembly.md @@ -1,5 +1,5 @@ --- -title: Assemble a launch against other machines up to its network +title: Play a game against other machines from a client's launch file category: feature release: 0.2.0 targets: @@ -9,13 +9,13 @@ targets: credit: [ZivDero] --- -A launch file describing a game against other machines is now read and judged like any -other, and the match it asks for is assembled whole — the options, the seats, the alliances, -the start positions, and the address each machine is reached on, whether directly or through -a tunnel. The launch is then refused at the network itself, which the game does not yet -open, with the reason shown. +A launch file describing a game against other machines now plays it. The match is +assembled whole from the file — the options, the seats, the alliances, the start +positions and the addresses — and carried over the network the file names: through a +CnCNet tunnel when one is given, where every machine is known by its tunnel number, or +straight between the machines at the addresses they wrote for one another. -Such a match is held to two rules a skirmish is not: every person must be named, and no two -may be named the same. The seat order the machines have to agree on is settled by color and -then by name, so a match missing those names is not one match. Sharing a color is still -allowed. +Such a match is held to two rules a skirmish is not: every person must be named, and no +two may be named the same. The seat order the machines have to agree on is settled by +color and then by name, so a match missing those names is not one match. Sharing a color +is still allowed. diff --git a/manual/content/formats/spawn-ini.md b/manual/content/formats/spawn-ini.md index 3a94f011..85a6ee48 100644 --- a/manual/content/formats/spawn-ini.md +++ b/manual/content/formats/spawn-ini.md @@ -130,9 +130,12 @@ The seats are ordered by color, and a name is what breaks a tie between two of o a match without those names is not the same match on every machine. Colors themselves may still be shared. -The game reads the file, judges it, and assembles the whole match — the options, the seats, -the alliances, the start positions and the addresses — before it is refused at the network, -which this game does not yet open. The reason is shown and the game exits. +The seed is taken exactly as written, the same on every machine — including `0`, which in a +match against other machines is a seed like any other rather than a draw from chance. + +When a `[Tunnel]` section names a server, the match is played through it; otherwise each +machine is reached straight at the address its section carries, while this machine listens +on the port its own `Port` key names. ## When something is wrong @@ -155,6 +158,6 @@ These keys are read but do not yet change anything, because the game has no such to give them to: `Tournament`, `GameID`, `MapHash`, `BuildOffAlly`, the automatic-save scheduling keys, `QuickMatch`, `SkipScoreScreen`, `WriteStatistics`, `CoachMode`, `AutoSurrender`, `AttackNeutralUnits`, `ScrapMetal`, `ContinueWithoutHumans`, `PlayMoviesInMultiplayer`, -`CustomLoadScreen`, `CustomLoadScreenPos`, and `DifficultyName`. The timeout keys, the port -this machine listens on, and the tunnel's own address wait on the network the game does not -yet open. +`CustomLoadScreen`, `CustomLoadScreenPos`, `DifficultyName`, and the two timeout keys, +`ReconnectTimeout` and `ConnTimeout` — how patiently to wait for a machine that has gone +quiet is part of the timing the game keeps for itself. diff --git a/manual/site/tests/documentation-source-contract.test.mjs b/manual/site/tests/documentation-source-contract.test.mjs index e68b9ad4..ccc4d1b2 100644 --- a/manual/site/tests/documentation-source-contract.test.mjs +++ b/manual/site/tests/documentation-source-contract.test.mjs @@ -367,15 +367,33 @@ test('Saved games are named in one folder rather than searched for', () => { ); }); -test('A match against other machines is assembled whole and refused at the network', () => { +test('A match against other machines is assembled whole and wired to its network last', () => { const spawner = source('code/spawner.cpp'); assertOrdered(functionBody(spawner, 'bool Spawner_Prepare(bool & gameloaded)'), [ 'SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)', 'Spawner_Setup_Session();', 'SpawnConfig.Session_Identity_CRC()', - 'if (Session.Type == GAME_INTERNET) {', - ], 'the match is judged, assembled and named before the missing network refuses it'); + 'Session.Type == GAME_INTERNET && !Spawner_Wire_Network()', + ], 'the match is judged, assembled and named before its network is opened'); + + assertOrdered(functionBody(spawner, 'static bool Spawner_Wire_Network(void)'), [ + 'Ipx.Configure_Tunnel(', + 'Ipx.Configure_Direct_Peers(', + 'Ipx.Add_Peer(Session.Players[index]->Address);', + 'if (!Ipx.Init()) {', + ], 'the transport is chosen, the peers named, and only then the network opened'); + + assertOrdered(functionBody(source('code/scenario.cpp'), 'void Assign_Houses(void)'), [ + 'stricmp(Session.Players[j]->Name, Session.Players[index]->Name) < 0', + 'PlayerPtr = housep;', + ], 'a color tie is settled by name, so every machine creates the houses in one order'); + + assertOrdered(functionBody(source('code/scenario.cpp'), 'static NodeNameType * Seated_Node(int seat)'), [ + 'Session.Players[i]->Player.ID == seat', + 'Session.Computers[i]->Player.ID == seat', + ], 'a seat is found by the house it was assigned, not by its place in the list'); + assert.match( functionBody(spawner, 'static void Spawner_Setup_Session(void)'), @@ -383,12 +401,22 @@ test('A match against other machines is assembled whole and refused at the netwo 'one assembly serves both kinds of match', ); - assertOrdered(functionBody(spawner, 'static void Spawner_Seat_Humans(void)'), [ + assertOrdered(functionBody(spawner, 'static void Spawner_Seat_Human(int index)'), [ 'if (SpawnConfig.TunnelPort != 0) {', 'node->Address.Set_Address(0, htons((unsigned short)seat.Port));', 'inet_addr(seat.Address.c_str())', ], 'a tunnelled machine is named by its tunnel number before an address is read'); + assertOrdered(functionBody(spawner, 'static void Spawner_Seat_Humans(void)'), [ + 'Spawner_Seat_Human(SpawnConfig.LocalSlot);', + 'if (index != SpawnConfig.LocalSlot) {', + ], 'the local seat leads the player list the rest of the game reads'); + + assertOrdered(functionBody(spawner, 'static void Spawner_Setup_Session(void)'), [ + 'GAME_INTERNET : GAME_SKIRMISH;', + 'Seed = SpawnConfig.Seed;', + ], 'one seed is taken as written, since no lobby hands one around'); + assertOrdered( functionBody( source('code/spawnerconfig.cpp'), From 45605e00461001b834bc899625c612c35130c455 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 17:51:03 +0300 Subject: [PATCH 11/35] Resume a saved game against other machines from a launch file --- code/init.cpp | 6 +- code/language/language.rc | 16 +- code/saveload.cpp | 144 +++++------------- code/saveload.h | 1 + code/spawner.cpp | 99 +++++++----- manual/changes/multiplayer-save-button.md | 16 ++ manual/changes/resume-spawn-launch.md | 10 +- manual/content/formats/spawn-ini.md | 14 +- .../documentation-source-contract.test.mjs | 27 +++- 9 files changed, 179 insertions(+), 154 deletions(-) create mode 100644 manual/changes/multiplayer-save-button.md diff --git a/code/init.cpp b/code/init.cpp index 450d5075..634f3a08 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -1865,7 +1865,11 @@ void Init_Random(void) ** a recording; the random number generator is initialized by loading ** the game. */ - if (Session.LoadGame || Session.Play) { + if (Session.LoadGame) { + return; + } + + if (Session.Play) { Scen->RandomNumber = Seed; NonCriticalRandomNumber = Seed; DebugString("Seed is %08x\n", Seed); diff --git a/code/language/language.rc b/code/language/language.rc index af1c8189..010b9e2c 100644 --- a/code/language/language.rc +++ b/code/language/language.rc @@ -866,16 +866,18 @@ BEGIN 47,97,115,14 END -IDD_OPT_CTRL_MP DIALOG DISCARDABLE 0, 0, 209, 75 +IDD_OPT_CTRL_MP DIALOG DISCARDABLE 0, 0, 209, 93 STYLE WS_CHILD FONT 8, "MS Sans Serif" BEGIN CONTROL "Resume Mission",IDC_RESUME_MISSION,"Button", - BS_OWNERDRAW,55,48,99,14 + BS_OWNERDRAW,55,66,99,14 CONTROL "Game Controls",IDC_GAME_CONTROLS,"Button",BS_OWNERDRAW, 55,12,99,14 - CONTROL "Abort Mission",IDC_ABORT_MISSION,"Button",BS_OWNERDRAW, + CONTROL "Save Game",IDC_SAVE_GAME,"Button",BS_OWNERDRAW, 55,30,99,14 + CONTROL "Abort Mission",IDC_ABORT_MISSION,"Button",BS_OWNERDRAW, + 55,48,99,14 END IDD_SERIAL_PHONE_LIST DIALOG DISCARDABLE 0, 0, 344, 167 @@ -1426,11 +1428,13 @@ STYLE WS_CHILD FONT 8, "MS Sans Serif" BEGIN CONTROL "Resume Mission",IDC_RESUME_MISSION,"Button", - BS_OWNERDRAW,120,48,99,14 + BS_OWNERDRAW,120,59,99,14 CONTROL "Game Controls",IDC_GAME_CONTROLS,"Button",BS_OWNERDRAW, - 120,12,99,14 + 120,8,99,14 + CONTROL "Save Game",IDC_SAVE_GAME,"Button",BS_OWNERDRAW, + 120,25,99,14 CONTROL "Abort Mission",IDC_ABORT_MISSION,"Button",BS_OWNERDRAW, - 120,30,99,14 + 120,42,99,14 CONTROL "Slider1",IDC_GAME_SPEED_SLIDER,"msctls_trackbar32", TBS_BOTH | TBS_NOTICKS,95,115,148,13 LTEXT "Game Speed",-1,39,115,58,13,SS_CENTERIMAGE | NOT diff --git a/code/saveload.cpp b/code/saveload.cpp index 8f952579..4d3f5e8b 100644 --- a/code/saveload.cpp +++ b/code/saveload.cpp @@ -149,8 +149,6 @@ static bool MultiplayerSavePending = false; static std::string PendingSaveFileName; static std::string PendingSaveDescription; -static int Reconcile_Players(void); - _COM_SMARTPTR_TYPEDEF(ILinkStream, __uuidof(ILinkStream)); @@ -1349,134 +1347,72 @@ bool Get_Savefile_Info(char const * name, SaveVersionInfo * info) } -/*************************************************************************** - * Reconcile_Players -- Reconciles loaded data with the 'Players' vector * - * * - * This function is for supporting loading a saved multiplayer game. * - * When the game is loaded, we have to figure out which house goes with * - * which entry in the Players vector. We also have to figure out if * - * everyone who was originally in the game is still with us, and if not, * - * turn their stuff over to the computer. * - * * - * So, this function does the following: * - * - For every name in 'Players', makes sure that name is in the House * - * array; if not, it's a fatal error. * - * - For every human-controlled house, makes sure there's a player * - * with that name; if not, it turns that house over to the computer. * - * - Fills in the Player's house ID * - * * - * This assumes that each player MUST keep their name the same as it was * - * when the game was saved! It's also assumed that the network * - * connections have not been formed yet, since Player[i]->Player.ID will * - * be invalid until this routine has been called. * - * * - * INPUT: * - * none. * - * * - * OUTPUT: * - * true = OK, false = error * - * * - * WARNINGS: * - * none. * - * * - * HISTORY: * - * 09/29/1995 BRR : Created. * - *=========================================================================*/ -static int Reconcile_Players(void) +/// +/// Marries the players a session source seated to the houses a saved game restored. +/// Each seat is given the house carrying its name, so the connections formed afterwards +/// reach the right houses. The save must hold a house for everybody seated, and this +/// machine's own seat must be the house this machine played, or the resumed game is not +/// the one the file describes. A house whose player was not seated again fights on under +/// the computer. Nothing here forms connections; the seats are only named. +/// +/// bool; Do the seats and the saved houses agree? +bool Reconcile_Players(void) { - #if 0 - int i; - int found; - HousesType house; - HouseClass * housep; - - /* - ** If there are no players, there's nothing to do. - */ - if (Session.Players.Count()==0) + if (Session.Players.Count() == 0) { return(true); + } - /* - ** Make sure every name we're connected to can be found in a House - */ - for (i = 0; i < Session.Players.Count(); i++) { - found = 0; - for (house = HOUSE_MULTI1; house < HOUSE_MULTI1 + - Session.MaxPlayers; house++) { - - housep = Houses[house]; - if (!housep) { - continue; - } + for (int i = 0; i < Session.Players.Count(); i++) { + HouseClass * found = NULL; - if (!stricmp(Session.Players[i]->Name, housep->IniName)) { - found = 1; + for (int house = 0; house < Houses.Count(); house++) { + if (Houses[house]->IsHuman && stricmp(Session.Players[i]->Name, Houses[house]->IniName) == 0) { + found = Houses[house]; break; } } - if (!found) + + if (found == NULL) { return(false); + } + + Session.Players[i]->Player.ID = found->HeapID; } - // - // Loop through all Houses; if we find a human-owned house that we're - // not connected to, turn it over to the computer. - // - for (house = HOUSE_MULTI1; house < HOUSE_MULTI1 + - Session.MaxPlayers; house++) { - housep = Houses[house]; - if (!housep) { - continue; - } + /* + * The first seat is the player at this machine, and PlayerPtr is the house the machine + * that wrote the save was playing; the two agreeing is what makes this save its own. + */ + if (Houses[Session.Players[0]->Player.ID] != PlayerPtr) { + return(false); + } - // - // Skip this house if it wasn't human to start with. - // + for (int house = 0; house < Houses.Count(); house++) { + HouseClass * housep = Houses[house]; if (!housep->IsHuman) { continue; } - // - // Try to find this name in the Players vector; if it's found, set - // its ID to this house. - // - found = 0; - for (i = 0; i < Session.Players.Count(); i++) { - if (!stricmp(Session.Players[i]->Name, housep->IniName)) { - found = 1; - Session.Players[i]->Player.ID = house; + bool seated = false; + for (int i = 0; i < Session.Players.Count(); i++) { + if (Session.Players[i]->Player.ID == housep->HeapID) { + seated = true; break; } } /* - ** If this name wasn't found, remove it - */ - if (!found) { - - /* - ** Turn the player's house over to the computer's AI - */ + * A player who did not return leaves their house fighting on under the computer. + */ + if (!seated) { housep->IsHuman = false; housep->IsStarted = true; -// housep->Smartness = IQ_MENSA; housep->IQ = Rule->MaxIQ; - housep->IniName = Text_String(TXT_COMPUTER); - - Session.NumPlayers--; + housep->IniName = Fetch_String(TXT_COMPUTER); } } - // - // If all went well, our Session.NumPlayers value should now equal the value - // from the saved game, minus any players we removed. - // - if (Session.NumPlayers == Session.Players.Count()) { - return(true); - } else { - return(false); - } - #endif + return(true); } diff --git a/code/saveload.h b/code/saveload.h index 67ee5d92..2d6bb8fc 100644 --- a/code/saveload.h +++ b/code/saveload.h @@ -23,6 +23,7 @@ int Load_Misc_Values(IStream * stream); int Save_Misc_Values(IStream * stream); bool Get_Savefile_Info(char const * name, SaveVersionInfo * info); bool Load_Game(const char *file_name); +bool Reconcile_Players(void); bool Request_Save_Game(char const * file_name, char const * descr); void Process_Pending_Save_Game(void); void Reset_Multiplayer_Save_State(void); diff --git a/code/spawner.cpp b/code/spawner.cpp index 6fc3a1da..7896da31 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -307,10 +307,40 @@ static void Spawner_Bind_Scenario(void) } +/// +/// Opens the network a game against other machines is played over. Through a tunnel every +/// machine is named by its tunnel number; otherwise each is reached at its own address, +/// and this machine listens where the file told the others to find it. The other players' +/// seats become the addresses a broadcast fans out to. +/// +/// bool; Is the network ready to carry the match? +static bool Spawner_Wire_Network(void) +{ + if (SpawnConfig.TunnelPort != 0) { + Ipx.Configure_Tunnel(htons((unsigned short)SpawnConfig.TunnelId), + inet_addr(SpawnConfig.TunnelAddress.c_str()), htons((unsigned short)SpawnConfig.TunnelPort)); + } else { + Ipx.Configure_Direct_Peers((unsigned short)SpawnConfig.ListenPort); + } + + // The local seat leads the player list, so everybody after it is another machine. + for (int index = 1; index < Session.Players.Count(); index++) { + Ipx.Add_Peer(Session.Players[index]->Address); + } + + if (!Ipx.Init()) { + return(Spawner_Refuse("The network could not be opened.")); + } + + return(true); +} + + /// /// Resumes the saved game a launch file names. The save carries the kind of game, the options -/// and the houses, so nothing else in the file is consulted, and the expansion comes back with -/// the save rather than from the file. +/// and the houses, and the expansion comes back with it rather than from the file. A game +/// played alone takes nothing else from the file; one against other machines takes its seats, +/// which name the same people at the addresses their machines answer on now. /// /// Set when the save loads, so the caller starts no scenario. /// bool; Is the saved game running? @@ -330,18 +360,46 @@ static bool Spawner_Resume(bool & gameloaded) } /* - * A save made against other machines restores the connections along with everything else, - * and there are none to restore it into until the network is wired up. + * A game the menu arranged over the local network is nothing a client launched, so no + * launch file describes the match such a save would resume. */ GameType type = (GameType)info.Get_Game_Type(); - if (type != GAME_NORMAL && type != GAME_SKIRMISH) { - return(Spawner_Refuse("Resuming a game against other machines is not supported yet.")); + if (type == GAME_IPX) { + return(Spawner_Refuse("Resuming a game arranged over the local network is not supported.")); + } + + /* + * Against other machines the save restores the houses while the file seats the same + * people afresh, so the seats are judged and the network opened before the save is + * read, and the queue is told to shake hands again at the resumed frame. + */ + if (type == GAME_INTERNET) { + std::string fault; + if (!SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)) { + return(Spawner_Refuse("%s", fault.c_str())); + } + + Clear_Vector(&Session.Players); + Clear_Vector(&Session.Computers); + + Spawner_Seat_Local(); + Spawner_Seat_Humans(); + + if (!Spawner_Wire_Network()) { + return(false); + } + + Session.LoadGame = true; } if (!LoadOptionsClass().Load_File(SpawnConfig.SaveGameName.c_str())) { return(Spawner_Refuse("The saved game %s could not be loaded.", SpawnConfig.SaveGameName.c_str())); } + if (type == GAME_INTERNET && !Reconcile_Players()) { + return(Spawner_Refuse("The saved game and the file do not agree on who is playing.")); + } + gameloaded = true; return(true); @@ -414,35 +472,6 @@ static void Spawner_Setup_Session(void) } -/// -/// Opens the network a game against other machines is played over. Through a tunnel every -/// machine is named by its tunnel number; otherwise each is reached at its own address, -/// and this machine listens where the file told the others to find it. The other players' -/// seats become the addresses a broadcast fans out to. -/// -/// bool; Is the network ready to carry the match? -static bool Spawner_Wire_Network(void) -{ - if (SpawnConfig.TunnelPort != 0) { - Ipx.Configure_Tunnel(htons((unsigned short)SpawnConfig.TunnelId), - inet_addr(SpawnConfig.TunnelAddress.c_str()), htons((unsigned short)SpawnConfig.TunnelPort)); - } else { - Ipx.Configure_Direct_Peers((unsigned short)SpawnConfig.ListenPort); - } - - // The local seat leads the player list, so everybody after it is another machine. - for (int index = 1; index < Session.Players.Count(); index++) { - Ipx.Add_Peer(Session.Players[index]->Address); - } - - if (!Ipx.Init()) { - return(Spawner_Refuse("The network could not be opened.")); - } - - return(true); -} - - /// /// Notes that a client asked the game to launch what its file describes. /// diff --git a/manual/changes/multiplayer-save-button.md b/manual/changes/multiplayer-save-button.md new file mode 100644 index 00000000..d0efdfb5 --- /dev/null +++ b/manual/changes/multiplayer-save-button.md @@ -0,0 +1,16 @@ +--- +title: Offer the save in a network game's options +category: feature +release: 0.2.0 +targets: +- type: format + id: save-games + effect: changed +credit: [ZivDero] +--- + +The options dialog of a game against other machines now offers Save Game. The game has +long known how to make the save — one press submits the synchronized command and every +machine writes its own copy at the same frame — but the network dialogs never carried the +button that asks for it. It greys out once a player has left the match, as the saving +rules always said. diff --git a/manual/changes/resume-spawn-launch.md b/manual/changes/resume-spawn-launch.md index fe742e05..ed775c4d 100644 --- a/manual/changes/resume-spawn-launch.md +++ b/manual/changes/resume-spawn-launch.md @@ -14,9 +14,13 @@ the kind of game it was, the options it was played under and the houses that pla nothing else in the file is consulted — which is what clients already write, their resume files naming little beyond the save. -A save the folder does not hold, one made by another version of the game, and one made in a -game against other machines each refuse the launch with the reason shown; resuming a game -against other machines arrives with the network work it needs. +A save from a game against other machines resumes too: every machine loads its own copy of +the synchronized save while the launch file seats the same people at the addresses they +answer on now, a player who does not return leaves their house to the computer, and the +machines compare the games they loaded before play goes on. A save the folder does not +hold, one made by another version of the game, one whose seats disagree with the file, and +one from a game the menu arranged over the local network each refuse the launch with the +reason shown. Which saved game a launch file names is now part of the identity two machines compare a match by, since a resume is a match of its own. diff --git a/manual/content/formats/spawn-ini.md b/manual/content/formats/spawn-ini.md index 85a6ee48..d0cabd26 100644 --- a/manual/content/formats/spawn-ini.md +++ b/manual/content/formats/spawn-ini.md @@ -46,9 +46,17 @@ the houses that played it, so nothing else in the file decides those. A client r campaign writes little more than the name of the save. The name is a file inside the game's saved-games folder, and a name written with a path of -its own is reduced to its last part. A save the folder does not hold, one made by another -version of the game, and one made in a game against other machines each refuse the launch -with the reason shown. +its own is reduced to its last part. A save the folder does not hold, or one made by +another version of the game, refuses the launch with the reason shown. + +A save from a game against other machines resumes as well. Every machine loads its own +copy of the save — the synchronized in-game save writes one on each of them, named +`SAVEGAME.NET` — while the file seats the same people again, with the addresses their +machines answer on now. A player who does not return leaves their house fighting on under +the computer, and before play resumes the machines compare the games they loaded, so +mismatched saves refuse rather than drift apart. The launch is refused when the seats and +the save disagree on who is playing, or when the save came from a game the menu arranged +over the local network. ## A campaign mission diff --git a/manual/site/tests/documentation-source-contract.test.mjs b/manual/site/tests/documentation-source-contract.test.mjs index ccc4d1b2..e329692b 100644 --- a/manual/site/tests/documentation-source-contract.test.mjs +++ b/manual/site/tests/documentation-source-contract.test.mjs @@ -323,10 +323,33 @@ test('A resume is judged before it is loaded, and the save answers for the rest' 'SpawnConfig.SaveGameName.empty()', 'Get_Savefile_Info(SpawnConfig.SaveGameName.c_str(), &info)', 'info.Get_Internal_Version() != ExpectedGameVersion', - 'type != GAME_NORMAL && type != GAME_SKIRMISH', + 'type == GAME_IPX', + 'SpawnConfig.Is_Playable(HouseTypes.Count(), MAX_MPLAYER_COLORS, fault)', + 'Spawner_Seat_Humans();', + 'Spawner_Wire_Network()', + 'Session.LoadGame = true;', 'LoadOptionsClass().Load_File(SpawnConfig.SaveGameName.c_str())', + 'Reconcile_Players()', 'gameloaded = true;', - ], 'a save is named, found, stamped and of a kind that can be resumed before it is read'); + ], 'a network resume seats the players and opens the network before the save is read'); + + for (const dialog of ['IDD_OPT_CTRL_MP', 'IDD_OPT_CTRL_WOL']) { + const template = source('code/language/language.rc'); + const body = template.slice(template.indexOf(dialog + ' DIALOG')); + assert.match( + body.slice(0, body.indexOf('END')), + /IDC_SAVE_GAME/, + `${dialog} offers the synchronized save the options handler has always known`, + ); + } + + assertOrdered(functionBody(source('code/saveload.cpp'), 'bool Reconcile_Players(void)'), [ + 'stricmp(Session.Players[i]->Name, Houses[house]->IniName) == 0', + 'Session.Players[i]->Player.ID = found->HeapID;', + 'Houses[Session.Players[0]->Player.ID] != PlayerPtr', + 'housep->IsHuman = false;', + 'housep->IniName = Fetch_String(TXT_COMPUTER);', + ], 'every seat is matched and this machine identified before any house changes hands'); assertOrdered(functionBody(source('code/saveload.cpp'), 'bool Load_Game(const char *file_name)'), [ 'Session.Type = (GameType)info.Get_Game_Type();', From b2463505d04a19fe1c58b0ce36dcb30198381874 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 19:52:31 +0300 Subject: [PATCH 12/35] Trim the client launch comments to what the code cannot show --- code/gamedirs.cpp | 9 +-- code/gamedirs.h | 4 +- code/init.cpp | 23 ++---- code/loaddlg.cpp | 7 +- code/saveload.cpp | 17 ++--- code/scenario.cpp | 31 +++----- code/session.h | 16 ++--- code/spawner.cpp | 122 +++++++++----------------------- code/spawnerconfig.cpp | 81 ++++++--------------- code/spawnerconfig.h | 68 ++++-------------- code/theme.cpp | 10 +-- tests/spawner/spawncontract.cpp | 5 +- 12 files changed, 104 insertions(+), 289 deletions(-) diff --git a/code/gamedirs.cpp b/code/gamedirs.cpp index 07c168bf..2f26c196 100644 --- a/code/gamedirs.cpp +++ b/code/gamedirs.cpp @@ -320,13 +320,10 @@ std::string User_File_Write_Name(char const * filename) /// -/// Names a saved game inside the folder they are kept in, which sits with the rest of the -/// player's own files. Saved games are written, so the folder is deliberately not one of the -/// searched ones: every save, load, listing and deletion names it. It is created the first -/// time the game asks for a saved game, so a launcher browsing for them finds it whether or -/// not one has been written yet. +/// Names a saved game inside the folder they are kept in. That folder is deliberately not +/// one of the searched ones, and is made on the way so a launcher can browse it before the +/// first save is written. /// -/// The name of a saved game, or a pattern matching several. /// The name to open, delete or scan for. std::string Saved_Game_Name(char const * filename) { diff --git a/code/gamedirs.h b/code/gamedirs.h index 8a569645..7adefc06 100644 --- a/code/gamedirs.h +++ b/code/gamedirs.h @@ -38,8 +38,8 @@ char const * Game_Directory_Error(void); std::string User_File_Write_Name(char const * filename); /* - * Where the player's saved games are. They are written, so they are never searched for: the - * folder is named outright wherever a saved game is opened, listed or removed. + * Where the player's saved games are. They are never searched for: the folder is named + * outright wherever a saved game is opened, listed or removed. */ std::string Saved_Game_Name(char const * filename); diff --git a/code/init.cpp b/code/init.cpp index 634f3a08..5ad192ba 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -653,10 +653,8 @@ void Init_Campaigns(void) /// -/// Reads the countries and the sides they belong to from the rules. -/// This runs before a game is set up, so that a house's side is known before anything asks -/// for it. The side roster is only established by reading it: a country carries the name of -/// its side, and the rules' own side list decides what order the sides are registered in. +/// Reads the countries and the sides they belong to from the rules, so a house's side is +/// known before anything asks for it. Only this reading establishes the roster. /// void Prepare_Side_Roster(void) { @@ -1094,9 +1092,8 @@ bool Select_Game(bool ) } /* - * A client-requested launch takes the place of the menu, once. A spawned match that - * has ended, or a launch that was refused, answers false so the process leaves and - * the client sees it go. + * A launch takes the place of the menu once; an ended match or a refusal answers false, + * so the process leaves and the client sees it go. */ if (Spawner_Is_Requested()) { if (!Spawner_Prepare(gameloaded)) { @@ -1394,10 +1391,7 @@ bool Select_Game(bool ) Session.PlayerIsGDI = stricmp(HouseTypes[Session.Players[0]->Player.House]->Name(), "GDI") == 0; } - /* - * The campaign handicap pair follows the menu's difficulty setting on every path but - * a client launch, which chose the pair itself. - */ + // The menu sets the pair on every path but a client launch, which chose it itself. if (!Spawner_Is_Active()) { Session.CampaignDifficulty = (DiffType)Options.Difficulty; Session.CampaignCDifficulty = (DiffType)(DIFF_COUNT - 1 - Options.Difficulty); @@ -1421,12 +1415,7 @@ bool Select_Game(bool ) } } - /* - * A mission reached through a client's launch file starts with the scenario flags - * that file carried over, which the mission read has just cleared. The flags a - * mission chain carries between its own missions arrive the same way, once the - * mission after this one is started. - */ + // The mission read clears these, so a launch file's carried-over flags are set after it. if (Spawner_Is_Active() && Session.Type == GAME_NORMAL) { for (int index = 0; index < ARRAY_SIZE(Environment.Globals); index++) { Scen->Set_Global_To(index, Environment.Globals[index]); diff --git a/code/loaddlg.cpp b/code/loaddlg.cpp index 5b540be8..a3306cc3 100644 --- a/code/loaddlg.cpp +++ b/code/loaddlg.cpp @@ -359,12 +359,9 @@ LRESULT CALLBACK LoadOptionsClass::Delete_Dialog_Proc(HWND window, UINT message, /// -/// Is a saved game of this name already there? -/// Asked before one is written, since a name the folder already holds is written over -/// rather than added to. +/// Is a saved game of this name already there? Asked before one is written, since a name the +/// folder holds is written over rather than added to. /// -/// The name of the saved game to look for. -/// bool; Does the saved games folder already hold this name? static bool Saved_Game_Exists(char const * name) { return(GetFileAttributes(Saved_Game_Name(name).c_str()) != INVALID_FILE_ATTRIBUTES); diff --git a/code/saveload.cpp b/code/saveload.cpp index 4d3f5e8b..8e9bc5ec 100644 --- a/code/saveload.cpp +++ b/code/saveload.cpp @@ -1348,12 +1348,8 @@ bool Get_Savefile_Info(char const * name, SaveVersionInfo * info) /// -/// Marries the players a session source seated to the houses a saved game restored. -/// Each seat is given the house carrying its name, so the connections formed afterwards -/// reach the right houses. The save must hold a house for everybody seated, and this -/// machine's own seat must be the house this machine played, or the resumed game is not -/// the one the file describes. A house whose player was not seated again fights on under -/// the computer. Nothing here forms connections; the seats are only named. +/// Gives each seated player the restored house carrying its name, so the connections formed +/// afterwards reach the right houses. /// /// bool; Do the seats and the saved houses agree? bool Reconcile_Players(void) @@ -1379,10 +1375,7 @@ bool Reconcile_Players(void) Session.Players[i]->Player.ID = found->HeapID; } - /* - * The first seat is the player at this machine, and PlayerPtr is the house the machine - * that wrote the save was playing; the two agreeing is what makes this save its own. - */ + // The first seat is this machine, and PlayerPtr the house that wrote the save. if (Houses[Session.Players[0]->Player.ID] != PlayerPtr) { return(false); } @@ -1401,9 +1394,7 @@ bool Reconcile_Players(void) } } - /* - * A player who did not return leaves their house fighting on under the computer. - */ + // A player who did not return leaves their house fighting on under the computer. if (!seated) { housep->IsHuman = false; housep->IsStarted = true; diff --git a/code/scenario.cpp b/code/scenario.cpp index b7d71a7a..37362f8d 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -2063,12 +2063,9 @@ void Write_Scenario_INI(char const * fname, bool mplayer) /// -/// Fetches the node a seat of the match was described by. -/// The seats are numbered in the order their houses are created: the players first, then the -/// computer players a session source seated. The player list is held in each machine's own -/// order, so a seat is found by the house it was assigned rather than by list position. +/// Fetches the node a seat of the match was described by. The player list is held in each +/// machine's own order, so a seat is found by the house it was assigned, not by position. /// -/// The seat to fetch, counted from zero. /// The node describing that seat, or NULL if the match does not hold it. static NodeNameType * Seated_Node(int seat) { @@ -2150,10 +2147,7 @@ void Assign_Houses(void) continue; } - /* - * Each machine holds this list in its own order, with itself first, so a color - * tie is settled by name to keep the houses created in one order everywhere. - */ + // Each machine holds this list itself-first, so a color tie is settled by name. if (index == -1 || Session.Players[j]->Player.Color < lowest_color || (Session.Players[j]->Player.Color == lowest_color && stricmp(Session.Players[j]->Name, Session.Players[index]->Name) < 0)) { @@ -2274,10 +2268,8 @@ void Assign_Houses(void) } /* - * The alliance table names seats, in the order the houses above were created. While the - * scenario is still assembling, a pact is always permitted and is made quietly, so the - * table stands before the first frame is played. The seats are walked before the neutral - * and special houses exist, since neither is a seat of the match. + * The alliance table names seats in the order the houses above were created, and is + * applied before the neutral and special houses exist, since neither is a seat. */ int seated = Session.Players.Count() + Session.Computers.Count(); for (int seatnum = 0; seatnum < seated; seatnum++) { @@ -2339,9 +2331,8 @@ static void Remove_AI_Players(void) /// -/// Makes up a shortfall of starting locations with open ground. -/// A map is not obliged to declare a start position for everybody playing, so spots are -/// drawn until there are enough of them to go round. +/// Makes up a shortfall of starting locations with open ground, since a map need not declare +/// a start position for everybody playing. /// /// The list of starting locations to append to. /// How many of the locations may actually be started from; raised as @@ -2369,12 +2360,8 @@ static void Append_Open_Start_Positions(DynamicVectorClass & waypts, int & /// -/// Fetches the starting locations available to a multiplayer game. -/// The scenario's own waypoints are preferred, but a map that does not supply enough of -/// them for everyone playing has the shortfall made up with random spots on open ground. -/// When a house has asked for a position by number, the list is built so that an entry's -/// place in it is the waypoint of that number, and a waypoint the map does not declare -/// keeps its place as a hole rather than letting the ones after it slide down. +/// Fetches the starting locations a multiplayer game may use, making up any shortfall with +/// open ground. Keeping identity numbers each entry by waypoint, undeclared ones left as holes. /// /// Is this one of the maps that shipped with the game? /// Must an entry's place in the list be its waypoint number? diff --git a/code/session.h b/code/session.h index 2b977b8f..e304fc1b 100644 --- a/code/session.h +++ b/code/session.h @@ -230,10 +230,7 @@ struct NodeNameType { } Chat; }; - /* - * A node starts with nothing asked for, so that a session source which names none of - * these leaves the game its own choice of start position and difficulty. - */ + // A node asks for nothing, leaving the game its own start position and difficulty. NodeNameType(void) { memset(this, 0, sizeof(*this)); @@ -527,9 +524,8 @@ class SessionClass int Solo; // 1 = player can play alone /* - * The handicap pair a campaign mission is played at, set by whichever path starts - * the campaign. It lives here because the scenario's own copy is wiped before every - * mission is read, while a restart or the next mission must keep the pair chosen. + * The pair a campaign mission is played at. It lives here because the scenario's own + * copy is wiped before each mission, while a restart or the next one must keep it. */ DiffType CampaignDifficulty; DiffType CampaignCDifficulty; @@ -708,11 +704,7 @@ class SessionClass DynamicVectorClass Players; // list of players DynamicVectorClass Chat; // list of chat nodes - /* - * The computer players a session source seated, in the order their houses are - * created, after the human ones. The menu leaves this empty and lets the game draw - * its own computer players. - */ + // The computer players a session source seated, after the humans; the menu leaves it empty. DynamicVectorClass Computers; int Suspended; diff --git a/code/spawner.cpp b/code/spawner.cpp index 7896da31..d66310e5 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -42,9 +42,8 @@ /* - * A spawned launch happens at most once for the life of the process: the client that asked - * for it watches for the process to exit, so a finished or refused spawn ends the program - * rather than falling into the menu. + * A launch is spent once for the life of the process: the client watches for the game to + * exit, so a finished or refused spawn ends it rather than falling into the menu. */ static bool SpawnRequested = false; static bool SpawnConsumed = false; @@ -75,7 +74,6 @@ static bool Spawner_Refuse(char const * fault, ...) /// /// Folds a seat's alliance list into the bitfield the houses are allied by. /// -/// The seat whose alliances are wanted. /// One bit set per seat this one is allied with. static unsigned Spawner_Allies_Mask(SpawnerConfigClass::SlotType const & seat) { @@ -94,8 +92,6 @@ static unsigned Spawner_Allies_Mask(SpawnerConfigClass::SlotType const & seat) /// /// The difficulty one seat is played at, saying so when it is not the one asked for. /// -/// Which seat, counted from zero. -/// The difficulty the launch file asked for. /// The difficulty to play the seat at, or -1 for the session default. static int Spawner_Seat_Handicap(int index, int asked) { @@ -126,10 +122,8 @@ static void Spawner_Seat_Local(void) /// -/// Puts one person's seat into the list the houses are created from, with the address the -/// machine playing it is reached on when the match is against other machines. +/// Puts one person's seat into the list the houses are created from. /// -/// Which seat, counted from zero. static void Spawner_Seat_Human(int index) { SpawnerConfigClass::SlotType const & seat = SpawnConfig.Slots[index]; @@ -143,10 +137,7 @@ static void Spawner_Seat_Human(int index) node->Player.SpawnChoice = seat.StartingPosition; node->Player.AlliesMask = Spawner_Allies_Mask(seat); - /* - * Through a tunnel a machine is named by its tunnel number alone, carried where a port - * would go; reached directly, it is named by the address it answers on. - */ + // Through a tunnel a machine is named by its tunnel number, carried where a port would go. if (SpawnConfig.TunnelPort != 0) { node->Address.Set_Address(0, htons((unsigned short)seat.Port)); } else if (seat.Port > 0) { @@ -158,10 +149,8 @@ static void Spawner_Seat_Human(int index) /// -/// Puts the people playing into the list the houses are created from. The game takes the -/// first entry of this list to be the player at this machine, so the local seat leads and -/// the rest follow in seat order; the houses take their own order from what the seats say -/// rather than from this list. +/// Puts the people playing into the list the houses are created from, this machine's own +/// seat first, since the game takes the first entry to be the local player. /// static void Spawner_Seat_Humans(void) { @@ -197,10 +186,7 @@ static void Spawner_Seat_Computers(void) node->Player.SpawnChoice = seat.StartingPosition; node->Player.AlliesMask = Spawner_Allies_Mask(seat); - /* - * A computer player is named for the difficulty it is actually played at, which is - * the one its seat asked for, or else the one the session gives every computer. - */ + // The session's difficulty runs the other way from a seat's, so it is turned around here. if (SpawnConfig.AINamesByDifficulty) { int played = node->Player.Handicap >= 0 ? node->Player.Handicap @@ -215,9 +201,7 @@ static void Spawner_Seat_Computers(void) /// -/// Tells the session what every house plays under. -/// The order below follows the launch file's own, so that what the game takes from a launch -/// can be read against what the file carries. +/// Tells the session what every house plays under, in the launch file's own order. /// static void Spawner_Bind_Options(void) { @@ -235,31 +219,19 @@ static void Spawner_Bind_Options(void) Session.Options.FogOfWar = SpawnConfig.FogOfWar; Session.Options.MCVRedeploy = SpawnConfig.MCVRedeploy; - /* - * A skirmish never reaches the pregame setup that hands this to the simulation, so the - * session records what was asked for while the map's own setting still decides it. - */ + // A skirmish takes harvester immunity from the map, so this is recorded but not obeyed. Session.Options.HarvTruce = SpawnConfig.HarvesterTruce; - /* - * These two are options as much as anything above, but they live outside the block the - * session keeps them in; the menu's own commit sets both the same way. - */ + // Game options too, though the session keeps these two outside its own block. Options.GameSpeed = SpawnConfig.GameSpeed; BuildLevel = SpawnConfig.TechLevel; - /* - * The seed is left where a launch option leaves it, since the random numbers are only - * settled once the session type is known. A file naming no seed leaves it to the clock. - */ + // Init_Random settles this for a game played alone, and draws its own when it is zero. CustomSeed = SpawnConfig.Seed; /* - * Read, not honored. Every field the reader carries is either bound above, consumed to - * refuse a launch, or named here with the reason, so that adding a field to the reader - * forces a decision rather than a silent omission. A field named here is not a defect: - * the launch file is the client's vocabulary, and much of it describes machinery this - * game does not have yet. + * Read, not honored. Every field the reader carries is bound above, consumed to refuse a + * launch, or named here, so a new field forces a decision rather than a silent omission. * * IsHost, Tournament, GameID - the client's own bookkeeping of the match. * MapName - shown while loading; bound with the scenario below. @@ -283,15 +255,12 @@ static void Spawner_Bind_Options(void) * SaveGameName - read to decide what kind of launch this is, and to * name the saved game a resume restores. * Slots[].IsSpectator - read to refuse a launch. - * - * The timing keys a client writes are not read at all: the game keeps its own. */ } /// -/// Tells the session which scenario is being played, in place of the map list the menu picks -/// from, which a client-launched game never shows. +/// Tells the session which scenario is being played, in place of the menu's map list. /// static void Spawner_Bind_Scenario(void) { @@ -308,10 +277,8 @@ static void Spawner_Bind_Scenario(void) /// -/// Opens the network a game against other machines is played over. Through a tunnel every -/// machine is named by its tunnel number; otherwise each is reached at its own address, -/// and this machine listens where the file told the others to find it. The other players' -/// seats become the addresses a broadcast fans out to. +/// Opens the network a game against other machines is played over, through the tunnel the +/// file names or straight to the addresses its seats carry. /// /// bool; Is the network ready to carry the match? static bool Spawner_Wire_Network(void) @@ -323,7 +290,7 @@ static bool Spawner_Wire_Network(void) Ipx.Configure_Direct_Peers((unsigned short)SpawnConfig.ListenPort); } - // The local seat leads the player list, so everybody after it is another machine. + // The local seat leads the list, so everybody after it is another machine. for (int index = 1; index < Session.Players.Count(); index++) { Ipx.Add_Peer(Session.Players[index]->Address); } @@ -337,10 +304,9 @@ static bool Spawner_Wire_Network(void) /// -/// Resumes the saved game a launch file names. The save carries the kind of game, the options -/// and the houses, and the expansion comes back with it rather than from the file. A game -/// played alone takes nothing else from the file; one against other machines takes its seats, -/// which name the same people at the addresses their machines answer on now. +/// Resumes the saved game a launch file names. The save carries the game and its houses; a +/// match against other machines takes its seats from the file, at the addresses they answer +/// on now. /// /// Set when the save loads, so the caller starts no scenario. /// bool; Is the saved game running? @@ -359,19 +325,15 @@ static bool Spawner_Resume(bool & gameloaded) return(Spawner_Refuse("The saved game was made by another version of the game.")); } - /* - * A game the menu arranged over the local network is nothing a client launched, so no - * launch file describes the match such a save would resume. - */ + // No client launches a game the menu arranged, so no file describes such a match. GameType type = (GameType)info.Get_Game_Type(); if (type == GAME_IPX) { return(Spawner_Refuse("Resuming a game arranged over the local network is not supported.")); } /* - * Against other machines the save restores the houses while the file seats the same - * people afresh, so the seats are judged and the network opened before the save is - * read, and the queue is told to shake hands again at the resumed frame. + * The save restores the houses while the file seats the same people afresh, so the + * network is open before the load and the queue shakes hands at the resumed frame. */ if (type == GAME_INTERNET) { std::string fault; @@ -429,10 +391,7 @@ static bool Spawner_Setup_Campaign(void) Session.CampaignCDifficulty = (DiffType)SpawnConfig.CampaignCDifficulty; Scen->Campaign = (CampaignType)SpawnConfig.CampaignID; - /* - * The flags are left where a mission carries them over from the one before it, since a - * fresh launch has nothing else of its own to carry. - */ + // A fresh launch carries nothing over from an earlier mission, so the file's flags stand in. new (&Environment) EnvironmentClass; for (int index = 0; index < SpawnerConfigClass::GLOBAL_FLAG_COUNT; index++) { Environment.Globals[index] = SpawnConfig.GlobalFlags[index]; @@ -445,18 +404,14 @@ static bool Spawner_Setup_Campaign(void) /// -/// Assembles the session a launch asks for, in place of what the skirmish or the lobby dialog -/// commits when a player presses OK. +/// Assembles the session a launch asks for, in place of what a setup dialog commits. /// static void Spawner_Setup_Session(void) { Session.Type = SpawnConfig.Launch_Type() == SpawnerConfigClass::LaunchType::Multiplayer ? GAME_INTERNET : GAME_SKIRMISH; - /* - * Against other machines the random numbers must fall the same way everywhere, and no - * lobby is there to hand a seed around, so the file's own is taken exactly as written. - */ + // Every machine must draw alike, and no lobby is there to hand a seed around. if (Session.Type == GAME_INTERNET) { Seed = SpawnConfig.Seed; } @@ -484,7 +439,6 @@ void Spawner_Request(void) /// /// Did a client ask the game to launch what its file describes? /// -/// bool; Was a launch requested on the command line? bool Spawner_Is_Requested(void) { return(SpawnRequested); @@ -492,11 +446,9 @@ bool Spawner_Is_Requested(void) /// -/// Is the game being played the one a launch file described? -/// This answers for the game itself rather than for the command line, so a path that must -/// leave a client's choices alone can tell that a launch file made them. +/// Is the game being played the one a launch file described? A path that must leave a +/// client's choices alone asks this rather than the command line. /// -/// bool; Was the game assembled from a launch file? bool Spawner_Is_Active(void) { return(SpawnConsumed); @@ -504,9 +456,8 @@ bool Spawner_Is_Active(void) /// -/// Reads the launch file and assembles the game it describes. -/// This stands in place of the menu, and answers false once the game it launched has ended, -/// so that the process leaves rather than showing a menu the client never meant to show. +/// Reads the launch file and assembles the game it describes, in place of the menu. Answers +/// false once that game has ended, so the process leaves instead of showing one. /// /// Set when the launch resumed a saved game. /// bool; Is a game ready to start? @@ -525,16 +476,9 @@ bool Spawner_Prepare(bool & gameloaded) ini.Load(file, false); SpawnConfig.Read_INI(ini); - /* - * A launch is spent as soon as it is read, so that a refusal ends the process the same - * way a finished game does. - */ SpawnConsumed = true; - /* - * The countries a seat may name are the rules', so the roster is read before the seats - * are judged, as the menu paths setting up a game do. - */ + // A seat names its country by the rules' own numbering, so the roster is read first. Prepare_Side_Roster(); switch (SpawnConfig.Launch_Type()) { @@ -569,9 +513,7 @@ bool Spawner_Prepare(bool & gameloaded) DebugString("[Spawner] Launching %s with session identity %08x.\n", Scen->ScenarioName, SpawnConfig.Session_Identity_CRC()); - /* - * The network comes last, once the session it will carry is assembled whole. - */ + // The network comes last, once the session it will carry is assembled whole. if (Session.Type == GAME_INTERNET && !Spawner_Wire_Network()) { return(false); } diff --git a/code/spawnerconfig.cpp b/code/spawnerconfig.cpp index 8a920f9b..0413e449 100644 --- a/code/spawnerconfig.cpp +++ b/code/spawnerconfig.cpp @@ -34,10 +34,6 @@ char const * const SETTINGS = "Settings"; /// /// Reads a string entry. /// -/// The launch file being read. -/// The section to read from. -/// The key to read. -/// What an absent key means. /// The value written, or the fallback. std::string Read_Text(INIClass const & ini, char const * section, char const * entry, std::string const & fallback) { @@ -52,10 +48,6 @@ std::string Read_Text(INIClass const & ini, char const * section, char const * e /// /// Reads one of the eight numbered entries a section names its seats by. /// -/// The launch file being read. -/// The section holding the numbered entries. -/// Which seat to read, counted from zero. -/// What an absent entry means. /// The value written for that seat. int Read_Slot_Int(INIClass const & ini, char const * section, int slot, int fallback) { @@ -67,7 +59,6 @@ int Read_Slot_Int(INIClass const & ini, char const * section, int slot, int fall /// /// Names the fault that refuses a launch. /// -/// Where to leave the sentence describing the fault. /// A printf style description of the fault. /// false, so a caller can name a fault and refuse in one statement. bool Fault(std::string & fault, char const * format, ...) @@ -87,14 +78,10 @@ bool Fault(std::string & fault, char const * format, ...) /// -/// Reads the match's seats. -/// A seat is human because the file wrote a section for it, so an unwritten section is what -/// makes a seat a computer player. The seats are read in the order the file names them, -/// then sorted into the order their houses will be created in -- humans by ascending color, -/// then computer players -- because everything naming a seat by position afterwards means -/// that order. +/// Reads the match's seats. A seat is human because the file wrote a section for it, and the +/// seats are then sorted into the order their houses will be created in, because everything +/// naming a seat by position afterwards means that order. /// -/// The launch file to read. void SpawnerConfigClass::Read_Slots(INIClass const & ini) { std::array staging; @@ -118,12 +105,8 @@ void SpawnerConfigClass::Read_Slots(INIClass const & ini) } /* - * The houses are created humans first, in the order their colors fall. Sorting here is - * what makes a seat's index the index of the house it becomes. Every machine writes - * its own file with itself first, so the order must come from what the seats say and - * never from where the file said it: names break a color tie, which a well-formed - * launcher never writes but a hand-edited file otherwise turns into a different match - * on every machine. + * Sorting by color makes a seat's index the house it becomes. Every machine writes its + * own file with itself first, so a name breaks a color tie rather than file order. */ std::vector humans; std::vector rest; @@ -195,10 +178,9 @@ void SpawnerConfigClass::Read_Slots(INIClass const & ini) /// -/// What kind of game this file asks for. Resuming a saved game answers the question by -/// itself, because the save carries the type, the options and the houses the game had. +/// What kind of game this file asks for. Resuming a saved game answers by itself, since the +/// save carries the type, the options and the houses. /// -/// The kind of game to launch. SpawnerConfigClass::LaunchType SpawnerConfigClass::Launch_Type(void) const { if (LoadSaveGame) { @@ -215,13 +197,10 @@ SpawnerConfigClass::LaunchType SpawnerConfigClass::Launch_Type(void) const /// -/// The identity of the match this file asks for. -/// This gathers every value the course of the match depends upon, and nothing that is only -/// shown to a player, so that two machines handed the same match agree on the number while -/// a difference in what either displays cannot move it. The version leads, since the same -/// file read by two different readings is not the same match. +/// The identity of the match this file asks for. It gathers every value the course of the +/// match depends upon and nothing merely shown, so two machines handed the same match agree. +/// The version leads: one file under two readings is not one match. /// -/// The identity of the configured match. int SpawnerConfigClass::Session_Identity_CRC(void) const { CRCEngine crc; @@ -279,11 +258,9 @@ int SpawnerConfigClass::Session_Identity_CRC(void) const /// -/// The difficulty a seat is played at, given the one it asked for. -/// A client may offer more easy settings than the game holds; the easiest one the game has -/// is what any easier request comes to. A seat asking for nothing keeps the session default. +/// The difficulty a seat is played at. A client may offer more easy settings than the game +/// holds, and any easier request comes to the easiest one it has. /// -/// The difficulty the launch file asked for. /// The difficulty to play the seat at, or -1 for the session default. int SpawnerConfigClass::Playable_Handicap(int asked) { @@ -298,12 +275,9 @@ int SpawnerConfigClass::Playable_Handicap(int asked) /// -/// Judges whether this reading describes a game that can be played. -/// The countries and colors are handed in because they are the rules', settled only once the -/// game has loaded them, while everything else a launch is refused for is here in the file. +/// Judges whether this reading describes a game that can be played. The countries and colors +/// are handed in because they are the rules', settled only once the game has loaded them. /// -/// How many countries the rules declared. -/// How many colors a house may be given. /// Where to leave the sentence describing the first fault found. /// bool; Can the game this file describes be played? bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fault) const @@ -324,19 +298,13 @@ bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fa bool human = slot.Occupancy == OccupancyType::Human; - /* - * A computer seat may leave its country and its color to the game, as a game set up - * from the menu does. A person's seat names both. - */ + // A computer seat may leave its country and color to the game; a person's seat names both. if ((human || slot.Country != -1) && (slot.Country < 0 || slot.Country >= countries)) { return(Fault(fault, "Seat %d is given country %d, and there are %d to choose from.", index + 1, slot.Country, countries)); } - /* - * A shared color is not a fault: co-op matches deliberately give one team's houses - * the same color. Only a color the game has no scheme for refuses. - */ + // A co-op team shares one color deliberately; only a color with no scheme refuses. if ((human || slot.Color != -1) && (slot.Color < 0 || slot.Color >= colors)) { return(Fault(fault, "Seat %d is given color %d, and there are %d to choose from.", index + 1, slot.Color, colors)); @@ -360,10 +328,8 @@ bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fa } /* - * Against other machines a person's name is what breaks a tie between two seats of one - * color, and what tells one seat from another at the connection it is reached on. An - * unnamed person, or two under one name, leaves the match without the single seat order - * every machine has to arrive at from its own file. + * A name breaks a tie between two seats of one color, so without one every machine + * reading its own file would arrive at a different seat order. */ if (human && multiplayer) { if (slot.Name.empty()) { @@ -385,11 +351,9 @@ bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fa /// -/// Reads what the CnCNet client asked the game to launch. Reading cannot fail: every key has a -/// settled meaning when absent, a value the reader cannot make sense of keeps that meaning, -/// and a key the game does not know is passed over. +/// Reads what the CnCNet client asked the game to launch. Reading cannot fail: an unwritten +/// key has a settled meaning, a nonsense value keeps it, and an unknown key is passed over. /// -/// The launch file to read. void SpawnerConfigClass::Read_INI(INIClass const & ini) { IsCampaign = ini.Get_Bool(SETTINGS, "IsSinglePlayer", IsCampaign); @@ -443,9 +407,8 @@ void SpawnerConfigClass::Read_INI(INIClass const & ini) ConnTimeout = ini.Get_Int(SETTINGS, "ConnTimeout", ConnTimeout); /* - * One key carries the port twice: a machine listens on it, and a tunnel names that - * machine by it. They part company only when the key is absent, where a tunnel has no - * name to go by while the game still has a port to listen on. + * One key carries the port twice: a machine listens on it and a tunnel names the machine + * by it. Absent, a tunnel has no name while the game still has a port to listen on. */ TunnelId = ini.Get_Int(SETTINGS, "Port", TunnelId); ListenPort = ini.Get_Int(SETTINGS, "Port", ListenPort); diff --git a/code/spawnerconfig.h b/code/spawnerconfig.h index ec68357f..835ab2a9 100644 --- a/code/spawnerconfig.h +++ b/code/spawnerconfig.h @@ -17,36 +17,21 @@ class INIClass; /* - * What a client asked the game to launch. - * - * The file this is read from belongs to the CnCNet client, not to the game: its spelling, its - * defaults and its shape are fixed by what that client already writes. This class is the game's - * own reading of it, so that nothing downstream has to parse anything, and so that the - * values a match's outcome depends upon can be told apart from the ones only shown to a - * player. Reading cannot fail; whether what was read describes a playable game is judged - * where the launch is attempted, against the tables the game has loaded by then. + * What a client asked the game to launch. The file's spelling, defaults and shape belong to + * the CnCNet client; reading cannot fail, and what was read is judged where a launch begins. */ class SpawnerConfigClass { public: - /* - * The version of this reading. It counts changes to what the game makes of a launch - * file, never changes to the file's own vocabulary, which belongs to the client. - */ + // Counts changes to what the game makes of a launch file, never the file's own vocabulary. static constexpr int SCHEMA_VERSION = 1; - /* - * A match holds this many seats, one per house it may hold. The scenario flags are - * the fifty the engine keeps; a client allowing more writes ones the game passes over. - */ + // One seat per house a match may hold, and the fifty scenario flags the engine keeps. static constexpr int SLOT_COUNT = 8; static constexpr int GLOBAL_FLAG_COUNT = 50; - /* - * What kind of game the file asks for. Resume overrides the rest: a saved game - * carries its own type, options and houses, so nothing else in the file decides them. - */ + // Resume overrides the rest: a saved game carries its own type, options and houses. enum class LaunchType { Skirmish, Campaign, @@ -54,10 +39,7 @@ class SpawnerConfigClass Resume, }; - /* - * Who occupies a seat. A launch file marks a seat human by writing a section for it, - * so an unwritten section is what makes a seat a computer player or nothing at all. - */ + // A file marks a seat human by writing a section for it; an unwritten one is a computer. enum class OccupancyType { Empty, Human, @@ -65,10 +47,8 @@ class SpawnerConfigClass }; /* - * One seat of the match. The seats are held in the order the houses are created in -- - * humans first by ascending color, then computer players -- so that a seat's index is - * the index of the house it becomes, which is what alliances and start positions are - * named by. + * One seat of the match, held in the order the houses are created in, so a seat's index + * is the house it becomes -- which is what alliances and start positions name. */ struct SlotType { OccupancyType Occupancy = OccupancyType::Empty; @@ -87,27 +67,19 @@ class SpawnerConfigClass LaunchType Launch_Type(void) const; int Session_Identity_CRC(void) const; - /* - * Reading cannot fail, so what was read is judged separately, against the tables - * the game has loaded by the time a launch is attempted. Those are handed in rather - * than reached for, so that a reading can be judged without the game running. - */ + // The rules' tables are handed in, so a reading can be judged without the game running. bool Is_Playable(int countries, int colors, std::string & fault) const; static int Playable_Handicap(int asked); - /* - * What kind of game to start. - */ + // What kind of game to start. bool IsCampaign = false; bool IsHost = false; int CampaignID = -1; int Tournament = 0; int GameID = 0; - /* - * The scenario and the saved game. - */ + // The scenario and the saved game. std::string ScenarioName = "spawnmap.ini"; std::string MapName; std::string MapHash; @@ -117,9 +89,7 @@ class SpawnerConfigClass int NextCampaignAutoSave = 0; int NextSkirmishAutoSave = 0; - /* - * The options every house plays under. - */ + // The options every house plays under. bool Bases = true; int Credits = 10000; bool BridgeDestroy = true; @@ -142,11 +112,7 @@ class SpawnerConfigClass int CampaignCDifficulty = 1; std::array GlobalFlags = {}; - /* - * Where the machines reach one another. A launcher settles these with the service - * that arranged the match, so they are the one part of the network it decides; the - * timing the machines keep is the game's own and is not read from a launch file. - */ + // Where the machines reach one another, settled by whatever service arranged the match. int ReconnectTimeout = 2400; int ConnTimeout = 3600; int TunnelId = 0; @@ -154,9 +120,7 @@ class SpawnerConfigClass std::string TunnelAddress = "0.0.0.0"; int TunnelPort = 0; - /* - * What a player is shown. - */ + // What a player is shown. bool QuickMatch = false; bool SkipScoreScreen = false; bool WriteStatistics = false; @@ -172,9 +136,7 @@ class SpawnerConfigClass int CustomLoadScreenY = 0; std::string DifficultyName; - /* - * The match's seats, and where in them the machine reading the file sits. - */ + // The match's seats, and where in them the machine reading the file sits. std::array Slots; int HumanCount = 0; int LocalSlot = 0; diff --git a/code/theme.cpp b/code/theme.cpp index 07c32ad5..37a8bd59 100644 --- a/code/theme.cpp +++ b/code/theme.cpp @@ -316,10 +316,7 @@ ThemeType ThemeClass::Next_Song(ThemeType theme) const { int i; - /* - * A score that repeats is played again, but only while the game actually holds it. One - * it does not would otherwise answer this forever, and nothing else would ever be picked. - */ + // A score the game does not hold would repeat forever, and nothing else be picked. if ((unsigned)theme >= (unsigned)Themes.Count() || !Themes[theme]->Available || (!Themes[theme]->Repeat && !IsRepeat)) { if (IsShuffle == true) { @@ -437,9 +434,8 @@ int ThemeClass::Play_Song(ThemeType theme) Audio.StreamLowImpact = false; /* - * A score that would not start is not the one playing. Recording it as the - * current one silences the game for good: stopping a score that never - * started does nothing, so the one that failed would stay current. + * Stopping a score that never started does nothing, so recording one that failed to + * start as the current score would silence the game for good. */ if (Current == -1) { DebugString("Theme::PlaySong(%d) - Unavailable\n", theme); diff --git a/tests/spawner/spawncontract.cpp b/tests/spawner/spawncontract.cpp index 9d220248..5be905bb 100644 --- a/tests/spawner/spawncontract.cpp +++ b/tests/spawner/spawncontract.cpp @@ -287,9 +287,8 @@ int main(void) } /* - * Every machine writes its own file with itself first, so a color tie must be broken - * by what the seats say rather than where the file said it, or two machines would - * assemble two different matches. + * Every machine writes its own file with itself first, so a color tie must be broken by + * what the seats say rather than where the file said it, or the matches would differ. */ { char const view_a[] = From f04bac97fe6eb63c0fdfe6a2bdce8ab29d061ec6 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 20:20:45 +0300 Subject: [PATCH 13/35] Condense the client launch change records --- manual/changes/campaign-spawn-launch.md | 25 ------------------ manual/changes/chosen-start-positions.md | 23 ----------------- manual/changes/client-driven-launch.md | 31 +++++++++++++---------- manual/changes/multiplayer-save-button.md | 16 ------------ manual/changes/network-spawn-assembly.md | 21 --------------- manual/changes/resume-spawn-launch.md | 26 ------------------- manual/changes/saved-games-folder.md | 9 +++---- manual/changes/spawn-ini-reader.md | 12 --------- 8 files changed, 21 insertions(+), 142 deletions(-) delete mode 100644 manual/changes/campaign-spawn-launch.md delete mode 100644 manual/changes/chosen-start-positions.md delete mode 100644 manual/changes/multiplayer-save-button.md delete mode 100644 manual/changes/network-spawn-assembly.md delete mode 100644 manual/changes/resume-spawn-launch.md delete mode 100644 manual/changes/spawn-ini-reader.md diff --git a/manual/changes/campaign-spawn-launch.md b/manual/changes/campaign-spawn-launch.md deleted file mode 100644 index eda1f37b..00000000 --- a/manual/changes/campaign-spawn-launch.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: Launch a campaign mission from a client's launch file -category: feature -release: 0.2.0 -targets: -- type: format - id: spawn-ini - effect: changed -credit: [ZivDero] ---- - -A launch file marked as a single-player game now starts the mission it names. The campaign -the mission belongs to, the two difficulties, and the scenario flags a client carries over -from an earlier mission all reach the game's own state before the mission is read, so a -mission launched partway through a chain begins in the state the missions before it left. - -The two difficulties are named apart, so a client may combine all nine pairings where the -menu offers its three coupled ones. To make that possible the pair became session state: -whichever path starts a campaign sets it, the menu deriving the same pair the mission -reader used to compute for itself, and the mission reader now takes the pair from the -session. A restart or the next mission keeps it, as before. - -A dead difficulty table fell out of the campaign menu on the way. It was overwritten by the -mission reader on every start, and two of its five cases answered settings the game's -three-setting slider cannot reach. Live behavior is unchanged. diff --git a/manual/changes/chosen-start-positions.md b/manual/changes/chosen-start-positions.md deleted file mode 100644 index 108f8cd9..00000000 --- a/manual/changes/chosen-start-positions.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: Seat a house at the start position it asked for -category: feature -release: 0.2.0 -targets: -- type: format - id: save-games - effect: changed -credit: [ZivDero] ---- - -A house may now be placed at a named map start position rather than one the game picks. A -position is named by the map's own waypoint number, so a map that declares some of its -first eight waypoints and not others keeps the numbering it wrote: an undeclared waypoint -stays a gap instead of shifting the positions after it. A position the map does not declare, -or one another house has already taken, falls back to the game's own choice. - -A game that names no positions is placed exactly as before, drawing the same random numbers -in the same order. - -The house record in a saved game gained the start position the house was placed at. Saved -games from other versions were already refused; within this unreleased development cycle, -saves made before this change do not interoperate with builds made after it. diff --git a/manual/changes/client-driven-launch.md b/manual/changes/client-driven-launch.md index ff1b501c..92a32ae1 100644 --- a/manual/changes/client-driven-launch.md +++ b/manual/changes/client-driven-launch.md @@ -1,5 +1,5 @@ --- -title: Launch a game from a client's launch file +title: Launch and play a game from a client's launch file category: feature release: 0.2.0 targets: @@ -9,20 +9,25 @@ targets: - type: format id: spawn-ini effect: added +- type: format + id: save-games + effect: changed credit: [ZivDero] --- -Starting the game with `-SPAWN` now plays the skirmish that `SPAWN.INI` describes. The -startup movies and the main menu are both skipped, the map list the menu would scan is not -read, and the game exits when the match ends rather than returning to a menu the client -never meant to show. The settings file a client manages is no longer written back to while -a launch is in progress. +Starting the game with `-SPAWN` now plays the match `SPAWN.INI` describes: a skirmish +against seated computer players, a campaign mission, a game against other machines carried +over a CnCNet tunnel or straight between them, or any of those resumed from a saved game. +The startup movies, the main menu and the map list the menu would scan are all skipped, the +settings file a client manages is not written back to, and the game exits when the match +ends. The launch file's own page lists what the game takes from a file and what it does not. -The file names the options every house plays under, who is playing, each seat's country, -color, difficulty and start position, and the alliances between them. Anything the file asks -for that the game cannot honor is listed on the launch file's own page. +A house can now be seated at a start position the file names, by the map's own waypoint +number; a game naming none is placed exactly as before, drawing the same random numbers in +the same order. The house record in a saved game gained that position, and the options +dialog of a game against other machines gained the Save Game button its synchronized save +always lacked. -The node the player and lobby lists are made of now initializes itself rather than starting -as whatever the heap last held, and carries the start position, difficulty and alliances a -seat asked for. No packet the game sends and no save it writes carries that node, so -neither format changes, and a game set up from the menu is assembled exactly as before. +The campaign handicap pair became session state, so a client may combine all nine pairings +where the menu offers its three coupled ones, and a restart or the next mission keeps the +pair. A game set up from the menu is assembled exactly as before. diff --git a/manual/changes/multiplayer-save-button.md b/manual/changes/multiplayer-save-button.md deleted file mode 100644 index d0efdfb5..00000000 --- a/manual/changes/multiplayer-save-button.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -title: Offer the save in a network game's options -category: feature -release: 0.2.0 -targets: -- type: format - id: save-games - effect: changed -credit: [ZivDero] ---- - -The options dialog of a game against other machines now offers Save Game. The game has -long known how to make the save — one press submits the synchronized command and every -machine writes its own copy at the same frame — but the network dialogs never carried the -button that asks for it. It greys out once a player has left the match, as the saving -rules always said. diff --git a/manual/changes/network-spawn-assembly.md b/manual/changes/network-spawn-assembly.md deleted file mode 100644 index a01ec91b..00000000 --- a/manual/changes/network-spawn-assembly.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: Play a game against other machines from a client's launch file -category: feature -release: 0.2.0 -targets: -- type: format - id: spawn-ini - effect: changed -credit: [ZivDero] ---- - -A launch file describing a game against other machines now plays it. The match is -assembled whole from the file — the options, the seats, the alliances, the start -positions and the addresses — and carried over the network the file names: through a -CnCNet tunnel when one is given, where every machine is known by its tunnel number, or -straight between the machines at the addresses they wrote for one another. - -Such a match is held to two rules a skirmish is not: every person must be named, and no -two may be named the same. The seat order the machines have to agree on is settled by -color and then by name, so a match missing those names is not one match. Sharing a color -is still allowed. diff --git a/manual/changes/resume-spawn-launch.md b/manual/changes/resume-spawn-launch.md deleted file mode 100644 index ed775c4d..00000000 --- a/manual/changes/resume-spawn-launch.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: Resume a saved game from a client's launch file -category: feature -release: 0.2.0 -targets: -- type: format - id: spawn-ini - effect: changed -credit: [ZivDero] ---- - -A launch file with `LoadSaveGame=yes` now resumes the saved game it names. The save carries -the kind of game it was, the options it was played under and the houses that played it, so -nothing else in the file is consulted — which is what clients already write, their resume -files naming little beyond the save. - -A save from a game against other machines resumes too: every machine loads its own copy of -the synchronized save while the launch file seats the same people at the addresses they -answer on now, a player who does not return leaves their house to the computer, and the -machines compare the games they loaded before play goes on. A save the folder does not -hold, one made by another version of the game, one whose seats disagree with the file, and -one from a game the menu arranged over the local network each refuse the launch with the -reason shown. - -Which saved game a launch file names is now part of the identity two machines compare a -match by, since a resume is a match of its own. diff --git a/manual/changes/saved-games-folder.md b/manual/changes/saved-games-folder.md index b059855e..e3dfd3d7 100644 --- a/manual/changes/saved-games-folder.md +++ b/manual/changes/saved-games-folder.md @@ -11,15 +11,12 @@ credit: [ZivDero] Saved games now live in a `Saved Games` folder — beside the game, or inside the user data directory when one is named — created the first time the game asks for a saved game. Every -save, load, listing and deletion names that folder, and it is deliberately not one of the -folders the game searches: a saved game is written, so it is named rather than found. That -is where the launchers which browse saved games already look, which is what makes resuming -one from a launch file possible. +save, load, listing and deletion names that folder rather than searching for it, which is +where the launchers that browse saved games already look. Saves made by earlier builds sit beside the game and are no longer listed; moving the `.SAV` files into `Saved Games` restores them. Following a load, the campaign difficulty pair now comes from the save rather than from the menu's difficulty setting, so the next mission of a resumed campaign is played at the -difficulty the campaign was saved at. Before, it silently took whatever the setting happened -to say at the time. +difficulty it was saved at. diff --git a/manual/changes/spawn-ini-reader.md b/manual/changes/spawn-ini-reader.md deleted file mode 100644 index 93a717bf..00000000 --- a/manual/changes/spawn-ini-reader.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Add a headless reader for client launch files -category: internal -release: 0.2.0 -targets: [] -credit: [ZivDero] ---- - -The engine gains a tested reading of the SPAWN.INI launch file the CnCNet -client writes, with the vocabulary and defaults that client already uses. -Nothing launches from it yet, so no player- or modder-visible behavior -changes. From 74bd1d09cc33fe5f2fbfa1aa0767d1fb89d7d313 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 20:25:26 +0300 Subject: [PATCH 14/35] Credit the spawners the launch file vocabulary comes from --- manual/changes/client-driven-launch.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/manual/changes/client-driven-launch.md b/manual/changes/client-driven-launch.md index 92a32ae1..10e45642 100644 --- a/manual/changes/client-driven-launch.md +++ b/manual/changes/client-driven-launch.md @@ -12,7 +12,7 @@ targets: - type: format id: save-games effect: changed -credit: [ZivDero] +credit: [ZivDero, Rampastring, dkeeton, FunkyFr3sh, CCHyper, Belonit, hifi, Iran] --- Starting the game with `-SPAWN` now plays the match `SPAWN.INI` describes: a skirmish @@ -31,3 +31,7 @@ always lacked. The campaign handicap pair became session state, so a client may combine all nine pairings where the menu offers its three coupled ones, and a restart or the next mission keeps the pair. A game set up from the menu is assembled exactly as before. + +The launch file's vocabulary is not this project's. It was settled by the CnCNet client and +by the spawners written for it before this one, and the game reads it as they wrote it; the +people who built those are credited above alongside this reading of it. From b665f7665948ddfb0e924bf8998a91da3c4ceee7 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 20:31:44 +0300 Subject: [PATCH 15/35] Shorten the client launch change records --- manual/changes/client-driven-launch.md | 25 +++++++------------------ manual/changes/saved-games-folder.md | 11 ++++------- 2 files changed, 11 insertions(+), 25 deletions(-) diff --git a/manual/changes/client-driven-launch.md b/manual/changes/client-driven-launch.md index 10e45642..56cfe7bb 100644 --- a/manual/changes/client-driven-launch.md +++ b/manual/changes/client-driven-launch.md @@ -15,23 +15,12 @@ targets: credit: [ZivDero, Rampastring, dkeeton, FunkyFr3sh, CCHyper, Belonit, hifi, Iran] --- -Starting the game with `-SPAWN` now plays the match `SPAWN.INI` describes: a skirmish -against seated computer players, a campaign mission, a game against other machines carried -over a CnCNet tunnel or straight between them, or any of those resumed from a saved game. -The startup movies, the main menu and the map list the menu would scan are all skipped, the -settings file a client manages is not written back to, and the game exits when the match -ends. The launch file's own page lists what the game takes from a file and what it does not. +Starting the game with `-SPAWN` now plays the match `SPAWN.INI` describes: a skirmish, a +campaign mission, a game against other machines through a CnCNet tunnel or straight between +them, or any of those resumed from a saved game. The startup movies and the menu are +skipped, and the game exits when the match ends. -A house can now be seated at a start position the file names, by the map's own waypoint -number; a game naming none is placed exactly as before, drawing the same random numbers in -the same order. The house record in a saved game gained that position, and the options -dialog of a game against other machines gained the Save Game button its synchronized save -always lacked. +A game against other machines can now be saved from its options dialog. A game set up from +the menu is assembled as before. -The campaign handicap pair became session state, so a client may combine all nine pairings -where the menu offers its three coupled ones, and a restart or the next mission keeps the -pair. A game set up from the menu is assembled exactly as before. - -The launch file's vocabulary is not this project's. It was settled by the CnCNet client and -by the spawners written for it before this one, and the game reads it as they wrote it; the -people who built those are credited above alongside this reading of it. +The people credited here wrote the earlier spawners this one follows. diff --git a/manual/changes/saved-games-folder.md b/manual/changes/saved-games-folder.md index e3dfd3d7..63d2406e 100644 --- a/manual/changes/saved-games-folder.md +++ b/manual/changes/saved-games-folder.md @@ -9,14 +9,11 @@ targets: credit: [ZivDero] --- -Saved games now live in a `Saved Games` folder — beside the game, or inside the user data -directory when one is named — created the first time the game asks for a saved game. Every -save, load, listing and deletion names that folder rather than searching for it, which is -where the launchers that browse saved games already look. +Saved games now live in a `Saved Games` folder, beside the game or inside the user data +directory when one is named. Every save, load, listing and deletion names that folder, which +is where launchers that browse saved games look. Saves made by earlier builds sit beside the game and are no longer listed; moving the `.SAV` files into `Saved Games` restores them. -Following a load, the campaign difficulty pair now comes from the save rather than from the -menu's difficulty setting, so the next mission of a resumed campaign is played at the -difficulty it was saved at. +After a load, the campaign difficulty now comes from the save rather than the menu setting. From e877e071a366069723701b6c6f75ac8facaa0a0b Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 21:10:13 +0300 Subject: [PATCH 16/35] Stop reading the launch keys the game will never take --- code/spawner.cpp | 32 ++++++++++++++-------------- code/spawnerconfig.cpp | 4 ---- code/spawnerconfig.h | 3 --- manual/content/formats/spawn-ini.md | 22 ++++++++++--------- manual/data/ini-read-exclusions.yaml | 2 +- 5 files changed, 29 insertions(+), 34 deletions(-) diff --git a/code/spawner.cpp b/code/spawner.cpp index d66310e5..872c668f 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -232,29 +232,29 @@ static void Spawner_Bind_Options(void) /* * Read, not honored. Every field the reader carries is bound above, consumed to refuse a * launch, or named here, so a new field forces a decision rather than a silent omission. + * A key this game will never take is not read at all, so everything below awaits the + * feature that will honor it. * - * IsHost, Tournament, GameID - the client's own bookkeeping of the match. * MapName - shown while loading; bound with the scenario below. - * MapHash - the client checks that the machines hold one map. + * IsCampaign, LoadSaveGame, + * SaveGameName - read to decide the kind of launch and name the save. + * Slots[].IsSpectator - read to refuse a launch. + * IsHost - which machine hosts matters once one can leave. + * Tournament, GameID, + * WriteStatistics - naming a match and reporting how it went. * AutoSaveInterval, * NextCampaignAutoSave, - * NextSkirmishAutoSave - saving by itself is not wired up. - * BuildOffAlly - the game has no such option to give it to. - * ReconnectTimeout, ConnTimeout - how patiently to wait for a machine that has gone - * quiet is part of the timing the game keeps for itself. + * NextSkirmishAutoSave - saving on a schedule. + * BuildOffAlly, AttackNeutralUnits, + * ScrapMetal, AutoSurrender, + * ContinueWithoutHumans - options the game has no setting of its own for yet. + * CoachMode - watching and advising rather than playing. * QuickMatch, SkipScoreScreen, - * WriteStatistics, CoachMode, - * AutoSurrender, AttackNeutralUnits, - * ScrapMetal, ContinueWithoutHumans, - * PlayMoviesInMultiplayer - behaviors no part of this game offers yet. + * PlayMoviesInMultiplayer, * CustomLoadScreen, * CustomLoadScreenX, - * CustomLoadScreenY - the loading backdrop belongs to the campaign path. - * DifficultyName - shown, never played by. - * IsCampaign, LoadSaveGame, - * SaveGameName - read to decide what kind of launch this is, and to - * name the saved game a resume restores. - * Slots[].IsSpectator - read to refuse a launch. + * CustomLoadScreenY, + * DifficultyName - what a player is shown around the match. */ } diff --git a/code/spawnerconfig.cpp b/code/spawnerconfig.cpp index 0413e449..ede08f1e 100644 --- a/code/spawnerconfig.cpp +++ b/code/spawnerconfig.cpp @@ -364,7 +364,6 @@ void SpawnerConfigClass::Read_INI(INIClass const & ini) ScenarioName = Read_Text(ini, SETTINGS, "Scenario", ScenarioName); MapName = Read_Text(ini, SETTINGS, "UIMapName", MapName); - MapHash = Read_Text(ini, SETTINGS, "MapHash", MapHash); LoadSaveGame = ini.Get_Bool(SETTINGS, "LoadSaveGame", LoadSaveGame); @@ -403,9 +402,6 @@ void SpawnerConfigClass::Read_INI(INIClass const & ini) CampaignDifficulty = ini.Get_Int(SETTINGS, "DifficultyModeHuman", CampaignDifficulty); CampaignCDifficulty = ini.Get_Int(SETTINGS, "DifficultyModeComputer", CampaignCDifficulty); - ReconnectTimeout = ini.Get_Int(SETTINGS, "ReconnectTimeout", ReconnectTimeout); - ConnTimeout = ini.Get_Int(SETTINGS, "ConnTimeout", ConnTimeout); - /* * One key carries the port twice: a machine listens on it and a tunnel names the machine * by it. Absent, a tunnel has no name while the game still has a port to listen on. diff --git a/code/spawnerconfig.h b/code/spawnerconfig.h index 835ab2a9..be632f29 100644 --- a/code/spawnerconfig.h +++ b/code/spawnerconfig.h @@ -82,7 +82,6 @@ class SpawnerConfigClass // The scenario and the saved game. std::string ScenarioName = "spawnmap.ini"; std::string MapName; - std::string MapHash; bool LoadSaveGame = false; std::string SaveGameName; int AutoSaveInterval = 10800; @@ -113,8 +112,6 @@ class SpawnerConfigClass std::array GlobalFlags = {}; // Where the machines reach one another, settled by whatever service arranged the match. - int ReconnectTimeout = 2400; - int ConnTimeout = 3600; int TunnelId = 0; int ListenPort = 1234; std::string TunnelAddress = "0.0.0.0"; diff --git a/manual/content/formats/spawn-ini.md b/manual/content/formats/spawn-ini.md index d0cabd26..1a2b095c 100644 --- a/manual/content/formats/spawn-ini.md +++ b/manual/content/formats/spawn-ini.md @@ -159,13 +159,15 @@ easiest one the game does have. ## What the game does not take from a launch file -The file's timing keys are not read at all. How far ahead the machines run and how often -they exchange their orders is the game's own business, and no launch file changes it. - -These keys are read but do not yet change anything, because the game has no such behavior -to give them to: `Tournament`, `GameID`, `MapHash`, `BuildOffAlly`, the automatic-save -scheduling keys, `QuickMatch`, `SkipScoreScreen`, `WriteStatistics`, `CoachMode`, `AutoSurrender`, -`AttackNeutralUnits`, `ScrapMetal`, `ContinueWithoutHumans`, `PlayMoviesInMultiplayer`, -`CustomLoadScreen`, `CustomLoadScreenPos`, `DifficultyName`, and the two timeout keys, -`ReconnectTimeout` and `ConnTimeout` — how patiently to wait for a machine that has gone -quiet is part of the timing the game keeps for itself. +The timing keys are not read at all, `ReconnectTimeout` and `ConnTimeout` among them. How +far ahead the machines run, how often they exchange their orders, and how long they wait for +one that has gone quiet are the game's own business, and no launch file changes them. +`MapHash` is not read either: the machines compare the games they have loaded before play +begins, which settles the same question for themselves. + +These keys are read but do not change anything yet, each awaiting the behavior that will +honor it: `IsHost`, `Tournament`, `GameID`, `WriteStatistics`, the automatic-save scheduling +keys, `BuildOffAlly`, `AttackNeutralUnits`, `ScrapMetal`, `AutoSurrender`, +`ContinueWithoutHumans`, `CoachMode`, `QuickMatch`, `SkipScoreScreen`, +`PlayMoviesInMultiplayer`, `CustomLoadScreen`, `CustomLoadScreenPos`, and +`DifficultyName`. diff --git a/manual/data/ini-read-exclusions.yaml b/manual/data/ini-read-exclusions.yaml index ea4e179b..753d06da 100644 --- a/manual/data/ini-read-exclusions.yaml +++ b/manual/data/ini-read-exclusions.yaml @@ -144,6 +144,6 @@ site_exclusions: reason: Each names one seat of a match inside a section the CnCNet client numbers per seat, not an authored game-data setting. - path: code/spawnerconfig.cpp function: SpawnerConfigClass::Read_INI - keys: [AIDifficulty, AIPlayers, AlliesAllowed, AttackNeutralUnits, AutoSaveGame, AutoSurrender, Bases, BridgeDestroy, BuildOffAlly, CampaignID, CoachMode, ConnTimeout, ContinueWithoutHumans, Crates, Credits, DifficultyBasedAINames, DifficultyModeComputer, DifficultyModeHuman, Firestorm, FogOfWar, GameID, GameSpeed, HarvesterTruce, Host, IsSinglePlayer, LoadSaveGame, MCVRedeploy, MultiEngineer, NextSPAutoSaveId, NextSkirmishAutoSaveId, PlayMoviesInMultiplayer, Port, QuickMatch, ReconnectTimeout, ScrapMetal, Seed, ShortGame, SkipScoreScreen, TechLevel, Tournament, UnitCount, WriteStatistics] + keys: [AIDifficulty, AIPlayers, AlliesAllowed, AttackNeutralUnits, AutoSaveGame, AutoSurrender, Bases, BridgeDestroy, BuildOffAlly, CampaignID, CoachMode, ContinueWithoutHumans, Crates, Credits, DifficultyBasedAINames, DifficultyModeComputer, DifficultyModeHuman, Firestorm, FogOfWar, GameID, GameSpeed, HarvesterTruce, Host, IsSinglePlayer, LoadSaveGame, MCVRedeploy, MultiEngineer, NextSPAutoSaveId, NextSkirmishAutoSaveId, PlayMoviesInMultiplayer, Port, QuickMatch, ScrapMetal, Seed, ShortGame, SkipScoreScreen, TechLevel, Tournament, UnitCount, WriteStatistics] classification: excluded reason: The CnCNet client writes these per launch to describe one match, so they are not part of the authored game-data key catalog. From 63bef667277299eaae335f52f0c11fd2bc7f7cc5 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 02:25:06 +0300 Subject: [PATCH 17/35] Simplify switch --- code/spawner.cpp | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/code/spawner.cpp b/code/spawner.cpp index 872c668f..917f441b 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -481,14 +481,8 @@ bool Spawner_Prepare(bool & gameloaded) // A seat names its country by the rules' own numbering, so the roster is read first. Prepare_Side_Roster(); - switch (SpawnConfig.Launch_Type()) { - case SpawnerConfigClass::LaunchType::Resume: - return(Spawner_Resume(gameloaded)); - - case SpawnerConfigClass::LaunchType::Multiplayer: - case SpawnerConfigClass::LaunchType::Campaign: - case SpawnerConfigClass::LaunchType::Skirmish: - break; + if (SpawnConfig.Launch_Type() == SpawnerConfigClass::LaunchType::Resume) { + return(Spawner_Resume(gameloaded)); } Disable_Addon(ADDON_ANY); From d08d35fc57ccd0692f8468da6eb560bcfd452f84 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 11:42:21 +0300 Subject: [PATCH 18/35] CI fix --- tests/spawner/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/spawner/CMakeLists.txt b/tests/spawner/CMakeLists.txt index f635058f..dc597a9c 100644 --- a/tests/spawner/CMakeLists.txt +++ b/tests/spawner/CMakeLists.txt @@ -26,7 +26,7 @@ target_compile_features(SpawnContract PRIVATE cxx_std_20) target_include_directories(SpawnContract PRIVATE "${CMAKE_SOURCE_DIR}/code") -target_compile_definitions(SpawnContract PRIVATE WIN32 _WINDOWS _MBCS) +target_compile_definitions(SpawnContract PRIVATE WIN32 _WINDOWS _MBCS NOMINMAX) target_compile_options(SpawnContract PRIVATE $<$:/MTd /EHsc /Zc:__cplusplus> From d5636f4788f2f4169d254d910be4656f46423052 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:09:34 +0300 Subject: [PATCH 19/35] Judge every launch value the game cannot survive --- code/spawner.cpp | 11 +++ code/spawnerconfig.cpp | 82 ++++++++++++++++++-- code/spawnerconfig.h | 6 ++ manual/content/formats/spawn-ini.md | 17 ++-- tests/spawner/spawncontract.cpp | 116 ++++++++++++++++++++++++++-- 5 files changed, 213 insertions(+), 19 deletions(-) diff --git a/code/spawner.cpp b/code/spawner.cpp index 917f441b..be27c77f 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -45,6 +45,9 @@ * A launch is spent once for the life of the process: the client watches for the game to * exit, so a finished or refused spawn ends it rather than falling into the menu. */ +static_assert(SpawnerConfigClass::NAME_KEPT == MPLAYER_NAME_MAX - 1, + "a seat is judged and ordered by the name the session carries"); + static bool SpawnRequested = false; static bool SpawnConsumed = false; static SpawnerConfigClass SpawnConfig; @@ -478,6 +481,14 @@ bool Spawner_Prepare(bool & gameloaded) SpawnConsumed = true; + /* + * Every kind of launch is played at this speed, so it is judged before they part. + */ + if (SpawnConfig.GameSpeed < 0 || SpawnConfig.GameSpeed >= OptionsClass::MAX_SPEED_SETTING) { + return(Spawner_Refuse("The file asks for game speed %d, and the game has 0 through %d.", + SpawnConfig.GameSpeed, OptionsClass::MAX_SPEED_SETTING - 1)); + } + // A seat names its country by the rules' own numbering, so the roster is read first. Prepare_Side_Roster(); diff --git a/code/spawnerconfig.cpp b/code/spawnerconfig.cpp index ede08f1e..24ad0bf2 100644 --- a/code/spawnerconfig.cpp +++ b/code/spawnerconfig.cpp @@ -56,6 +56,30 @@ int Read_Slot_Int(INIClass const & ini, char const * section, int slot, int fall } +/// +/// Reads a dotted address, so that a seat naming an unreachable machine is refused where +/// every other fault is. The game's own resolver is not reachable from here. +/// +/// bool; Is this four numbers between 0 and 255? +bool Is_Address(std::string const & text) +{ + unsigned quad[4] = {}; + char tail = '\0'; + + if (std::sscanf(text.c_str(), "%u.%u.%u.%u%c", &quad[0], &quad[1], &quad[2], &quad[3], &tail) != 4) { + return(false); + } + + for (unsigned part : quad) { + if (part > 255) { + return(false); + } + } + + return(quad[0] != 0 || quad[1] != 0 || quad[2] != 0 || quad[3] != 0); +} + + /// /// Names the fault that refuses a launch. /// @@ -92,7 +116,7 @@ void SpawnerConfigClass::Read_Slots(INIClass const & ini) SlotType & slot = staging[index]; if (ini.Section_Present(section.c_str())) { slot.Occupancy = OccupancyType::Human; - slot.Name = Read_Text(ini, section.c_str(), "Name", ""); + slot.Name = Read_Text(ini, section.c_str(), "Name", "").substr(0, NAME_KEPT); slot.Color = ini.Get_Int(section.c_str(), "Color", -1); slot.Country = ini.Get_Int(section.c_str(), "Side", -1); slot.Address = Read_Text(ini, section.c_str(), "Ip", slot.Address); @@ -282,7 +306,22 @@ int SpawnerConfigClass::Playable_Handicap(int asked) /// bool; Can the game this file describes be played? bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fault) const { - bool multiplayer = Launch_Type() == LaunchType::Multiplayer; + /* + * A resumed match against other machines is seated from the file like any other, so it + * is held to the same rules; its kind is the save's rather than the file's. + */ + LaunchType kind = Launch_Type(); + bool multiplayer = kind == LaunchType::Multiplayer || + (kind == LaunchType::Resume && HumanCount > 1); + + if (kind != LaunchType::Campaign && HumanCount == 0) { + return(Fault(fault, "The file seats nobody at this machine.")); + } + + if (AIDifficulty < 0 || AIDifficulty >= DIFF_COUNT) { + return(Fault(fault, "The file plays the computer at difficulty %d, and there are %d.", + AIDifficulty, DIFF_COUNT)); + } int free_seats = SLOT_COUNT - HumanCount; if (AIPlayers < 0 || AIPlayers > free_seats) { @@ -304,7 +343,7 @@ bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fa index + 1, slot.Country, countries)); } - // A co-op team shares one color deliberately; only a color with no scheme refuses. + // A computer seat may share a color; only a color with no scheme refuses outright. if ((human || slot.Color != -1) && (slot.Color < 0 || slot.Color >= colors)) { return(Fault(fault, "Seat %d is given color %d, and there are %d to choose from.", index + 1, slot.Color, colors)); @@ -316,7 +355,8 @@ bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fa } for (int ally : slot.Alliances) { - if (ally < -1 || ally >= SLOT_COUNT) { + if (ally < -1 || ally >= SLOT_COUNT || + (ally >= 0 && Slots[ally].Occupancy == OccupancyType::Empty)) { return(Fault(fault, "Seat %d is allied to seat %d, which the match does not hold.", index + 1, ally + 1)); } @@ -328,8 +368,10 @@ bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fa } /* - * A name breaks a tie between two seats of one color, so without one every machine - * reading its own file would arrive at a different seat order. + * The seats are ordered by color, and the client keys what it writes for each of them + * by an order of its own that no other machine can rebuild. Two people of one color + * would therefore take each other's start position and alliances, so a match against + * other machines gives every person a color and a name of their own. */ if (human && multiplayer) { if (slot.Name.empty()) { @@ -337,11 +379,35 @@ bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fa } for (int other = 0; other < index; other++) { - if (Slots[other].Occupancy == OccupancyType::Human && - _stricmp(Slots[other].Name.c_str(), slot.Name.c_str()) == 0) { + if (Slots[other].Occupancy != OccupancyType::Human) { + continue; + } + + if (_stricmp(Slots[other].Name.c_str(), slot.Name.c_str()) == 0) { return(Fault(fault, "Seats %d and %d are both played by %s.", other + 1, index + 1, slot.Name.c_str())); } + + if (Slots[other].Color == slot.Color) { + return(Fault(fault, "Seats %d and %d are both given color %d.", + other + 1, index + 1, slot.Color)); + } + } + + /* + * Through a tunnel a machine is named by the number carried where its port would + * go, so every seat but this one needs that number either way. + */ + if (index != LocalSlot) { + if (slot.Port < 1 || slot.Port > 65535) { + return(Fault(fault, "Seat %d is reached on port %d, which names no machine.", + index + 1, slot.Port)); + } + + if (TunnelPort == 0 && !Is_Address(slot.Address)) { + return(Fault(fault, "Seat %d is reached at %s, which names no machine.", + index + 1, slot.Address.c_str())); + } } } } diff --git a/code/spawnerconfig.h b/code/spawnerconfig.h index be632f29..53ad0a6f 100644 --- a/code/spawnerconfig.h +++ b/code/spawnerconfig.h @@ -39,6 +39,12 @@ class SpawnerConfigClass Resume, }; + /* + * How much of a person's name the game keeps. A seat is judged and ordered by what is + * kept, since that is what every machine compares. + */ + static constexpr int NAME_KEPT = 19; + // A file marks a seat human by writing a section for it; an unwritten one is a computer. enum class OccupancyType { Empty, diff --git a/manual/content/formats/spawn-ini.md b/manual/content/formats/spawn-ini.md index 1a2b095c..74a9a3d4 100644 --- a/manual/content/formats/spawn-ini.md +++ b/manual/content/formats/spawn-ini.md @@ -122,8 +122,10 @@ to the game to choose, which is also what writing no position means. Alliances a exactly as written, before the first frame is played, and quietly: a match whose file forbids new pacts still starts with the ones it wrote. -Two seats may share a color deliberately — a cooperative team does — and the game does not -refuse it. +A computer player may share the color a person plays. Two people may not, in a game against +other machines: the seats are ordered by color, and the client keys what it writes for each +of them by an order no other machine can rebuild, so two people of one color would take each +other's start position and alliances. ## A game against other machines @@ -149,10 +151,13 @@ on the port its own `Port` key names. A file describing a game that cannot be played is refused: the reason is shown and written to the log, and the game exits rather than falling back to its menu. A launch is refused -when it asks for more computer players than there are seats, names a country or color the -loaded rules do not have, names a difficulty that is not one, allies a seat with one the -match does not hold, or asks for a seat that watches rather than plays. A match against -other machines is refused as well when a person is left unnamed or two are named the same. +when it seats nobody at this machine, asks for more computer players than there are seats, +names a country or color the loaded rules do not have, plays the computer at a difficulty +the game does not have, asks for a game speed it does not have, names a difficulty that is +not one, allies a seat with one the match does not hold, or asks for a seat that watches +rather than plays. A match against other machines is refused as well when a person is left +unnamed, when two are named the same or given one color, and when a machine other than this +one is given no port to answer on or no address to answer at. A difficulty easier than the three the game has is not refused: the seat is played at the easiest one the game does have. diff --git a/tests/spawner/spawncontract.cpp b/tests/spawner/spawncontract.cpp index 5be905bb..8c633923 100644 --- a/tests/spawner/spawncontract.cpp +++ b/tests/spawner/spawncontract.cpp @@ -538,9 +538,23 @@ int main(void) "[Other1]\n" "Name=Bravo\n" "Side=1\n" - "Color=3\n"; - Check(Judge(shared_color, sizeof(shared_color) - 1, 2, 8, fault), - "a cooperative team shares one color on purpose"); + "Color=3\n" + "Ip=10.0.0.9\n" + "Port=50002\n"; + Check(!Judge(shared_color, sizeof(shared_color) - 1, 2, 8, fault), + "two people of one color are refused against other machines"); + + char const shared_with_computer[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=3\n" + "AIPlayers=1\n" + "\n" + "[HouseColors]\n" + "Multi2=3\n"; + Check(Judge(shared_with_computer, sizeof(shared_with_computer) - 1, 2, 8, fault), + "a computer player may take the color its opponent plays"); char const past_difficulty[] = "[Settings]\n" @@ -594,7 +608,9 @@ int main(void) "\n" "[Other1]\n" "Side=1\n" - "Color=5\n"; + "Color=5\n" + "Ip=10.0.0.9\n" + "Port=50002\n"; Check(!Judge(nameless_machine, sizeof(nameless_machine) - 1, 2, 8, fault), "a person the file leaves unnamed is refused against other machines"); @@ -607,7 +623,9 @@ int main(void) "[Other1]\n" "Name=alpha\n" "Side=1\n" - "Color=5\n"; + "Color=5\n" + "Ip=10.0.0.9\n" + "Port=50002\n"; Check(!Judge(one_name, sizeof(one_name) - 1, 2, 8, fault) && fault.find("1") != std::string::npos && fault.find("2") != std::string::npos, "two people under one name are refused however either is spelled"); @@ -619,6 +637,94 @@ int main(void) Check(Judge(alone, sizeof(alone) - 1, 2, 8, fault), "somebody playing alone need not be named"); + char const nobody[] = + "[GlobalFlags]\n" + "GlobalFlag0=yes\n"; + Check(!Judge(nobody, sizeof(nobody) - 1, 2, 8, fault), + "a file seating nobody at this machine is refused"); + + char const past_ai_difficulty[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "AIDifficulty=7\n"; + Check(!Judge(past_ai_difficulty, sizeof(past_ai_difficulty) - 1, 2, 8, fault), + "a computer difficulty the game does not have is refused"); + + char const unreachable[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=5\n" + "Ip=10.0.0.9\n"; + Check(!Judge(unreachable, sizeof(unreachable) - 1, 2, 8, fault), + "a machine the file gives no port is refused"); + + char const nowhere[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=5\n" + "Ip=10.0.0.\n" + "Port=50002\n"; + Check(!Judge(nowhere, sizeof(nowhere) - 1, 2, 8, fault), + "an address naming no machine is refused"); + + char const tunnelled[] = + "[Settings]\n" + "Name=Alpha\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=Bravo\n" + "Side=1\n" + "Color=5\n" + "Port=50002\n" + "\n" + "[Tunnel]\n" + "Ip=88.99.11.22\n" + "Port=50010\n"; + Check(Judge(tunnelled, sizeof(tunnelled) - 1, 2, 8, fault), + "a tunnelled machine is named by its number rather than an address"); + + char const one_kept_name[] = + "[Settings]\n" + "Name=CommanderAlphaOmegaX\n" + "Side=0\n" + "Color=3\n" + "\n" + "[Other1]\n" + "Name=CommanderAlphaOmegaY\n" + "Side=1\n" + "Color=5\n" + "Ip=10.0.0.9\n" + "Port=50002\n"; + Check(!Judge(one_kept_name, sizeof(one_kept_name) - 1, 2, 8, fault), + "two names the game keeps as one are refused"); + + char const unheld_ally[] = + "[Settings]\n" + "Name=Commander\n" + "Side=0\n" + "Color=0\n" + "\n" + "[Multi1_Alliances]\n" + "HouseAllyOne=5\n"; + Check(!Judge(unheld_ally, sizeof(unheld_ally) - 1, 2, 8, fault), + "an alliance with a seat nobody occupies is refused"); + char const past_seats[] = "[Settings]\n" "Name=Commander\n" From f87f6b54a4ab82af47583062a923953f2428b8a2 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:11:06 +0300 Subject: [PATCH 20/35] Turn the AI difficulty the right way round --- code/spawner.cpp | 3 ++- code/spawnerconfig.cpp | 10 +++++++--- manual/content/formats/spawn-ini.md | 4 ++-- tests/spawner/spawncontract.cpp | 6 +++--- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/code/spawner.cpp b/code/spawner.cpp index be27c77f..30e728db 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -174,7 +174,8 @@ static void Spawner_Seat_Humans(void) /// static void Spawner_Seat_Computers(void) { - static char const * const _ai_names[DIFF_COUNT] = { "Easy AI", "Medium AI", "Hard AI" }; + // A seat played at the gentlest of the rules' tables is the hardest opponent to beat. + static char const * const _ai_names[DIFF_COUNT] = { "Hard AI", "Medium AI", "Easy AI" }; for (int index = SpawnConfig.HumanCount; index < SpawnerConfigClass::SLOT_COUNT; index++) { SpawnerConfigClass::SlotType const & seat = SpawnConfig.Slots[index]; diff --git a/code/spawnerconfig.cpp b/code/spawnerconfig.cpp index 24ad0bf2..91ec4ae5 100644 --- a/code/spawnerconfig.cpp +++ b/code/spawnerconfig.cpp @@ -282,8 +282,8 @@ int SpawnerConfigClass::Session_Identity_CRC(void) const /// -/// The difficulty a seat is played at. A client may offer more easy settings than the game -/// holds, and any easier request comes to the easiest one it has. +/// The difficulty a seat is played at. A client may offer gentler settings than the game +/// holds, and any gentler request comes to the gentlest opponent it has. /// /// The difficulty to play the seat at, or -1 for the session default. int SpawnerConfigClass::Playable_Handicap(int asked) @@ -291,8 +291,12 @@ int SpawnerConfigClass::Playable_Handicap(int asked) if (asked < 0) { return(-1); } + /* + * The tables run the other way from the opponent they make: a seat played at the hardest + * of them is the gentlest to play against, and that is what a gentler request comes to. + */ if (asked > DIFF_HARD) { - return(DIFF_EASY); + return(DIFF_HARD); } return(asked); } diff --git a/manual/content/formats/spawn-ini.md b/manual/content/formats/spawn-ini.md index 74a9a3d4..46ae072b 100644 --- a/manual/content/formats/spawn-ini.md +++ b/manual/content/formats/spawn-ini.md @@ -159,8 +159,8 @@ rather than plays. A match against other machines is refused as well when a pers unnamed, when two are named the same or given one color, and when a machine other than this one is given no port to answer on or no address to answer at. -A difficulty easier than the three the game has is not refused: the seat is played at the -easiest one the game does have. +A difficulty gentler than the three the game has is not refused: the seat is played as the +gentlest opponent the game does have. ## What the game does not take from a launch file diff --git a/tests/spawner/spawncontract.cpp b/tests/spawner/spawncontract.cpp index 8c633923..ef10d3ad 100644 --- a/tests/spawner/spawncontract.cpp +++ b/tests/spawner/spawncontract.cpp @@ -581,9 +581,9 @@ int main(void) "a difficulty easier than the game holds is played, not refused"); Check(SpawnerConfigClass::Playable_Handicap(-1) == -1 && SpawnerConfigClass::Playable_Handicap(0) == 0 && - SpawnerConfigClass::Playable_Handicap(2) == 2 && SpawnerConfigClass::Playable_Handicap(3) == 0 && - SpawnerConfigClass::Playable_Handicap(6) == 0, - "an easier setting than the game has comes to the easiest it has"); + SpawnerConfigClass::Playable_Handicap(2) == 2 && SpawnerConfigClass::Playable_Handicap(3) == 2 && + SpawnerConfigClass::Playable_Handicap(6) == 2, + "a gentler setting than the game has comes to the gentlest opponent it has"); char const two_machines[] = "[Settings]\n" From 499918f62b8d3f0f0189e3075be47358e6ee4578 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:12:13 +0300 Subject: [PATCH 21/35] Carry the match options through a network resume --- code/saveload.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/code/saveload.cpp b/code/saveload.cpp index 8e9bc5ec..a727165e 100644 --- a/code/saveload.cpp +++ b/code/saveload.cpp @@ -618,8 +618,12 @@ static bool Put_All(IStream *stream, int save_net) return(false); } - if (Session.Type == GAME_SKIRMISH) { - DebugString("Writing Skirmish Session.Options\n"); + /* + * A campaign takes its options from the mission; every other kind was told them once, + * when the game was set up, so the save is the only place a resume can find them. + */ + if (Session.Type != GAME_NORMAL) { + DebugString("Writing Session.Options\n"); if (!Session.Options.Save(stream)) { DebugString("\t***** FAILED!\n"); return(false); @@ -868,8 +872,8 @@ static bool Get_All(IStream *stream, bool save_net) return(false); } - if (Session.Type == GAME_SKIRMISH) { - DebugString("Reading Skirmish Session.Options\n"); + if (Session.Type != GAME_NORMAL) { + DebugString("Reading Session.Options\n"); if (!Session.Options.Load(stream)) { DebugString("\t***** FAILED!\n"); return(false); From 95ab7afd3cdddded00212cf5df427c858df9d472 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:14:04 +0300 Subject: [PATCH 22/35] Commit the session specials a spawned match plays under --- code/netshare.cpp | 20 +++++++++++++++----- code/netshare.h | 1 + code/spawner.cpp | 11 ++++++++++- manual/content/formats/spawn-ini.md | 6 +++--- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/code/netshare.cpp b/code/netshare.cpp index 5909278d..797a7ef3 100644 --- a/code/netshare.cpp +++ b/code/netshare.cpp @@ -1261,6 +1261,20 @@ int CALLBACK Scenario_DlgProc(HWND window, UINT message, WPARAM wparam, LPARAM l } +/// +/// Puts the options every machine agreed on into the globals the simulation reads, so a match +/// against other machines is played under one set of rules however it was set up. +/// +void Commit_Session_Specials(void) +{ + Special.IsHarvesterImmune = Session.Options.HarvTruce; + Special.IsDestroyBridges = Session.Options.BridgeDestruction; + Special.IsTGrowth = true; + Special.IsTSpread = true; + Special.Apply_To_Game(); +} + + /// /// Performs the last setup step before a multiplayer game begins. /// This routine copies the agreed session options into the globals the game logic actually @@ -1274,11 +1288,7 @@ void PregameSetup(void) DebugString("Pregame setup for %d players.\n", Session.NumPlayers); Options.GameSpeed = Session.Options.GameSpeed; Session.CommProtocol = DEFAULT_COMM_PROTOCOL; - Special.IsHarvesterImmune = Session.Options.HarvTruce; - Special.IsDestroyBridges = Session.Options.BridgeDestruction; - Special.IsTGrowth = true; - Special.IsTSpread = true; - Special.Apply_To_Game(); + Commit_Session_Specials(); } diff --git a/code/netshare.h b/code/netshare.h index 27200fc6..3d18e8eb 100644 --- a/code/netshare.h +++ b/code/netshare.h @@ -20,6 +20,7 @@ int ODMessageBox(const char *text, int type, bool (*callback)(void), bool large int CALLBACK ODMessageBox_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); bool Set_Scenario_Info_From_Index(int index); +void Commit_Session_Specials(void); void PregameSetup(void); void Update_Network_Dialog_Preview(HWND win); void Receive_Random_Map_Preview(void); diff --git a/code/spawner.cpp b/code/spawner.cpp index 30e728db..9efbbc39 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -28,6 +28,7 @@ #include "language\language.h" #include "loaddlg.h" #include "mplayer.h" +#include "netshare.h" #include "msgbox.h" #include "saveload.h" #include "savever.h" @@ -223,7 +224,10 @@ static void Spawner_Bind_Options(void) Session.Options.FogOfWar = SpawnConfig.FogOfWar; Session.Options.MCVRedeploy = SpawnConfig.MCVRedeploy; - // A skirmish takes harvester immunity from the map, so this is recorded but not obeyed. + /* + * Recorded for every kind of launch, but only a match against other machines commits it + * to the simulation; a skirmish has never played by it, from a file or from the menu. + */ Session.Options.HarvTruce = SpawnConfig.HarvesterTruce; // Game options too, though the session keeps these two outside its own block. @@ -424,6 +428,11 @@ static void Spawner_Setup_Session(void) Clear_Vector(&Session.Computers); Spawner_Bind_Options(); + + if (Session.Type == GAME_INTERNET) { + Commit_Session_Specials(); + } + Spawner_Seat_Local(); Spawner_Seat_Humans(); Spawner_Seat_Computers(); diff --git a/manual/content/formats/spawn-ini.md b/manual/content/formats/spawn-ini.md index 46ae072b..81037fa8 100644 --- a/manual/content/formats/spawn-ini.md +++ b/manual/content/formats/spawn-ini.md @@ -87,9 +87,9 @@ A written `Seed` makes a launch repeatable: the same file played twice places ev the same way. A seed of `0` leaves the placement to chance, which is also what an absent `Seed` means. -`HarvesterTruce` is read and recorded with the rest of the match's options, but a skirmish -takes harvester immunity from the scenario's own `[SPECIAL]` section, so the key does not -change how a skirmish is played. +`HarvesterTruce` is played by in a game against other machines. A skirmish records it with +the rest of the match's options but is not played by it, as a skirmish set up from the menu +is not: only a match against other machines commits harvester immunity to the simulation. ## Who is playing From c7c3cc8748310c13a12204c797e162be12797038 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:14:31 +0300 Subject: [PATCH 23/35] Bind the file's game speed on every launch kind --- code/spawner.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/code/spawner.cpp b/code/spawner.cpp index 9efbbc39..3ca0f442 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -370,6 +370,12 @@ static bool Spawner_Resume(bool & gameloaded) return(Spawner_Refuse("The saved game and the file do not agree on who is playing.")); } + /* + * A save carries the options it was played under, but the speed is the player's own and + * is taken from the file that asked for the resume. + */ + Options.GameSpeed = SpawnConfig.GameSpeed; + gameloaded = true; return(true); @@ -395,6 +401,7 @@ static bool Spawner_Setup_Campaign(void) } Session.Type = GAME_NORMAL; + Options.GameSpeed = SpawnConfig.GameSpeed; Session.CampaignDifficulty = (DiffType)SpawnConfig.CampaignDifficulty; Session.CampaignCDifficulty = (DiffType)SpawnConfig.CampaignCDifficulty; Scen->Campaign = (CampaignType)SpawnConfig.CampaignID; From b75c301210104a780d9d679546705e1fc60843a2 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:15:04 +0300 Subject: [PATCH 24/35] Clamp the loading screen to the sides that have art --- code/scenario.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/code/scenario.cpp b/code/scenario.cpp index 37362f8d..2c5bc685 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -1429,6 +1429,14 @@ char const * Pick_Load_Background_Name(Point2D & pos) player = Session.Players[player]->Player.House; } + /* + * Only the two sides of the war have loading art, so a house from anywhere else in the + * rules is shown the first side's rather than a name from past the list. + */ + if (player < 0 || player > 1) { + player = 0; + } + int choice = (player << 1) + Random_Pick(0, 1); if (VisibleRect.Width == 640) { From 2cbe6e925b0702d2f6e2c227c6ce72ba350a37a6 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:17:33 +0300 Subject: [PATCH 25/35] Reserve chosen start positions before anybody picks --- code/scenario.cpp | 28 +++++++++++++++++-- .../documentation-source-contract.test.mjs | 12 +++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/code/scenario.cpp b/code/scenario.cpp index 2c5bc685..dfa5a887 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -2512,6 +2512,30 @@ static void Create_Units(bool official) taken[index] = choices && index < waypts.Count() && waypts[index] == CELL_NONE; } + /* + * A house that named a position holds it before anybody draws, so a house that named + * none cannot take one somebody asked for. Two naming the same position: first keeps it. + */ + int reserved[MAX_PLAYERS * 2]; + for (int index = 0; index < ARRAY_SIZE(reserved); index++) { + reserved[index] = -1; + } + + if (choices) { + for (int index = 0; index < Houses.Count(); index++) { + HouseClass * housep = Houses[index]; + if (housep == NULL || housep->Class->IsMultiplayPassive) { + continue; + } + + int spot = housep->SpawnWaypoint; + if (spot >= 0 && spot < waypts.Count() && !taken[spot]) { + reserved[spot] = index; + taken[spot] = true; + } + } + } + /* ** Loop through all houses. Computer-controlled houses, with Session.Options.Bases ** ON, are treated as though bases are OFF (since we have no base-building @@ -2559,9 +2583,9 @@ static void Create_Units(bool official) ** one of the valid locations at random. The other houses pick the furthest ** wapoint from the existing houses. */ - if (choices && hptr->SpawnWaypoint >= 0 && hptr->SpawnWaypoint < waypts.Count() && !taken[hptr->SpawnWaypoint]) { + if (choices && hptr->SpawnWaypoint >= 0 && hptr->SpawnWaypoint < waypts.Count() && + reserved[hptr->SpawnWaypoint] == (int)house) { centroid = waypts[hptr->SpawnWaypoint]; - taken[hptr->SpawnWaypoint] = true; numtaken++; } else if (numtaken == 0) { int pick; diff --git a/manual/site/tests/documentation-source-contract.test.mjs b/manual/site/tests/documentation-source-contract.test.mjs index e329692b..71ef29f5 100644 --- a/manual/site/tests/documentation-source-contract.test.mjs +++ b/manual/site/tests/documentation-source-contract.test.mjs @@ -268,9 +268,10 @@ test('A chosen start position keeps its number and is claimed before the game pi 'Houses[index]->SpawnWaypoint >= 0', 'Build_Start_Waypoint_List(official, choices)', 'taken[index] = choices && index < waypts.Count() && waypts[index] == CELL_NONE;', - 'if (choices && hptr->SpawnWaypoint >= 0', + 'reserved[spot] = index;', + 'reserved[hptr->SpawnWaypoint] == (int)house', '} else if (numtaken == 0) {', - ], 'holes are spoken for before the claim, and the claim comes before the game picks'); + ], 'every named position is held before the game picks for anybody who named none'); }); test('The campaign handicap pair lives on the session, and the mission reader never asks the spawner', () => { @@ -446,11 +447,14 @@ test('A match against other machines is assembled whole and wired to its network 'bool SpawnerConfigClass::Is_Playable(int countries, int colors, std::string & fault) const', ), [ - 'bool multiplayer = Launch_Type() == LaunchType::Multiplayer;', + 'kind == LaunchType::Multiplayer ||', + '(kind == LaunchType::Resume && HumanCount > 1)', 'if (human && multiplayer) {', 'slot.Name.empty()', '_stricmp(Slots[other].Name.c_str(), slot.Name.c_str()) == 0', + 'Slots[other].Color == slot.Color', + 'slot.Port < 1 || slot.Port > 65535', ], - 'the seat order the machines share is what the name rules are held for', + 'the seat order the machines share is what the name and color rules are held for', ); }); From c78cbcb1bedc3619f4292117de84978def29715c Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:23:37 +0300 Subject: [PATCH 26/35] Say the easiest opponent plays at the hardest setting --- code/spawner.cpp | 2 +- code/spawnerconfig.cpp | 8 ++++---- manual/content/formats/spawn-ini.md | 5 +++-- tests/spawner/spawncontract.cpp | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/code/spawner.cpp b/code/spawner.cpp index 3ca0f442..446491d2 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -175,7 +175,7 @@ static void Spawner_Seat_Humans(void) /// static void Spawner_Seat_Computers(void) { - // A seat played at the gentlest of the rules' tables is the hardest opponent to beat. + // A seat played at the easiest of the rules' tables is the hardest opponent to beat. static char const * const _ai_names[DIFF_COUNT] = { "Hard AI", "Medium AI", "Easy AI" }; for (int index = SpawnConfig.HumanCount; index < SpawnerConfigClass::SLOT_COUNT; index++) { diff --git a/code/spawnerconfig.cpp b/code/spawnerconfig.cpp index 91ec4ae5..4347d740 100644 --- a/code/spawnerconfig.cpp +++ b/code/spawnerconfig.cpp @@ -282,8 +282,8 @@ int SpawnerConfigClass::Session_Identity_CRC(void) const /// -/// The difficulty a seat is played at. A client may offer gentler settings than the game -/// holds, and any gentler request comes to the gentlest opponent it has. +/// The difficulty a seat is played at. A client may ask for an easier opponent than the game +/// holds, and any easier request comes to the easiest opponent it has. /// /// The difficulty to play the seat at, or -1 for the session default. int SpawnerConfigClass::Playable_Handicap(int asked) @@ -292,8 +292,8 @@ int SpawnerConfigClass::Playable_Handicap(int asked) return(-1); } /* - * The tables run the other way from the opponent they make: a seat played at the hardest - * of them is the gentlest to play against, and that is what a gentler request comes to. + * A table makes the opposite of what it is named: a seat played at the hardest of them is + * the easiest opponent, and that is what a request for an easier one comes to. */ if (asked > DIFF_HARD) { return(DIFF_HARD); diff --git a/manual/content/formats/spawn-ini.md b/manual/content/formats/spawn-ini.md index 81037fa8..d7a4bad2 100644 --- a/manual/content/formats/spawn-ini.md +++ b/manual/content/formats/spawn-ini.md @@ -159,8 +159,9 @@ rather than plays. A match against other machines is refused as well when a pers unnamed, when two are named the same or given one color, and when a machine other than this one is given no port to answer on or no address to answer at. -A difficulty gentler than the three the game has is not refused: the seat is played as the -gentlest opponent the game does have. +A difficulty easier than the three the game has is not refused: the seat is played as the +easiest opponent the game does have. The two run opposite ways: the easiest opponent is the +one played at the hardest of the game's three settings. ## What the game does not take from a launch file diff --git a/tests/spawner/spawncontract.cpp b/tests/spawner/spawncontract.cpp index ef10d3ad..de284cb3 100644 --- a/tests/spawner/spawncontract.cpp +++ b/tests/spawner/spawncontract.cpp @@ -583,7 +583,7 @@ int main(void) Check(SpawnerConfigClass::Playable_Handicap(-1) == -1 && SpawnerConfigClass::Playable_Handicap(0) == 0 && SpawnerConfigClass::Playable_Handicap(2) == 2 && SpawnerConfigClass::Playable_Handicap(3) == 2 && SpawnerConfigClass::Playable_Handicap(6) == 2, - "a gentler setting than the game has comes to the gentlest opponent it has"); + "an easier opponent than the game has comes to the easiest opponent it has"); char const two_machines[] = "[Settings]\n" From a00ce532d79b9c6f082b65f208bb51480fd014de Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:24:47 +0300 Subject: [PATCH 27/35] Keep the map generator's settings reachable --- code/mapgen.cpp | 21 ++++++++++++++++++--- manual/changes/saved-games-folder.md | 5 +++-- manual/content/formats/save-games.md | 2 +- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/code/mapgen.cpp b/code/mapgen.cpp index 349b8846..00aef074 100644 --- a/code/mapgen.cpp +++ b/code/mapgen.cpp @@ -29,6 +29,7 @@ #include "coord.h" #include "data.h" #include "dbgprint.h" +#include "gamedirs.h" #include "house.h" #include "houstype.h" #include "incdec.h" @@ -4375,11 +4376,23 @@ bool MapSeedClass::Save(const char * name) /// Description to keep with the settings. This is the text the load /// dialog lists the map under. /// bool; Were the settings written? +/// +/// Is this the generator's own map rather than settings a player saved? That one travels to +/// the other machines with the match, so it is kept where the game's own files are. +/// +static bool Is_Shared_Map_File(char const * file_name) +{ + return(stricmp(file_name, RANDOM_MAP_FILE_NAME) == 0); +} + + bool MapSeedClass::Save_File(const char * file_name, const char * descr) { if (file_name != NULL) { DebugString("Saving random map: %s - %s\n", file_name, descr); - CCFileClass file(file_name); + CCFileClass shared(file_name); + RawFileClass owned(Saved_Game_Name(file_name).c_str()); + FileClass & file = Is_Shared_Map_File(file_name) ? (FileClass &)shared : (FileClass &)owned; INIClass ini; ini.Put_String("RandomMap", "Description", descr); ini.Put_Int("RandomMap", "Width", Width, 0); @@ -4439,7 +4452,9 @@ bool MapSeedClass::Load_File(const char * file_name) { if (file_name != NULL) { DebugString("Loading random map: %s\n", file_name); - CCFileClass file(file_name); + CCFileClass shared(file_name); + RawFileClass owned(Saved_Game_Name(file_name).c_str()); + FileClass & file = Is_Shared_Map_File(file_name) ? (FileClass &)shared : (FileClass &)owned; INIClass ini; if (ini.Load(file)) { @@ -4511,7 +4526,7 @@ bool MapSeedClass::Read_File(FileEntryClass * entry, WIN32_FIND_DATAA * ff) if (entry != NULL && ff != NULL) { if (stricmp(ff->cFileName, RANDOM_MAP_FILE_NAME)) { - CCFileClass file(ff->cFileName); + RawFileClass file(Saved_Game_Name(ff->cFileName).c_str()); INIClass ini; if (ini.Load(file)) { if (ini.Get_String("RandomMap", "Description", 0, buffer, sizeof(buffer)) > 0 ) diff --git a/manual/changes/saved-games-folder.md b/manual/changes/saved-games-folder.md index 63d2406e..2e0da009 100644 --- a/manual/changes/saved-games-folder.md +++ b/manual/changes/saved-games-folder.md @@ -13,7 +13,8 @@ Saved games now live in a `Saved Games` folder, beside the game or inside the us directory when one is named. Every save, load, listing and deletion names that folder, which is where launchers that browse saved games look. -Saves made by earlier builds sit beside the game and are no longer listed; moving the `.SAV` -files into `Saved Games` restores them. +The settings the random map generator saves keep to that folder too. Saves made by earlier +builds sit beside the game, or in the user data directory when one is named, and are no +longer listed; moving the files into `Saved Games` restores them. After a load, the campaign difficulty now comes from the save rather than the menu setting. diff --git a/manual/content/formats/save-games.md b/manual/content/formats/save-games.md index 56500b84..45489dc0 100644 --- a/manual/content/formats/save-games.md +++ b/manual/content/formats/save-games.md @@ -27,7 +27,7 @@ The save dialog creates `.SAV` files. Each file is an OLE compound document: the Saved games keep to a `Saved Games` folder of their own, beside the game or inside the [user directory](/using/game-data/) when one is named, created the first time the game asks for a saved game. Every save, load, listing and deletion names that folder outright: unlike the files the game reads, a saved game is never looked for anywhere else. A client that browses saved games therefore finds them in one place, whichever layout the game was installed in. -The dialog names a new save `SAVE` followed by four hexadecimal digits, drawing again until it finds a name no existing file answers to; saving over a listed game reuses that game's name. A multiplayer save is written under one fixed name instead and is never offered in the list. +The dialog names a new save `SAVE` followed by four hexadecimal digits, drawing again until it finds a name no existing file answers to; saving over a listed game reuses that game's name. A multiplayer save is written under one fixed name instead and is never offered in the list. The random map generator keeps its saved settings in the same folder, under names of its own; the map a host generates for a match is not one of them, and stays with the game's files so that it can travel to the other machines. ## When the file is written From eb58ba6779055bb6e35feaec1c4778c1344a3f68 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:25:17 +0300 Subject: [PATCH 28/35] Take the Save button off the dialog that cannot load --- code/language/language.rc | 8 +++----- manual/changes/client-driven-launch.md | 4 ++-- manual/site/tests/documentation-source-contract.test.mjs | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/code/language/language.rc b/code/language/language.rc index 2360baa7..1e0d0433 100644 --- a/code/language/language.rc +++ b/code/language/language.rc @@ -866,18 +866,16 @@ BEGIN 47,97,115,14 END -IDD_OPT_CTRL_MP DIALOG DISCARDABLE 0, 0, 209, 93 +IDD_OPT_CTRL_MP DIALOG DISCARDABLE 0, 0, 209, 75 STYLE WS_CHILD FONT 8, "MS Sans Serif" BEGIN CONTROL "Resume Mission",IDC_RESUME_MISSION,"Button", - BS_OWNERDRAW,55,66,99,14 + BS_OWNERDRAW,55,48,99,14 CONTROL "Game Controls",IDC_GAME_CONTROLS,"Button",BS_OWNERDRAW, 55,12,99,14 - CONTROL "Save Game",IDC_SAVE_GAME,"Button",BS_OWNERDRAW, - 55,30,99,14 CONTROL "Abort Mission",IDC_ABORT_MISSION,"Button",BS_OWNERDRAW, - 55,48,99,14 + 55,30,99,14 END IDD_SERIAL_PHONE_LIST DIALOG DISCARDABLE 0, 0, 344, 167 diff --git a/manual/changes/client-driven-launch.md b/manual/changes/client-driven-launch.md index 56cfe7bb..e63b644b 100644 --- a/manual/changes/client-driven-launch.md +++ b/manual/changes/client-driven-launch.md @@ -20,7 +20,7 @@ campaign mission, a game against other machines through a CnCNet tunnel or strai them, or any of those resumed from a saved game. The startup movies and the menu are skipped, and the game exits when the match ends. -A game against other machines can now be saved from its options dialog. A game set up from -the menu is assembled as before. +A client-launched game against other machines can now be saved from its options dialog. A +game set up from the menu is assembled as before. The people credited here wrote the earlier spawners this one follows. diff --git a/manual/site/tests/documentation-source-contract.test.mjs b/manual/site/tests/documentation-source-contract.test.mjs index 71ef29f5..d4050716 100644 --- a/manual/site/tests/documentation-source-contract.test.mjs +++ b/manual/site/tests/documentation-source-contract.test.mjs @@ -334,7 +334,7 @@ test('A resume is judged before it is loaded, and the save answers for the rest' 'gameloaded = true;', ], 'a network resume seats the players and opens the network before the save is read'); - for (const dialog of ['IDD_OPT_CTRL_MP', 'IDD_OPT_CTRL_WOL']) { + for (const dialog of ['IDD_OPT_CTRL_WOL']) { const template = source('code/language/language.rc'); const body = template.slice(template.indexOf(dialog + ' DIALOG')); assert.match( From f2b72735653aa84ea9052612c8036686b531c552 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:31:44 +0300 Subject: [PATCH 29/35] Place the map settings helper above its own documentation --- code/mapgen.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/code/mapgen.cpp b/code/mapgen.cpp index 00aef074..cef9b17c 100644 --- a/code/mapgen.cpp +++ b/code/mapgen.cpp @@ -4367,25 +4367,26 @@ bool MapSeedClass::Save(const char * name) } -/// -/// Writes the map generator settings to a file. -/// This routine records everything the random map dialog offers, so that loading the file -/// back and generating again lays down the very same terrain. -/// -/// Name of the settings file to write. -/// Description to keep with the settings. This is the text the load -/// dialog lists the map under. -/// bool; Were the settings written? /// /// Is this the generator's own map rather than settings a player saved? That one travels to /// the other machines with the match, so it is kept where the game's own files are. /// +/// bool; Is this the map a match is played on rather than a saved setting? static bool Is_Shared_Map_File(char const * file_name) { return(stricmp(file_name, RANDOM_MAP_FILE_NAME) == 0); } +/// +/// Writes the map generator settings to a file. +/// This routine records everything the random map dialog offers, so that loading the file +/// back and generating again lays down the very same terrain. +/// +/// Name of the settings file to write. +/// Description to keep with the settings. This is the text the load +/// dialog lists the map under. +/// bool; Were the settings written? bool MapSeedClass::Save_File(const char * file_name, const char * descr) { if (file_name != NULL) { From 2ff1ceb93cd5932d154ed6ba962d8eb92db40ac7 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:43:46 +0300 Subject: [PATCH 30/35] Trim the loading screen comment --- code/scenario.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/code/scenario.cpp b/code/scenario.cpp index dfa5a887..e87f8ae0 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -1429,10 +1429,7 @@ char const * Pick_Load_Background_Name(Point2D & pos) player = Session.Players[player]->Player.House; } - /* - * Only the two sides of the war have loading art, so a house from anywhere else in the - * rules is shown the first side's rather than a name from past the list. - */ + // Only two sides have loading art, so any other house is shown the first side's. if (player < 0 || player > 1) { player = 0; } From 2bd3a0da169cde49eb156419a4c445176c4598f1 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:43:46 +0300 Subject: [PATCH 31/35] Leave a node's address as its constructor made it --- code/session.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/code/session.h b/code/session.h index e304fc1b..613b3cc9 100644 --- a/code/session.h +++ b/code/session.h @@ -236,6 +236,9 @@ struct NodeNameType { memset(this, 0, sizeof(*this)); Player.SpawnChoice = -1; Player.Handicap = -1; + + // Zeroing the node above wipes an address that names nobody in particular. + Address = IPXAddressClass(); } }; From 8ecbaeb9211fc9d649a39f1b754cd831c4515a78 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:56:00 +0300 Subject: [PATCH 32/35] Judge a seat by the name length the game already names --- code/house.h | 2 -- code/house.hh | 3 +++ code/spawner.cpp | 2 +- code/spawnerconfig.cpp | 3 ++- code/spawnerconfig.h | 8 ++------ 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/code/house.h b/code/house.h index aac1c0a9..bb2bb797 100644 --- a/code/house.h +++ b/code/house.h @@ -83,8 +83,6 @@ class ObjectTypeClass; class SaveStreamClass; template class DynamicVectorClass; -#define HOUSE_NAME_MAX 20 - /**************************************************************************** ** Certain aspects of the house "country" are initially set by the scenario diff --git a/code/house.hh b/code/house.hh index 73bdac43..300db80b 100644 --- a/code/house.hh +++ b/code/house.hh @@ -18,6 +18,9 @@ ** The houses that can be played are listed here. Each has their own ** personality and strengths. */ +#define HOUSE_NAME_MAX 20 + + enum HousesType { HOUSE_NONE=-1, diff --git a/code/spawner.cpp b/code/spawner.cpp index 446491d2..0d7d1843 100644 --- a/code/spawner.cpp +++ b/code/spawner.cpp @@ -46,7 +46,7 @@ * A launch is spent once for the life of the process: the client watches for the game to * exit, so a finished or refused spawn ends it rather than falling into the menu. */ -static_assert(SpawnerConfigClass::NAME_KEPT == MPLAYER_NAME_MAX - 1, +static_assert(HOUSE_NAME_MAX == MPLAYER_NAME_MAX, "a seat is judged and ordered by the name the session carries"); static bool SpawnRequested = false; diff --git a/code/spawnerconfig.cpp b/code/spawnerconfig.cpp index 4347d740..213a3196 100644 --- a/code/spawnerconfig.cpp +++ b/code/spawnerconfig.cpp @@ -116,7 +116,8 @@ void SpawnerConfigClass::Read_Slots(INIClass const & ini) SlotType & slot = staging[index]; if (ini.Section_Present(section.c_str())) { slot.Occupancy = OccupancyType::Human; - slot.Name = Read_Text(ini, section.c_str(), "Name", "").substr(0, NAME_KEPT); + // A seat is judged and ordered by the name the game keeps, as every machine is. + slot.Name = Read_Text(ini, section.c_str(), "Name", "").substr(0, HOUSE_NAME_MAX - 1); slot.Color = ini.Get_Int(section.c_str(), "Color", -1); slot.Country = ini.Get_Int(section.c_str(), "Side", -1); slot.Address = Read_Text(ini, section.c_str(), "Ip", slot.Address); diff --git a/code/spawnerconfig.h b/code/spawnerconfig.h index 53ad0a6f..b7e69e03 100644 --- a/code/spawnerconfig.h +++ b/code/spawnerconfig.h @@ -10,6 +10,8 @@ #pragma once +#include "house.hh" + #include #include @@ -39,12 +41,6 @@ class SpawnerConfigClass Resume, }; - /* - * How much of a person's name the game keeps. A seat is judged and ordered by what is - * kept, since that is what every machine compares. - */ - static constexpr int NAME_KEPT = 19; - // A file marks a seat human by writing a section for it; an unwritten one is a computer. enum class OccupancyType { Empty, From ef6d2d7c5fc81e390c6cb9f0b686117412de4902 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 13:57:42 +0300 Subject: [PATCH 33/35] Move define above unrelated comment --- code/house.hh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/code/house.hh b/code/house.hh index 300db80b..a0df86a4 100644 --- a/code/house.hh +++ b/code/house.hh @@ -14,13 +14,12 @@ #pragma once +#define HOUSE_NAME_MAX 20 + /********************************************************************** ** The houses that can be played are listed here. Each has their own ** personality and strengths. */ -#define HOUSE_NAME_MAX 20 - - enum HousesType { HOUSE_NONE=-1, From 4aeb13ad0bf75db4dce2de6f3734dd0009471e68 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 14:06:04 +0300 Subject: [PATCH 34/35] Leave the house sort as the unique colors it assumes --- code/scenario.cpp | 13 +++---------- .../tests/documentation-source-contract.test.mjs | 5 ----- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/code/scenario.cpp b/code/scenario.cpp index e87f8ae0..f3215235 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -2134,8 +2134,8 @@ void Assign_Houses(void) // DebugString( "Assign_Houses()\n" ); //------------------------------------------------------------------------ // Assign each player in 'Players' to a multiplayer house. Players will - // be sorted by their chosen color value (a tie between colors is - // settled by the players' names). + // be sorted by their chosen color value (this value must be unique among + // all the players). //------------------------------------------------------------------------ for (i = 0; i < Session.Players.Count(); i++) { @@ -2148,14 +2148,7 @@ void Assign_Houses(void) //.................................................................. // If we've already assigned this house, skip it. //.................................................................. - if (assigned[j]) { - continue; - } - - // Each machine holds this list itself-first, so a color tie is settled by name. - if (index == -1 || Session.Players[j]->Player.Color < lowest_color || - (Session.Players[j]->Player.Color == lowest_color && - stricmp(Session.Players[j]->Name, Session.Players[index]->Name) < 0)) { + if (!assigned[j] && (lowest_color == -1 || Session.Players[j]->Player.Color < lowest_color)) { lowest_color = Session.Players[j]->Player.Color; index = j; } diff --git a/manual/site/tests/documentation-source-contract.test.mjs b/manual/site/tests/documentation-source-contract.test.mjs index d4050716..106f9d2b 100644 --- a/manual/site/tests/documentation-source-contract.test.mjs +++ b/manual/site/tests/documentation-source-contract.test.mjs @@ -408,11 +408,6 @@ test('A match against other machines is assembled whole and wired to its network 'if (!Ipx.Init()) {', ], 'the transport is chosen, the peers named, and only then the network opened'); - assertOrdered(functionBody(source('code/scenario.cpp'), 'void Assign_Houses(void)'), [ - 'stricmp(Session.Players[j]->Name, Session.Players[index]->Name) < 0', - 'PlayerPtr = housep;', - ], 'a color tie is settled by name, so every machine creates the houses in one order'); - assertOrdered(functionBody(source('code/scenario.cpp'), 'static NodeNameType * Seated_Node(int seat)'), [ 'Session.Players[i]->Player.ID == seat', 'Session.Computers[i]->Player.ID == seat', From 74a0ac8539649e389f717ba828f5414341e96b7e Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 14:08:42 +0300 Subject: [PATCH 35/35] Say only what the alliance mask names --- code/scenario.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/code/scenario.cpp b/code/scenario.cpp index f3215235..7c0814f5 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -2265,10 +2265,7 @@ void Assign_Houses(void) } } - /* - * The alliance table names seats in the order the houses above were created, and is - * applied before the neutral and special houses exist, since neither is a seat. - */ + // A seat's mask names other seats, not the houses they became. int seated = Session.Players.Count() + Session.Computers.Count(); for (int seatnum = 0; seatnum < seated; seatnum++) { NodeNameType * node = Seated_Node(seatnum);