From 13eee7be5e6dec7148584736617c99112d97ad82 Mon Sep 17 00:00:00 2001 From: lightbull Date: Mon, 10 Aug 2026 17:43:15 +0200 Subject: [PATCH 01/80] Remove defunct hooks --- mods/src/patches/parts/sync.cc | 131 +-------------------------------- 1 file changed, 1 insertion(+), 130 deletions(-) diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index 80f12ee9f..1e614c75c 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -2093,134 +2093,11 @@ void InstallSyncPatches() !game_server_model_registry.isValidHelper()) { ErrorMsg::MissingHelper("Core", "GameServerModelRegistry"); } else { - auto *ptr = game_server_model_registry.GetMethod("ProcessResultInternal"); - if (ptr == nullptr) { + if (auto *const ptr = game_server_model_registry.GetMethod("ProcessResultInternal"); ptr == nullptr) { ErrorMsg::MissingMethod("GameServerModelRegistry", "ProcessResultInternal"); } else { SPUD_STATIC_DETOUR(ptr, GameServerModelRegistry_ProcessResultInternal); } - - ptr = game_server_model_registry.GetMethod("ParseBinaryObjectsHelper"); - if (ptr == nullptr) { - ErrorMsg::MissingMethod("GameServerModelRegistry", "ParseBinaryObjectsHelper"); - } else { - SPUD_STATIC_DETOUR(ptr, GameServerModelRegistry_ParseBinaryObjectsHelper); - } - } -#if 0 - if (auto platform_model_registry = - il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimePlatform.Core", "PlatformModelRegistry"); - !platform_model_registry.isValidHelper()) { - ErrorMsg::MissingHelper("Core", "PlatformModelRegistry"); - } else { - if (auto *const ptr = platform_model_registry.GetMethod("ProcessResultInternal"); ptr == nullptr) { - ErrorMsg::MissingMethod("PlatformModelRegistry", "ProcessResultInternal"); - } else { - SPUD_STATIC_DETOUR(ptr, GameServerModelRegistry_ProcessResultInternal); - } - } -#endif - - if (auto buff_data_container = - il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Services", "BuffDataContainer"); - !buff_data_container.isValidHelper()) { - ErrorMsg::MissingHelper("Services", "BuffDataContainer"); - } else { - if (auto *const ptr = buff_data_container.GetMethod("ParseBinaryObject"); ptr == nullptr) { - ErrorMsg::MissingMethod("BuffDataContainer", "ParseBinaryObject"); - } else { - SPUD_STATIC_DETOUR(ptr, DataContainer_ParseBinaryObject); - } - } - -#if __APPLE__ - // 1.000.49105: BuffService.ParseBinaryObject is a 0x18-byte body immediately before HandleResponseData. - // Spud's ARM64 absolute jump is larger than that, so detouring it overwrites the next function entry. - spdlog::info("Skipping BuffService hook lookup on macOS"); -#else - if (auto buff_service = - il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Services", "BuffService"); - !buff_service.isValidHelper()) { - ErrorMsg::MissingHelper("Services", "BuffService"); - } else { - if (auto *const ptr = buff_service.GetMethod("ParseBinaryObject"); ptr == nullptr) { - ErrorMsg::MissingMethod("BuffService", "ParseBinaryObject"); - } else { - SPUD_STATIC_DETOUR(ptr, DataContainer_ParseBinaryObject); - } - } -#endif - - if (auto inventory_data_container = il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", - "Digit.PrimeServer.Services", "InventoryDataContainer"); - !inventory_data_container.isValidHelper()) { - ErrorMsg::MissingHelper("Services", "InventoryDataContainer"); - } else { - if (auto *const ptr = inventory_data_container.GetMethod("ParseBinaryObject"); ptr == nullptr) { - ErrorMsg::MissingMethod("InventoryDataContainer", "ParseBinaryObject"); - } else { - SPUD_STATIC_DETOUR(ptr, DataContainer_ParseBinaryObject); - } - } - - if (auto job_service = - il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Services", "JobService"); - !job_service.isValidHelper()) { - ErrorMsg::MissingHelper("Services", "JobService"); - } else { - if (auto *const ptr = job_service.GetMethod("ParseBinaryObject"); ptr == nullptr) { - ErrorMsg::MissingMethod("JobService", "ParseBinaryObject"); - } else { - SPUD_STATIC_DETOUR(ptr, DataContainer_ParseBinaryObject); - } - } - - if (auto job_service_data_container = il2cpp_get_class_helper( - "Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Services", "JobServiceDataContainer"); - !job_service_data_container.isValidHelper()) { - ErrorMsg::MissingHelper("Services", "JobServiceDataContainer"); - } else { - if (auto *const ptr = job_service_data_container.GetMethod("ParseBinaryObject"); ptr == nullptr) { - ErrorMsg::MissingMethod("JobServiceDataContainer", "ParseBinaryObject"); - } else { - SPUD_STATIC_DETOUR(ptr, DataContainer_ParseBinaryObject); - } - } - - if (auto missions_data_container = - il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "MissionsDataContainer"); - !missions_data_container.isValidHelper()) { - ErrorMsg::MissingHelper("Models", "MissionsDataContainer"); - } else { - if (auto *const ptr = missions_data_container.GetMethod("ParseBinaryObject"); ptr == nullptr) { - ErrorMsg::MissingMethod("MissionsDataContainer", "ParseBinaryObject"); - } else { - SPUD_STATIC_DETOUR(ptr, DataContainer_ParseBinaryObject); - } - } - - if (auto research_data_container = il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", - "Digit.PrimeServer.Services", "ResearchDataContainer"); - !research_data_container.isValidHelper()) { - ErrorMsg::MissingHelper("Services", "ResearchDataContainer"); - } else { - if (auto *const ptr = research_data_container.GetMethod("ParseBinaryObject"); ptr == nullptr) { - ErrorMsg::MissingHelper("ResearchDataContainer", "ParseBinaryObject"); - } else { - SPUD_STATIC_DETOUR(ptr, DataContainer_ParseBinaryObject); - } - } - - if (auto research_service = - il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Services", "ResearchService"); - !research_service.isValidHelper()) { - ErrorMsg::MissingHelper("Services", "ResearchService"); - } else { - if (auto *const ptr = research_service.GetMethod("ParseBinaryObject"); ptr == nullptr) { - ErrorMsg::MissingMethod("ResearchService", "ParseBinaryObject"); - } else { - SPUD_STATIC_DETOUR(ptr, DataContainer_ParseBinaryObject); - } } if (auto slot_data_container = @@ -2228,12 +2105,6 @@ void InstallSyncPatches() !slot_data_container.isValidHelper()) { ErrorMsg::MissingHelper("Services", "SlotDataContainer"); } else { - if (auto *const ptr = slot_data_container.GetMethod("ParseBinaryObject"); ptr == nullptr) { - ErrorMsg::MissingMethod("SlotDataContainer", "ParseBinaryObject"); - } else { - SPUD_STATIC_DETOUR(ptr, DataContainer_ParseBinaryObject); - } - if (auto *const ptr = slot_data_container.GetMethod("ParseEntitySlotsData"); ptr == nullptr) { ErrorMsg::MissingMethod("SlotDataContainer", "ParseEntitySlotsData"); } else { From 8de3ef4ff645bce5bc5f5da14e94999b3dffccac Mon Sep 17 00:00:00 2001 From: lightbull Date: Mon, 10 Aug 2026 17:55:12 +0200 Subject: [PATCH 02/80] Update protobuf message definitions --- .../proto/Digit.PrimeServer.Models.proto | 296 ++++++++++++++---- 1 file changed, 236 insertions(+), 60 deletions(-) diff --git a/mods/src/prime/proto/Digit.PrimeServer.Models.proto b/mods/src/prime/proto/Digit.PrimeServer.Models.proto index 2421eeba6..93efa50e7 100644 --- a/mods/src/prime/proto/Digit.PrimeServer.Models.proto +++ b/mods/src/prime/proto/Digit.PrimeServer.Models.proto @@ -155,6 +155,58 @@ enum StatusCounterType { STATUSCOUNTERTYPE_ASSIMILATIONPOINTS = 1; } +enum EntityType { + ENTITYTYPE_INVALID = 0; + ENTITYTYPE_PLAYER = 859804212; + ENTITYTYPE_ALLIANCE = 1794481137; + ENTITYTYPE_DEPLOYEDFLEET = 2129559302; + ENTITYTYPE_NPC = 2100681965; + ENTITYTYPE_OFFICERS = 1211023646; + ENTITYTYPE_SHIP = 73015892; + ENTITYTYPE_SYSTEM = 232004982; + ENTITYTYPE_PARTY = 2028949976; + ENTITYTYPE_GAMEWORLD = 1025095082; + ENTITYTYPE_OUTPOST = 853180751; +} + +enum ActivatedAbilityType { + ACTIVATEDABILITYTYPE_INVALID = 0; + ACTIVATEDABILITYTYPE_INSTANTWARP = 1; + ACTIVATEDABILITYTYPE_TOWING = 2; + ACTIVATEDABILITYTYPE_CLOAKING = 3; + ACTIVATEDABILITYTYPE_SUPPORTSHIP = 4; + ACTIVATEDABILITYTYPE_DEBUFFSHIP = 5; + ACTIVATEDABILITYTYPE_WARSHIELD = 6; + ACTIVATEDABILITYTYPE_ARMADASUPPORT = 7; + ACTIVATEDABILITYTYPE_WEAPONDAMAGE = 8; + ACTIVATEDABILITYTYPE_WEAPONPENETRATION = 9; + ACTIVATEDABILITYTYPE_WEAPONSHOTS = 10; + ACTIVATEDABILITYTYPE_CRITICALDAMAGE = 11; + ACTIVATEDABILITYTYPE_SYSTEMWIDEBUFF = 12; + ACTIVATEDABILITYTYPE_SYSTEMWIDESUPREMEBUFF = 13; + ACTIVATEDABILITYTYPE_REVEAL = 14; + ACTIVATEDABILITYTYPE_DEEPSCAN = 15; + ACTIVATEDABILITYTYPE_ISOLYTICDAMAGE = 16; + ACTIVATEDABILITYTYPE_ISOLYTICMITIGATION = 17; + ACTIVATEDABILITYTYPE_CUTTINGBEAM = 18; + ACTIVATEDABILITYTYPE_ASSIMILATION = 19; + ACTIVATEDABILITYTYPE_SYSTEMREVEAL = 20; + ACTIVATEDABILITYTYPE_WORMHOLE = 21; + ACTIVATEDABILITYTYPE_CUTTINGBEAMIMMUNITY = 22; + ACTIVATEDABILITYTYPE_SELFHEALING = 24; + ACTIVATEDABILITYTYPE_INDESTRUCTIBILITY = 25; + ACTIVATEDABILITYTYPE_TAUNT = 71001; + ACTIVATEDABILITYTYPE_STATICABILITY = 75001; + ACTIVATEDABILITYTYPE_CHARGEDABILITY = 75002; + ACTIVATEDABILITYTYPE_CHAINSHOT = 77001; + ACTIVATEDABILITYTYPE_CHAINSHOTIMMUNITY = 78001; + ACTIVATEDABILITYTYPE_AUTOHUNTING = 83001; + ACTIVATEDABILITYTYPE_ENHANCEABILITY = 86001; + ACTIVATEDABILITYTYPE_REVEALHOSTILEVGER = 86002; + ACTIVATEDABILITYTYPE_BORGSPHEREPROTOCOL = 89001; + ACTIVATEDABILITYTYPE_AMPLIFYABILITY = 91001; +} + enum ArmadaAttackState { ARMADAATTACKSTATE_IDLE = 0; ARMADAATTACKSTATE_PREBATTLE = 1; @@ -827,20 +879,6 @@ enum InventoryId { INVENTORYID_UNDEFINED = -1; } -enum EntityType { - ENTITYTYPE_INVALID = 0; - ENTITYTYPE_PLAYER = 859804212; - ENTITYTYPE_ALLIANCE = 1794481137; - ENTITYTYPE_DEPLOYEDFLEET = 2129559302; - ENTITYTYPE_NPC = 2100681965; - ENTITYTYPE_OFFICERS = 1211023646; - ENTITYTYPE_SHIP = 73015892; - ENTITYTYPE_SYSTEM = 232004982; - ENTITYTYPE_PARTY = 2028949976; - ENTITYTYPE_GAMEWORLD = 1025095082; - ENTITYTYPE_OUTPOST = 853180751; -} - enum SlotType { SLOTTYPE_CONSUMABLE = 0; SLOTTYPE_WORKER = 1; @@ -1065,44 +1103,6 @@ enum ActionBehaviours { ACTIONBEHAVIOURS_ACTIONBEHAVIOURREQUESTCONFIRMATION = 50; } -enum ActivatedAbilityType { - ACTIVATEDABILITYTYPE_INVALID = 0; - ACTIVATEDABILITYTYPE_INSTANTWARP = 1; - ACTIVATEDABILITYTYPE_TOWING = 2; - ACTIVATEDABILITYTYPE_CLOAKING = 3; - ACTIVATEDABILITYTYPE_SUPPORTSHIP = 4; - ACTIVATEDABILITYTYPE_DEBUFFSHIP = 5; - ACTIVATEDABILITYTYPE_WARSHIELD = 6; - ACTIVATEDABILITYTYPE_ARMADASUPPORT = 7; - ACTIVATEDABILITYTYPE_WEAPONDAMAGE = 8; - ACTIVATEDABILITYTYPE_WEAPONPENETRATION = 9; - ACTIVATEDABILITYTYPE_WEAPONSHOTS = 10; - ACTIVATEDABILITYTYPE_CRITICALDAMAGE = 11; - ACTIVATEDABILITYTYPE_SYSTEMWIDEBUFF = 12; - ACTIVATEDABILITYTYPE_SYSTEMWIDESUPREMEBUFF = 13; - ACTIVATEDABILITYTYPE_REVEAL = 14; - ACTIVATEDABILITYTYPE_DEEPSCAN = 15; - ACTIVATEDABILITYTYPE_ISOLYTICDAMAGE = 16; - ACTIVATEDABILITYTYPE_ISOLYTICMITIGATION = 17; - ACTIVATEDABILITYTYPE_CUTTINGBEAM = 18; - ACTIVATEDABILITYTYPE_ASSIMILATION = 19; - ACTIVATEDABILITYTYPE_SYSTEMREVEAL = 20; - ACTIVATEDABILITYTYPE_WORMHOLE = 21; - ACTIVATEDABILITYTYPE_CUTTINGBEAMIMMUNITY = 22; - ACTIVATEDABILITYTYPE_SELFHEALING = 24; - ACTIVATEDABILITYTYPE_INDESTRUCTIBILITY = 25; - ACTIVATEDABILITYTYPE_TAUNT = 71001; - ACTIVATEDABILITYTYPE_STATICABILITY = 75001; - ACTIVATEDABILITYTYPE_CHARGEDABILITY = 75002; - ACTIVATEDABILITYTYPE_CHAINSHOT = 77001; - ACTIVATEDABILITYTYPE_CHAINSHOTIMMUNITY = 78001; - ACTIVATEDABILITYTYPE_AUTOHUNTING = 83001; - ACTIVATEDABILITYTYPE_ENHANCEABILITY = 86001; - ACTIVATEDABILITYTYPE_REVEALHOSTILEVGER = 86002; - ACTIVATEDABILITYTYPE_BORGSPHEREPROTOCOL = 89001; - ACTIVATEDABILITYTYPE_AMPLIFYABILITY = 91001; -} - enum BuffSchemaType { BUFFSCHEMATYPE_NONE = 0; BUFFSCHEMATYPE_BATTLEBUFFSCHEMA = 1; @@ -1195,6 +1195,42 @@ enum PeaceShieldType { PEACESHIELDTYPE_MINE = 3; } +message ActivatedAbilityDataStatusEffect { + int64 entityId = 1; + EntityType entityType = 2; + int64 statusEffect = 3; + google.protobuf.Timestamp expiryDate = 4; +} + +message ActivatedAbilityDataCooldown { + int64 entityId = 1; + EntityType entityType = 2; + ActivatedAbilityType activatedAbilityType = 3; + google.protobuf.Timestamp expiryDate = 4; +} + +message ActivatedAbilityDeepScanData { + int64 starbaseSystemId = 1; + google.protobuf.Timestamp starbasePeaceShieldExpiryTimestamp = 2; +} + +message ActivatedAbilityDataMetaEntry { + int64 entityId = 1; + EntityType entityType = 2; + ActivatedAbilityDeepScanData deepScanResponse = 3; + bool successful = 4; + int64 systemId = 5; + google.protobuf.Timestamp expiryDate = 6; + int64 abilityId = 7; +} + +message ActivatedAbilityDataResponse { + ActivatedAbilityDataStatusEffect statusEffect = 1; + ActivatedAbilityDataCooldown cooldown = 2; + repeated ActivatedAbilityDataStatusEffect targetedFleetsStatusEffects = 3; + repeated ActivatedAbilityDataMetaEntry abilityData = 4; +} + message AllianceProfile { int64 id = 1; string name = 2; @@ -1207,6 +1243,46 @@ message AllianceProfile { int32 league = 9; repeated int32 profileTags = 10; bool allowApplication = 11; + string slogan = 12; + string announcement = 13; +} + +message AllianceDiplomacyRelationshipsResponse { + map relationships = 1; +} + +message AllianceJobEntry { + string userId = 1; + int32 jobType = 2; + google.protobuf.Timestamp jobDueTime = 3; + repeated string helpingUserIds = 4; +} + +message AllianceJobHelpInfoResponse { + map jobs = 1; +} + +message AllianceMembersActivityResponse { + map membersActivity = 1; +} + +message AllianceMembersContributionResponse { + map membersContribution = 1; +} + +message AllianceMembersResponse { + map members = 1; +} + +message AllianceMemberEntry { + int64 rank = 1; + google.protobuf.Timestamp createdAt = 2; +} + +message AllianceRankChangeResponse { + int64 allianceId = 1; + int64 newRankId = 2; + string memberUserId = 3; } message ArmadaAttack { @@ -1420,6 +1496,14 @@ message FactionStandingsResponse { map factionTiers = 1; } +message FleetSkillEntry { + map cooldowns = 1; +} + +message FleetSkillsResponse { + map playerFleetSkills = 1; +} + message ForbiddenTech { int32 level = 1; int32 tier = 2; @@ -2109,6 +2193,10 @@ message Inventory { int32 numExtensions = 4; } +message ResourcesResponse { + repeated ResourceEntry resources = 1; +} + message ResearchTreeSpec { int64 id = 1; IdRefs idRefs = 2; @@ -2176,6 +2264,14 @@ message OfficerData { int64 officerRank = 3; } +message StarbaseShield { + google.protobuf.Timestamp triggeredOn = 1; + google.protobuf.Timestamp expiryTime = 3; + int64 id = 4; + int32 target = 5; + int64 peaceShieldResourceId = 6; +} + message EntitySlot { oneof Params { ConsumableSlotParams consumableSlotParams = 5; @@ -2573,6 +2669,32 @@ message TraitCostsPerLevelSpec { map costsPerLevel = 2; } +message ResourceProducerEntry { + google.protobuf.Timestamp lastSnapshot = 1; + google.protobuf.Timestamp lastHarvest = 2; + double currentAmount = 3; + bool isActive = 4; + double totalAmount = 5; + int32 id = 6; +} + +message ResourceProducersResponse { + repeated ResourceProducerEntry producers = 1; +} + +message StarbaseModuleInfo { + int64 id = 1; + int64 level = 2; +} + +message StarbaseModulesResponse { + repeated StarbaseModuleInfo moduleStates = 1; +} + +message StarbaseResponse { + StarbaseInfo starbase = 1; +} + message AllianceAssignedTerritorySlot { enum TerritorySlotState { TERRITORYSLOTSTATE_TAKEOVER = 0; @@ -2963,6 +3085,11 @@ message ChallengeConfig { int32 minimumLevelRequired = 1; } +message CheatConfigResponse { + repeated string prebuiltShipNames = 1; + repeated string filterSetNames = 2; +} + message ClientShipStatLookupSpec { repeated ShipStats shipStats = 1; } @@ -3144,6 +3271,14 @@ message OfficerConfig { map statMaxima = 1; } +message OpenedChestItems { + repeated InventoryItem items = 1; +} + +message OpenedChestsResponse { + map openedChests = 1; +} + message PeaceShieldRulesConfig { int32 suspendSystemLevel = 1; } @@ -3152,6 +3287,12 @@ message PlacementConfig { int32 containerLimit = 1; } +message ResourceEntry { + int64 id = 1; + int64 currentAmount = 2; + int64 upperCapacity = 3; +} + message ResourceConfig { int64 officerExchangeCurrencyId = 1; int32 harvestCooldown = 2; @@ -3159,6 +3300,19 @@ message ResourceConfig { int64 hardCurrencyId = 4; } +message ResourceDeltaEntry { + int64 id = 1; + int64 amount = 2; +} + +message ResourcesDeltaResponse { + repeated ResourceDeltaEntry resources = 1; +} + +message AllianceResourcesDeltaResponse { + repeated ResourceDeltaEntry resources = 1; +} + message EntityGroup { enum Type { TYPE_USERPROFILES = 0; @@ -3349,6 +3503,29 @@ enum Type { TYPE_CROSSALLIANCEARMADASFROMREGIONALRESPONSE = 216; TYPE_PLAYERPARTYRESPONSE = 217; TYPE_USERHISTORY = 218; + TYPE_BATTLERESULTHEADERS = 219; + TYPE_HAZARDRESULTHEADERS = 220; + TYPE_BATTLEREPORT = 221; + TYPE_ACTIVATEDABILITYDATA = 222; + TYPE_MYSKILLDATA = 223; + TYPE_CHEATCONFIG = 224; + TYPE_ALLIANCEDETAILS = 225; + TYPE_ALLIANCEMEMBERS = 226; + TYPE_ALLIANCEMEMBER = 227; + TYPE_ALLIANCEDIPLOMACY = 228; + TYPE_ALLIANCECONTRIBUTIONS = 229; + TYPE_ALLIANCEMEMBERACTIVITY = 230; + TYPE_ALLIANCEJOBHELPINFO = 231; + TYPE_ALLIANCERANKCHANGE = 232; + TYPE_ALLIANCECREATEAPPLICATION = 233; + TYPE_RESOURCES = 234; + TYPE_RESOURCESDELTA = 235; + TYPE_RESOURCESDELTAALLIANCE = 236; + TYPE_OPENEDCHESTS = 237; + TYPE_MYSHIELDSTATE = 238; + TYPE_STARBASEMODULES = 239; + TYPE_RESOURCEPRODUCERS = 240; + TYPE_STARBASE = 241; TYPE_OUTPOSTSTATICDATA = 8401; TYPE_OUTPOSTUPGRADERESPONSE = 8402; TYPE_OUTPOSTSYNC = 8403; @@ -3707,9 +3884,8 @@ message CrewData { map officers = 2; } -message StarbaseShield { - google.protobuf.Timestamp triggeredOn = 1; - google.protobuf.Timestamp expiryTime = 3; +message StarbaseShieldResponse { + StarbaseShield shield = 1; } message StarbaseDetailedScanResponse { @@ -4803,11 +4979,11 @@ message StarbaseInfo { Point coordinates = 4; google.protobuf.Timestamp lastRelocation = 5; NodeAddress destination = 6; -} - -message StarbaseModuleInfo { - int64 id = 1; - int64 level = 2; + StarbaseShield peaceShield = 7; + string battleId = 8; + google.protobuf.Timestamp ceasefireBrokenAt = 9; + string userId = 10; + google.protobuf.Timestamp autoPeaceShieldNextTrigger = 11; } message ResourceHarvester { From 08e7e0ddbc40e011e34d83fb7d0a91bd438a5991 Mon Sep 17 00:00:00 2001 From: lightbull Date: Mon, 10 Aug 2026 22:05:18 +0200 Subject: [PATCH 03/80] Update EntityGroup enum --- mods/src/prime/EntityGroup.h | 397 +++++++++++++++++++---------------- 1 file changed, 220 insertions(+), 177 deletions(-) diff --git a/mods/src/prime/EntityGroup.h b/mods/src/prime/EntityGroup.h index b307fcf1f..641b38797 100644 --- a/mods/src/prime/EntityGroup.h +++ b/mods/src/prime/EntityGroup.h @@ -40,183 +40,226 @@ struct ByteString { struct EntityGroup { public: enum Type { - UserProfiles = 0, - HullSpecs = 1, - ResourceSpecs = 2, - ResourceConversionSpecs = 3, - JobSpeedupResourceSpecs = 4, - StarbaseSpecs = 5, - OfficerSpecs = 7, - FactionSpecs = 8, - FactionBehaviourSpecs = 9, - UserConsumableSpecs = 10, // 0x0000000A - PlayerXpSpecs = 11, // 0x0000000B - ComponentSpecs = 12, // 0x0000000C - ObjectiveDefinitions = 13, // 0x0000000D - AllianceRankSpecs = 14, // 0x0000000E - AllianceLevelSpecs = 15, // 0x0000000F - AlliancePermissionSpecs = 16, // 0x00000010 - OfficerAbilityBuffSpecs = 17, // 0x00000011 - OfficerCoreStatSpecs = 18, // 0x00000012 - OfficerIntelRequirementSpecs = 19, // 0x00000013 - OfficerSynergyFactorSpecs = 20, // 0x00000014 - BlueprintSpecs = 21, // 0x00000015 - NavigationConfig = 22, // 0x00000016 - FleetConfig = 23, // 0x00000017 - FleetIconConfig = 24, // 0x00000018 - AllianceConfig = 25, // 0x00000019 - ConsistencyConfig = 26, // 0x0000001A - FtueConfig = 27, // 0x0000001B - PlacementConfig = 28, // 0x0000001C - DialogConfig = 29, // 0x0000001D - FactionConfig = 30, // 0x0000001E - ResourceConfig = 31, // 0x0000001F - FtueProgressionConfig = 32, // 0x00000020 - NewPlayerConfig = 33, // 0x00000021 - ThreatConfig = 34, // 0x00000022 - StationShieldConfig = 35, // 0x00000023 - PlanetSlotsConfig = 36, // 0x00000024 - OfficerConfig = 37, // 0x00000025 - BattleConfig = 38, // 0x00000026 - StarbaseConfig = 39, // 0x00000027 - ShipXpConfig = 40, // 0x00000028 - OptimisedGalaxy = 41, // 0x00000029 - Json = 42, // 0x0000002A - Officers = 43, // 0x0000002B - OfficerCoreStatThresholdsSpecs = 44, // 0x0000002C - OfficerPromotionSpecs = 45, // 0x0000002D - PlayerInventories = 46, // 0x0000002E - Notifications = 47, // 0x0000002F - ClientShipStatLookupSpecs = 48, // 0x00000030 - BaseShipTierSpecs = 49, // 0x00000031 - ShipTierSpecs = 50, // 0x00000032 - ShipBonusBuffSpecs = 51, // 0x00000033 - MitigationCapsSpecs = 52, // 0x00000034 - GlobalDamageReductionConfig = 53, // 0x00000035 - BuffTargetSpecs = 54, // 0x00000036 - BuffTriggerSpecs = 55, // 0x00000037 - Jobs = 56, // 0x00000038 - StarbaseDetailedScan = 57, // 0x00000039 - MissionSpecs = 58, // 0x0000003A - AvailableMissions = 59, // 0x0000003B - NodeMissions = 60, // 0x0000003C - ActiveMissions = 61, // 0x0000003D - ActionSpecs = 62, // 0x0000003E - AllianceMembersStarbasesLocations = 63, // 0x0000003F - CompletedMissions = 64, // 0x00000040 - ShipLevelUpBonusBuffsSpecs = 65, // 0x00000041 - ResearchSpecs = 66, // 0x00000042 - ResearchTreesState = 67, // 0x00000043 - StarbaseBuffs = 68, // 0x00000044 - GlobalActiveBuffs = 69, // 0x00000045 - Requirements = 70, // 0x00000046 - AllianceProfiles = 71, // 0x00000047 - AllianceInvites = 72, // 0x00000048 - AllianceLeaderInvites = 73, // 0x00000049 - PvpBanding = 74, // 0x0000004A - UserTemplates = 75, // 0x0000004B - ArmadaAttack = 76, // 0x0000004C - ArmadaAttackSpecs = 77, // 0x0000004D - ArmadaAttackSystemList = 78, // 0x0000004E - ArmadaAttackUserList = 79, // 0x0000004F - ArmadaConfig = 80, // 0x00000050 - ArmadaAttackAllianceAttackingList = 81, // 0x00000051 - ArmadaAttackIncomingThreatList = 82, // 0x00000052 - ServerTransferConfig = 83, // 0x00000053 - MiningSetupConfig = 84, // 0x00000054 - ScrapyardSpecs = 85, // 0x00000055 - ScrapyardJob = 86, // 0x00000056 - CosmeticSpecs = 87, // 0x00000057 - Ceasefire = 88, // 0x00000058 - SetAllianceDiplomacy = 89, // 0x00000059 - ArmadaPveSpecs = 90, // 0x0000005A - ToolingLootRoll = 91, // 0x0000005B - ToolingRespawnTimes = 92, // 0x0000005C - ArmadaEnRouteInfoList = 93, // 0x0000005D - FleetRepairCosts = 94, // 0x0000005E - PrestigeData = 95, // 0x0000005F - TerritoryStaticData = 96, // 0x00000060 - TerritoryAllOwners = 97, // 0x00000061 - TerritoryOwner = 98, // 0x00000062 - TerritoryAllTakeovers = 99, // 0x00000063 - TerritoryTakeover = 100, // 0x00000064 - TerritoryTakeoverCanJoin = 101, // 0x00000065 - AllianceGetBankResources = 102, // 0x00000066 - TerritoryActiveServices = 103, // 0x00000067 - TerritoryServiceSlots = 104, // 0x00000068 - TerritoryAllianceSlots = 105, // 0x00000069 - TerritoryCanActivateService = 106, // 0x0000006A - WorkerSpecs = 107, // 0x0000006B - BatchAttributeResponse = 108, // 0x0000006C - BuffsGetAttribute = 109, // 0x0000006D - AwayAssignmentsStatic = 110, // 0x0000006E - AwayAssignmentsList = 111, // 0x0000006F - AwayAssignmentsParameter = 112, // 0x00000070 - AwayAssignmentsInstance = 113, // 0x00000071 - ConsumableSpecs = 114, // 0x00000072 - SlotSpecs = 115, // 0x00000073 - ConsumableBuffs = 116, // 0x00000074 - EntitySlots = 117, // 0x00000075 - TraitsSpecs = 118, // 0x00000076 - OfficerTraitsSpecs = 119, // 0x00000077 - ActiveOfficerTraits = 120, // 0x00000078 - EntitySlotsData = 121, // 0x00000079 - LoyaltySpecs = 122, // 0x0000007A - PeaceShieldRulesSpecs = 123, // 0x0000007B - MarauderInfo = 124, // 0x0000007C - AllianceStarbaseConfig = 125, // 0x0000007D - StarbaseService = 126, // 0x0000007E - Gameworld = 127, // 0x0000007F - ActivatedAbilitySpecs = 128, // 0x00000080 - AchievementsConfig = 129, // 0x00000081 - ArmadaPvpSpecs = 130, // 0x00000082 - OfficerProgressRewardSpecs = 131, // 0x00000083 - CommanderSkillSpecs = 132, // 0x00000084 - CommanderIntelRequirementSpecs = 133, // 0x00000085 - HailingFreqConfig = 134, // 0x00000086 - OfficerLevelRewardsSpecs = 135, // 0x00000087 - ResourceGroupsSpec = 136, // 0x00000088 - ChallengeLadderSpecs = 137, // 0x00000089 - BundleRewardsSpecs = 138, // 0x0000008A - ForbiddenTechSpecs = 139, // 0x0000008B - ForbiddenTechs = 140, // 0x0000008C - ForbiddenTechBuffs = 141, // 0x0000008D - ForbiddenTechRemovalCosts = 142, // 0x0000008E - ForbiddenTechInstance = 143, // 0x0000008F - ForbiddenTechChances = 144, // 0x00000090 - ForbiddenTechConfig = 145, // 0x00000091 - ChallengeConfig = 146, // 0x00000092 - ForbiddenTechUpgradeCosts = 147, // 0x00000093 - HazardSpecs = 148, // 0x00000094 - ActivatedShipAbilitiesConfigs = 149, // 0x00000095 - WaveDefenseStaticData = 150, // 0x00000096 - WaveDefensePartyData = 151, // 0x00000097 - WaveDefenseSyncData = 152, // 0x00000098 - WaveDefenseChallengeData = 153, // 0x00000099 - UserProfileSettings = 154, // 0x0000009A - ActiveWormholes = 155, // 0x0000009B - MirrorUniversePortalData = 156, // 0x0000009C - MirrorUniverseDuration = 157, // 0x0000009D - CheckAccountResponse = 158, // 0x0000009E - AllianceLoyaltyStaticData = 159, // 0x0000009F - AllianceGetGameProperties = 160, // 0x000000A0 - LoyaltyTierRewards = 161, // 0x000000A1 - GameActivityRanksData = 162, // 0x000000A2 - GameActivity = 163, // 0x000000A3 - GameActivitySpecs = 164, // 0x000000A4 - GameActivityParticipantSpecs = 165, // 0x000000A5 - GameActivityDetailedSpec = 166, // 0x000000A6 - GameActivityScheduleSpec = 167, // 0x000000A7 - AllianceParties = 168, // 0x000000A8 - PlayerParty = 169, // 0x000000A9 - Party = 200, // 0x000000C8 - CreatedParty = 201, // 0x000000C9 - PartySpecs = 202, // 0x000000CA - ResourceAutoConvertSpecs = 203, // 0x000000CB - VisitedSystems = 204, // 0x000000CC - AllianceTagSpecs = 207, // 0x000000CF - GameActivityRewardsSpec = 208, // 0x000000D0 - GameActivityIndex = 209, // 0x000000D1 + UserProfiles = 0, + HullSpecs = 1, + ResourceSpecs = 2, + ResourceConversionSpecs = 3, + JobSpeedupResourceSpecs = 4, + StarbaseSpecs = 5, + OfficerSpecs = 7, + FactionSpecs = 8, + FactionBehaviourSpecs = 9, + UserConsumableSpecs = 10, // 0x0000000A + PlayerXpSpecs = 11, // 0x0000000B + ComponentSpecs = 12, // 0x0000000C + ObjectiveDefinitions = 13, // 0x0000000D + AllianceRankSpecs = 14, // 0x0000000E + AllianceLevelSpecs = 15, // 0x0000000F + AlliancePermissionSpecs = 16, // 0x00000010 + OfficerAbilityBuffSpecs = 17, // 0x00000011 + OfficerCoreStatSpecs = 18, // 0x00000012 + OfficerIntelRequirementSpecs = 19, // 0x00000013 + OfficerSynergyFactorSpecs = 20, // 0x00000014 + BlueprintSpecs = 21, // 0x00000015 + NavigationConfig = 22, // 0x00000016 + FleetConfig = 23, // 0x00000017 + FleetIconConfig = 24, // 0x00000018 + AllianceConfig = 25, // 0x00000019 + ConsistencyConfig = 26, // 0x0000001A + FtueConfig = 27, // 0x0000001B + PlacementConfig = 28, // 0x0000001C + DialogConfig = 29, // 0x0000001D + FactionConfig = 30, // 0x0000001E + ResourceConfig = 31, // 0x0000001F + FtueProgressionConfig = 32, // 0x00000020 + NewPlayerConfig = 33, // 0x00000021 + ThreatConfig = 34, // 0x00000022 + StationShieldConfig = 35, // 0x00000023 + PlanetSlotsConfig = 36, // 0x00000024 + OfficerConfig = 37, // 0x00000025 + BattleConfig = 38, // 0x00000026 + StarbaseConfig = 39, // 0x00000027 + ShipXpConfig = 40, // 0x00000028 + OptimisedGalaxy = 41, // 0x00000029 + Json = 42, // 0x0000002A + Officers = 43, // 0x0000002B + OfficerCoreStatThresholdsSpecs = 44, // 0x0000002C + OfficerPromotionSpecs = 45, // 0x0000002D + PlayerInventories = 46, // 0x0000002E + Notifications = 47, // 0x0000002F + ClientShipStatLookupSpecs = 48, // 0x00000030 + BaseShipTierSpecs = 49, // 0x00000031 + ShipTierSpecs = 50, // 0x00000032 + ShipBonusBuffSpecs = 51, // 0x00000033 + MitigationCapsSpecs = 52, // 0x00000034 + GlobalDamageReductionConfig = 53, // 0x00000035 + BuffTargetSpecs = 54, // 0x00000036 + BuffTriggerSpecs = 55, // 0x00000037 + Jobs = 56, // 0x00000038 + StarbaseDetailedScan = 57, // 0x00000039 + MissionSpecs = 58, // 0x0000003A + AvailableMissions = 59, // 0x0000003B + NodeMissions = 60, // 0x0000003C + ActiveMissions = 61, // 0x0000003D + ActionSpecs = 62, // 0x0000003E + AllianceMembersStarbasesLocations = 63, // 0x0000003F + CompletedMissions = 64, // 0x00000040 + ShipLevelUpBonusBuffsSpecs = 65, // 0x00000041 + ResearchSpecs = 66, // 0x00000042 + ResearchTreesState = 67, // 0x00000043 + StarbaseBuffs = 68, // 0x00000044 + GlobalActiveBuffs = 69, // 0x00000045 + Requirements = 70, // 0x00000046 + AllianceProfiles = 71, // 0x00000047 + AllianceInvites = 72, // 0x00000048 + AllianceLeaderInvites = 73, // 0x00000049 + PvpBanding = 74, // 0x0000004A + UserTemplates = 75, // 0x0000004B + ArmadaAttack = 76, // 0x0000004C + ArmadaAttackSpecs = 77, // 0x0000004D + ArmadaAttackSystemList = 78, // 0x0000004E + ArmadaAttackUserList = 79, // 0x0000004F + ArmadaConfig = 80, // 0x00000050 + ArmadaAttackAllianceAttackingList = 81, // 0x00000051 + ArmadaAttackIncomingThreatList = 82, // 0x00000052 + ServerTransferConfig = 83, // 0x00000053 + MiningSetupConfig = 84, // 0x00000054 + ScrapyardSpecs = 85, // 0x00000055 + ScrapyardJob = 86, // 0x00000056 + CosmeticSpecs = 87, // 0x00000057 + Ceasefire = 88, // 0x00000058 + SetAllianceDiplomacy = 89, // 0x00000059 + ArmadaPveSpecs = 90, // 0x0000005A + ToolingLootRoll = 91, // 0x0000005B + ToolingRespawnTimes = 92, // 0x0000005C + ArmadaEnRouteInfoList = 93, // 0x0000005D + FleetRepairCosts = 94, // 0x0000005E + PrestigeData = 95, // 0x0000005F + TerritoryStaticData = 96, // 0x00000060 + TerritoryAllOwners = 97, // 0x00000061 + TerritoryOwner = 98, // 0x00000062 + TerritoryAllTakeovers = 99, // 0x00000063 + TerritoryTakeover = 100, // 0x00000064 + TerritoryTakeoverCanJoin = 101, // 0x00000065 + AllianceGetBankResources = 102, // 0x00000066 + TerritoryActiveServices = 103, // 0x00000067 + TerritoryServiceSlots = 104, // 0x00000068 + TerritoryAllianceSlots = 105, // 0x00000069 + TerritoryCanActivateService = 106, // 0x0000006A + WorkerSpecs = 107, // 0x0000006B + BatchAttributeResponse = 108, // 0x0000006C + BuffsGetAttribute = 109, // 0x0000006D + AwayAssignmentsStatic = 110, // 0x0000006E + AwayAssignmentsList = 111, // 0x0000006F + AwayAssignmentsParameter = 112, // 0x00000070 + AwayAssignmentsInstance = 113, // 0x00000071 + ConsumableSpecs = 114, // 0x00000072 + SlotSpecs = 115, // 0x00000073 + ConsumableBuffs = 116, // 0x00000074 + EntitySlots = 117, // 0x00000075 + TraitsSpecs = 118, // 0x00000076 + OfficerTraitsSpecs = 119, // 0x00000077 + ActiveOfficerTraits = 120, // 0x00000078 + EntitySlotsData = 121, // 0x00000079 + LoyaltySpecs = 122, // 0x0000007A + PeaceShieldRulesSpecs = 123, // 0x0000007B + MarauderInfo = 124, // 0x0000007C + AllianceStarbaseConfig = 125, // 0x0000007D + StarbaseService = 126, // 0x0000007E + Gameworld = 127, // 0x0000007F + ActivatedAbilitySpecs = 128, // 0x00000080 + AchievementsConfig = 129, // 0x00000081 + ArmadaPvpSpecs = 130, // 0x00000082 + OfficerProgressRewardSpecs = 131, // 0x00000083 + CommanderSkillSpecs = 132, // 0x00000084 + CommanderIntelRequirementSpecs = 133, // 0x00000085 + HailingFreqConfig = 134, // 0x00000086 + OfficerLevelRewardsSpecs = 135, // 0x00000087 + ResourceGroupsSpec = 136, // 0x00000088 + ChallengeLadderSpecs = 137, // 0x00000089 + BundleRewardsSpecs = 138, // 0x0000008A + ForbiddenTechSpecs = 139, // 0x0000008B + ForbiddenTechs = 140, // 0x0000008C + ForbiddenTechBuffs = 141, // 0x0000008D + ForbiddenTechRemovalCosts = 142, // 0x0000008E + ForbiddenTechInstance = 143, // 0x0000008F + ForbiddenTechChances = 144, // 0x00000090 + ForbiddenTechConfig = 145, // 0x00000091 + ChallengeConfig = 146, // 0x00000092 + ForbiddenTechUpgradeCosts = 147, // 0x00000093 + HazardSpecs = 148, // 0x00000094 + ActivatedShipAbilitiesConfigs = 149, // 0x00000095 + WaveDefenseStaticData = 150, // 0x00000096 + WaveDefensePartyData = 151, // 0x00000097 + WaveDefenseSyncData = 152, // 0x00000098 + WaveDefenseChallengeData = 153, // 0x00000099 + UserProfileSettings = 154, // 0x0000009A + ActiveWormholes = 155, // 0x0000009B + MirrorUniversePortalData = 156, // 0x0000009C + MirrorUniverseDuration = 157, // 0x0000009D + CheckAccountResponse = 158, // 0x0000009E + AllianceLoyaltyStaticData = 159, // 0x0000009F + AllianceGetGameProperties = 160, // 0x000000A0 + LoyaltyTierRewards = 161, // 0x000000A1 + GameActivityRanksData = 162, // 0x000000A2 + GameActivity = 163, // 0x000000A3 + GameActivitySpecs = 164, // 0x000000A4 + GameActivityParticipantSpecs = 165, // 0x000000A5 + GameActivityDetailedSpec = 166, // 0x000000A6 + GameActivityScheduleSpec = 167, // 0x000000A7 + AllianceParties = 168, // 0x000000A8 + PlayerParty = 169, // 0x000000A9 + Party = 200, // 0x000000C8 + CreatedParty = 201, // 0x000000C9 + PartySpecs = 202, // 0x000000CA + ResourceAutoConvertSpecs = 203, // 0x000000CB + VisitedSystems = 204, // 0x000000CC + LeasedQueueSpecs = 205, // 0x000000CD + LeasedQueueRecord = 206, // 0x000000CE + AllianceTagSpecs = 207, // 0x000000CF + GameActivityRewardsSpec = 208, // 0x000000D0 + GameActivityIndex = 209, // 0x000000D1 + AllianceApplication = 210, // 0x000000D2 + ScrapyardRewards = 211, // 0x000000D3 + FactionStanding = 212, // 0x000000D4 + ArmadaAttackUserListSyncResponse = 213, // 0x000000D5 + ArmadaAttackGetFromAllianceResponse = 214, // 0x000000D6 + ArmadaAttackIncomingThreatListResponse = 215, // 0x000000D7 + CrossAllianceArmadasFromRegionalResponse = 216, // 0x000000D8 + PlayerPartyResponse = 217, // 0x000000D9 + UserHistory = 218, // 0x000000DA + BattleResultHeaders = 219, // 0x000000DB + HazardResultHeaders = 220, // 0x000000DC + BattleReport = 221, // 0x000000DD + ActivatedAbilityData = 222, // 0x000000DE + MySkillData = 223, // 0x000000DF + CheatConfig = 224, // 0x000000E0 + AllianceDetails = 225, // 0x000000E1 + AllianceMembers = 226, // 0x000000E2 + AllianceMember = 227, // 0x000000E3 + AllianceDiplomacy = 228, // 0x000000E4 + AllianceContributions = 229, // 0x000000E5 + AllianceMemberActivity = 230, // 0x000000E6 + AllianceJobHelpInfo = 231, // 0x000000E7 + AllianceRankChange = 232, // 0x000000E8 + AllianceCreateApplication = 233, // 0x000000E9 + Resources = 234, // 0x000000EA + ResourcesDelta = 235, // 0x000000EB + ResourcesDeltaAlliance = 236, // 0x000000EC + OpenedChests = 237, // 0x000000ED + MyShieldState = 238, // 0x000000EE + StarbaseModules = 239, // 0x000000EF + ResourceProducers = 240, // 0x000000F0 + Starbase = 241, // 0x000000F1 + OutpostStaticData = 8401, // 0x000020D1 + OutpostUpgradeResponse = 8402, // 0x000020D2 + OutpostSync = 8403, // 0x000020D3 + OutpostDefaultResponse = 8404, // 0x000020D4 + OutpostStaticSpecs = 8405, // 0x000020D5 + GalacticAnomalyBuffSpecs = 8501, // 0x00002135 + DynamicGalaxyDataSync = 8502, // 0x00002136 + CrossAllianceArmadas = 87001, // 0x000153D9 + Tracker = 9301, // 0x00002455 }; __declspec(property(get = __get_Type)) Type Type_; From 0a62fff828dfcec771e65603e12c6470c31d2301 Mon Sep 17 00:00:00 2001 From: lightbull Date: Tue, 11 Aug 2026 01:00:26 +0200 Subject: [PATCH 04/80] Refactor `sync.cc`, fix resources and starbase module sync, add support for alliance resources --- mods/src/patches/parts/sync.cc | 2427 ++++++++++++++++++-------------- 1 file changed, 1352 insertions(+), 1075 deletions(-) diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index 1e614c75c..63c2cf059 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -3,13 +3,12 @@ #include "file.h" #include "str_utils.h" -#include #include +#include #include #include -#include -#include #include +#include #include #include @@ -67,6 +66,55 @@ struct WinRtApartmentGuard { }; #endif +NLOHMANN_JSON_NAMESPACE_BEGIN +template struct adl_serializer> { + static void to_json(json& j, const google::protobuf::RepeatedField& proto) + { + j = json::array(); + + for (const auto& v : proto) { + j.push_back(v); + } + } + + static void from_json(const json& j, google::protobuf::RepeatedField& proto) + { + if (j.is_array()) { + for (const auto& v : j) { + proto.Add(v.get()); + } + } + } +}; + +template <> struct adl_serializer { + static void to_json(json& j, const SyncConfig::Type t) + { + for (const auto& opt : SyncOptions) { + if (opt.type == t) { + j = opt.type_str; + return; + } + } + + j = nullptr; + } + + static void from_json(const json& j, SyncConfig::Type& t) + { + if (j.is_string()) { + const auto& s = j.get_ref(); + + for (const auto& opt : SyncOptions) { + if (opt.type_str == s) { + t = opt.type; + return; + } + } + } + } +}; +NLOHMANN_JSON_NAMESPACE_END namespace http { @@ -77,10 +125,51 @@ namespace headers static std::string instanceSessionId; static int32_t instanceId; static std::string unityVersion{"6000.0.59f2-deadlock-fix-test"}; - static std::string primeVersion{"1.000.50540"}; + static std::string primeVersion{"1.000.51336"}; static std::string poweredBy{"stfc community mod/" VER_FILE_VERSION_STR}; } // namespace headers +namespace logging +{ + static constexpr std::string CURL_TYPE_UPLOAD = "UPLOAD"; + static constexpr std::string CURL_TYPE_DOWNLOAD = "DOWNLOAD"; + + static void error(const std::string& type, const std::string& target, const std::string& text) + { + if (Config::Get().sync_logging) { + spdlog::error("SYNC-{} - {}: {}", type, target, text); + } + } + + static void warn(const std::string& type, const std::string& target, const std::string& text) + { + if (Config::Get().sync_logging) { + spdlog::warn("SYNC-{} - {}: {}", type, target, text); + } + } + + static void info(const std::string& type, const std::string& target, const std::string& text) + { + if (Config::Get().sync_logging) { + spdlog::info("SYNC-{} - {}: {}", type, target, text); + } + } + + static void debug(const std::string& type, const std::string& target, const std::string& text) + { + if (Config::Get().sync_logging && Config::Get().sync_debug) { + spdlog::debug("SYNC-{} - {}: {}", type, target, text); + } + } + + static void trace(const std::string& type, const std::string& target, const std::string& text) + { + if (Config::Get().sync_logging && Config::Get().sync_debug) { + spdlog::trace("SYNC-{} - {}: {}", type, target, text); + } + } +} + [[nodiscard]] static std::string newUUID() { #ifdef _WIN32 @@ -173,44 +262,6 @@ class Url std::string m_url; }; -static void sync_log_error(const std::string& type, const std::string& target, const std::string& text) -{ - if (Config::Get().sync_logging) { - spdlog::error("SYNC-{} - {}: {}", type, target, text); - } -} - -static void sync_log_warn(const std::string& type, const std::string& target, const std::string& text) -{ - if (Config::Get().sync_logging) { - spdlog::warn("SYNC-{} - {}: {}", type, target, text); - } -} - -static void sync_log_info(const std::string& type, const std::string& target, const std::string& text) -{ - if (Config::Get().sync_logging) { - spdlog::info("SYNC-{} - {}: {}", type, target, text); - } -} - -static void sync_log_debug(const std::string& type, const std::string& target, const std::string& text) -{ - if (Config::Get().sync_logging && Config::Get().sync_debug) { - spdlog::debug("SYNC-{} - {}: {}", type, target, text); - } -} - -static void sync_log_trace(const std::string& type, const std::string& target, const std::string& text) -{ - if (Config::Get().sync_logging && Config::Get().sync_debug) { - spdlog::trace("SYNC-{} - {}: {}", type, target, text); - } -} - -static constexpr std::string CURL_TYPE_UPLOAD = "UPLOAD"; -static constexpr std::string CURL_TYPE_DOWNLOAD = "DOWNLOAD"; - // Per-target worker thread infrastructure struct TargetWorker { TargetWorker() = default; @@ -271,28 +322,28 @@ static void target_worker_thread(std::shared_ptr worker) if (is_first_sync) { headers.insert_or_assign("X-PRIME-SYNC", "2"); - sync_log_trace(CURL_TYPE_UPLOAD, identifier, "Adding X-Prime-Sync header for initial sync"); + logging::trace(logging::CURL_TYPE_UPLOAD, identifier, "Adding X-Prime-Sync header for initial sync"); } else { headers.erase("X-PRIME-SYNC"); } httpClient->SetBody(cpr::Body{post_data}); - sync_log_debug(CURL_TYPE_UPLOAD, identifier, "Sending data to " + httpClient->GetFullRequestUrl()); + logging::debug(logging::CURL_TYPE_UPLOAD, identifier, "Sending data to " + httpClient->GetFullRequestUrl()); // Synchronously wait for response const auto response = httpClient->Post(); if (response.status_code == 0) { - sync_log_error(CURL_TYPE_UPLOAD, identifier, "Failed to send request: " + response.error.message); + logging::error(logging::CURL_TYPE_UPLOAD, identifier, "Failed to send request: " + response.error.message); } else if (response.status_code >= 400) { if (response.status_code == 501) { - sync_log_error(CURL_TYPE_UPLOAD, identifier, STR_FORMAT("Sync target does not support this operation ({}, after {:.0f} ms). Consider turning off this upload category for the target.", response.status_line, response.elapsed * 1'000)); + logging::error(logging::CURL_TYPE_UPLOAD, identifier, STR_FORMAT("Sync target does not support this operation ({}, after {:.0f} ms). Consider turning off this upload category for the target.", response.status_line, response.elapsed * 1'000)); } else { - sync_log_error(CURL_TYPE_UPLOAD, identifier, STR_FORMAT("Failed to communicate with server: {} (after {:.0f} ms)", response.status_line, response.elapsed * 1'000)); + logging::error(logging::CURL_TYPE_UPLOAD, identifier, STR_FORMAT("Failed to communicate with server: {} (after {:.0f} ms)", response.status_line, response.elapsed * 1'000)); } } else { - sync_log_debug(CURL_TYPE_UPLOAD, identifier, STR_FORMAT("Response: {} ({:.0f} ms elapsed)", response.status_line, response.elapsed * 1'000)); + logging::debug(logging::CURL_TYPE_UPLOAD, identifier, STR_FORMAT("Response: {} ({:.0f} ms elapsed)", response.status_line, response.elapsed * 1'000)); } } catch (const std::runtime_error& e) { ErrorMsg::SyncRuntime(identifier.c_str(), e); @@ -367,7 +418,7 @@ static void send_data(SyncConfig::Type type, const std::string& post_data, bool std::call_once(emit_warning, [targets] { if (targets.empty()) { - sync_log_warn(CURL_TYPE_UPLOAD, "GLOBAL", "No target found, will not attempt to send"); + logging::warn(logging::CURL_TYPE_UPLOAD, "GLOBAL", "No target found, will not attempt to send"); } }); @@ -384,7 +435,7 @@ static void send_data(SyncConfig::Type type, const std::string& post_data, bool { std::scoped_lock lk(worker->queue_mtx); worker->request_queue.emplace(target_identifier, post_data, is_first_sync); - sync_log_trace(CURL_TYPE_UPLOAD, target_identifier, + logging::trace(logging::CURL_TYPE_UPLOAD, target_identifier, STR_FORMAT("Queued request (queue size: {})", worker->request_queue.size())); } worker->queue_cv.notify_all(); @@ -445,7 +496,7 @@ static std::string get_scopely_data(const std::string& path, const std::string& if (Config::Get().sync_targets.empty()) { std::call_once(emit_warning, [] { - sync_log_warn(CURL_TYPE_UPLOAD, "GLOBAL", "No target found, will not attempt to retrieve data"); + logging::warn(logging::CURL_TYPE_UPLOAD, "GLOBAL", "No target found, will not attempt to retrieve data"); }); return {}; @@ -472,12 +523,12 @@ static std::string get_scopely_data(const std::string& path, const std::string& const auto response = httpClient->Post(); if (response.status_code == 0) { - sync_log_error(CURL_TYPE_DOWNLOAD, path, "Failed to send request: " + response.error.message); + logging::error(logging::CURL_TYPE_DOWNLOAD, path, "Failed to send request: " + response.error.message); return {}; } if (response.status_code >= 400) { - sync_log_error(CURL_TYPE_DOWNLOAD, path, "Failed to communicate with server: " + response.status_line); + logging::error(logging::CURL_TYPE_DOWNLOAD, path, "Failed to communicate with server: " + response.status_line); return {}; } @@ -490,7 +541,7 @@ static std::string get_scopely_data(const std::string& path, const std::string& type = "unknown"; } - sync_log_debug(CURL_TYPE_DOWNLOAD, path, + logging::debug(logging::CURL_TYPE_DOWNLOAD, path, STR_FORMAT("Response: {} ({}), {:.0f} ms elapsed,", response.status_line, type, response.elapsed * 1'000)); response_text = response.text; } @@ -500,169 +551,233 @@ static std::string get_scopely_data(const std::string& path, const std::string& } // namespace http -NLOHMANN_JSON_NAMESPACE_BEGIN -template struct adl_serializer> { - static void to_json(json& j, const google::protobuf::RepeatedField& proto) - { - j = json::array(); +namespace trackers +{ - for (const auto& v : proto) { - j.push_back(v); +namespace types +{ + struct RankLevelState { + explicit RankLevelState(const int32_t r = -1, const int32_t l = -1) + : rank(r) + , level(l) + { } - } - static void from_json(const json& j, google::protobuf::RepeatedField& proto) - { - if (j.is_array()) { - for (const auto& v : j) { - proto.Add(v.get()); - } - } - } -}; + bool operator==(const RankLevelState& other) const + { return this->rank == other.rank && this->level == other.level; } -template <> struct adl_serializer { - static void to_json(json& j, const SyncConfig::Type t) - { - for (const auto& opt : SyncOptions) { - if (opt.type == t) { - j = opt.type_str; - return; - } + private: + int64_t rank = -1; + int64_t level = -1; + }; + + struct RankLevelShardsState { + explicit RankLevelShardsState(const int32_t r = -1, const int32_t l = -1, const int32_t s = -1) + : rank(r) + , level(l) + , shards(s) + { } - j = nullptr; - } + bool operator==(const RankLevelShardsState& other) const + { return this->rank == other.rank && this->level == other.level && this->shards == other.shards; } - static void from_json(const json& j, SyncConfig::Type& t) - { - if (j.is_string()) { - const auto& s = j.get_ref(); + private: + int32_t rank = -1; + int32_t level = -1; + int32_t shards = -1; + }; - for (const auto& opt : SyncOptions) { - if (opt.type_str == s) { - t = opt.type; - return; - } - } + struct ShipState { + explicit ShipState(const int32_t t = -1, const int32_t l = -1, const double_t lp = -1.0, + const std::vector& c = {}) + : tier(t) + , level(l) + , level_percentage(lp) + , components(c) + { } - } -}; -NLOHMANN_JSON_NAMESPACE_END - -std::mutex sync_data_mtx; -std::condition_variable sync_data_cv; -std::queue> sync_data_queue; -std::mutex combat_log_data_mtx; -std::condition_variable combat_log_data_cv; -std::queue combat_log_data_queue; + bool operator==(const ShipState& other) const + { + return this->tier == other.tier && this->level == other.level + && std::fabs(this->level_percentage - other.level_percentage) < 0.01 + && this->components == other.components; + } -struct CachedPlayerData { - std::string name; - int64_t alliance{-1}; - std::chrono::steady_clock::time_point expires_at; -}; + private: + int32_t tier = -1; + int32_t level = -1; + double_t level_percentage = -1.0; + std::vector components; + }; -std::unordered_map player_data_cache; -std::mutex player_data_cache_mtx; + struct pairhash { + template std::size_t operator()(const std::pair& x) const + { return std::hash()(x.first) ^ std::hash()(x.second); } + }; -struct CachedAllianceData { - std::string name; - std::string tag; - std::chrono::steady_clock::time_point expires_at; -}; + struct CachedPlayerData { + std::string name; + int64_t alliance{-1}; + std::chrono::steady_clock::time_point expires_at; + }; -std::unordered_map alliance_data_cache; -std::mutex alliance_data_cache_mtx; + struct CachedAllianceData { + std::string name; + std::string tag; + std::chrono::steady_clock::time_point expires_at; + }; +} // namespace types -void queue_data(SyncConfig::Type type, const std::string& data, bool is_first_sync = false) +namespace names { - { - std::scoped_lock lk(sync_data_mtx); - sync_data_queue.emplace(type, data, is_first_sync); - http::sync_log_debug("QUEUE", to_string(type), "Added data to sync queue"); - } + using namespace types; - sync_data_cv.notify_all(); -} + static std::unordered_map player_data_cache; + static std::mutex player_data_cache_mtx; -void queue_data(SyncConfig::Type type, const nlohmann::json& data, bool is_first_sync = false) -{ + static std::unordered_map alliance_data_cache; + static std::mutex alliance_data_cache_mtx; + + static void cache_player_names(std::unique_ptr&& bytes) { - std::scoped_lock lk(sync_data_mtx); - sync_data_queue.emplace(type, data.dump(), is_first_sync); - http::sync_log_debug("QUEUE", to_string(type), STR_FORMAT("Added {} entries to sync queue", data.size())); - } + if (auto response = Digit::PrimeServer::Models::UserProfilesResponse(); response.ParseFromString(*bytes)) { - sync_data_cv.notify_all(); -} + std::unordered_map names; + const auto expires_at = + std::chrono::steady_clock::now() + std::chrono::seconds(Config::Get().sync_resolver_cache_ttl); -struct RankLevelState { - explicit RankLevelState(const int32_t r = -1, const int32_t l = -1) - : rank(r) - , level(l) - { + for (const auto& profile : response.userprofiles()) { + names.insert_or_assign( + profile.userid(), + CachedPlayerData{.name = profile.name(), .alliance = profile.allianceid(), .expires_at = expires_at}); + } + + { + std::scoped_lock lk(player_data_cache_mtx); + player_data_cache.insert(names.begin(), names.end()); + } + } else { + spdlog::error("Failed to parse user profile"); + } } - bool operator==(const RankLevelState& other) const + static void cache_alliance_names(std::unique_ptr&& bytes) { - return this->rank == other.rank && this->level == other.level; - } + if (auto response = Digit::PrimeServer::Models::GetAllianceProfilesResponse(); response.ParseFromString(*bytes)) { -private: - int64_t rank = -1; - int64_t level = -1; -}; + std::unordered_map names; + const auto expires_at = + std::chrono::steady_clock::now() + std::chrono::seconds(Config::Get().sync_resolver_cache_ttl); -struct RankLevelShardsState { - explicit RankLevelShardsState(const int32_t r = -1, const int32_t l = -1, const int32_t s = -1) - : rank(r) - , level(l) - , shards(s) - { + for (const auto& alliance : response.allianceprofiles()) { + if (alliance.id() > 0) { + names.insert_or_assign( + alliance.id(), + CachedAllianceData{.name = alliance.name(), .tag = alliance.tag(), .expires_at = expires_at}); + } + } + + { + std::scoped_lock lk(alliance_data_cache_mtx); + alliance_data_cache.insert(names.begin(), names.end()); + } + + } else { + spdlog::error("Failed to parse alliance profile"); + } } - bool operator==(const RankLevelShardsState& other) const + static inline void collect_user_ids_from_fleet(const nlohmann::json& fleet_data, + std::unordered_set& user_ids) { - return this->rank == other.rank && this->level == other.level && this->shards == other.shards; + if (!fleet_data.contains("ref_ids") || fleet_data["ref_ids"].is_null()) { + for (const auto& fleet : fleet_data["deployed_fleets"]) { + const auto& player_id = fleet["uid"].get(); + user_ids.insert(player_id); + } + } } -private: - int32_t rank = -1; - int32_t level = -1; - int32_t shards = -1; -}; - -struct ShipState { - explicit ShipState(const int32_t t = -1, const int32_t l = -1, const double_t lp = -1.0, - const std::vector& c = {}) - : tier(t) - , level(l) - , level_percentage(lp) - , components(c) + static inline void collect_alliance_ids(const nlohmann::json& names, std::unordered_set& alliance_ids) { + for (const auto& [player_id, entry] : names.items()) { + try { + const auto alliance_id = entry["alliance_id"].get(); + alliance_ids.insert(alliance_id); + } catch (const nlohmann::json::exception&) { + } + } } - bool operator==(const ShipState& other) const + static void resolve_player_names(const std::unordered_set& user_ids, nlohmann::json& out_names, + nlohmann::json& out_request_ids, + const std::chrono::time_point now) { - return this->tier == other.tier && this->level == other.level - && std::fabs(this->level_percentage - other.level_percentage) < 0.01 && this->components == other.components; + std::scoped_lock lk(player_data_cache_mtx); + + for (const auto& user_id : user_ids) { + const auto it = player_data_cache.find(user_id); + if (it != player_data_cache.end()) { + if (it->second.expires_at > now) { + out_names[user_id] = {{"name", it->second.name}, + {"alliance_id", it->second.alliance}, + {"alliance_name", nullptr}, + {"alliance_tag", nullptr}}; + } else { + // expired entry: erase and queue for fetch + player_data_cache.erase(it); + out_request_ids.push_back(user_id); + } + } else { + // cache miss: queue for fetch + out_request_ids.push_back(user_id); + } + } } -private: - int32_t tier = -1; - int32_t level = -1; - double_t level_percentage = -1.0; - std::vector components; -}; - -struct pairhash { - template std::size_t operator()(const std::pair& x) const + static void resolve_alliance_names(const std::unordered_set& alliance_ids, nlohmann::json& out_names, + nlohmann::json& out_request_ids, + const std::chrono::time_point now) { - return std::hash()(x.first) ^ std::hash()(x.second); + std::scoped_lock lk(alliance_data_cache_mtx); + + for (const auto& alliance_id : alliance_ids) { + const auto it = alliance_data_cache.find(alliance_id); + if (it != alliance_data_cache.end()) { + if (it->second.expires_at > now) { + for (const auto& [player_id, entry] : out_names.items()) { + try { + if (entry["alliance_id"].get() == alliance_id) { + entry["alliance_name"] = it->second.name; + entry["alliance_tag"] = it->second.tag; + entry.erase("alliance_id"); + } + } catch (const nlohmann::json::exception&) { + } + } + } else { + // expired entry: erase and queue for fetch + alliance_data_cache.erase(it); + out_request_ids.push_back(alliance_id); + } + } + } } -}; +} // namespace names + +static std::unordered_map module_states; +static std::mutex module_states_mtx; + +static std::unordered_map resource_states; +static std::mutex resource_states_mtx; + +static std::unordered_map resource_states_alliance; +static std::mutex resource_states_alliance_mtx; + +static std::unordered_map slot_states; +static std::mutex slot_states_mtx; static eastl::ring_buffer previously_sent_battlelogs; static std::mutex previously_sent_battlelogs_mtx; @@ -724,284 +839,263 @@ static void save_previously_sent_logs() spdlog::error("Unknown error while saving battles JSON."); } } +} // namespace trackers -void process_active_missions(std::unique_ptr&& bytes) +namespace workers { - using json = nlohmann::json; - static std::unordered_set active_mission_states; - static std::mutex active_mission_states_mtx; - - if (auto response = Digit::PrimeServer::Models::ActiveMissionsResponse(); response.ParseFromString(*bytes)) { - http::sync_log_trace("PROCESS", "active missions", - STR_FORMAT("Processing {} active missions", response.activemissions_size())); - - std::unordered_set active_missions; - for (const auto& mission : response.activemissions()) { - active_missions.insert(mission.id()); - } - - bool changed = false; - { - std::scoped_lock lk(active_mission_states_mtx); - if (active_mission_states != active_missions) { - changed = true; - active_mission_states = std::move(active_missions); - } - } - - if (changed && !active_mission_states.empty()) { - auto mission_array = json::array(); +static std::mutex sync_data_mtx; +static std::condition_variable sync_data_cv; +static std::queue> sync_data_queue; - for (const auto mission : active_mission_states) { - mission_array.push_back({{"type", "active_" + SyncConfig::Type::Missions}, {"mid", mission}}); - } +static std::mutex combat_log_data_mtx; +static std::condition_variable combat_log_data_cv; +static std::queue combat_log_data_queue; - queue_data(SyncConfig::Type::Missions, mission_array); - } - } else { - spdlog::error("Failed to parse active missions"); +static void queue_data(SyncConfig::Type type, const std::string& data, bool is_first_sync = false) +{ + { + std::scoped_lock lk(sync_data_mtx); + sync_data_queue.emplace(type, data, is_first_sync); + http::logging::debug("QUEUE", to_string(type), "Added data to sync queue"); } + + sync_data_cv.notify_all(); } -void process_completed_missions(std::unique_ptr&& bytes) +static void queue_data(SyncConfig::Type type, const nlohmann::json& data, bool is_first_sync = false) { - using json = nlohmann::json; - static std::vector completed_mission_states; - static std::mutex completed_mission_states_mtx; - - if (auto response = Digit::PrimeServer::Models::CompletedMissionsResponse(); response.ParseFromString(*bytes)) { - - http::sync_log_trace("PROCESS", "completed missions", - STR_FORMAT("Processing {} completed missions", response.completedmissions_size())); + { + std::scoped_lock lk(sync_data_mtx); + sync_data_queue.emplace(type, data.dump(), is_first_sync); + http::logging::debug("QUEUE", to_string(type), STR_FORMAT("Added {} entries to sync queue", data.size())); + } - const auto& missions = response.completedmissions(); - std::vector completed_missions{missions.begin(), missions.end()}; - std::vector diff; + sync_data_cv.notify_all(); +} - // Assume the completed missions list is append-only: new entries may be added, but existing ones are never removed. - { - std::scoped_lock lk(completed_mission_states_mtx); - std::ranges::set_difference(completed_missions, completed_mission_states, std::back_inserter(diff)); +static void ship_sync_data() +{ +#if _WIN32 + WinRtApartmentGuard apartmentGuard; +#endif - if (!diff.empty()) { - completed_mission_states = std::move(completed_missions); + try { + for (;;) { + std::tuple sync_data; + { + std::unique_lock lock(sync_data_mtx); + sync_data_cv.wait(lock, []() { return !sync_data_queue.empty(); }); + // Move the item out while holding the lock to avoid races/UB + sync_data = std::move(sync_data_queue.front()); + sync_data_queue.pop(); } - } - - if (!diff.empty()) { - auto mission_array = json::array(); - for (const auto mission : diff) { - mission_array.push_back({{"type", SyncConfig::Type::Missions}, {"mid", mission}}); + try { + auto& [type, data, is_first_sync] = sync_data; + http::send_data(type, data, is_first_sync); + } catch (const std::runtime_error& e) { + ErrorMsg::SyncRuntime("ship", e); + } catch (const std::exception& e) { + ErrorMsg::SyncMsg("ship", e.what()); + } catch (const std::wstring& sz) { + ErrorMsg::SyncMsg("ship", sz); +#if _WIN32 + } catch (winrt::hresult_error const& ex) { + ErrorMsg::SyncWinRT("ship", ex); +#endif + } catch (...) { + ErrorMsg::SyncMsg("ship", "Unknown error during sending of sync data"); } - - queue_data(SyncConfig::Type::Missions, mission_array); } - } else { - spdlog::error("Failed to parse completed missions"); + } catch (const std::exception& e) { + spdlog::critical("ship_sync_data thread terminated: {}", e.what()); + } catch (...) { + spdlog::critical("ship_sync_data thread terminated: unknown exception"); } } -void process_player_inventories(std::unique_ptr&& bytes) +static void ship_combat_log_data() { - using json = nlohmann::json; - using item_t = std::underlying_type_t; - static std::unordered_map, int64_t, pairhash> inventory_states; - static std::mutex inventory_states_mtx; - static std::atomic_bool is_first_sync{true}; - - if (auto response = Digit::PrimeServer::Models::InventoryResponse(); response.ParseFromString(*bytes)) { + using json = nlohmann::json; + using namespace trackers::names; - http::sync_log_trace("PROCESS", "player inventories", - STR_FORMAT("Processing {} inventories", response.inventories_size())); +#if _WIN32 + WinRtApartmentGuard apartmentGuard; +#endif - auto inventory_items = json::array(); + for (;;) { + uint64_t journal_id; { - std::scoped_lock lk(inventory_states_mtx); - - for (const auto& inventory : response.inventories() | std::views::values) { - for (const auto& item : inventory.items()) { - if (item.has_commonparams()) { - const auto item_id = item.commonparams().refid(); - const auto count = item.count(); - const auto key = std::make_pair(item.type(), item_id); - - if (const auto& it = inventory_states.find(key); it == inventory_states.end() || it->second != count) { - inventory_states[key] = count; - inventory_items.push_back({{"type", SyncConfig::Type::Inventory}, - {"item_type", item.type()}, - {"refid", item_id}, - {"count", count}}); - } - } - } - } + std::unique_lock lock(combat_log_data_mtx); + combat_log_data_cv.wait(lock, [] { return !combat_log_data_queue.empty(); }); + // Move the item out while holding the lock to avoid races/UB + journal_id = combat_log_data_queue.front(); + combat_log_data_queue.pop(); } - if (!inventory_items.empty()) { - const bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); - queue_data(SyncConfig::Type::Inventory, inventory_items, first_sync); - } - } else { - spdlog::error("Failed to parse player inventories"); - } -} + try { + http::logging::trace("PROCESS", "combat log", STR_FORMAT("Fetching combat log for battle {}", journal_id)); -void process_research_trees_state(std::unique_ptr&& bytes) -{ - using json = nlohmann::json; - static std::unordered_map research_states; - static std::mutex research_states_mtx; + const json journals_body{{"journal_id", journal_id}}; + auto battle_log = http::get_scopely_data("/journals/get", journals_body.dump()); + json battle_json; - if (auto response = Digit::PrimeServer::Models::ResearchTreesState(); response.ParseFromString(*bytes)) { + if (battle_log.empty()) { + continue; + } - http::sync_log_trace("PROCESS", "research trees state", - STR_FORMAT("Processing {} research projects", response.researchprojectlevels_size())); + try { + battle_json = std::move(json::parse(battle_log)); + } catch (const json::exception& e) { + spdlog::error("Error parsing journal response from game server: {}", e.what()); + continue; + } - auto research_array = json::array(); - { - std::scoped_lock lk(research_states_mtx); + const auto& journal = battle_json["journal"]; + const auto& target_fleet_data = journal["target_fleet_data"]; + const auto& initiator_fleet_data = journal["initiator_fleet_data"]; - for (const auto& [id, level] : response.researchprojectlevels()) { - if (const auto& it = research_states.find(id); it == research_states.end() || it->second != level) { - research_states[id] = level; - research_array.push_back({{"type", SyncConfig::Type::Research}, {"rid", id}, {"level", level}}); - } - } - } + auto names = json::object(); + const auto now = std::chrono::steady_clock::now(); + const auto expires_at = now + std::chrono::seconds(Config::Get().sync_resolver_cache_ttl); - if (!research_array.empty()) { - queue_data(SyncConfig::Type::Research, research_array); - } - } else { - spdlog::error("Failed to parse research trees state"); - } -} + { + std::unordered_set user_ids; + collect_user_ids_from_fleet(target_fleet_data, user_ids); + collect_user_ids_from_fleet(initiator_fleet_data, user_ids); -void process_officers(std::unique_ptr&& bytes) -{ - using json = nlohmann::json; - static std::unordered_map officer_states; - static std::mutex officer_states_mtx; + json profiles_request{{"user_ids", json::array()}}; + resolve_player_names(user_ids, names, profiles_request["user_ids"], now); - if (auto response = Digit::PrimeServer::Models::OfficersResponse(); response.ParseFromString(*bytes)) { + const auto fetch_count = profiles_request["user_ids"].size(); + if (fetch_count > 0) { + http::logging::trace("PROCESS", "combat log", STR_FORMAT("Fetching {} player profiles", fetch_count)); - http::sync_log_trace("PROCESS", "officers", STR_FORMAT("Processing {} officers", response.officers_size())); + auto profiles = http::get_scopely_data("/user_profile/profiles", profiles_request.dump()); + auto profiles_json = json::parse(profiles); - auto officers_array = json::array(); - { - std::scoped_lock lk(officer_states_mtx); + std::scoped_lock lk(player_data_cache_mtx); - for (const auto& officer : response.officers()) { - const RankLevelShardsState officer_state{officer.rankindex(), officer.level(), officer.shardcount()}; + try { + for (const auto& [player_id, profile] : profiles_json["user_profiles"].get()) { + const auto& name = profile["name"].get(); + const auto& alliance_id = profile["alliance_id"].get(); - if (const auto& it = officer_states.find(officer.id()); - it == officer_states.end() || it->second != officer_state) { - officer_states[officer.id()] = officer_state; - officers_array.push_back({{"type", SyncConfig::Type::Officer}, - {"oid", officer.id()}, - {"rank", officer.rankindex()}, - {"level", officer.level()}, - {"shard_count", officer.shardcount()}}); + names[player_id] = { + {"name", name}, {"alliance_id", alliance_id}, {"alliance_name", nullptr}, {"alliance_tag", nullptr}}; + player_data_cache[player_id] = {.name=name, .alliance=alliance_id, .expires_at=expires_at}; + } + } catch (const json::exception& e) { + spdlog::error("Failed to parse user profiles: {}", e.what()); + } } } - } - if (!officers_array.empty()) { - queue_data(SyncConfig::Type::Officer, officers_array); - } - } else { - spdlog::error("Failed to parse officers"); - } -} + { + std::unordered_set alliance_ids; + json alliances_request{{"user_current_rank", 0}, {"alliance_id", 0}, {"alliance_ids", json::array()}}; -void process_forbidden_techs(std::unique_ptr&& bytes) -{ - using json = nlohmann::json; - static std::unordered_map tech_states; - static std::mutex tech_states_mtx; + collect_alliance_ids(names, alliance_ids); + resolve_alliance_names(alliance_ids, names, alliances_request["alliance_ids"], now); - if (auto response = Digit::PrimeServer::Models::ForbiddenTechsResponse(); response.ParseFromString(*bytes)) { + const auto fetch_count = alliances_request["alliance_ids"].size(); + if (fetch_count > 0) { + http::logging::trace("PROCESS", "combat log", STR_FORMAT("Fetching {} alliance profiles", fetch_count)); - http::sync_log_trace("PROCESS", "techs", - STR_FORMAT("Processing {} forbidden/chaos techs", response.forbiddentechs_size())); + auto profiles = http::get_scopely_data("/alliance/get_alliances_public_info", alliances_request.dump()); + auto profiles_json = json::parse(profiles); - auto tech_array = json::array(); - { - std::scoped_lock lk(tech_states_mtx); + std::scoped_lock lk(alliance_data_cache_mtx); - for (const auto& tech : response.forbiddentechs()) { - const RankLevelShardsState tech_state{tech.tier(), tech.level(), tech.shardcount()}; + try { + for (const auto& [alliance_id_str, profile] : profiles_json["alliances_info"].get()) { + const auto id = profile["id"].get(); + const auto& name = profile["name"].get(); + const auto& tag = profile["tag"].get(); - if (const auto& it = tech_states.find(tech.id()); it == tech_states.end() || it->second != tech_state) { - tech_states[tech.id()] = tech_state; - tech_array.push_back({{"type", SyncConfig::Type::Tech}, - {"fid", tech.id()}, - {"tier", tech.tier()}, - {"level", tech.level()}, - {"shard_count", tech.shardcount()}}); + alliance_data_cache[id] = {.name=name, .tag=tag, .expires_at=expires_at}; + } + } catch (json::exception& e) { + spdlog::error("Failed to parse alliance profiles: {}", e.what()); + } + + for (const auto& [player_id, entry] : names.items()) { + try { + if (entry.contains("alliance_id")) { + const auto alliance_id = entry["alliance_id"].get(); + const auto it = alliance_data_cache.find(alliance_id); + if (it != alliance_data_cache.end()) { + entry["alliance_name"] = it->second.name; + entry["alliance_tag"] = it->second.tag; + entry.erase("alliance_id"); + } + } + } catch (json::exception& e) { + spdlog::error("Failed to update cached player data: {}", e.what()); + } + } } } - } - if (!tech_array.empty()) { - queue_data(SyncConfig::Type::Tech, tech_array); + auto battle_array = json::array(); + battle_array.push_back( + {{"type", SyncConfig::Type::Battles}, {"names", names}, {"journal", battle_json["journal"]}}); + + try { + auto ship_data = battle_array.dump(); + http::send_data(SyncConfig::Type::Battles, ship_data, false); + } catch (const std::runtime_error& e) { + ErrorMsg::SyncRuntime("combat", e); + } catch (const std::exception& e) { + ErrorMsg::SyncException("combat", e); + } catch (const std::wstring& sz) { + ErrorMsg::SyncMsg("combat", sz); +#if _WIN32 + } catch (winrt::hresult_error const& ex) { + ErrorMsg::SyncWinRT("combat", ex); +#endif + } catch (...) { + ErrorMsg::SyncMsg("combat", "Unknown error during sending of sync data"); + } + + } catch (json::exception& e) { + spdlog::error("Error parsing combat log or profiles: {}", e.what()); + } catch (std::exception& e) { + spdlog::error("Error processing combat log: {}", e.what()); + } catch (...) { + spdlog::error("Unknown error during processing of combat log data"); } - } else { - spdlog::error("Failed to parse forbidden techs"); } } -void process_active_officer_traits(std::unique_ptr&& bytes) -{ - using json = nlohmann::json; - static std::unordered_map, int32_t, pairhash> trait_states; - static std::mutex trait_states_mtx; - - if (auto response = Digit::PrimeServer::Models::OfficerTraitsResponse(); response.ParseFromString(*bytes)) { - - http::sync_log_trace("PROCESS", "active officer traits", - STR_FORMAT("Processing {} active officer traits", response.activeofficertraits_size())); - - auto trait_array = json::array(); - { - std::scoped_lock lk(trait_states_mtx); +} // namespace workers - for (const auto& [officer_id, officer_traits] : response.activeofficertraits()) { - for (const auto& trait : officer_traits.activetraits() | std::views::values) { - const auto& key = std::make_pair(officer_id, trait.traitid()); +namespace processors +{ - if (const auto& it = trait_states.find(key); it == trait_states.end() || it->second != trait.level()) { - trait_states[key] = trait.level(); - trait_array.push_back({{"type", SyncConfig::Type::Traits}, - {"oid", officer_id}, - {"tid", trait.traitid()}, - {"level", trait.level()}}); - } - } - } - } +static void battle_result_headers(std::unique_ptr&& bytes) +{ + // TODO: Placeholder for future client support; currently unused by the game client. + spdlog::debug("process_battle_result_headers() was called"); +} - if (!trait_array.empty()) { - queue_data(SyncConfig::Type::Traits, trait_array); - } - } else { - spdlog::error("Failed to parse active officer traits"); - } +static void battle_report(std::unique_ptr&& bytes) +{ + // TODO: Placeholder for future client support; currently unused by the game client. + spdlog::debug("process_battle_report() was called"); } -void process_global_active_buffs(std::unique_ptr&& bytes) +static void global_active_buffs(std::unique_ptr&& bytes) { using json = nlohmann::json; static std::unordered_map> buff_states; static std::mutex buff_states_mtx; static std::atomic_bool is_first_sync{true}; - if (auto response = Digit::PrimeServer::Models::GlobalActiveBuffsResponse(); response.ParseFromString(*bytes)) { - http::sync_log_trace("PROCESS", "global buffs", + http::logging::trace("PROCESS", "global buffs", STR_FORMAT("Processing {} active buffs", response.globalactivebuffs_size())); auto buff_array = json::array(); @@ -1018,263 +1112,143 @@ void process_global_active_buffs(std::unique_ptr&& bytes) const auto state = std::make_pair(buff.level(), expires ? buff.activebuff().expirytime().seconds() : -1); if (const auto& it = buff_states.find(buff.buffid()); it == buff_states.end() || it->second != state) { - buff_states[buff.buffid()] = state; - buff_array.push_back({{"type", SyncConfig::Type::Buffs}, - {"bid", buff.buffid()}, - {"level", state.first}, - {"expiry_time", expires ? json(state.second) : json(nullptr)}}); - } - } - - // Remove buffs that are no longer present and record each removal. - for (auto it = buff_states.begin(); it != buff_states.end(); ) { - if (!present_ids.contains(it->first)) { - buff_array.push_back({ - {"type", "expired_" + SyncConfig::Type::Buffs}, - {"bid", it->first}, - }); - it = buff_states.erase(it); - } else { - ++it; - } - } - } - - if (!buff_array.empty()) { - const bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); - queue_data(SyncConfig::Type::Buffs, buff_array, first_sync); - } - } else { - spdlog::error("Failed to parse global active buffs"); - } -} - -static std::unordered_map slot_states; -static std::mutex slot_states_mtx; - -void process_single_slot(const Digit::PrimeServer::Models::EntitySlot& slot, nlohmann::json& slot_array) -{ - using json = nlohmann::json; - - json slot_params; - int64_t state_value = slot.has_slotitemid() ? slot.slotitemid().value() : -1; - - switch (slot.slottype()) { - case Digit::PrimeServer::Models::SLOTTYPE_CONSUMABLE: - if (slot.has_consumableslotparams()) { - const auto& consumable = slot.consumableslotparams(); - slot_params["expiry_time"] = - consumable.has_expirytime() ? json(consumable.expirytime().seconds()) : json(nullptr); - } - break; - case Digit::PrimeServer::Models::SLOTTYPE_OFFICERPRESET: - if (slot.has_officerpresetslotparams()) { - const auto& preset = slot.officerpresetslotparams(); - slot_params = {{"name", preset.name()}, {"order", preset.order()}, {"officer_ids", preset.officerids()}}; - state_value = static_cast(std::hash{}(slot_params)); - } - break; - case Digit::PrimeServer::Models::SLOTTYPE_FLEETCOMMANDER: - if (slot.has_fleetcommanderslotparams()) { - slot_params["order"] = slot.fleetcommanderslotparams().order(); - } - break; - case Digit::PrimeServer::Models::SLOTTYPE_SELECTABLESKILL: - if (slot.has_selectableskillslotparams()) { - const auto& skill = slot.selectableskillslotparams(); - slot_params["cooldown_expiration"] = - skill.has_cooldownexpiration() ? json(skill.cooldownexpiration().seconds()) : json(nullptr); - } - break; - case Digit::PrimeServer::Models::SLOTTYPE_FORBIDDENTECH: - if (slot.has_forbiddentechslotparams()) { - const auto& tech = slot.forbiddentechslotparams(); - slot_params = {{"owner_id", tech.ownerid()}, {"tier", tech.tier()}, {"level", tech.level()}}; - state_value = static_cast(std::hash{}(slot_params)); - } - break; - case Digit::PrimeServer::Models::SLOTTYPE_FLEETPRESET: - if (slot.has_fleetpresetslotparams()) { - const auto& preset = slot.fleetpresetslotparams(); - auto setup_json = json::array(); - - for (const auto& setup : preset.setups()) { - setup_json.push_back({{"drydock_id", setup.drydockid()}, - {"ship_id", setup.shipids()[0]}, - {"officer_ids", setup.officerids()}}); + buff_states[buff.buffid()] = state; + buff_array.push_back({{"type", SyncConfig::Type::Buffs}, + {"bid", buff.buffid()}, + {"level", state.first}, + {"expiry_time", expires ? json(state.second) : json(nullptr)}}); } + } - slot_params = {{"name", preset.name()}, {"order", preset.order()}, {"setup", setup_json}}; - state_value = static_cast(std::hash{}(slot_params)); + // Remove buffs that are no longer present and record each removal. + for (auto it = buff_states.begin(); it != buff_states.end();) { + if (!present_ids.contains(it->first)) { + buff_array.push_back({ + {"type", "expired_" + SyncConfig::Type::Buffs}, + {"bid", it->first}, + }); + it = buff_states.erase(it); + } else { + ++it; + } } - break; - default: - return; - } + } - if (const auto& it = slot_states.find(slot.id()); it == slot_states.end() || it->second != state_value) { - slot_states[slot.id()] = state_value; - slot_array.push_back({{"type", SyncConfig::Type::Slots}, - {"sid", slot.id()}, - {"slot_type", slot.slottype()}, - {"spec_id", slot.slotspecid()}, - {"item_id", slot.has_slotitemid() ? json(slot.slotitemid().value()) : json(nullptr)}, - {"params", slot_params}}); + if (!buff_array.empty()) { + const bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); + workers::queue_data(SyncConfig::Type::Buffs, buff_array, first_sync); + } + } else { + spdlog::error("Failed to parse global active buffs"); } } -void process_entity_slots(std::unique_ptr&& bytes) +static void alliance_games_props(std::unique_ptr&& bytes) { using json = nlohmann::json; + static std::atomic_int32_t emerald_chain_level{-1}; - if (auto response = Digit::PrimeServer::Models::EntitySlots(); response.ParseFromString(*bytes)) { + if (auto response = Digit::PrimeServer::Models::AllianceGamePropertiesResponse(); response.ParseFromString(*bytes)) { - http::sync_log_trace("PROCESS", "entity slots", STR_FORMAT("Processing {} slots", response.entityslots__size())); + for (const auto& prop : response.properties()) { + if (prop.propertyname() == "claimed_loyalty_tiers") { + const auto& claimed_ec_levels = prop.valuelist(); + const auto& max_element = + std::ranges::max_element(claimed_ec_levels, {}, [](const auto& level) { return std::stoi(level); }); + const auto ec_level = max_element != claimed_ec_levels.end() ? std::stoi(*max_element) : -1; - auto slot_array = json::array(); - { - std::scoped_lock lk(slot_states_mtx); + int32_t current_level = emerald_chain_level.load(); + if (ec_level != current_level && emerald_chain_level.compare_exchange_strong(current_level, ec_level)) { + auto ag_array = json::array(); + ag_array.push_back({{"type", SyncConfig::Type::EmeraldChain}, {"level", ec_level}}); + workers::queue_data(SyncConfig::Type::EmeraldChain, ag_array); + } - for (const auto& slot : response.entityslots_()) { - process_single_slot(slot, slot_array); + break; } } - - if (!slot_array.empty()) { - queue_data(SyncConfig::Type::Slots, slot_array); - } } else { - spdlog::error("Failed to parse entity slots"); + spdlog::error("Failed to parse alliance games properties"); } } -void process_entity_slots_data(std::unique_ptr&& bytes) +static void starbase_modules(std::unique_ptr&& bytes) { using json = nlohmann::json; + using trackers::module_states; + using trackers::module_states_mtx; - if (auto response = Digit::PrimeServer::Models::EntitySlotsData(); response.ParseFromString(*bytes)) { - - http::sync_log_trace("PROCESS", "entity slots data", STR_FORMAT("Processing {} slots", response.entityslots_size())); + if (auto response = Digit::PrimeServer::Models::StarbaseModulesResponse(); response.ParseFromString(*bytes)) { + http::logging::trace("PROCESS", "starbase modules", STR_FORMAT("Processing {} buildings", response.modulestates_size())); - auto slot_array = json::array(); + auto starbase_array = json::array(); { - std::scoped_lock lk(slot_states_mtx); + std::scoped_lock lk(module_states_mtx); - for (const auto& slots : response.entityslots()) { - http::sync_log_trace("PROCESS", "entity slots data", STR_FORMAT("Processing {} slots for entity {}", slots.slots_size(), static_cast(slots.entitytype()))); - - for (const auto& slot : slots.slots()) { - process_single_slot(slot, slot_array); + for (const auto& module : response.modulestates()) { + if (const auto& it = module_states.find(module.id()); it == module_states.end() || it->second != module.level()) { + module_states[module.id()] = module.level(); + starbase_array.push_back({{"type", SyncConfig::Type::Buildings}, {"bid", module.id()}, {"level", module.level()}}); } } } - if (!slot_array.empty()) { - queue_data(SyncConfig::Type::Slots, slot_array); + if (!starbase_array.empty()) { + workers::queue_data(SyncConfig::Type::Buildings, starbase_array); } } else { - spdlog::error("Failed to parse entity slots data"); + spdlog::error("Failed to parse resources"); } } -void process_entity_slots_rtc(std::unique_ptr&& json_payload) +static void player_inventories(std::unique_ptr&& bytes) { - using json = nlohmann::json; + using json = nlohmann::json; + using trackers::types::pairhash; + using item_t = std::underlying_type_t; - try { - auto data = json::parse(*json_payload); - http::sync_log_trace("PROCESS", "entity slots (RTC)", "Processing entity slot update"); + static std::unordered_map, int64_t, pairhash> inventory_states; + static std::mutex inventory_states_mtx; + static std::atomic_bool is_first_sync{true}; - const auto item = data["slot_item_id"]; - const auto item_id = item.is_null() ? -1 : item.get(); + if (auto response = Digit::PrimeServer::Models::InventoryResponse(); response.ParseFromString(*bytes)) { - json slot_params; - int64_t state_value = item_id; + http::logging::trace("PROCESS", "player inventories", + STR_FORMAT("Processing {} inventories", response.inventories_size())); - const auto type = data["slot_type"].get(); - switch (type) { - case Digit::PrimeServer::Models::SLOTTYPE_CONSUMABLE: - if (const auto& expiry_time = data["consumable_slot_params"]["expiry_time"]; expiry_time.is_null()) { - slot_params["expiry_time"] = nullptr; - } else { - const auto timestamp = parse_timestamp(data["consumable_slot_params"]["expiry_time"].get_ref()); - if (timestamp.has_value()) { - slot_params["expiry_time"] = timestamp.value().time_since_epoch().count(); - } else { - slot_params["expiry_time"] = nullptr; - } - } - break; - case Digit::PrimeServer::Models::SLOTTYPE_OFFICERPRESET: - if (const auto& preset = data["officer_preset_slot_params"]; !preset.is_null()) { - slot_params = preset; - state_value = static_cast(std::hash{}(slot_params)); - } - break; - case Digit::PrimeServer::Models::SLOTTYPE_FLEETCOMMANDER: - if (const auto& params = data["fleet_commander_slot_params"]; !params.is_null()) { - slot_params["order"] = params["order"]; - } - break; - case Digit::PrimeServer::Models::SLOTTYPE_SELECTABLESKILL: - if (const auto& params = data["selectable_skill_slot_params"]; !params.is_null()) { - if (const auto& expiry_time = params["cooldown_expiration"]; expiry_time.is_null()) { - slot_params["cooldown_expiration"] = nullptr; - } else { - const auto timestamp = parse_timestamp(params["cooldown_expiration"].get_ref()); - if (timestamp.has_value()) { - slot_params["cooldown_expiration"] = timestamp.value().time_since_epoch().count(); - } else { - slot_params["cooldown_expiration"] = nullptr; + auto inventory_items = json::array(); + { + std::scoped_lock lk(inventory_states_mtx); + + for (const auto& inventory : response.inventories() | std::views::values) { + for (const auto& item : inventory.items()) { + if (item.has_commonparams()) { + const auto item_id = item.commonparams().refid(); + const auto count = item.count(); + const auto key = std::make_pair(item.type(), item_id); + + if (const auto& it = inventory_states.find(key); it == inventory_states.end() || it->second != count) { + inventory_states[key] = count; + inventory_items.push_back({{"type", SyncConfig::Type::Inventory}, + {"item_type", item.type()}, + {"refid", item_id}, + {"count", count}}); } } } - break; - case Digit::PrimeServer::Models::SLOTTYPE_FORBIDDENTECH: - if (const auto& tech = data["forbidden_tech_slot_params"]; !tech.is_null()) { - slot_params = tech; - state_value = static_cast(std::hash{}(slot_params)); - } - break; - case Digit::PrimeServer::Models::SLOTTYPE_FLEETPRESET: - if (const auto& params = data["fleet_preset_slot_params"]; !params.is_null()) { - auto setup_json = json::array(); - for (const auto& setup : params["setups"]) { - setup_json.push_back({{"drydock_id", setup["d"]}, {"ship_id", setup["s"][0]}, {"officer_ids", setup["o"]}}); - } - - slot_params = {{"name", params["name"]}, {"order", params["order"]}, {"setup", setup_json}}; - state_value = static_cast(std::hash{}(slot_params)); - } - break; - default: - return; + } } - const auto id = data["slot_id"].get(); - { - std::scoped_lock lk(slot_states_mtx); - - if (const auto& it = slot_states.find(id); it == slot_states.end() || it->second != state_value) { - slot_states[id] = state_value; - - auto slot_array = json::array(); - slot_array.push_back({{"type", SyncConfig::Type::Slots}, - {"sid", id}, - {"slot_type", type}, - {"spec_id", data["slot_spec_id"]}, - {"item_id", item}, - {"params", slot_params}}); - - queue_data(SyncConfig::Type::Slots, slot_array); - } + if (!inventory_items.empty()) { + const bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); + workers::queue_data(SyncConfig::Type::Inventory, inventory_items, first_sync); } - } catch (json::exception& e) { - spdlog::error("Failed to parse slots JSON: {}", e.what()); + } else { + spdlog::error("Failed to parse player inventories"); } } -void process_jobs(std::unique_ptr&& bytes) +static void jobs(std::unique_ptr&& bytes) { using json = nlohmann::json; static std::unordered_set jobs_active; @@ -1283,7 +1257,7 @@ void process_jobs(std::unique_ptr&& bytes) if (auto response = Digit::PrimeServer::Models::JobResponse(); response.ParseFromString(*bytes)) { - http::sync_log_trace("PROCESS", "jobs", STR_FORMAT("Processing {} jobs", response.jobs_size())); + http::logging::trace("PROCESS", "jobs", STR_FORMAT("Processing {} jobs", response.jobs_size())); std::unordered_set uuids_in_response; uuids_in_response.reserve(response.jobs_size()); @@ -1333,561 +1307,814 @@ void process_jobs(std::unique_ptr&& bytes) {"duration", job.duration()}, {"reduction", job.reductioninseconds()}}); - job_data.update(job_params); - job_array.push_back(std::move(job_data)); - } + job_data.update(job_params); + job_array.push_back(std::move(job_data)); + } + + // Prune entries that are no longer present to prevent unbounded growth + { + std::scoped_lock lk(jobs_active_mtx); + for (auto it = jobs_active.begin(); it != jobs_active.end();) { + if (!uuids_in_response.contains(*it)) { + job_array.push_back({{"type", "completed_" + SyncConfig::Type::Jobs}, {"uuid", *it}}); + it = jobs_active.erase(it); + } else { + ++it; + } + } + } + + if (!job_array.empty()) { + const bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); + workers::queue_data(SyncConfig::Type::Jobs, job_array, first_sync); + } + } else { + spdlog::error("Failed to parse jobs"); + } +} + +static void active_missions(std::unique_ptr&& bytes) +{ + using json = nlohmann::json; + static std::unordered_set active_mission_states; + static std::mutex active_mission_states_mtx; + + if (auto response = Digit::PrimeServer::Models::ActiveMissionsResponse(); response.ParseFromString(*bytes)) { + + http::logging::trace("PROCESS", "active missions", + STR_FORMAT("Processing {} active missions", response.activemissions_size())); + + std::unordered_set active_missions; + for (const auto& mission : response.activemissions()) { + active_missions.insert(mission.id()); + } + + bool changed = false; + { + std::scoped_lock lk(active_mission_states_mtx); + if (active_mission_states != active_missions) { + changed = true; + active_mission_states = std::move(active_missions); + } + } + + if (changed && !active_mission_states.empty()) { + auto mission_array = json::array(); + + for (const auto mission : active_mission_states) { + mission_array.push_back({{"type", "active_" + SyncConfig::Type::Missions}, {"mid", mission}}); + } + + workers::queue_data(SyncConfig::Type::Missions, mission_array); + } + } else { + spdlog::error("Failed to parse active missions"); + } +} + +static void completed_missions(std::unique_ptr&& bytes) +{ + using json = nlohmann::json; + static std::vector completed_mission_states; + static std::mutex completed_mission_states_mtx; + + if (auto response = Digit::PrimeServer::Models::CompletedMissionsResponse(); response.ParseFromString(*bytes)) { + + http::logging::trace("PROCESS", "completed missions", + STR_FORMAT("Processing {} completed missions", response.completedmissions_size())); + + const auto& missions = response.completedmissions(); + std::vector completed_missions{missions.begin(), missions.end()}; + std::vector diff; + + // Assume the completed missions list is append-only: new entries may be added, but existing ones are never removed. + { + std::scoped_lock lk(completed_mission_states_mtx); + std::ranges::set_difference(completed_missions, completed_mission_states, std::back_inserter(diff)); + + if (!diff.empty()) { + completed_mission_states = std::move(completed_missions); + } + } + + if (!diff.empty()) { + auto mission_array = json::array(); + + for (const auto mission : diff) { + mission_array.push_back({{"type", SyncConfig::Type::Missions}, {"mid", mission}}); + } + + workers::queue_data(SyncConfig::Type::Missions, mission_array); + } + } else { + spdlog::error("Failed to parse completed missions"); + } +} + +static void officers(std::unique_ptr&& bytes) +{ + using json = nlohmann::json; + using trackers::types::RankLevelShardsState; + + static std::unordered_map officer_states; + static std::mutex officer_states_mtx; + + if (auto response = Digit::PrimeServer::Models::OfficersResponse(); response.ParseFromString(*bytes)) { + + http::logging::trace("PROCESS", "officers", STR_FORMAT("Processing {} officers", response.officers_size())); + + auto officers_array = json::array(); + { + std::scoped_lock lk(officer_states_mtx); + + for (const auto& officer : response.officers()) { + const RankLevelShardsState officer_state{officer.rankindex(), officer.level(), officer.shardcount()}; + + if (const auto& it = officer_states.find(officer.id()); + it == officer_states.end() || it->second != officer_state) { + officer_states[officer.id()] = officer_state; + officers_array.push_back({{"type", SyncConfig::Type::Officer}, + {"oid", officer.id()}, + {"rank", officer.rankindex()}, + {"level", officer.level()}, + {"shard_count", officer.shardcount()}}); + } + } + } + + if (!officers_array.empty()) { + workers::queue_data(SyncConfig::Type::Officer, officers_array); + } + } else { + spdlog::error("Failed to parse officers"); + } +} + +static void research_trees_state(std::unique_ptr&& bytes) +{ + using json = nlohmann::json; + static std::unordered_map research_states; + static std::mutex research_states_mtx; + + if (auto response = Digit::PrimeServer::Models::ResearchTreesState(); response.ParseFromString(*bytes)) { - // Prune entries that are no longer present to prevent unbounded growth + http::logging::trace("PROCESS", "research trees state", + STR_FORMAT("Processing {} research projects", response.researchprojectlevels_size())); + + auto research_array = json::array(); { - std::scoped_lock lk(jobs_active_mtx); - for (auto it = jobs_active.begin(); it != jobs_active.end();) { - if (!uuids_in_response.contains(*it)) { - job_array.push_back({ - {"type", "completed_" + SyncConfig::Type::Jobs}, - {"uuid", *it} - }); - it = jobs_active.erase(it); - } else { - ++it; + std::scoped_lock lk(research_states_mtx); + + for (const auto& [id, level] : response.researchprojectlevels()) { + if (const auto& it = research_states.find(id); it == research_states.end() || it->second != level) { + research_states[id] = level; + research_array.push_back({{"type", SyncConfig::Type::Research}, {"rid", id}, {"level", level}}); } } } - if (!job_array.empty()) { - const bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); - queue_data(SyncConfig::Type::Jobs, job_array, first_sync); + if (!research_array.empty()) { + workers::queue_data(SyncConfig::Type::Research, research_array); } } else { - spdlog::error("Failed to parse jobs"); + spdlog::error("Failed to parse research trees state"); } } -void process_alliance_games_props(std::unique_ptr&& bytes) +static void resources(std::unique_ptr&& bytes) { using json = nlohmann::json; - static std::atomic_int32_t emerald_chain_level{-1}; + using trackers::resource_states; + using trackers::resource_states_mtx; + static std::atomic_bool is_first_sync{true}; - if (auto response = Digit::PrimeServer::Models::AllianceGamePropertiesResponse(); response.ParseFromString(*bytes)) { + if (auto response = Digit::PrimeServer::Models::ResourcesResponse(); response.ParseFromString(*bytes)) { + http::logging::trace("PROCESS", "resources", STR_FORMAT("Processing {} resources", response.resources_size())); - for (const auto& prop : response.properties()) { - if (prop.propertyname() == "claimed_loyalty_tiers") { - const auto& claimed_ec_levels = prop.valuelist(); - const auto& max_element = - std::ranges::max_element(claimed_ec_levels, {}, [](const auto& level) { return std::stoi(level); }); - const auto ec_level = max_element != claimed_ec_levels.end() ? std::stoi(*max_element) : -1; + auto resource_array = json::array(); + { + std::scoped_lock lk(resource_states_mtx); - int32_t current_level = emerald_chain_level.load(); - if (ec_level != current_level && emerald_chain_level.compare_exchange_strong(current_level, ec_level)) { - auto ag_array = json::array(); - ag_array.push_back({{"type", SyncConfig::Type::EmeraldChain}, {"level", ec_level}}); - queue_data(SyncConfig::Type::EmeraldChain, ag_array); + for (const auto& resource : response.resources()) { + if (const auto& it = resource_states.find(resource.id()); it == resource_states.end() || it->second != resource.currentamount()) { + resource_states[resource.id()] = resource.currentamount(); + resource_array.push_back({{"type", SyncConfig::Type::Resources}, {"rid", resource.id()}, {"amount", resource.currentamount()}}); } - - break; } } + + if (!resource_array.empty()) { + bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); + workers::queue_data(SyncConfig::Type::Resources, resource_array, first_sync); + } } else { - spdlog::error("Failed to parse alliance games properties"); + spdlog::error("Failed to parse resources"); } } -void process_battle_headers(const nlohmann::json& section) +static void resources_delta(std::unique_ptr&& bytes) { - http::sync_log_trace("PROCESS", "battle headers", STR_FORMAT("Processing {} battle headers", section.size())); - - std::vector battle_ids; - battle_ids.reserve(section.size()); + using json = nlohmann::json; + using trackers::resource_states; + using trackers::resource_states_mtx; - for (const auto& battle : section) { - const auto id = battle["id"].get(); - battle_ids.push_back(id); - } + if (auto response = Digit::PrimeServer::Models::ResourcesDeltaResponse(); response.ParseFromString(*bytes)) { + http::logging::trace("PROCESS", "resources_delta", STR_FORMAT("Processing {} resource changes", response.resources_size())); - std::vector to_enqueue; - { - std::scoped_lock lk(previously_sent_battlelogs_mtx); + auto resource_array = json::array(); + { + std::scoped_lock lk(resource_states_mtx); - for (const auto id : battle_ids | std::views::reverse) { - if (eastl::find(previously_sent_battlelogs.begin(), previously_sent_battlelogs.end(), id) - == previously_sent_battlelogs.end()) { - previously_sent_battlelogs.push_back(id); - to_enqueue.push_back(id); + for (const auto& resource : response.resources()) { + if (const auto& it = resource_states.find(resource.id()); it == resource_states.end() || it->second != resource.amount()) { + resource_states[resource.id()] = resource.amount(); + resource_array.push_back({{"type", SyncConfig::Type::Resources}, {"rid", resource.id()}, {"amount", resource.amount()}}); + } } } + + if (!resource_array.empty()) { + workers::queue_data(SyncConfig::Type::Resources, resource_array); + } + + } else { + spdlog::error("Failed to parse resources delta"); } +} + +static void alliance_resources(std::unique_ptr&& bytes) +{ + using json = nlohmann::json; + using trackers::resource_states_alliance; + using trackers::resource_states_alliance_mtx; + static std::atomic_bool is_first_sync{true}; - if (!to_enqueue.empty()) { - http::sync_log_trace("PROCESS", "battle headers", - STR_FORMAT("Queuing {} battles for background processing", to_enqueue.size())); + if (auto response = Digit::PrimeServer::Models::AllianceGetBankResourcesResponse(); response.ParseFromString(*bytes)) { + http::logging::trace("PROCESS", "alliance_resources", STR_FORMAT("Processing {} alliance resources", response.items_size())); + auto resource_array = json::array(); { - std::scoped_lock lk(combat_log_data_mtx); - for (const auto id : to_enqueue) { - combat_log_data_queue.push(id); + std::scoped_lock lk(resource_states_alliance_mtx); + + for (const auto& item : response.items()) { + if (item.has_commonparams()) { + const auto id = item.commonparams().refid(); + const auto amount = item.count(); + + if (const auto& it = resource_states_alliance.find(id); it == resource_states_alliance.end() || it->second != amount) { + resource_states_alliance[id] = amount; + resource_array.push_back({{"type", SyncConfig::Type::Resources}, {"rid", id}, {"amount", amount}}); + } + } } } - save_previously_sent_logs(); - combat_log_data_cv.notify_all(); + if (!resource_array.empty()) { + bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); + workers::queue_data(SyncConfig::Type::Resources, resource_array, first_sync); + } + } else { + spdlog::error("Failed to parse alliance resources"); } } -void process_resources(const nlohmann::json& section) +static void alliance_resources_delta(std::unique_ptr&& bytes) { using json = nlohmann::json; - static std::unordered_map resource_states; - static std::mutex resource_states_mtx; - static std::atomic_bool is_first_sync{true}; + using trackers::resource_states_alliance; + using trackers::resource_states_alliance_mtx; - http::sync_log_trace("PROCESS", "resources", STR_FORMAT("Processing {} resources", section.size())); + if (auto response = Digit::PrimeServer::Models::AllianceResourcesDeltaResponse(); response.ParseFromString(*bytes)) { + http::logging::trace("PROCESS", "alliance_resources_delta", STR_FORMAT("Processing {} alliance resource changes", response.resources_size())); - auto resource_array = json::array(); - { - std::scoped_lock lk(resource_states_mtx); - - for (const auto& [str_id, resource] : section.get()) { - auto id = std::stoll(str_id); - auto amount = resource["current_amount"].get(); + auto resource_array = json::array(); + { + std::scoped_lock lk(resource_states_alliance_mtx); - if (const auto& it = resource_states.find(id); it == resource_states.end() || it->second != amount) { - resource_states[id] = amount; - resource_array.push_back({{"type", SyncConfig::Type::Resources}, {"rid", id}, {"amount", amount}}); + for (const auto& resource : response.resources()) { + if (const auto& it = resource_states_alliance.find(resource.id()); it == resource_states_alliance.end() || it->second != resource.amount()) { + resource_states_alliance[resource.id()] = resource.amount(); + resource_array.push_back({{"type", SyncConfig::Type::Resources}, {"rid", resource.id()}, {"amount", resource.amount()}}); + } } } - } - if (!resource_array.empty()) { - bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); - queue_data(SyncConfig::Type::Resources, resource_array, first_sync); + if (!resource_array.empty()) { + workers::queue_data(SyncConfig::Type::Resources, resource_array); + } + } else { + spdlog::error("Failed to parse alliance resources delta"); } } -void process_starbase_modules(const nlohmann::json& section) +static void single_slot_locked(const Digit::PrimeServer::Models::EntitySlot& slot, nlohmann::json& slot_array) { using json = nlohmann::json; - static std::unordered_map module_states; - static std::mutex module_states_mtx; - http::sync_log_trace("PROCESS", "starbase modules", STR_FORMAT("Processing {} buildings", section.size())); + json slot_params; + int64_t state_value = slot.has_slotitemid() ? slot.slotitemid().value() : -1; - auto starbase_array = json::array(); - { - std::scoped_lock lk(module_states_mtx); + switch (slot.slottype()) { + case Digit::PrimeServer::Models::SLOTTYPE_CONSUMABLE: + if (slot.has_consumableslotparams()) { + const auto& consumable = slot.consumableslotparams(); + slot_params["expiry_time"] = + consumable.has_expirytime() ? json(consumable.expirytime().seconds()) : json(nullptr); + } + break; + case Digit::PrimeServer::Models::SLOTTYPE_OFFICERPRESET: + if (slot.has_officerpresetslotparams()) { + const auto& preset = slot.officerpresetslotparams(); + slot_params = {{"name", preset.name()}, {"order", preset.order()}, {"officer_ids", preset.officerids()}}; + state_value = static_cast(std::hash{}(slot_params)); + } + break; + case Digit::PrimeServer::Models::SLOTTYPE_FLEETCOMMANDER: + if (slot.has_fleetcommanderslotparams()) { + slot_params["order"] = slot.fleetcommanderslotparams().order(); + } + break; + case Digit::PrimeServer::Models::SLOTTYPE_SELECTABLESKILL: + if (slot.has_selectableskillslotparams()) { + const auto& skill = slot.selectableskillslotparams(); + slot_params["cooldown_expiration"] = + skill.has_cooldownexpiration() ? json(skill.cooldownexpiration().seconds()) : json(nullptr); + } + break; + case Digit::PrimeServer::Models::SLOTTYPE_FORBIDDENTECH: + if (slot.has_forbiddentechslotparams()) { + const auto& tech = slot.forbiddentechslotparams(); + slot_params = {{"owner_id", tech.ownerid()}, {"tier", tech.tier()}, {"level", tech.level()}}; + state_value = static_cast(std::hash{}(slot_params)); + } + break; + case Digit::PrimeServer::Models::SLOTTYPE_FLEETPRESET: + if (slot.has_fleetpresetslotparams()) { + const auto& preset = slot.fleetpresetslotparams(); + auto setup_json = json::array(); - for (const auto& module : section.get() | std::views::values) { - const auto id = module["id"].get(); - const auto level = module["level"].get(); + for (const auto& setup : preset.setups()) { + setup_json.push_back({{"drydock_id", setup.drydockid()}, + {"ship_id", setup.shipids()[0]}, + {"officer_ids", setup.officerids()}}); + } - if (const auto& it = module_states.find(id); it == module_states.end() || it->second != level) { - module_states[id] = level; - starbase_array.push_back({{"type", SyncConfig::Type::Buildings}, {"bid", id}, {"level", level}}); + slot_params = {{"name", preset.name()}, {"order", preset.order()}, {"setup", setup_json}}; + state_value = static_cast(std::hash{}(slot_params)); } - } + break; + default: + return; } - if (!starbase_array.empty()) { - queue_data(SyncConfig::Type::Buildings, starbase_array); + // Lock is not held because caller already does + using trackers::slot_states; + + if (const auto& it = slot_states.find(slot.id()); it == slot_states.end() || it->second != state_value) { + slot_states[slot.id()] = state_value; + slot_array.push_back({{"type", SyncConfig::Type::Slots}, + {"sid", slot.id()}, + {"slot_type", slot.slottype()}, + {"spec_id", slot.slotspecid()}, + {"item_id", slot.has_slotitemid() ? json(slot.slotitemid().value()) : json(nullptr)}, + {"params", slot_params}}); } } -void process_ships(const nlohmann::json& section) +static void entity_slots(std::unique_ptr&& bytes) { using json = nlohmann::json; - static std::unordered_map ship_states; - static std::mutex ship_states_mtx; - static std::atomic_bool is_first_sync{true}; - http::sync_log_trace("PROCESS", "ships", STR_FORMAT("Processing {} ships", section.size())); + if (auto response = Digit::PrimeServer::Models::EntitySlots(); response.ParseFromString(*bytes)) { - auto ship_array = json::array(); - { - std::scoped_lock lk(ship_states_mtx); + http::logging::trace("PROCESS", "entity slots", STR_FORMAT("Processing {} slots", response.entityslots__size())); - for (const auto& ship : section.get() | std::views::values) { - const auto id = ship["id"].get(); - const auto tier = ship["tier"].get(); - const auto level = ship["level"].get(); - const auto level_percentage = ship["level_percentage"].get(); - const auto components = ship["components"].get>(); - const ShipState state{tier, level, level_percentage, components}; + auto slot_array = json::array(); + { + std::scoped_lock lk(trackers::slot_states_mtx); - if (const auto& it = ship_states.find(id); it == ship_states.end() || it->second != state) { - ship_states[id] = state; - ship_array.push_back({{"type", SyncConfig::Type::Ships}, - {"psid", id}, - {"level", level}, - {"level_percentage", level_percentage}, - {"tier", tier}, - {"hull_id", ship["hull_id"].get()}, - {"components", components}}); + for (const auto& slot : response.entityslots_()) { + single_slot_locked(slot, slot_array); } } - } - if (!ship_array.empty()) { - bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); - queue_data(SyncConfig::Type::Ships, ship_array, first_sync); + if (!slot_array.empty()) { + workers::queue_data(SyncConfig::Type::Slots, slot_array); + } + } else { + spdlog::error("Failed to parse entity slots"); } } -void process_json(std::unique_ptr&& bytes) +static void entity_slots_data(std::unique_ptr&& bytes) { using json = nlohmann::json; - try { - const auto result = json::parse(bytes->begin(), bytes->end()); - - for (const auto& [key, section] : result.items()) { - if (key == "battle_result_headers") { - if (!Config::Get().sync_options.battlelogs) { - continue; - } - - process_battle_headers(section); - - } else if (key == "resources") { - if (!Config::Get().sync_options.resources) { - continue; - } + if (auto response = Digit::PrimeServer::Models::EntitySlotsData(); response.ParseFromString(*bytes)) { - process_resources(section); + http::logging::trace("PROCESS", "entity slots data", + STR_FORMAT("Processing {} slots", response.entityslots_size())); - } else if (key == "starbase_modules") { - if (!Config::Get().sync_options.buildings) { - continue; - } + auto slot_array = json::array(); + { + std::scoped_lock lk(trackers::slot_states_mtx); - process_starbase_modules(section); + for (const auto& slots : response.entityslots()) { + http::logging::trace("PROCESS", "entity slots data", + STR_FORMAT("Processing {} slots for entity {}", slots.slots_size(), + static_cast(slots.entitytype()))); - } else if (key == "ships") { - if (!Config::Get().sync_options.ships) { - continue; + for (const auto& slot : slots.slots()) { + single_slot_locked(slot, slot_array); } - - process_ships(section); } } - } catch (const json::exception& e) { - spdlog::error("Error parsing json: {}", e.what()); + + if (!slot_array.empty()) { + workers::queue_data(SyncConfig::Type::Slots, slot_array); + } + } else { + spdlog::error("Failed to parse entity slots data"); } } -void cache_player_names(std::unique_ptr&& bytes) +static void forbidden_tech(std::unique_ptr&& bytes) { - if (auto response = Digit::PrimeServer::Models::UserProfilesResponse(); response.ParseFromString(*bytes)) { + using json = nlohmann::json; + using trackers::types::RankLevelShardsState; + + static std::unordered_map tech_states; + static std::mutex tech_states_mtx; - std::unordered_map names; - const auto expires_at = - std::chrono::steady_clock::now() + std::chrono::seconds(Config::Get().sync_resolver_cache_ttl); + if (auto response = Digit::PrimeServer::Models::ForbiddenTechsResponse(); response.ParseFromString(*bytes)) { - for (const auto& profile : response.userprofiles()) { - names.insert_or_assign(profile.userid(), CachedPlayerData{.name=profile.name(), .alliance=profile.allianceid(), .expires_at=expires_at}); - } + http::logging::trace("PROCESS", "techs", + STR_FORMAT("Processing {} forbidden/chaos techs", response.forbiddentechs_size())); + auto tech_array = json::array(); { - std::scoped_lock lk(player_data_cache_mtx); - player_data_cache.insert(names.begin(), names.end()); + std::scoped_lock lk(tech_states_mtx); + + for (const auto& tech : response.forbiddentechs()) { + const RankLevelShardsState tech_state{tech.tier(), tech.level(), tech.shardcount()}; + + if (const auto& it = tech_states.find(tech.id()); it == tech_states.end() || it->second != tech_state) { + tech_states[tech.id()] = tech_state; + tech_array.push_back({{"type", SyncConfig::Type::Tech}, + {"fid", tech.id()}, + {"tier", tech.tier()}, + {"level", tech.level()}, + {"shard_count", tech.shardcount()}}); + } + } + } + + if (!tech_array.empty()) { + workers::queue_data(SyncConfig::Type::Tech, tech_array); } } else { - spdlog::error("Failed to parse user profile"); + spdlog::error("Failed to parse forbidden techs"); } } -void cache_alliance_names(std::unique_ptr&& bytes) +static void active_officer_traits(std::unique_ptr&& bytes) { - if (auto response = Digit::PrimeServer::Models::GetAllianceProfilesResponse(); response.ParseFromString(*bytes)) { + using json = nlohmann::json; + using trackers::types::pairhash; - std::unordered_map names; - const auto expires_at = - std::chrono::steady_clock::now() + std::chrono::seconds(Config::Get().sync_resolver_cache_ttl); + static std::unordered_map, int32_t, pairhash> trait_states; + static std::mutex trait_states_mtx; - for (const auto& alliance : response.allianceprofiles()) { - if (alliance.id() > 0) { - names.insert_or_assign(alliance.id(), CachedAllianceData{.name=alliance.name(), .tag=alliance.tag(), .expires_at=expires_at}); - } - } + if (auto response = Digit::PrimeServer::Models::OfficerTraitsResponse(); response.ParseFromString(*bytes)) { + + http::logging::trace("PROCESS", "active officer traits", + STR_FORMAT("Processing {} active officer traits", response.activeofficertraits_size())); + auto trait_array = json::array(); { - std::scoped_lock lk(alliance_data_cache_mtx); - alliance_data_cache.insert(names.begin(), names.end()); + std::scoped_lock lk(trait_states_mtx); + + for (const auto& [officer_id, officer_traits] : response.activeofficertraits()) { + for (const auto& trait : officer_traits.activetraits() | std::views::values) { + const auto& key = std::make_pair(officer_id, trait.traitid()); + + if (const auto& it = trait_states.find(key); it == trait_states.end() || it->second != trait.level()) { + trait_states[key] = trait.level(); + trait_array.push_back({{"type", SyncConfig::Type::Traits}, + {"oid", officer_id}, + {"tid", trait.traitid()}, + {"level", trait.level()}}); + } + } + } } + if (!trait_array.empty()) { + workers::queue_data(SyncConfig::Type::Traits, trait_array); + } } else { - spdlog::error("Failed to parse alliance profile"); + spdlog::error("Failed to parse active officer traits"); } } -void ship_sync_data() +namespace json { -#if _WIN32 - WinRtApartmentGuard apartmentGuard; -#endif + static void battle_result_headers(const nlohmann::json& section) + { + http::logging::trace("PROCESS", "battle headers", STR_FORMAT("Processing {} battle headers", section.size())); - try { - for (;;) { - std::tuple sync_data; - { - std::unique_lock lock(sync_data_mtx); - sync_data_cv.wait(lock, []() { return !sync_data_queue.empty(); }); - // Move the item out while holding the lock to avoid races/UB - sync_data = std::move(sync_data_queue.front()); - sync_data_queue.pop(); - } + std::vector battle_ids; + battle_ids.reserve(section.size()); - try { - auto& [type, data, is_first_sync] = sync_data; - http::send_data(type, data, is_first_sync); - } catch (const std::runtime_error& e) { - ErrorMsg::SyncRuntime("ship", e); - } catch (const std::exception& e) { - ErrorMsg::SyncMsg("ship", e.what()); - } catch (const std::wstring& sz) { - ErrorMsg::SyncMsg("ship", sz); -#if _WIN32 - } catch (winrt::hresult_error const& ex) { - ErrorMsg::SyncWinRT("ship", ex); -#endif - } catch (...) { - ErrorMsg::SyncMsg("ship", "Unknown error during sending of sync data"); - } + for (const auto& battle : section) { + const auto id = battle["id"].get(); + battle_ids.push_back(id); } - } catch (const std::exception& e) { - spdlog::critical("ship_sync_data thread terminated: {}", e.what()); - } catch (...) { - spdlog::critical("ship_sync_data thread terminated: unknown exception"); - } -} -inline void collect_user_ids_from_fleet(const nlohmann::json& fleet_data, std::unordered_set& user_ids) -{ - if (!fleet_data.contains("ref_ids") || fleet_data["ref_ids"].is_null()) { - for (const auto& fleet : fleet_data["deployed_fleets"]) { - const auto& player_id = fleet["uid"].get(); - user_ids.insert(player_id); - } - } -} + std::vector to_enqueue; + { + using trackers::previously_sent_battlelogs; + using trackers::previously_sent_battlelogs_mtx; + + std::scoped_lock lk(previously_sent_battlelogs_mtx); -inline void collect_alliance_ids(const nlohmann::json& names, std::unordered_set& alliance_ids) -{ - for (const auto& [player_id, entry] : names.items()) { - try { - const auto alliance_id = entry["alliance_id"].get(); - alliance_ids.insert(alliance_id); - } catch (const nlohmann::json::exception&) { + for (const auto id : battle_ids | std::views::reverse) { + if (eastl::find(previously_sent_battlelogs.begin(), previously_sent_battlelogs.end(), id) + == previously_sent_battlelogs.end()) { + previously_sent_battlelogs.push_back(id); + to_enqueue.push_back(id); + } + } } - } -} -void resolve_player_names(const std::unordered_set& user_ids, nlohmann::json& out_names, - nlohmann::json& out_request_ids, const std::chrono::time_point now) -{ - std::scoped_lock lk(player_data_cache_mtx); - - for (const auto& user_id : user_ids) { - const auto it = player_data_cache.find(user_id); - if (it != player_data_cache.end()) { - if (it->second.expires_at > now) { - out_names[user_id] = {{"name", it->second.name}, - {"alliance_id", it->second.alliance}, - {"alliance_name", nullptr}, - {"alliance_tag", nullptr}}; - } else { - // expired entry: erase and queue for fetch - player_data_cache.erase(it); - out_request_ids.push_back(user_id); + if (!to_enqueue.empty()) { + http::logging::debug("QUEUE", "battle headers", + STR_FORMAT("Queuing {} battles for background processing", to_enqueue.size())); + + { + std::scoped_lock lk(workers::combat_log_data_mtx); + for (const auto id : to_enqueue) { + workers::combat_log_data_queue.push(id); + } } - } else { - // cache miss: queue for fetch - out_request_ids.push_back(user_id); + + trackers::save_previously_sent_logs(); + workers::combat_log_data_cv.notify_all(); } } -} -void resolve_alliance_names(const std::unordered_set& alliance_ids, nlohmann::json& out_names, - nlohmann::json& out_request_ids, - const std::chrono::time_point now) -{ - std::scoped_lock lk(alliance_data_cache_mtx); + static void resources(const nlohmann::json& section) + { + using json = nlohmann::json; + using trackers::resource_states; + using trackers::resource_states_mtx; + static std::atomic_bool is_first_sync{true}; - for (const auto& alliance_id : alliance_ids) { - const auto it = alliance_data_cache.find(alliance_id); - if (it != alliance_data_cache.end()) { - if (it->second.expires_at > now) { - for (const auto& [player_id, entry] : out_names.items()) { - try { - if (entry["alliance_id"].get() == alliance_id) { - entry["alliance_name"] = it->second.name; - entry["alliance_tag"] = it->second.tag; - entry.erase("alliance_id"); - } - } catch (const nlohmann::json::exception&) { - } + http::logging::trace("PROCESS", "resources", STR_FORMAT("Processing {} resources (JSON)", section.size())); + + auto resource_array = json::array(); + { + std::scoped_lock lk(resource_states_mtx); + + for (const auto& [str_id, resource] : section.get()) { + auto id = std::stoll(str_id); + auto amount = resource["current_amount"].get(); + + if (const auto& it = resource_states.find(id); it == resource_states.end() || it->second != amount) { + resource_states[id] = amount; + resource_array.push_back({{"type", SyncConfig::Type::Resources}, {"rid", id}, {"amount", amount}}); } - } else { - // expired entry: erase and queue for fetch - alliance_data_cache.erase(it); - out_request_ids.push_back(alliance_id); } } - } -} -void ship_combat_log_data() -{ - using json = nlohmann::json; + if (!resource_array.empty()) { + bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); + workers::queue_data(SyncConfig::Type::Resources, resource_array, first_sync); + } + } -#if _WIN32 - WinRtApartmentGuard apartmentGuard; -#endif + static void starbase_modules(const nlohmann::json& section) + { + using json = nlohmann::json; + using trackers::module_states; + using trackers::module_states_mtx; + http::logging::trace("PROCESS", "starbase modules", STR_FORMAT("Processing {} buildings (JSON)", section.size())); - for (;;) { - uint64_t journal_id; + auto starbase_array = json::array(); { - std::unique_lock lock(combat_log_data_mtx); - combat_log_data_cv.wait(lock, [] { return !combat_log_data_queue.empty(); }); - // Move the item out while holding the lock to avoid races/UB - journal_id = combat_log_data_queue.front(); - combat_log_data_queue.pop(); + std::scoped_lock lk(module_states_mtx); + + for (const auto& module : section.get() | std::views::values) { + const auto id = module["id"].get(); + const auto level = module["level"].get(); + + if (const auto& it = module_states.find(id); it == module_states.end() || it->second != level) { + module_states[id] = level; + starbase_array.push_back({{"type", SyncConfig::Type::Buildings}, {"bid", id}, {"level", level}}); + } + } } - try { - http::sync_log_trace("PROCESS", "combat log", STR_FORMAT("Fetching combat log for battle {}", journal_id)); + if (!starbase_array.empty()) { + workers::queue_data(SyncConfig::Type::Buildings, starbase_array); + } + } - const json journals_body{{"journal_id", journal_id}}; - auto battle_log = http::get_scopely_data("/journals/get", journals_body.dump()); - json battle_json; + static void ships(const nlohmann::json& section) + { + using json = nlohmann::json; + using trackers::types::ShipState; - if (battle_log.empty()) { - continue; - } + static std::unordered_map ship_states; + static std::mutex ship_states_mtx; + static std::atomic_bool is_first_sync{true}; - try { - battle_json = std::move(json::parse(battle_log)); - } catch (const json::exception& e) { - spdlog::error("Error parsing journal response from game server: {}", e.what()); - continue; + http::logging::trace("PROCESS", "ships", STR_FORMAT("Processing {} ships (JSON)", section.size())); + + auto ship_array = json::array(); + { + std::scoped_lock lk(ship_states_mtx); + + for (const auto& ship : section.get() | std::views::values) { + const auto id = ship["id"].get(); + const auto tier = ship["tier"].get(); + const auto level = ship["level"].get(); + const auto level_percentage = ship["level_percentage"].get(); + const auto components = ship["components"].get>(); + const ShipState state{tier, level, level_percentage, components}; + + if (const auto& it = ship_states.find(id); it == ship_states.end() || it->second != state) { + ship_states[id] = state; + ship_array.push_back({{"type", SyncConfig::Type::Ships}, + {"psid", id}, + {"level", level}, + {"level_percentage", level_percentage}, + {"tier", tier}, + {"hull_id", ship["hull_id"].get()}, + {"components", components}}); + } } + } - const auto& journal = battle_json["journal"]; - const auto& target_fleet_data = journal["target_fleet_data"]; - const auto& initiator_fleet_data = journal["initiator_fleet_data"]; + if (!ship_array.empty()) { + bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); + workers::queue_data(SyncConfig::Type::Ships, ship_array, first_sync); + } + } - auto names = json::object(); - const auto now = std::chrono::steady_clock::now(); - const auto expires_at = now + std::chrono::seconds(Config::Get().sync_resolver_cache_ttl); + static void parse(std::unique_ptr&& bytes) + { + using json = nlohmann::json; + const auto& sync_options = Config::Get().sync_options; - { - std::unordered_set user_ids; - collect_user_ids_from_fleet(target_fleet_data, user_ids); - collect_user_ids_from_fleet(initiator_fleet_data, user_ids); + try { + const auto result = json::parse(bytes->begin(), bytes->end()); - json profiles_request{{"user_ids", json::array()}}; - resolve_player_names(user_ids, names, profiles_request["user_ids"], now); + for (const auto& [key, section] : result.items()) { + if (key == "battle_result_headers") { + if (!sync_options.battlelogs) { + continue; + } - const auto fetch_count = profiles_request["user_ids"].size(); - if (fetch_count > 0) { - http::sync_log_trace("PROCESS", "combat log", STR_FORMAT("Fetching {} player profiles", fetch_count)); + battle_result_headers(section); - auto profiles = http::get_scopely_data("/user_profile/profiles", profiles_request.dump()); - auto profiles_json = json::parse(profiles); + } else if (key == "resources") { + if (!sync_options.resources) { + continue; + } - std::scoped_lock lk(player_data_cache_mtx); + resources(section); - try { - for (const auto& [player_id, profile] : profiles_json["user_profiles"].get()) { - const auto& name = profile["name"].get(); - const auto& alliance_id = profile["alliance_id"].get(); + } else if (key == "starbase_modules") { + if (!sync_options.buildings) { + continue; + } - names[player_id] = { - {"name", name}, {"alliance_id", alliance_id}, {"alliance_name", nullptr}, {"alliance_tag", nullptr}}; - player_data_cache[player_id] = {.name=name, .alliance=alliance_id, .expires_at=expires_at}; - } - } catch (const json::exception& e) { - spdlog::error("Failed to parse user profiles: {}", e.what()); + starbase_modules(section); + + } else if (key == "ships") { + if (!sync_options.ships) { + continue; } + + ships(section); } } + } catch (const json::exception& e) { + spdlog::error("Error parsing json: {}", e.what()); + } + } +} // namespace json - { - std::unordered_set alliance_ids; - json alliances_request{{"user_current_rank", 0}, {"alliance_id", 0}, {"alliance_ids", json::array()}}; - - collect_alliance_ids(names, alliance_ids); - resolve_alliance_names(alliance_ids, names, alliances_request["alliance_ids"], now); - - const auto fetch_count = alliances_request["alliance_ids"].size(); - if (fetch_count > 0) { - http::sync_log_trace("PROCESS", "combat log", STR_FORMAT("Fetching {} alliance profiles", fetch_count)); +namespace rtc +{ + static void entity_slots(std::unique_ptr&& json_payload) + { + using json = nlohmann::json; - auto profiles = http::get_scopely_data("/alliance/get_alliances_public_info", alliances_request.dump()); - auto profiles_json = json::parse(profiles); + try { + auto data = json::parse(*json_payload); + http::logging::trace("PROCESS", "entity slots (RTC)", "Processing entity slot update"); - std::scoped_lock lk(alliance_data_cache_mtx); + const auto item = data["slot_item_id"]; + const auto item_id = item.is_null() ? -1 : item.get(); - try { - for (const auto& [alliance_id_str, profile] : profiles_json["alliances_info"].get()) { - const auto id = profile["id"].get(); - const auto& name = profile["name"].get(); - const auto& tag = profile["tag"].get(); + json slot_params; + int64_t state_value = item_id; - alliance_data_cache[id] = {.name=name, .tag=tag, .expires_at=expires_at}; + const auto type = data["slot_type"].get(); + switch (type) { + case Digit::PrimeServer::Models::SLOTTYPE_CONSUMABLE: + if (const auto& expiry_time = data["consumable_slot_params"]["expiry_time"]; expiry_time.is_null()) { + slot_params["expiry_time"] = nullptr; + } else { + const auto timestamp = + parse_timestamp(data["consumable_slot_params"]["expiry_time"].get_ref()); + if (timestamp.has_value()) { + slot_params["expiry_time"] = timestamp.value().time_since_epoch().count(); + } else { + slot_params["expiry_time"] = nullptr; } - } catch (json::exception& e) { - spdlog::error("Failed to parse alliance profiles: {}", e.what()); } - - for (const auto& [player_id, entry] : names.items()) { - try { - if (entry.contains("alliance_id")) { - const auto alliance_id = entry["alliance_id"].get(); - const auto it = alliance_data_cache.find(alliance_id); - if (it != alliance_data_cache.end()) { - entry["alliance_name"] = it->second.name; - entry["alliance_tag"] = it->second.tag; - entry.erase("alliance_id"); - } + break; + case Digit::PrimeServer::Models::SLOTTYPE_OFFICERPRESET: + if (const auto& preset = data["officer_preset_slot_params"]; !preset.is_null()) { + slot_params = preset; + state_value = static_cast(std::hash{}(slot_params)); + } + break; + case Digit::PrimeServer::Models::SLOTTYPE_FLEETCOMMANDER: + if (const auto& params = data["fleet_commander_slot_params"]; !params.is_null()) { + slot_params["order"] = params["order"]; + } + break; + case Digit::PrimeServer::Models::SLOTTYPE_SELECTABLESKILL: + if (const auto& params = data["selectable_skill_slot_params"]; !params.is_null()) { + if (const auto& expiry_time = params["cooldown_expiration"]; expiry_time.is_null()) { + slot_params["cooldown_expiration"] = nullptr; + } else { + const auto timestamp = parse_timestamp(params["cooldown_expiration"].get_ref()); + if (timestamp.has_value()) { + slot_params["cooldown_expiration"] = timestamp.value().time_since_epoch().count(); + } else { + slot_params["cooldown_expiration"] = nullptr; } - } catch (json::exception& e) { - spdlog::error("Failed to update cached player data: {}", e.what()); } } - } + break; + case Digit::PrimeServer::Models::SLOTTYPE_FORBIDDENTECH: + if (const auto& tech = data["forbidden_tech_slot_params"]; !tech.is_null()) { + slot_params = tech; + state_value = static_cast(std::hash{}(slot_params)); + } + break; + case Digit::PrimeServer::Models::SLOTTYPE_FLEETPRESET: + if (const auto& params = data["fleet_preset_slot_params"]; !params.is_null()) { + auto setup_json = json::array(); + for (const auto& setup : params["setups"]) { + setup_json.push_back( + {{"drydock_id", setup["d"]}, {"ship_id", setup["s"][0]}, {"officer_ids", setup["o"]}}); + } + + slot_params = {{"name", params["name"]}, {"order", params["order"]}, {"setup", setup_json}}; + state_value = static_cast(std::hash{}(slot_params)); + } + break; + default: + return; } - auto battle_array = json::array(); - battle_array.push_back( - {{"type", SyncConfig::Type::Battles}, {"names", names}, {"journal", battle_json["journal"]}}); + const auto id = data["slot_id"].get(); + { + using trackers::slot_states; + using trackers::slot_states_mtx; - try { - auto ship_data = battle_array.dump(); - http::send_data(SyncConfig::Type::Battles, ship_data, false); - } catch (const std::runtime_error& e) { - ErrorMsg::SyncRuntime("combat", e); - } catch (const std::exception& e) { - ErrorMsg::SyncException("combat", e); - } catch (const std::wstring& sz) { - ErrorMsg::SyncMsg("combat", sz); -#if _WIN32 - } catch (winrt::hresult_error const& ex) { - ErrorMsg::SyncWinRT("combat", ex); -#endif - } catch (...) { - ErrorMsg::SyncMsg("combat", "Unknown error during sending of sync data"); - } + std::scoped_lock lk(slot_states_mtx); + + if (const auto& it = slot_states.find(id); it == slot_states.end() || it->second != state_value) { + slot_states[id] = state_value; + + auto slot_array = json::array(); + slot_array.push_back({{"type", SyncConfig::Type::Slots}, + {"sid", id}, + {"slot_type", type}, + {"spec_id", data["slot_spec_id"]}, + {"item_id", item}, + {"params", slot_params}}); + workers::queue_data(SyncConfig::Type::Slots, slot_array); + } + } } catch (json::exception& e) { - spdlog::error("Error parsing combat log or profiles: {}", e.what()); - } catch (std::exception& e) { - spdlog::error("Error processing combat log: {}", e.what()); - } catch (...) { - spdlog::error("Unknown error during processing of combat log data"); + spdlog::error("Failed to parse slots JSON: {}", e.what()); } } -} +} // namespace rtc -void HandleEntityGroup(EntityGroup* entity_group) +} // namespace processors + +static void HandleEntityGroup(EntityGroup* entity_group) { if (entity_group == nullptr || entity_group->Group == nullptr || entity_group->Group->bytes == nullptr || entity_group->Group->Length <= 0) { @@ -1918,101 +2145,161 @@ void HandleEntityGroup(EntityGroup* entity_group) } }; + const auto& sync_options = Config::Get().sync_options; + switch (entity_group->Type_) { - case EntityGroup::Type::ActiveMissions: - if (Config::Get().sync_options.missions) { - submit_async(process_active_missions); + // battlelogs + case EntityGroup::Type::BattleResultHeaders: + if (sync_options.battlelogs) { + submit_async(processors::battle_result_headers); } break; - case EntityGroup::Type::CompletedMissions: - if (Config::Get().sync_options.missions) { - submit_async(process_completed_missions); + case EntityGroup::Type::BattleReport: + if (sync_options.battlelogs) { + submit_async(processors::battle_report); } break; - case EntityGroup::Type::PlayerInventories: - if (Config::Get().sync_options.inventory) { - submit_async(process_player_inventories); + case EntityGroup::Type::UserProfiles: + if (sync_options.battlelogs) { + submit_async(trackers::names::cache_player_names); } break; - case EntityGroup::Type::ResearchTreesState: - if (Config::Get().sync_options.research) { - submit_async(process_research_trees_state); + case EntityGroup::Type::AllianceProfiles: + if (sync_options.battlelogs) { + submit_async(trackers::names::cache_alliance_names); } break; - case EntityGroup::Type::Officers: - if (Config::Get().sync_options.officer) { - submit_async(process_officers); + + // buffs + case EntityGroup::Type::GlobalActiveBuffs: + if (sync_options.buffs) { + submit_async(processors::global_active_buffs); } break; - case EntityGroup::Type::ForbiddenTechs: - if (Config::Get().sync_options.tech) { - submit_async(process_forbidden_techs); + case EntityGroup::Type::AllianceGetGameProperties: + if (sync_options.buffs) { + submit_async(processors::alliance_games_props); } break; - case EntityGroup::Type::ActiveOfficerTraits: - if (Config::Get().sync_options.traits) { - submit_async(process_active_officer_traits); + + // buildings + case EntityGroup::Type::StarbaseModules: + if (sync_options.buildings) { + submit_async(processors::starbase_modules); } break; - case EntityGroup::Type::Json: - if (const auto& o = Config::Get().sync_options; o.battlelogs || o.resources || o.ships || o.buildings) { - submit_async(process_json); + + // inventory + case EntityGroup::Type::PlayerInventories: + if (sync_options.inventory) { + submit_async(processors::player_inventories); } break; + + // jobs case EntityGroup::Type::Jobs: - if (Config::Get().sync_options.jobs) { - submit_async(process_jobs); + if (sync_options.jobs) { + submit_async(processors::jobs); } break; - case EntityGroup::Type::GlobalActiveBuffs: - if (Config::Get().sync_options.buffs) { - submit_async(process_global_active_buffs); + + // missions + case EntityGroup::Type::ActiveMissions: + if (sync_options.missions) { + submit_async(processors::active_missions); } break; - case EntityGroup::Type::EntitySlots: - if (Config::Get().sync_options.slots) { - submit_async(process_entity_slots); + case EntityGroup::Type::CompletedMissions: + if (sync_options.missions) { + submit_async(processors::completed_missions); } break; - case EntityGroup::Type::EntitySlotsData: - if (Config::Get().sync_options.slots) { - submit_async(process_entity_slots_data); + + // officer + case EntityGroup::Type::Officers: + if (sync_options.officer) { + submit_async(processors::officers); } break; - case EntityGroup::Type::AllianceGetGameProperties: - if (Config::Get().sync_options.buffs) { - submit_async(process_alliance_games_props); + + // research + case EntityGroup::Type::ResearchTreesState: + if (sync_options.research) { + submit_async(processors::research_trees_state); } break; - case EntityGroup::Type::UserProfiles: - if (Config::Get().sync_options.battlelogs) { - submit_async(cache_player_names); + + // resources + case EntityGroup::Type::Resources: + if (sync_options.resources) { + submit_async(processors::resources); } break; - case EntityGroup::Type::AllianceProfiles: - if (Config::Get().sync_options.battlelogs) { - submit_async(cache_alliance_names); + case EntityGroup::Type::ResourcesDelta: + if (sync_options.resources) { + submit_async(processors::resources_delta); + } + break; + case EntityGroup::Type::AllianceGetBankResources: + if (sync_options.resources) { + submit_async(processors::alliance_resources); + } + break; + case EntityGroup::Type::ResourcesDeltaAlliance: + if (sync_options.resources) { + submit_async(processors::alliance_resources_delta); + } + break; + + // ships + // TODO: currently still part of JSON, likely to change in the future + + // slots + case EntityGroup::Type::EntitySlots: + if (sync_options.slots) { + submit_async(processors::entity_slots); + } + break; + case EntityGroup::Type::EntitySlotsData: + if (sync_options.slots) { + submit_async(processors::entity_slots_data); + } + break; + + // tech + case EntityGroup::Type::ForbiddenTechs: + if (sync_options.tech) { + submit_async(processors::forbidden_tech); + } + break; + + // traits + case EntityGroup::Type::ActiveOfficerTraits: + if (sync_options.traits) { + submit_async(processors::active_officer_traits); } break; + + // misc + case EntityGroup::Type::Json: + submit_async(processors::json::parse); + break; + default: break; } } -void DataContainer_ParseBinaryObject(auto original, void* _this, EntityGroup* group) +namespace hooks { - HandleEntityGroup(group); - return original(_this, group); -} - -void DataContainer_ParseEntitySlotsData(auto original, void* _this, EntityGroup* group) +static void DataContainer_ParseEntitySlotsData(auto original, void* _this, EntityGroup* group) { HandleEntityGroup(group); return original(_this, group); } -void* RtcParser_ParseFinalPayload(auto original, void* _this, void* centrifugoInfo, - RealtimeDataPayload* realtimeDataPayload, void* finalPayload) +static void* RtcParser_ParseFinalPayload(auto original, void* _this, void* centrifugoInfo, + RealtimeDataPayload* realtimeDataPayload, void* finalPayload) { if (realtimeDataPayload != nullptr && realtimeDataPayload->Target != nullptr && realtimeDataPayload->DataType != nullptr && realtimeDataPayload->Data != nullptr) { @@ -2025,7 +2312,7 @@ void* RtcParser_ParseFinalPayload(auto original, void* _this, void* centrifugoIn std::thread([p = std::move(payload)]() mutable { try { - process_entity_slots_rtc(std::move(p)); + processors::rtc::entity_slots(std::move(p)); } catch (const std::exception& e) { spdlog::error("Exception in RtcParser_ParseFinalPayload: {}", e.what()); } catch (...) { @@ -2038,55 +2325,45 @@ void* RtcParser_ParseFinalPayload(auto original, void* _this, void* centrifugoIn return original(_this, centrifugoInfo, realtimeDataPayload, finalPayload); } -void GameServerModelRegistry_ProcessResultInternal(auto original, void* _this, void* parsing_context, - ServiceResponse* service_response, MethodInfo* method) +static void GameServerModelRegistry_ProcessResultInternal(auto original, void* _this, void* parsing_context, + ServiceResponse* service_response, MethodInfo* method) { - auto *const entity_groups = service_response->EntityGroups; + auto* const entity_groups = service_response->EntityGroups; for (int i = 0; i < entity_groups->Count; ++i) { - auto *const entity_group = entity_groups->get_Item(i); + auto* const entity_group = entity_groups->get_Item(i); HandleEntityGroup(entity_group); } return original(_this, parsing_context, service_response, method); } -void GameServerModelRegistry_ParseBinaryObjectsHelper(auto original, void* _this, void* parsing_context, - ServiceResponse* service_response, void* parsedEntityTypes, - MethodInfo* method) -{ - auto *const entity_groups = service_response->EntityGroups; - for (int i = 0; i < entity_groups->Count; ++i) { - auto *const entity_group = entity_groups->get_Item(i); - HandleEntityGroup(entity_group); - } - - return original(_this, parsing_context, service_response, parsedEntityTypes, method); -} - -void PrimeApp_InitPrimeServer(auto original, void* _this, Il2CppString* gameServerUrl, Il2CppString* gatewayServerUrl, - Il2CppString* sessionId, Il2CppString* serverRegion) +static void PrimeApp_InitPrimeServer(auto original, void* _this, Il2CppString* gameServerUrl, + Il2CppString* gatewayServerUrl, Il2CppString* sessionId, + Il2CppString* serverRegion) { original(_this, gameServerUrl, gatewayServerUrl, sessionId, serverRegion); http::headers::instanceSessionId = to_string(sessionId); http::headers::gameServerUrl = to_string(gameServerUrl); } -void GameServer_Initialise(auto original, void* _this, Il2CppString* sessionId, Il2CppString* gameVersion, - bool encryptRequests, Il2CppString* serverRegion) +static void GameServer_Initialise(auto original, void* _this, Il2CppString* sessionId, Il2CppString* gameVersion, + bool encryptRequests, Il2CppString* serverRegion) { original(_this, sessionId, gameVersion, encryptRequests, serverRegion); http::headers::primeVersion = to_string(gameVersion); } -void GameServer_SetInstanceIdHeader(auto original, void* _this, int32_t instanceId) +static void GameServer_SetInstanceIdHeader(auto original, void* _this, int32_t instanceId) { original(_this, instanceId); http::headers::instanceId = instanceId; } +} // namespace hooks + void InstallSyncPatches() { - load_previously_sent_logs(); + trackers::load_previously_sent_logs(); if (auto game_server_model_registry = il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Core", "GameServerModelRegistry"); @@ -2096,7 +2373,7 @@ void InstallSyncPatches() if (auto *const ptr = game_server_model_registry.GetMethod("ProcessResultInternal"); ptr == nullptr) { ErrorMsg::MissingMethod("GameServerModelRegistry", "ProcessResultInternal"); } else { - SPUD_STATIC_DETOUR(ptr, GameServerModelRegistry_ProcessResultInternal); + SPUD_STATIC_DETOUR(ptr, hooks::GameServerModelRegistry_ProcessResultInternal); } } @@ -2108,7 +2385,7 @@ void InstallSyncPatches() if (auto *const ptr = slot_data_container.GetMethod("ParseEntitySlotsData"); ptr == nullptr) { ErrorMsg::MissingMethod("SlotDataContainer", "ParseEntitySlotsData"); } else { - SPUD_STATIC_DETOUR(ptr, DataContainer_ParseEntitySlotsData); + SPUD_STATIC_DETOUR(ptr, hooks::DataContainer_ParseEntitySlotsData); } } @@ -2119,7 +2396,7 @@ void InstallSyncPatches() if (auto *const ptr = prime_app.GetMethod("InitPrimeServer"); ptr == nullptr) { ErrorMsg::MissingMethod("PrimeApp", "InitPrimeServer"); } else { - SPUD_STATIC_DETOUR(ptr, PrimeApp_InitPrimeServer); + SPUD_STATIC_DETOUR(ptr, hooks::PrimeApp_InitPrimeServer); } } @@ -2137,13 +2414,13 @@ void InstallSyncPatches() } else if (auto *const ptr = game_server.GetMethod("Initialise"); ptr == nullptr) { ErrorMsg::MissingMethod("GameServer", "Initialise"); } else { - SPUD_STATIC_DETOUR(ptr, GameServer_Initialise); + SPUD_STATIC_DETOUR(ptr, hooks::GameServer_Initialise); } if (auto *const ptr = game_server.GetMethod("SetInstanceIdHeader"); ptr == nullptr) { ErrorMsg::MissingMethod("GameServer", "SetInstanceIdHeader"); } else { - SPUD_STATIC_DETOUR(ptr, GameServer_SetInstanceIdHeader); + SPUD_STATIC_DETOUR(ptr, hooks::GameServer_SetInstanceIdHeader); } } @@ -2154,7 +2431,7 @@ void InstallSyncPatches() if (auto* const ptr = slot_assign_parser.GetMethod("ParseFinalPayload"); ptr == nullptr) { ErrorMsg::MissingMethod("SlotAssignRtcParser", "ParseFinalPayload"); } else { - SPUD_STATIC_DETOUR(ptr, RtcParser_ParseFinalPayload); + SPUD_STATIC_DETOUR(ptr, hooks::RtcParser_ParseFinalPayload); } } @@ -2165,10 +2442,10 @@ void InstallSyncPatches() if (auto* const ptr = slot_clear_parser.GetMethod("ParseFinalPayload"); ptr == nullptr) { ErrorMsg::MissingMethod("SlotClearRtcParser", "ParseFinalPayload"); } else { - SPUD_STATIC_DETOUR(ptr, RtcParser_ParseFinalPayload); + SPUD_STATIC_DETOUR(ptr, hooks::RtcParser_ParseFinalPayload); } } - std::thread(ship_sync_data).detach(); - std::thread(ship_combat_log_data).detach(); + std::thread(workers::ship_sync_data).detach(); + std::thread(workers::ship_combat_log_data).detach(); } From 0c7368c1fa97681700c7183ef0e95785d5811767 Mon Sep 17 00:00:00 2001 From: lightbull Date: Tue, 11 Aug 2026 01:00:53 +0200 Subject: [PATCH 05/80] bump version to 1.1.6 --- mods/src/version.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mods/src/version.h b/mods/src/version.h index aa22170c5..380e9d460 100644 --- a/mods/src/version.h +++ b/mods/src/version.h @@ -3,7 +3,7 @@ // clang-format off #define VERSION_MAJOR 1 #define VERSION_MINOR 1 -#define VERSION_REVISION 5 +#define VERSION_REVISION 6 #define VERSION_PATCH 1 #define STRINGIFY_(s) #s From 615a551d2b0681bc69a7e0f919e134a0d0b89ef3 Mon Sep 17 00:00:00 2001 From: lightbull Date: Tue, 11 Aug 2026 01:45:16 +0200 Subject: [PATCH 06/80] Update libcurl configuration to use shared=false and enable openssl3 --- xmake.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xmake.lua b/xmake.lua index 1975b80f0..9c653a5a5 100644 --- a/xmake.lua +++ b/xmake.lua @@ -21,7 +21,7 @@ add_requires("spdlog") add_requires("toml++") add_requires("nlohmann_json") add_requires("cpr") -add_requireconfs("cpr.libcurl", { configs = { zlib = true } }) +add_requireconfs("cpr.libcurl", { configs = { shared = false, openssl3 = true, zlib = true } }) add_requires("protobuf 35.1") if is_plat("windows") then From b8fd4de9437f3ff8f0edd1293a5b9af687e2a557 Mon Sep 17 00:00:00 2001 From: lightbull Date: Tue, 11 Aug 2026 02:02:54 +0200 Subject: [PATCH 07/80] Revert to older xmake version, enhance CI configuration to upload diagnostics on failure --- .github/workflows/ci.yaml | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2145f9a6a..7a7a60fdd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,13 +23,23 @@ jobs: - name: Setup uses: xmake-io/github-action-setup-xmake@v1 with: - xmake-version: latest + xmake-version: '3.0.9' package-cache: true package-cache-key: ${{ hashFiles('**/xmake.lua', '!macos-*/xmake.lua') }} - name: Configure + id: configure_windows run: xmake f -m release -y + - name: Upload configure diagnostics + if: ${{ failure() && steps.configure_windows.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: xmake-configure-diagnostics-windows + path: ~/.xmake/cache/packages/**/installdir.failed/logs/install.txt + if-no-files-found: warn + include-hidden-files: true + - name: Build run: xmake -y @@ -59,14 +69,34 @@ jobs: package-cache-key: ${{ hashFiles('**/xmake.lua', '!win-*/xmake.lua') }} - name: Configure (arm64) + id: configure_arm64 run: xmake f -p macosx -a "arm64" -m release -y --target_minver=13.5 + - name: Upload configure diagnostics (arm64) + if: ${{ failure() && steps.configure_arm64.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: xmake-configure-diagnostics-macos-arm64 + path: ~/.xmake/cache/packages/**/installdir.failed/logs/install.txt + if-no-files-found: warn + include-hidden-files: true + - name: Build (arm64) run: xmake -y - name: Configure (x86) + id: configure_x86_64 run: xmake f -p macosx -a "x86_64" -m release -y --target_minver=13.5 + - name: Upload configure diagnostics (x86_64) + if: ${{ failure() && steps.configure_x86_64.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: xmake-configure-diagnostics-macos-x86_64 + path: ~/.xmake/cache/packages/**/installdir.failed/logs/install.txt + if-no-files-found: warn + include-hidden-files: true + - name: Build (x86) run: xmake -y From 4dc9f40f522253913e5ea71ff331c2154258af69 Mon Sep 17 00:00:00 2001 From: lightbull Date: Tue, 11 Aug 2026 02:10:28 +0200 Subject: [PATCH 08/80] Temporarily add timeouts to configuration jobs --- .github/workflows/ci.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7a7a60fdd..96ffabd27 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -29,6 +29,7 @@ jobs: - name: Configure id: configure_windows + timeout-minutes: 20 run: xmake f -m release -y - name: Upload configure diagnostics @@ -70,6 +71,7 @@ jobs: - name: Configure (arm64) id: configure_arm64 + timeout-minutes: 20 run: xmake f -p macosx -a "arm64" -m release -y --target_minver=13.5 - name: Upload configure diagnostics (arm64) @@ -86,6 +88,7 @@ jobs: - name: Configure (x86) id: configure_x86_64 + timeout-minutes: 20 run: xmake f -p macosx -a "x86_64" -m release -y --target_minver=13.5 - name: Upload configure diagnostics (x86_64) From 135014dee8848b06600b523385af0d7acb673de0 Mon Sep 17 00:00:00 2001 From: lightbull Date: Tue, 11 Aug 2026 02:22:52 +0200 Subject: [PATCH 09/80] Remove ParseEntitySlotsData due to changed function signature --- mods/src/patches/parts/sync.cc | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index 63c2cf059..c5b6ccc89 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -2292,12 +2292,6 @@ static void HandleEntityGroup(EntityGroup* entity_group) namespace hooks { -static void DataContainer_ParseEntitySlotsData(auto original, void* _this, EntityGroup* group) -{ - HandleEntityGroup(group); - return original(_this, group); -} - static void* RtcParser_ParseFinalPayload(auto original, void* _this, void* centrifugoInfo, RealtimeDataPayload* realtimeDataPayload, void* finalPayload) { @@ -2377,18 +2371,6 @@ void InstallSyncPatches() } } - if (auto slot_data_container = - il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Services", "SlotDataContainer"); - !slot_data_container.isValidHelper()) { - ErrorMsg::MissingHelper("Services", "SlotDataContainer"); - } else { - if (auto *const ptr = slot_data_container.GetMethod("ParseEntitySlotsData"); ptr == nullptr) { - ErrorMsg::MissingMethod("SlotDataContainer", "ParseEntitySlotsData"); - } else { - SPUD_STATIC_DETOUR(ptr, hooks::DataContainer_ParseEntitySlotsData); - } - } - if (auto prime_app = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.Core", "PrimeApp"); !prime_app.isValidHelper()) { ErrorMsg::MissingHelper("Core", "PrimeApp"); From ee0eb931d86f895f1d273fbc9170798557f5f41c Mon Sep 17 00:00:00 2001 From: lightbull Date: Tue, 11 Aug 2026 02:47:54 +0200 Subject: [PATCH 10/80] Enhance CI configuration for macOS builds and packaging - Update xmake version to latest in CI jobs - Implement matrix strategy for macOS builds to support arm64 and x86_64 architectures - Add steps for packaging intermediate builds and uploading artifacts - Improve diagnostics and validation steps for build artifacts --- .github/workflows/ci.yaml | 202 ++++++++++++++++++++++++++------- .github/workflows/release.yaml | 95 ++++++++++++---- xmake.lua | 2 + 3 files changed, 237 insertions(+), 62 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 96ffabd27..0bffec061 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,6 +14,7 @@ concurrency: jobs: build-win: runs-on: windows-2025-vs2026 + steps: - name: Checkout uses: actions/checkout@v6 @@ -23,7 +24,7 @@ jobs: - name: Setup uses: xmake-io/github-action-setup-xmake@v1 with: - xmake-version: '3.0.9' + xmake-version: latest package-cache: true package-cache-key: ${{ hashFiles('**/xmake.lua', '!macos-*/xmake.lua') }} @@ -55,7 +56,22 @@ jobs: if-no-files-found: error build-mac: - runs-on: macos-26 + name: build-mac (${{ matrix.arch }}) + + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + runner: macos-26 + artifact: macos-arm64-build + + - arch: x86_64 + runner: macos-26-intel + artifact: macos-x86_64-build + + runs-on: ${{ matrix.runner }} + steps: - name: Checkout uses: actions/checkout@v6 @@ -67,80 +83,184 @@ jobs: with: xmake-version: latest package-cache: true - package-cache-key: ${{ hashFiles('**/xmake.lua', '!win-*/xmake.lua') }} + package-cache-key: macos-${{ matrix.arch }}-${{ hashFiles('**/xmake.lua', '!win-*/xmake.lua') }} - - name: Configure (arm64) - id: configure_arm64 + - name: Configure + id: configure timeout-minutes: 20 - run: xmake f -p macosx -a "arm64" -m release -y --target_minver=13.5 + run: > + xmake f + -p macosx + -a "${{ matrix.arch }}" + -m release + -y + --target_minver=13.5 - - name: Upload configure diagnostics (arm64) - if: ${{ failure() && steps.configure_arm64.outcome == 'failure' }} + - name: Upload configure diagnostics + if: ${{ failure() && steps.configure.outcome == 'failure' }} uses: actions/upload-artifact@v7 with: - name: xmake-configure-diagnostics-macos-arm64 + name: xmake-configure-diagnostics-macos-${{ matrix.arch }} path: ~/.xmake/cache/packages/**/installdir.failed/logs/install.txt if-no-files-found: warn include-hidden-files: true - - name: Build (arm64) + - name: Build run: xmake -y - - name: Configure (x86) - id: configure_x86_64 - timeout-minutes: 20 - run: xmake f -p macosx -a "x86_64" -m release -y --target_minver=13.5 + - name: Rebuild launcher assets + if: ${{ matrix.arch == 'arm64' }} + run: | + xmake -r -y macOSLauncher + test -f build/macosx/arm64/release/macOSLauncher.app/Contents/Resources/Assets.car - - name: Upload configure diagnostics (x86_64) - if: ${{ failure() && steps.configure_x86_64.outcome == 'failure' }} + # Tar the build before artifact upload so Unix permissions, + # symlinks, and bundle metadata survive transfer to package-mac. + - name: Package intermediate build + run: | + tar -cf "macos-${{ matrix.arch }}-build.tar" \ + -C "build/macosx/${{ matrix.arch }}" \ + release + + - name: Upload intermediate build uses: actions/upload-artifact@v7 with: - name: xmake-configure-diagnostics-macos-x86_64 - path: ~/.xmake/cache/packages/**/installdir.failed/logs/install.txt - if-no-files-found: warn - include-hidden-files: true + name: ${{ matrix.artifact }} + path: macos-${{ matrix.arch }}-build.tar + compression-level: 0 + if-no-files-found: error - - name: Build (x86) - run: xmake -y + package-mac: + name: package-mac + needs: build-mac + runs-on: macos-26 - - name: Rebuild launcher assets + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: "recursive" + + - name: Download ARM64 build + uses: actions/download-artifact@v8 + with: + name: macos-arm64-build + path: .artifacts/arm64 + + - name: Download x86_64 build + uses: actions/download-artifact@v8 + with: + name: macos-x86_64-build + path: .artifacts/x86_64 + + - name: Restore architecture builds run: | - xmake f -p macosx -a "arm64" -m release -y --target_minver=13.5 - xmake -r -y macOSLauncher + mkdir -p build/macosx/arm64 + mkdir -p build/macosx/x86_64 + + tar -xf .artifacts/arm64/macos-arm64-build.tar \ + -C build/macosx/arm64 + + tar -xf .artifacts/x86_64/macos-x86_64-build.tar \ + -C build/macosx/x86_64 + + test -f build/macosx/arm64/release/macOSLauncher + test -f build/macosx/x86_64/release/macOSLauncher + test -f build/macosx/arm64/release/stfc-community-mod-loader + test -f build/macosx/x86_64/release/stfc-community-mod-loader + test -f build/macosx/arm64/release/libstfc-community-mod.dylib + test -f build/macosx/x86_64/release/libstfc-community-mod.dylib test -f build/macosx/arm64/release/macOSLauncher.app/Contents/Resources/Assets.car - name: Create App Bundle run: | - lipo -create build/macosx/arm64/release/macOSLauncher build/macosx/x86_64/release/macOSLauncher -output build/macosx/arm64/release/macOSLauncher.app/Contents/MacOS/macOSLauncher - lipo -create build/macosx/arm64/release/stfc-community-mod-loader build/macosx/x86_64/release/stfc-community-mod-loader -output build/macosx/arm64/release/macOSLauncher.app/Contents/stfc-community-mod-loader - lipo -create build/macosx/arm64/release/libstfc-community-mod.dylib build/macosx/x86_64/release/libstfc-community-mod.dylib -output build/macosx/arm64/release/macOSLauncher.app/Contents/libstfc-community-mod.dylib - cp assets/launcher.icns build/macosx/arm64/release/macOSLauncher.app/Contents/Resources/ - cp macos-launcher/src/Info.plist build/macosx/arm64/release/macOSLauncher.app/Contents/ - mv build/macosx/arm64/release/macOSLauncher.app build/macosx/arm64/release/STFC\ Community\ Mod.app - test -f build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents/Resources/Assets.car + lipo -create \ + build/macosx/arm64/release/macOSLauncher \ + build/macosx/x86_64/release/macOSLauncher \ + -output build/macosx/arm64/release/macOSLauncher.app/Contents/MacOS/macOSLauncher + + lipo -create \ + build/macosx/arm64/release/stfc-community-mod-loader \ + build/macosx/x86_64/release/stfc-community-mod-loader \ + -output build/macosx/arm64/release/macOSLauncher.app/Contents/stfc-community-mod-loader + + lipo -create \ + build/macosx/arm64/release/libstfc-community-mod.dylib \ + build/macosx/x86_64/release/libstfc-community-mod.dylib \ + -output build/macosx/arm64/release/macOSLauncher.app/Contents/libstfc-community-mod.dylib + + cp assets/launcher.icns \ + build/macosx/arm64/release/macOSLauncher.app/Contents/Resources/ + + cp macos-launcher/src/Info.plist \ + build/macosx/arm64/release/macOSLauncher.app/Contents/ + + mv \ + build/macosx/arm64/release/macOSLauncher.app \ + build/macosx/arm64/release/STFC\ Community\ Mod.app + + test -f \ + build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents/Resources/Assets.car + + - name: Verify universal binaries + run: | + lipo -archs \ + build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents/MacOS/macOSLauncher + + lipo -archs \ + build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents/stfc-community-mod-loader + + lipo -archs \ + build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents/libstfc-community-mod.dylib + + test "$(lipo -archs build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents/MacOS/macOSLauncher)" = "x86_64 arm64" \ + -o "$(lipo -archs build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents/MacOS/macOSLauncher)" = "arm64 x86_64" - name: Sign App Bundle - run: codesign --force --verify --verbose --deep --sign "-" build/macosx/arm64/release/STFC\ Community\ Mod.app + run: | + codesign \ + --force \ + --verify \ + --verbose \ + --deep \ + --sign "-" \ + build/macosx/arm64/release/STFC\ Community\ Mod.app + + - name: Install packaging tools + run: brew install zstd create-dmg - name: Package macOS mod archive run: | - brew install zstd mkdir -p stfc-community-mod-macos-universal + tar -cf - \ -C build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents \ libstfc-community-mod.dylib \ - | zstd -19 -T0 -o stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst - shasum -a 256 stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst \ + | zstd -19 -T0 \ + -o stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst + + shasum -a 256 \ + stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst \ | awk '{print $1}' \ > stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst.sha256 - - name: Install disk image creation tool - run: brew install create-dmg - - name: Create installer - run: create-dmg --filesystem APFS --format ULFO --volname "STFC Community Mod Installer" --volicon assets/launcher.icns --background assets/mac_installer_background.png --window-pos 200 120 --window-size 800 400 --icon-size 100 --icon "STFC Community Mod.app" 200 190 --app-drop-link 600 185 stfc-community-mod-installer.dmg build/macosx/arm64/release/ + run: | + create-dmg \ + --filesystem APFS \ + --format ULFO \ + --volname "STFC Community Mod Installer" \ + --volicon assets/launcher.icns \ + --background assets/mac_installer_background.png \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "STFC Community Mod.app" 200 190 \ + --app-drop-link 600 185 \ + stfc-community-mod-installer.dmg \ + build/macosx/arm64/release/ - - name: Upload + - name: Upload installer uses: actions/upload-artifact@v7 with: name: stfc-community-mod-installer diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c0ea2999f..e8b7d8308 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,75 +8,128 @@ on: jobs: create-release: runs-on: ubuntu-latest + name: Create Release + permissions: contents: write - name: Create Release + actions: read steps: - name: Checkout code uses: actions/checkout@v6 - # ✅ Step 1 — Check if Build succeeded for this same commit + # Step 1 — Find a successful Build workflow for this exact commit - name: Check latest Build success id: check_build run: | echo "Checking for successful Build workflow for commit ${{ github.sha }}..." + response=$(gh api \ -H "Accept: application/vnd.github+json" \ - /repos/${{ github.repository }}/actions/runs \ - --jq '.workflow_runs[] | select(.name=="Build" and .head_sha=="${{ github.sha }}" and .conclusion=="success") | .id' | head -n 1) - + "/repos/${{ github.repository }}/actions/runs?head_sha=${{ github.sha }}&status=success&per_page=100" \ + --jq '.workflow_runs[] | select(.name == "Build") | .id' \ + | head -n 1) + if [ -z "$response" ]; then echo "❌ No successful Build workflow found for this commit." exit 1 fi echo "✅ Found successful Build run ID: $response" - echo "build_run_id=$response" >> $GITHUB_OUTPUT + echo "build_run_id=$response" >> "$GITHUB_OUTPUT" env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Step 2 — Download only the final Build artifacts - # ✅ Step 2 — Download the Build artifacts - - name: Download artifact from Build workflow - uses: dawidd6/action-download-artifact@v18 + - name: Download Windows build + uses: actions/download-artifact@v8 + with: + name: stfc-community-mod + path: stfc-community-mod + github-token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository }} + run-id: ${{ steps.check_build.outputs.build_run_id }} + + - name: Download macOS universal archive + uses: actions/download-artifact@v8 + with: + name: stfc-community-mod-macos-universal + path: stfc-community-mod-macos-universal + github-token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository }} + run-id: ${{ steps.check_build.outputs.build_run_id }} + + - name: Download macOS installer + uses: actions/download-artifact@v8 with: - run_id: ${{ steps.check_build.outputs.build_run_id }} + name: stfc-community-mod-installer + path: stfc-community-mod-installer + github-token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository }} + run-id: ${{ steps.check_build.outputs.build_run_id }} + + # Step 3 — Validate downloaded release inputs + - name: Validate Build artifacts + run: | + test -f stfc-community-mod/version.dll + + test -f \ + stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst + + test -f \ + stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst.sha256 + + test -f \ + stfc-community-mod-installer/stfc-community-mod-installer.dmg - # ✅ Step 3 — Package up + # Step 4 — Package legacy ZIP - name: Package run: | (cd stfc-community-mod && zip -r - .) > stfc-community-mod.zip + # Step 5 — Package Windows mod archive - name: Package Windows mod archive run: | sudo apt-get update sudo apt-get install -y zstd + mkdir -p stfc-community-mod-windows-x64 - tar -cf - -C stfc-community-mod version.dll \ - | zstd -19 -T0 -o stfc-community-mod-windows-x64/stfc-community-mod-windows-x64.tar.zst - sha256sum stfc-community-mod-windows-x64/stfc-community-mod-windows-x64.tar.zst \ + + tar -cf - \ + -C stfc-community-mod \ + version.dll \ + | zstd -19 -T0 \ + -o stfc-community-mod-windows-x64/stfc-community-mod-windows-x64.tar.zst + + sha256sum \ + stfc-community-mod-windows-x64/stfc-community-mod-windows-x64.tar.zst \ | awk '{print $1}' \ > stfc-community-mod-windows-x64/stfc-community-mod-windows-x64.tar.zst.sha256 - # ✅ Step 4 — Detect prerelease (alpha/beta) + # Step 6 — Detect prerelease - name: Check Prerelease id: check-prerelease + shell: bash run: | - if echo "${{ github.ref_name }}" | grep -Pq "^v[\d]+.[\d]+.[\d]+(?:\.(alpha|beta)\.[\d]+)?$"; then + if [[ "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\.(alpha|beta)\.[0-9]+$ ]]; then echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" fi - # ✅ Step 5 — Create Release + # Step 7 — Create Release - name: Create Release id: create_release uses: ncipollo/release-action@v1 with: - tag: ${{ github.ref }} + tag: ${{ github.ref_name }} name: ${{ github.ref_name }} generateReleaseNotes: true - prerelease: ${{ steps.check-prerelease.outputs.prerelease == 'true' }} + prerelease: ${{ steps.check-prerelease.outputs.prerelease }} + + # Step 8 — Upload release assets - # ✅ Step 6 — Upload assets - name: Upload Community Mod - Standard uses: shogo82148/actions-upload-release-asset@v1 env: diff --git a/xmake.lua b/xmake.lua index 9c653a5a5..a15a3b827 100644 --- a/xmake.lua +++ b/xmake.lua @@ -22,6 +22,8 @@ add_requires("toml++") add_requires("nlohmann_json") add_requires("cpr") add_requireconfs("cpr.libcurl", { configs = { shared = false, openssl3 = true, zlib = true } }) +add_requireconfs("cpr.libcurl.openssl3", { configs = { shared = false } }) +add_requireconfs("cpr.libcurl.zlib", { configs = { shared = false } }) add_requires("protobuf 35.1") if is_plat("windows") then From 34fc1023c44f917effd44645a572851f976aa5c4 Mon Sep 17 00:00:00 2001 From: lightbull Date: Tue, 11 Aug 2026 03:25:17 +0200 Subject: [PATCH 11/80] Update cpr.libcurl configurations for platform-specific settings --- xmake.lua | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/xmake.lua b/xmake.lua index a15a3b827..514b0e43d 100644 --- a/xmake.lua +++ b/xmake.lua @@ -20,12 +20,27 @@ add_requires("eastl") add_requires("spdlog") add_requires("toml++") add_requires("nlohmann_json") -add_requires("cpr") -add_requireconfs("cpr.libcurl", { configs = { shared = false, openssl3 = true, zlib = true } }) -add_requireconfs("cpr.libcurl.openssl3", { configs = { shared = false } }) -add_requireconfs("cpr.libcurl.zlib", { configs = { shared = false } }) add_requires("protobuf 35.1") +add_requires("cpr") + +if is_plat("windows") then + add_requireconfs("cpr.libcurl", { + configs = { shared = false, openssl3 = false, zlib = true } + }) +elseif is_plat("macosx") then + add_requireconfs("cpr.libcurl", { + configs = { shared = false, openssl3 = true, apple_sectrust = true, zlib = true } + }) + add_requireconfs("cpr.libcurl.openssl3", { + configs = { shared = false } + }) +end + +add_requireconfs("cpr.libcurl.zlib", { + configs = { shared = false } +}) + if is_plat("windows") then includes("win-proxy-dll") add_links('rpcrt4') From 5c12050de24c62075f561a4da177d80b3a2ade4f Mon Sep 17 00:00:00 2001 From: lightbull Date: Tue, 11 Aug 2026 03:25:35 +0200 Subject: [PATCH 12/80] Refactor CI configuration for macOS builds to enhance launcher bundle finalization and packaging processes --- .github/workflows/ci.yaml | 110 +++++++++++++++++++++++++++++--------- 1 file changed, 84 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0bffec061..a4029bfa1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -108,16 +108,34 @@ jobs: - name: Build run: xmake -y - - name: Rebuild launcher assets + # Only the ARM64 build supplies the base .app bundle. + # Preserve its generated Info.plist before transferring the build + # to the separate package-mac job. + - name: Finalize launcher bundle if: ${{ matrix.arch == 'arm64' }} + shell: bash run: | + set -euo pipefail + xmake -r -y macOSLauncher - test -f build/macosx/arm64/release/macOSLauncher.app/Contents/Resources/Assets.car - # Tar the build before artifact upload so Unix permissions, - # symlinks, and bundle metadata survive transfer to package-mac. + APP="build/macosx/arm64/release/macOSLauncher.app" + + test -f "$APP/Contents/Resources/Assets.car" + test -f macos-launcher/src/Info.plist + + cp macos-launcher/src/Info.plist \ + "$APP/Contents/Info.plist" + + test -f "$APP/Contents/Info.plist" + + # Tar the build before artifact upload so executable permissions, + # symlinks, and bundle structure survive artifact transfer. - name: Package intermediate build + shell: bash run: | + set -euo pipefail + tar -cf "macos-${{ matrix.arch }}-build.tar" \ -C "build/macosx/${{ matrix.arch }}" \ release @@ -154,7 +172,10 @@ jobs: path: .artifacts/x86_64 - name: Restore architecture builds + shell: bash run: | + set -euo pipefail + mkdir -p build/macosx/arm64 mkdir -p build/macosx/x86_64 @@ -166,75 +187,109 @@ jobs: test -f build/macosx/arm64/release/macOSLauncher test -f build/macosx/x86_64/release/macOSLauncher + test -f build/macosx/arm64/release/stfc-community-mod-loader test -f build/macosx/x86_64/release/stfc-community-mod-loader + test -f build/macosx/arm64/release/libstfc-community-mod.dylib test -f build/macosx/x86_64/release/libstfc-community-mod.dylib - test -f build/macosx/arm64/release/macOSLauncher.app/Contents/Resources/Assets.car - - name: Create App Bundle + test -f \ + build/macosx/arm64/release/macOSLauncher.app/Contents/Resources/Assets.car + + test -f \ + build/macosx/arm64/release/macOSLauncher.app/Contents/Info.plist + + - name: Create universal App Bundle + shell: bash run: | + set -euo pipefail + + APP="build/macosx/arm64/release/macOSLauncher.app" + lipo -create \ build/macosx/arm64/release/macOSLauncher \ build/macosx/x86_64/release/macOSLauncher \ - -output build/macosx/arm64/release/macOSLauncher.app/Contents/MacOS/macOSLauncher + -output "$APP/Contents/MacOS/macOSLauncher" lipo -create \ build/macosx/arm64/release/stfc-community-mod-loader \ build/macosx/x86_64/release/stfc-community-mod-loader \ - -output build/macosx/arm64/release/macOSLauncher.app/Contents/stfc-community-mod-loader + -output "$APP/Contents/stfc-community-mod-loader" lipo -create \ build/macosx/arm64/release/libstfc-community-mod.dylib \ build/macosx/x86_64/release/libstfc-community-mod.dylib \ - -output build/macosx/arm64/release/macOSLauncher.app/Contents/libstfc-community-mod.dylib + -output "$APP/Contents/libstfc-community-mod.dylib" cp assets/launcher.icns \ - build/macosx/arm64/release/macOSLauncher.app/Contents/Resources/ - - cp macos-launcher/src/Info.plist \ - build/macosx/arm64/release/macOSLauncher.app/Contents/ + "$APP/Contents/Resources/" mv \ build/macosx/arm64/release/macOSLauncher.app \ - build/macosx/arm64/release/STFC\ Community\ Mod.app + "build/macosx/arm64/release/STFC Community Mod.app" - test -f \ - build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents/Resources/Assets.car + FINAL_APP="build/macosx/arm64/release/STFC Community Mod.app" + + test -f "$FINAL_APP/Contents/Info.plist" + test -f "$FINAL_APP/Contents/Resources/Assets.car" + test -f "$FINAL_APP/Contents/Resources/launcher.icns" + test -f "$FINAL_APP/Contents/MacOS/macOSLauncher" + test -f "$FINAL_APP/Contents/stfc-community-mod-loader" + test -f "$FINAL_APP/Contents/libstfc-community-mod.dylib" - name: Verify universal binaries + shell: bash run: | - lipo -archs \ - build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents/MacOS/macOSLauncher + set -euo pipefail + + APP="build/macosx/arm64/release/STFC Community Mod.app" - lipo -archs \ - build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents/stfc-community-mod-loader + verify_universal() { + local file="$1" + local archs - lipo -archs \ - build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents/libstfc-community-mod.dylib + archs="$(lipo -archs "$file")" + echo "$file: $archs" - test "$(lipo -archs build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents/MacOS/macOSLauncher)" = "x86_64 arm64" \ - -o "$(lipo -archs build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents/MacOS/macOSLauncher)" = "arm64 x86_64" + [[ " $archs " == *" arm64 "* ]] + [[ " $archs " == *" x86_64 "* ]] + } + + verify_universal \ + "$APP/Contents/MacOS/macOSLauncher" + + verify_universal \ + "$APP/Contents/stfc-community-mod-loader" + + verify_universal \ + "$APP/Contents/libstfc-community-mod.dylib" - name: Sign App Bundle + shell: bash run: | + set -euo pipefail + codesign \ --force \ --verify \ --verbose \ --deep \ --sign "-" \ - build/macosx/arm64/release/STFC\ Community\ Mod.app + "build/macosx/arm64/release/STFC Community Mod.app" - name: Install packaging tools run: brew install zstd create-dmg - name: Package macOS mod archive + shell: bash run: | + set -euo pipefail + mkdir -p stfc-community-mod-macos-universal tar -cf - \ - -C build/macosx/arm64/release/STFC\ Community\ Mod.app/Contents \ + -C "build/macosx/arm64/release/STFC Community Mod.app/Contents" \ libstfc-community-mod.dylib \ | zstd -19 -T0 \ -o stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst @@ -245,7 +300,10 @@ jobs: > stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst.sha256 - name: Create installer + shell: bash run: | + set -euo pipefail + create-dmg \ --filesystem APFS \ --format ULFO \ From cc2c773d08b9c4d635370f844f37abd1661b536f Mon Sep 17 00:00:00 2001 From: lightbull Date: Tue, 11 Aug 2026 03:36:10 +0200 Subject: [PATCH 13/80] Update cpr.libcurl configurations for macOS to include Apple-specific flags --- xmake.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/xmake.lua b/xmake.lua index 514b0e43d..afd4b5f5e 100644 --- a/xmake.lua +++ b/xmake.lua @@ -30,7 +30,13 @@ if is_plat("windows") then }) elseif is_plat("macosx") then add_requireconfs("cpr.libcurl", { - configs = { shared = false, openssl3 = true, apple_sectrust = true, zlib = true } + configs = { + shared = false, + openssl3 = true, + zlib = true, + cxflags = "-DUSE_APPLE_SECTRUST=1", + ldflags = "-framework Security -framework CoreFoundation -framework CoreServices" + } }) add_requireconfs("cpr.libcurl.openssl3", { configs = { shared = false } From 9ca5ecc42b55b385c5bbe7ce97d65fa819b11156 Mon Sep 17 00:00:00 2001 From: lightbull Date: Tue, 11 Aug 2026 03:38:08 +0200 Subject: [PATCH 14/80] Improve caching and packaging processes in CI runs --- .github/workflows/ci.yaml | 103 +++++++++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a4029bfa1..73912e4ba 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,10 +7,17 @@ on: pull_request: branches: ["main", "dev"] +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + XMAKE_VERSION: "3.1.0" + XMAKE_CACHE_SCHEMA: "v1" + jobs: build-win: runs-on: windows-2025-vs2026 @@ -19,14 +26,20 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: + fetch-depth: 1 submodules: "recursive" + show-progress: false - - name: Setup + - name: Setup XMake uses: xmake-io/github-action-setup-xmake@v1 with: - xmake-version: latest + xmake-version: ${{ env.XMAKE_VERSION }} package-cache: true - package-cache-key: ${{ hashFiles('**/xmake.lua', '!macos-*/xmake.lua') }} + package-cache-key: windows-x64-release-${{ env.XMAKE_CACHE_SCHEMA }}-${{ hashFiles('xmake-packages/**/xmake.lua') }} + + # Compiler/object build cache. + build-cache: true + build-cache-key: windows-x64-release-${{ env.XMAKE_CACHE_SCHEMA }} - name: Configure id: configure_windows @@ -41,18 +54,24 @@ jobs: path: ~/.xmake/cache/packages/**/installdir.failed/logs/install.txt if-no-files-found: warn include-hidden-files: true + retention-days: 3 - name: Build run: xmake -y - name: Package - run: mv build/windows/x64/release/stfc-community-mod.dll version.dll + shell: pwsh + run: | + Move-Item ` + "build/windows/x64/release/stfc-community-mod.dll" ` + "version.dll" - name: Upload uses: actions/upload-artifact@v7 with: name: stfc-community-mod path: version.dll + compression-level: 0 if-no-files-found: error build-mac: @@ -76,14 +95,20 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: + fetch-depth: 1 submodules: "recursive" + show-progress: false - - name: Setup + - name: Setup XMake uses: xmake-io/github-action-setup-xmake@v1 with: - xmake-version: latest + xmake-version: ${{ env.XMAKE_VERSION }} + package-cache: true - package-cache-key: macos-${{ matrix.arch }}-${{ hashFiles('**/xmake.lua', '!win-*/xmake.lua') }} + package-cache-key: macos-${{ matrix.arch }}-release-${{ env.XMAKE_CACHE_SCHEMA }}-${{ hashFiles('xmake-packages/**/xmake.lua') }} + + build-cache: true + build-cache-key: macos-${{ matrix.arch }}-release-${{ env.XMAKE_CACHE_SCHEMA }} - name: Configure id: configure @@ -104,23 +129,20 @@ jobs: path: ~/.xmake/cache/packages/**/installdir.failed/logs/install.txt if-no-files-found: warn include-hidden-files: true + retention-days: 3 - name: Build run: xmake -y - # Only the ARM64 build supplies the base .app bundle. - # Preserve its generated Info.plist before transferring the build - # to the separate package-mac job. - name: Finalize launcher bundle if: ${{ matrix.arch == 'arm64' }} shell: bash run: | set -euo pipefail - xmake -r -y macOSLauncher - APP="build/macosx/arm64/release/macOSLauncher.app" + test -f build/macosx/arm64/release/macOSLauncher test -f "$APP/Contents/Resources/Assets.car" test -f macos-launcher/src/Info.plist @@ -129,16 +151,36 @@ jobs: test -f "$APP/Contents/Info.plist" - # Tar the build before artifact upload so executable permissions, - # symlinks, and bundle structure survive artifact transfer. + # Only transfer the files needed by package-mac instead of the + # complete XMake release directory. - name: Package intermediate build shell: bash run: | set -euo pipefail - tar -cf "macos-${{ matrix.arch }}-build.tar" \ - -C "build/macosx/${{ matrix.arch }}" \ - release + RELEASE="build/macosx/${{ matrix.arch }}/release" + ARCHIVE="macos-${{ matrix.arch }}-build.tar" + + test -f "$RELEASE/macOSLauncher" + test -f "$RELEASE/stfc-community-mod-loader" + test -f "$RELEASE/libstfc-community-mod.dylib" + + if [[ "${{ matrix.arch }}" == "arm64" ]]; then + test -d "$RELEASE/macOSLauncher.app" + + tar -cf "$ARCHIVE" \ + -C "$RELEASE" \ + macOSLauncher \ + stfc-community-mod-loader \ + libstfc-community-mod.dylib \ + macOSLauncher.app + else + tar -cf "$ARCHIVE" \ + -C "$RELEASE" \ + macOSLauncher \ + stfc-community-mod-loader \ + libstfc-community-mod.dylib + fi - name: Upload intermediate build uses: actions/upload-artifact@v7 @@ -146,6 +188,7 @@ jobs: name: ${{ matrix.artifact }} path: macos-${{ matrix.arch }}-build.tar compression-level: 0 + retention-days: 1 if-no-files-found: error package-mac: @@ -157,7 +200,9 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - submodules: "recursive" + fetch-depth: 1 + submodules: false + show-progress: false - name: Download ARM64 build uses: actions/download-artifact@v8 @@ -176,14 +221,14 @@ jobs: run: | set -euo pipefail - mkdir -p build/macosx/arm64 - mkdir -p build/macosx/x86_64 + mkdir -p build/macosx/arm64/release + mkdir -p build/macosx/x86_64/release tar -xf .artifacts/arm64/macos-arm64-build.tar \ - -C build/macosx/arm64 + -C build/macosx/arm64/release tar -xf .artifacts/x86_64/macos-x86_64-build.tar \ - -C build/macosx/x86_64 + -C build/macosx/x86_64/release test -f build/macosx/arm64/release/macOSLauncher test -f build/macosx/x86_64/release/macOSLauncher @@ -279,7 +324,17 @@ jobs: "build/macosx/arm64/release/STFC Community Mod.app" - name: Install packaging tools - run: brew install zstd create-dmg + shell: bash + env: + HOMEBREW_NO_AUTO_UPDATE: "1" + run: | + set -euo pipefail + + # zstd is currently preinstalled on the macOS 26 image. + # Keep the fallback so the workflow remains robust if the + # runner image changes later. + command -v zstd >/dev/null 2>&1 || brew install zstd + command -v create-dmg >/dev/null 2>&1 || brew install create-dmg - name: Package macOS mod archive shell: bash @@ -291,7 +346,7 @@ jobs: tar -cf - \ -C "build/macosx/arm64/release/STFC Community Mod.app/Contents" \ libstfc-community-mod.dylib \ - | zstd -19 -T0 \ + | zstd -10 -T0 \ -o stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst shasum -a 256 \ From bdb52251848cdda3d2956a71291fda77c35aaef0 Mon Sep 17 00:00:00 2001 From: lightbull Date: Tue, 11 Aug 2026 04:24:06 +0200 Subject: [PATCH 15/80] Refactor release workflow --- .github/workflows/release.yaml | 176 +++++++++++++-------------------- 1 file changed, 68 insertions(+), 108 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e8b7d8308..aa1e08071 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,51 +3,55 @@ name: Create Release on: push: tags: - - "v*" # triggers when a tag like v0.6.1 or v0.6.1.alpha.9 is pushed + - "v*" # v0.6.1 or v0.6.1.alpha.9 + +permissions: + contents: write + actions: read jobs: create-release: - runs-on: ubuntu-latest name: Create Release - - permissions: - contents: write - actions: read + runs-on: ubuntu-24.04 steps: - - name: Checkout code - uses: actions/checkout@v6 - - # Step 1 — Find a successful Build workflow for this exact commit - - name: Check latest Build success + # Find a successful Build workflow for this exact commit. + # + # Release intentionally reuses the artifacts from CI rather than + # rebuilding Windows/macOS from the tag. + - name: Find successful Build id: check_build + shell: bash + env: + GH_TOKEN: ${{ github.token }} run: | + set -euo pipefail + echo "Checking for successful Build workflow for commit ${{ github.sha }}..." - response=$(gh api \ - -H "Accept: application/vnd.github+json" \ - "/repos/${{ github.repository }}/actions/runs?head_sha=${{ github.sha }}&status=success&per_page=100" \ - --jq '.workflow_runs[] | select(.name == "Build") | .id' \ - | head -n 1) + build_run_id="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "/repos/${{ github.repository }}/actions/runs?head_sha=${{ github.sha }}&status=success&per_page=100" \ + --jq '.workflow_runs[] | select(.name == "Build") | .id' \ + | head -n 1 + )" - if [ -z "$response" ]; then - echo "❌ No successful Build workflow found for this commit." + if [[ -z "$build_run_id" ]]; then + echo "::error::No successful Build workflow found for commit ${{ github.sha }}." exit 1 fi - echo "✅ Found successful Build run ID: $response" - echo "build_run_id=$response" >> "$GITHUB_OUTPUT" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # Step 2 — Download only the final Build artifacts + echo "Found successful Build run ID: $build_run_id" + echo "build_run_id=$build_run_id" >> "$GITHUB_OUTPUT" + # Download only release-facing CI artifacts. - name: Download Windows build uses: actions/download-artifact@v8 with: name: stfc-community-mod path: stfc-community-mod - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ github.token }} repository: ${{ github.repository }} run-id: ${{ steps.check_build.outputs.build_run_id }} @@ -56,7 +60,7 @@ jobs: with: name: stfc-community-mod-macos-universal path: stfc-community-mod-macos-universal - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ github.token }} repository: ${{ github.repository }} run-id: ${{ steps.check_build.outputs.build_run_id }} @@ -65,13 +69,15 @@ jobs: with: name: stfc-community-mod-installer path: stfc-community-mod-installer - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ github.token }} repository: ${{ github.repository }} run-id: ${{ steps.check_build.outputs.build_run_id }} - # Step 3 — Validate downloaded release inputs - name: Validate Build artifacts + shell: bash run: | + set -euo pipefail + test -f stfc-community-mod/version.dll test -f \ @@ -83,23 +89,37 @@ jobs: test -f \ stfc-community-mod-installer/stfc-community-mod-installer.dmg - # Step 4 — Package legacy ZIP - - name: Package + # Legacy Windows ZIP. + # + # Keep both historical names as release assets. + - name: Package legacy Windows ZIPs + shell: bash run: | - (cd stfc-community-mod && zip -r - .) > stfc-community-mod.zip + set -euo pipefail + + ( + cd stfc-community-mod + zip -q -r ../stfc-community-mod.zip . + ) + + cp \ + stfc-community-mod.zip \ + "stfc-community-mod-${{ github.ref_name }}.zip" - # Step 5 — Package Windows mod archive + # Native Windows archive used by newer installers/updaters. - name: Package Windows mod archive + shell: bash run: | - sudo apt-get update - sudo apt-get install -y zstd + set -euo pipefail + + command -v zstd >/dev/null mkdir -p stfc-community-mod-windows-x64 tar -cf - \ -C stfc-community-mod \ version.dll \ - | zstd -19 -T0 \ + | zstd -10 -T0 \ -o stfc-community-mod-windows-x64/stfc-community-mod-windows-x64.tar.zst sha256sum \ @@ -107,95 +127,35 @@ jobs: | awk '{print $1}' \ > stfc-community-mod-windows-x64/stfc-community-mod-windows-x64.tar.zst.sha256 - # Step 6 — Detect prerelease - name: Check Prerelease id: check-prerelease shell: bash run: | + set -euo pipefail + if [[ "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\.(alpha|beta)\.[0-9]+$ ]]; then echo "prerelease=true" >> "$GITHUB_OUTPUT" else echo "prerelease=false" >> "$GITHUB_OUTPUT" fi - # Step 7 — Create Release + # Create the release and upload every release asset in one action. - name: Create Release - id: create_release uses: ncipollo/release-action@v1 with: tag: ${{ github.ref_name }} name: ${{ github.ref_name }} + generateReleaseNotes: true prerelease: ${{ steps.check-prerelease.outputs.prerelease }} - # Step 8 — Upload release assets - - - name: Upload Community Mod - Standard - uses: shogo82148/actions-upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./stfc-community-mod.zip - asset_name: stfc-community-mod.zip - asset_content_type: application/zip - - - name: Upload Community Mod - Versioned - uses: shogo82148/actions-upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./stfc-community-mod.zip - asset_name: stfc-community-mod-${{ github.ref_name }}.zip - asset_content_type: application/zip - - - name: Upload Community Mod - Windows Archive - uses: shogo82148/actions-upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./stfc-community-mod-windows-x64/stfc-community-mod-windows-x64.tar.zst - asset_name: stfc-community-mod-windows-x64.tar.zst - asset_content_type: application/zstd - - - name: Upload Community Mod - Windows Archive Checksum - uses: shogo82148/actions-upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./stfc-community-mod-windows-x64/stfc-community-mod-windows-x64.tar.zst.sha256 - asset_name: stfc-community-mod-windows-x64.tar.zst.sha256 - asset_content_type: text/plain - - - name: Upload Community Mod - macOS Archive - uses: shogo82148/actions-upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst - asset_name: stfc-community-mod-macos-universal.tar.zst - asset_content_type: application/zstd - - - name: Upload Community Mod - macOS Archive Checksum - uses: shogo82148/actions-upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst.sha256 - asset_name: stfc-community-mod-macos-universal.tar.zst.sha256 - asset_content_type: text/plain + artifactErrorsFailBuild: true - - name: Upload Community Mod - Mac Installer - uses: shogo82148/actions-upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./stfc-community-mod-installer/stfc-community-mod-installer.dmg - asset_name: stfc-community-mod-installer.dmg - asset_content_type: application/x-apple-diskimage + artifacts: | + stfc-community-mod.zip + stfc-community-mod-${{ github.ref_name }}.zip + stfc-community-mod-windows-x64/stfc-community-mod-windows-x64.tar.zst + stfc-community-mod-windows-x64/stfc-community-mod-windows-x64.tar.zst.sha256 + stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst + stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst.sha256 + stfc-community-mod-installer/stfc-community-mod-installer.dmg From 0a26e70a76f2c92947a2c144318439d9d4b39986 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 2 Aug 2026 19:34:33 -0500 Subject: [PATCH 16/80] Add Windows DLL provenance marker --- .github/workflows/ci.yaml | 32 ++++++++++++++++++++- mods/src/version.h | 29 +++++++++++++++++++ win-proxy-dll/src/version.rc | 3 +- xmake.lua | 56 ++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 73912e4ba..ca5af9134 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -44,7 +44,27 @@ jobs: - name: Configure id: configure_windows timeout-minutes: 20 - run: xmake f -m release -y + shell: pwsh + run: | + $commit = (git rev-parse HEAD).Trim() + if ($commit -notmatch '^[0-9a-f]{40}$') { + throw "Unable to resolve the checked-out commit for DLL provenance." + } + + $sourceState = "git:$commit" + $buildInvocation = "github:${{ github.run_id }}:${{ github.run_attempt }}:windows" + $identity = "stfc-identity-v1;distribution=netniv.stfc-community-mod;source=$sourceState;" + + "base=$commit;build=$buildInvocation;mode=release;channel=ci" + "STFC_EXPECTED_IDENTITY=$identity" >> $env:GITHUB_ENV + + $xmakeArgs = @( + 'f', '-m', 'release', '-y', + "--stfc_source_state_id=$sourceState", + "--stfc_base_commit=$commit", + "--stfc_build_invocation_id=$buildInvocation", + '--stfc_build_channel=ci' + ) + xmake @xmakeArgs - name: Upload configure diagnostics if: ${{ failure() && steps.configure_windows.outcome == 'failure' }} @@ -66,6 +86,16 @@ jobs: "build/windows/x64/release/stfc-community-mod.dll" ` "version.dll" + - name: Verify DLL provenance + shell: pwsh + run: | + $dll = (Resolve-Path -LiteralPath 'version.dll').Path + $comments = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dll).Comments + if (-not [string]::Equals($comments, $env:STFC_EXPECTED_IDENTITY, [System.StringComparison]::Ordinal)) { + throw "DLL provenance mismatch. Expected '$env:STFC_EXPECTED_IDENTITY'; found '$comments'." + } + Write-Host "Verified DLL provenance: $comments" + - name: Upload uses: actions/upload-artifact@v7 with: diff --git a/mods/src/version.h b/mods/src/version.h index 380e9d460..45c151d9f 100644 --- a/mods/src/version.h +++ b/mods/src/version.h @@ -20,6 +20,35 @@ #define VER_FILE_VERSION VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION, VERSION_PATCH #define VER_FILE_VERSION_STR STRINGIFY(VERSION_MAJOR) "." STRINGIFY(VERSION_MINOR) "." STRINGIFY(VERSION_REVISION) "." STRINGIFY(VERSION_PATCH) +#define STFC_DISTRIBUTION_ID "netniv.stfc-community-mod" + +#ifndef STFC_SOURCE_STATE_ID +#define STFC_SOURCE_STATE_ID "unknown" +#endif + +#ifndef STFC_BASE_COMMIT +#define STFC_BASE_COMMIT "unknown" +#endif + +#ifndef STFC_BUILD_INVOCATION_ID +#define STFC_BUILD_INVOCATION_ID "xmake-direct" +#endif + +#ifndef STFC_BUILD_MODE +#define STFC_BUILD_MODE "unknown" +#endif + +#ifndef STFC_BUILD_CHANNEL +#define STFC_BUILD_CHANNEL "local" +#endif + +#define STFC_IDENTITY_COMMENT_STR "stfc-identity-v1;distribution=" STFC_DISTRIBUTION_ID \ + ";source=" STFC_SOURCE_STATE_ID \ + ";base=" STFC_BASE_COMMIT \ + ";build=" STFC_BUILD_INVOCATION_ID \ + ";mode=" STFC_BUILD_MODE \ + ";channel=" STFC_BUILD_CHANNEL + #define VER_PRODUCTNAME_STR "STFC: Community Mod" #define VER_PRODUCT_VERSION VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION #define VER_PRODUCT_VERSION_STR VER_FILE_VERSION_STR diff --git a/win-proxy-dll/src/version.rc b/win-proxy-dll/src/version.rc index 20154ba4b..86841ff61 100644 --- a/win-proxy-dll/src/version.rc +++ b/win-proxy-dll/src/version.rc @@ -27,6 +27,7 @@ BEGIN BEGIN VALUE "FileDescription", VER_FILE_DESCRIPTION_STR VALUE "FileVersion", VER_FILE_VERSION_STR + VALUE "Comments", STFC_IDENTITY_COMMENT_STR VALUE "InternalName", VER_INTERNAL_NAME_STR VALUE "LegalCopyright", VER_COPYRIGHT_STR VALUE "OriginalFilename", VER_ORIGINAL_FILENAME_STR @@ -39,4 +40,4 @@ BEGIN VALUE "Translation", 0x0409,1200 END END -#endif \ No newline at end of file +#endif diff --git a/xmake.lua b/xmake.lua index afd4b5f5e..c30f4ae58 100644 --- a/xmake.lua +++ b/xmake.lua @@ -66,6 +66,62 @@ add_rules("mode.debug") add_rules("mode.release") add_rules("mode.releasedbg") +option("stfc_source_state_id") + set_default(os.getenv("STFC_SOURCE_STATE_ID") or "unknown") + set_showmenu(true) + set_description("Clean commit or dirty source fingerprint embedded in Windows DLL provenance") +option_end() + +option("stfc_base_commit") + set_default(os.getenv("STFC_BASE_COMMIT") or "unknown") + set_showmenu(true) + set_description("Base source commit embedded in Windows DLL provenance") +option_end() + +option("stfc_build_invocation_id") + set_default(os.getenv("STFC_BUILD_INVOCATION_ID") or "xmake-direct") + set_showmenu(true) + set_description("Build correlation identifier embedded in Windows DLL provenance") +option_end() + +option("stfc_build_channel") + set_default(os.getenv("STFC_BUILD_CHANNEL") or "local") + set_showmenu(true) + set_description("Build channel embedded in Windows DLL provenance") +option_end() + +if is_plat("windows") then + rule("stfc.identity") + on_load(function(target) + local function identity_define(option_name, fallback) + local value = get_config(option_name) + if value == nil then + value = fallback + end + if type(value) ~= "string" or value == "" then + raise(option_name .. " must not be empty") + end + if #value > 160 then + raise(option_name .. " must not exceed 160 characters") + end + if not value:match("^[A-Za-z0-9%._:+/%-]+$") then + raise(option_name .. " contains unsupported identity characters") + end + return value + end + + target:add("defines", "STFC_SOURCE_STATE_ID=\"" .. + identity_define("stfc_source_state_id", "unknown") .. "\"") + target:add("defines", "STFC_BASE_COMMIT=\"" .. identity_define("stfc_base_commit", "unknown") .. "\"") + target:add("defines", "STFC_BUILD_INVOCATION_ID=\"" .. + identity_define("stfc_build_invocation_id", "xmake-direct") .. "\"") + target:add("defines", "STFC_BUILD_MODE=\"" .. identity_define("mode", "unknown") .. "\"") + target:add("defines", "STFC_BUILD_CHANNEL=\"" .. identity_define("stfc_build_channel", "local") .. "\"") + end) + rule_end() + add_rules("stfc.identity") +end + package("libil2cpp") on_fetch(function(package, opt) return { includedirs = path.join(os.scriptdir(), "third_party/libil2cpp") } From bfcb89e4e82e8abd1916e12b388784aaff2c3ba5 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 3 Aug 2026 02:45:43 -0500 Subject: [PATCH 17/80] Attest trusted Windows DLL builds --- .github/workflows/ci.yaml | 40 +++++++++++++++++++++++----------- README.md | 3 +++ docs/WINDOWS_DLL_PROVENANCE.md | 28 ++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 13 deletions(-) create mode 100644 docs/WINDOWS_DLL_PROVENANCE.md diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ca5af9134..e910eda60 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,17 +21,21 @@ env: jobs: build-win: runs-on: windows-2025-vs2026 + permissions: + contents: read + id-token: write + attestations: write steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 1 submodules: "recursive" show-progress: false - name: Setup XMake - uses: xmake-io/github-action-setup-xmake@v1 + uses: xmake-io/github-action-setup-xmake@3a1a5dddfc7fa625d9a698738334bf55655a861a # v1.2.5 with: xmake-version: ${{ env.XMAKE_VERSION }} package-cache: true @@ -68,7 +72,7 @@ jobs: - name: Upload configure diagnostics if: ${{ failure() && steps.configure_windows.outcome == 'failure' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: xmake-configure-diagnostics-windows path: ~/.xmake/cache/packages/**/installdir.failed/logs/install.txt @@ -96,8 +100,18 @@ jobs: } Write-Host "Verified DLL provenance: $comments" + - name: Attest Windows DLL + if: >- + github.event_name == 'push' && + github.repository == 'netniV/stfc-mod' && + github.ref_protected && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 + with: + subject-path: version.dll + - name: Upload - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: stfc-community-mod path: version.dll @@ -123,14 +137,14 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 1 submodules: "recursive" show-progress: false - name: Setup XMake - uses: xmake-io/github-action-setup-xmake@v1 + uses: xmake-io/github-action-setup-xmake@3a1a5dddfc7fa625d9a698738334bf55655a861a # v1.2.5 with: xmake-version: ${{ env.XMAKE_VERSION }} @@ -153,7 +167,7 @@ jobs: - name: Upload configure diagnostics if: ${{ failure() && steps.configure.outcome == 'failure' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: xmake-configure-diagnostics-macos-${{ matrix.arch }} path: ~/.xmake/cache/packages/**/installdir.failed/logs/install.txt @@ -213,7 +227,7 @@ jobs: fi - name: Upload intermediate build - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ matrix.artifact }} path: macos-${{ matrix.arch }}-build.tar @@ -228,20 +242,20 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 1 submodules: false show-progress: false - name: Download ARM64 build - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: macos-arm64-build path: .artifacts/arm64 - name: Download x86_64 build - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: macos-x86_64-build path: .artifacts/x86_64 @@ -404,7 +418,7 @@ jobs: build/macosx/arm64/release/ - name: Upload installer - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: stfc-community-mod-installer path: stfc-community-mod-installer.dmg @@ -412,7 +426,7 @@ jobs: if-no-files-found: error - name: Upload macOS mod archive - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: stfc-community-mod-macos-universal path: stfc-community-mod-macos-universal diff --git a/README.md b/README.md index 5130f281e..60ca7b4a9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,9 @@ This project is maintained solely at my own cost of time, energy and money. Any Please see the [INSTALL.md](INSTALL.md) instructions which has steps on how to use this mod with Star Trek Fleet Command. +For the Windows DLL identity marker, artifact-attestation trust boundary, and verification command, see +[Windows DLL provenance](docs/WINDOWS_DLL_PROVENANCE.md). + Please note, that whilst Mac support was added in this version, it's supported on an as-is basis due to lack of Mac development environments. ## Keyboard shortcuts diff --git a/docs/WINDOWS_DLL_PROVENANCE.md b/docs/WINDOWS_DLL_PROVENANCE.md new file mode 100644 index 000000000..b15ab5072 --- /dev/null +++ b/docs/WINDOWS_DLL_PROVENANCE.md @@ -0,0 +1,28 @@ +# Windows DLL provenance + +Windows builds publish a `stfc-identity-v1` marker in the DLL's standard `Comments` version resource. The marker is +minimal, descriptive, and self-declared. It helps identify a build, but it is not evidence of authenticity and must +not be used as an authorization or trust decision by itself. + +For protected `main` and `dev` pushes in `netniV/stfc-mod`, the upstream Build workflow creates a GitHub/Sigstore +build-provenance attestation for the exact `version.dll` bytes after verifying the embedded marker. Pull requests, +fork runs, unprotected refs, tags, and other repositories do not create authoritative attestations. + +After downloading and extracting an attested DLL, verify its byte integrity and expected upstream workflow identity +with a current GitHub CLI: + +```shell +gh attestation verify version.dll \ + --repo netniV/stfc-mod \ + --signer-workflow netniV/stfc-mod/.github/workflows/ci.yaml +``` + +Successful verification establishes that the exact file digest is covered by an attestation issued for the named +repository and signer workflow, including source commit provenance. It does not establish that the source, +dependencies, build environment, or resulting behavior are free from malicious or unsafe behavior. It also does not, +by itself, authorize the artifact as an official release. + +The first authoritative upstream attestation can exist only after the attestation workflow is merged and then runs on +an eligible protected upstream push. Existing releases are unattested. Downstream consumers, including STFC Mod +Bridge, must continue to use exact reviewed file size and SHA-256 allowlists until an attested upstream artifact is +actually available and their attestation policy is separately reviewed. From 7f5f8d07c8d71246cbee514b571e61b83be8722c Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 28 Aug 2026 23:24:37 -0500 Subject: [PATCH 18/80] Prevent duplicate Spud detour targets --- .../packages/s/spud/spud-src/CMakeLists.txt | 50 ++--- .../s/spud/spud-src/include/spud/detour.h | 67 ++++--- .../s/spud/spud-src/src/detour/detour.cc | 180 +++++++++++++----- .../spud-src/src/detour/target_registry.cc | 78 ++++++++ .../spud-src/src/detour/target_registry.h | 43 +++++ .../s/spud/spud-src/tests/detour_registry.cc | 180 ++++++++++++++++++ xmake-requires.lock | 14 +- xmake/dependencies/common.lua | 2 +- 8 files changed, 511 insertions(+), 103 deletions(-) create mode 100644 xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc create mode 100644 xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h create mode 100644 xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc diff --git a/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt b/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt index eba1509e8..234040179 100644 --- a/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt +++ b/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt @@ -217,6 +217,8 @@ target_sources( "src/detour/detour.cc" "src/detour/detour_impl.h" "src/detour/fwd.h" + "src/detour/target_registry.cc" + "src/detour/target_registry.h" "src/detour/remapper.cc" "src/detour/remapper.h" "src/memory/protection.cc" @@ -238,7 +240,7 @@ if(NOT SPUD_NO_INSTALL) ) endif() if(SPUD_BUILD_TESTS) - set(TEST_SRCS "tests/signature.cc") + set(TEST_SRCS "tests/detour_registry.cc") file( GLOB_RECURSE TEST_DETOUR_SHARED_SRCS tests/detour/shared/*.cc @@ -306,35 +308,35 @@ if(SPUD_BUILD_TESTS) add_executable( "spud.test" ${TEST_SRCS} - "tests/test_util.h" - "tests/signature.cc" ) - target_include_directories("spud.test" PRIVATE "tests") + target_include_directories("spud.test" PRIVATE "tests" "src") target_link_libraries( "spud.test" PRIVATE "Catch2" "Catch2::Catch2WithMain" "spud" ) add_dependencies("spud.test" "Catch2" "spud") - add_executable("spud.benchmark" ${BENCHMARK_SRCS}) - target_include_directories("spud.benchmark" PRIVATE "benchmark") - if(SPUD_COMPARE_LIBS) - target_link_libraries( - "spud.benchmark" - PRIVATE - "Catch2" - "Catch2::Catch2WithMain" - "spud" - "lime" - "minhook" - "PolyHook2" - ) - else() - target_link_libraries( - "spud.benchmark" - PRIVATE "Catch2" "Catch2::Catch2WithMain" "spud" - ) + if(BENCHMARK_SRCS) + add_executable("spud.benchmark" ${BENCHMARK_SRCS}) + target_include_directories("spud.benchmark" PRIVATE "benchmark") + if(SPUD_COMPARE_LIBS) + target_link_libraries( + "spud.benchmark" + PRIVATE + "Catch2" + "Catch2::Catch2WithMain" + "spud" + "lime" + "minhook" + "PolyHook2" + ) + else() + target_link_libraries( + "spud.benchmark" + PRIVATE "Catch2" "Catch2::Catch2WithMain" "spud" + ) + endif() + add_dependencies("spud.benchmark" "Catch2" "spud") + add_test(NAME spud.benchmark COMMAND spud.benchmark) endif() - add_dependencies("spud.benchmark" "Catch2" "spud") add_test(NAME spud.test COMMAND spud.test) - add_test(NAME spud.benchmark COMMAND spud.benchmark) endif() diff --git a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h index 946de5cc1..859370953 100644 --- a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h +++ b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #if __cpp_lib_source_location && SPUD_DETOUR_TRACING @@ -17,6 +18,14 @@ #include "utils.h" namespace spud { + +enum class detour_install_status : uint8_t { + not_installed, + installed, + already_installed, + duplicate_target, +}; + namespace detail { struct detour { @@ -29,27 +38,23 @@ struct detour { return this->trampoline_; } + detour_install_status last_install_status() const { + return last_install_status_; + } + using Self = detail::detour; public: detour(detour &&other) noexcept { - this->trampoline_ = other.trampoline_; - this->address_ = other.address_; - this->func_ = other.func_; - this->wrapper_ = other.wrapper_; - this->context_container_ = std::move(other.context_container_); - this->original_func_data_ = other.original_func_data_; - other.original_func_data_.clear(); + move_from(std::move(other)); } detour &operator=(detour &&other) noexcept { - this->trampoline_ = other.trampoline_; - this->address_ = other.address_; - this->func_ = other.func_; - this->wrapper_ = other.wrapper_; - this->context_container_ = std::move(other.context_container_); - this->original_func_data_ = other.original_func_data_; - other.original_func_data_.clear(); + if (this == &other) { + return *this; + } + remove(); + move_from(std::move(other)); return *this; } @@ -66,24 +71,19 @@ struct detour { #if __cpp_lib_source_location && SPUD_DETOUR_TRACING detour(uintptr_t address, uintptr_t func, uintptr_t wrapper, const std::source_location location = std::source_location::current()) - : address_(address), func_(func), wrapper_(wrapper), + : requested_address_(address), address_(address), func_(func), + wrapper_(wrapper), context_container_(std::make_unique()), location_(location) {} #else detour(uintptr_t address, uintptr_t func, uintptr_t wrapper) - : address_(address), func_(func), wrapper_(wrapper), + : requested_address_(address), address_(address), func_(func), + wrapper_(wrapper), context_container_(std::make_unique()) {} #endif Self &install(Arch arch = Arch::kHost); void remove(); - Self &detach() { - original_func_data_.clear(); - // We are intentionally ignoring the return value here - // TODO(alex): Move this to a global cleanup thing - (void)context_container_.release(); - - return *this; - } + Self &detach(); static uintptr_t get_context_value(); @@ -91,6 +91,22 @@ struct detour { detour(detour const &) = delete; detour &operator=(detour const &) = delete; + void move_from(detour &&other) noexcept { + requested_address_ = other.requested_address_; + address_ = other.address_; + func_ = other.func_; + wrapper_ = other.wrapper_; + context_container_ = std::move(other.context_container_); + trampoline_ = other.trampoline_; + original_func_data_ = std::move(other.original_func_data_); + installed_ = std::exchange(other.installed_, false); + last_install_status_ = other.last_install_status_; + + other.trampoline_ = 0; + other.last_install_status_ = detour_install_status::not_installed; + } + + uintptr_t requested_address_; uintptr_t address_; uintptr_t func_; uintptr_t wrapper_; @@ -98,6 +114,9 @@ struct detour { std::unique_ptr context_container_ = nullptr; uintptr_t trampoline_ = 0; std::vector original_func_data_ = {}; + bool installed_ = false; + detour_install_status last_install_status_ = + detour_install_status::not_installed; #if __cpp_lib_source_location && SPUD_DETOUR_TRACING const std::source_location location_ = std::source_location::current(); #endif diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc b/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc index e5cd87d42..98a5c41d6 100644 --- a/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc @@ -5,11 +5,14 @@ #include "detour_impl.h" #include "remapper.h" +#include "target_registry.h" #if SPUD_OS_APPLE #include #endif +#include +#include #include #if SPUD_OS_APPLE || SPUD_OS_LINUX @@ -22,6 +25,24 @@ extern "C" uintptr_t ASM_FUNC(spud_read_context_value, ()); namespace spud { namespace detail { +namespace { + +void report_conflict(const target_owner &candidate, + const target_owner &incumbent) noexcept { + std::fprintf( + stderr, + "spud: duplicate detour rejected (requested=0x%" PRIxPTR + ", canonical=0x%" PRIxPTR ", replacement=0x%" PRIxPTR + "); existing owner requested=0x%" PRIxPTR ", canonical=0x%" PRIxPTR + ", replacement=0x%" PRIxPTR ")\n", + candidate.requested_address, candidate.canonical_address, + candidate.replacement_address, incumbent.requested_address, + incumbent.canonical_address, incumbent.replacement_address); + std::fflush(stderr); +} + +} // namespace + struct DetourImpl { std::vector (*create_absolute_jump)(uintptr_t target, uintptr_t data); @@ -51,84 +72,127 @@ const static std::array kDetourImpls = { }; detail::detour &detour::install(Arch arch) { + if (installed_) { + last_install_status_ = detour_install_status::already_installed; + return *this; + } + if (context_container_ == nullptr) { + last_install_status_ = detour_install_status::not_installed; + return *this; + } + const auto &impl = kDetourImpls[arch]; // We don't want to hook things that point to a direct jump // This will resolve that jump and instead we hook the underlying function // func_ = impl.maybe_resolve_jump(func_); // TODO(alex): This will break hook stacking most likely right now... - address_ = impl.maybe_resolve_jump(address_); + const auto canonical_address = impl.maybe_resolve_jump(requested_address_); + const target_owner candidate = { + .requested_address = requested_address_, + .canonical_address = canonical_address, + .owner_token = reinterpret_cast(context_container_.get()), + .replacement_address = func_, + }; + const auto claim = detour_target_registry().claim(candidate); + if (claim.status == target_claim_status::conflict) { + last_install_status_ = detour_install_status::duplicate_target; + report_conflict(candidate, claim.incumbent); + return *this; + } + if (claim.status == target_claim_status::already_owned) { + last_install_status_ = detour_install_status::already_installed; + return *this; + } + + address_ = canonical_address; wrapper_ = impl.maybe_resolve_jump(wrapper_); - const auto jump = impl.create_absolute_jump( - wrapper_, reinterpret_cast(context_container_.get())); + bool jit_write_protection_disabled = false; + try { + const auto jump = impl.create_absolute_jump( + wrapper_, reinterpret_cast(context_container_.get())); - auto [relocation_infos, required_trampoline_size] = - impl.collect_relocations(address_, jump.size()); + auto [relocation_infos, required_trampoline_size] = + impl.collect_relocations(address_, jump.size()); - // Required trampoline size is how many bytes we have to take up of the - // original function This will then be used to calculate the expanded size, - // since we do have some instruction replacement and expansion going on + // Required trampoline size is how many bytes we have to take up of the + // original function This will then be used to calculate the expanded size, + // since we do have some instruction replacement and expansion going on - auto trampoline = impl.create_trampoline( - address_ + required_trampoline_size, - {reinterpret_cast(address_), required_trampoline_size}, - relocation_infos); + auto trampoline = impl.create_trampoline( + address_ + required_trampoline_size, + {reinterpret_cast(address_), required_trampoline_size}, + relocation_infos); - // TODO(tashcan): This isn't particularly amazing, we might have to ajdust - // permissions - disable_jit_write_protection(); + // TODO(tashcan): This isn't particularly amazing, we might have to adjust + // permissions + disable_jit_write_protection(); + jit_write_protection_disabled = true; - auto trampoline_address = alloc_executable_memory(trampoline.data.size()); - assert(trampoline_address != nullptr); + auto trampoline_address = alloc_executable_memory(trampoline.data.size()); + assert(trampoline_address != nullptr); - std::memcpy(trampoline_address, trampoline.data.data(), - trampoline.data.size()); - trampoline_ = - trampoline.start + reinterpret_cast(trampoline_address); + std::memcpy(trampoline_address, trampoline.data.data(), + trampoline.data.size()); + trampoline_ = + trampoline.start + reinterpret_cast(trampoline_address); - context_container_->func = func_; - context_container_->trampoline = trampoline_; + context_container_->func = func_; + context_container_->trampoline = trampoline_; #if __cpp_lib_source_location && SPUD_DETOUR_TRACING - context_container_->location = location_; + context_container_->location = location_; #endif - { - const auto copy_size = required_trampoline_size; - original_func_data_.resize(copy_size); - std::memcpy(original_func_data_.data(), reinterpret_cast(address_), - copy_size); - - remapper remap(address_, copy_size); { - SPUD_SCOPED_PROTECTION(remap, copy_size, - mem_protection::READ_WRITE_EXECUTE); - - // This will leave some trashed instructions - // Which is okay for now - // TODO(tashcan): Add some kind of NOP function to make the remaining - // stuff "valid" - std::memcpy(reinterpret_cast(uintptr_t(remap)), jump.data(), - jump.size()); + const auto copy_size = required_trampoline_size; + original_func_data_.resize(copy_size); + std::memcpy(original_func_data_.data(), + reinterpret_cast(address_), copy_size); + + remapper remap(address_, copy_size); + { + SPUD_SCOPED_PROTECTION(remap, copy_size, + mem_protection::READ_WRITE_EXECUTE); + + // This will leave some trashed instructions + // Which is okay for now + // TODO(tashcan): Add some kind of NOP function to make the remaining + // stuff "valid" + std::memcpy(reinterpret_cast(uintptr_t(remap)), jump.data(), + jump.size()); #if SPUD_OS_APPLE - sys_dcache_flush((void *)address_, copy_size); + sys_dcache_flush((void *)address_, copy_size); #endif - } + } #if SPUD_OS_APPLE - sys_icache_invalidate((void *)address_, copy_size); + sys_icache_invalidate((void *)address_, copy_size); #endif - } + } - enable_jit_write_protection(); + enable_jit_write_protection(); + jit_write_protection_disabled = false; + } catch (...) { + if (jit_write_protection_disabled) { + enable_jit_write_protection(); + } + detour_target_registry().release(candidate); + original_func_data_.clear(); + trampoline_ = 0; + address_ = requested_address_; + throw; + } + installed_ = true; + last_install_status_ = detour_install_status::installed; return *this; } void detour::remove() { - assert(context_container_.get() != nullptr); - if (original_func_data_.size() == 0) { + if (!installed_ || original_func_data_.empty()) { return; } + assert(context_container_.get() != nullptr); disable_jit_write_protection(); { @@ -143,6 +207,28 @@ void detour::remove() { enable_jit_write_protection(); original_func_data_.clear(); + detour_target_registry().release({ + .requested_address = requested_address_, + .canonical_address = address_, + .owner_token = reinterpret_cast(context_container_.get()), + .replacement_address = func_, + }); + installed_ = false; + trampoline_ = 0; + address_ = requested_address_; + last_install_status_ = detour_install_status::not_installed; +} + +detour::Self &detour::detach() { + if (!installed_ || original_func_data_.empty()) { + return *this; + } + + original_func_data_.clear(); + // The target remains patched, so both the callback context and target claim + // intentionally live until process exit. + (void)context_container_.release(); + return *this; } uintptr_t detour::get_context_value() { diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc new file mode 100644 index 000000000..ac3397e3f --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc @@ -0,0 +1,78 @@ +#include "target_registry.h" + +#include + +namespace spud::detail +{ + +namespace +{ + std::array target_keys(const target_owner &owner) + { return {owner.requested_address, owner.canonical_address}; } +} // namespace + +target_claim_result target_registry::claim(const target_owner &candidate) +{ + std::lock_guard lock(mutex_); + bool owner_seen = false; + + for (const auto key : target_keys(candidate)) { + const auto entry = targets_.find(key); + if (entry == targets_.end()) { + continue; + } + if (entry->second.owner_token != candidate.owner_token) { + return {target_claim_status::conflict, entry->second}; + } + owner_seen = true; + } + if (owner_seen) { + return {target_claim_status::already_owned, {}}; + } + + std::array inserted{}; + size_t inserted_count = 0; + try { + for (const auto key : target_keys(candidate)) { + if (targets_.contains(key)) { + continue; + } + targets_.emplace(key, candidate); + inserted[inserted_count++] = key; + } + } catch (...) { + for (size_t index = 0; index < inserted_count; ++index) { + targets_.erase(inserted[index]); + } + throw; + } + + return {target_claim_status::claimed, {}}; +} + +void target_registry::release(const target_owner &owner) +{ + std::lock_guard lock(mutex_); + for (const auto key : target_keys(owner)) { + const auto entry = targets_.find(key); + if (entry != targets_.end() && entry->second.owner_token == owner.owner_token) { + targets_.erase(entry); + } + } +} + +size_t target_registry::size() const +{ + std::lock_guard lock(mutex_); + return targets_.size(); +} + +target_registry &detour_target_registry() +{ + // Detours can be destroyed during static teardown, so the registry must + // intentionally outlive every static detour object. + static auto *registry = new target_registry(); + return *registry; +} + +} // namespace spud::detail diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h new file mode 100644 index 000000000..cf8c67f9c --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include + +namespace spud::detail +{ + +struct target_owner { + uintptr_t requested_address; + uintptr_t canonical_address; + uintptr_t owner_token; + uintptr_t replacement_address; +}; + +enum class target_claim_status { + claimed, + already_owned, + conflict, +}; + +struct target_claim_result { + target_claim_status status; + target_owner incumbent{}; +}; + +class target_registry +{ +public: + target_claim_result claim(const target_owner &candidate); + void release(const target_owner &owner); + size_t size() const; + +private: + mutable std::mutex mutex_; + std::unordered_map targets_; +}; + +target_registry &detour_target_registry(); + +} // namespace spud::detail diff --git a/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc b/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc new file mode 100644 index 000000000..189cea49b --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc @@ -0,0 +1,180 @@ +#include + +#include "detour/target_registry.h" + +#include +#include +#include +#include + +#include + +namespace +{ + +#if defined(_MSC_VER) +#define SPUD_TEST_NOINLINE __declspec(noinline) +#else +#define SPUD_TEST_NOINLINE __attribute__((noinline)) +#endif + +SPUD_TEST_NOINLINE int duplicate_target(int value) +{ return value + 1; } + +SPUD_TEST_NOINLINE int move_target(int value) +{ return value + 2; } + +SPUD_TEST_NOINLINE int reinstall_target(int value) +{ return value + 3; } + +SPUD_TEST_NOINLINE int detached_target(int value) +{ return value + 4; } + +int add_ten(int (*original)(int), int value) +{ return original(value) + 10; } + +int add_twenty(int (*original)(int), int value) +{ return original(value) + 20; } + +} // namespace + +TEST_CASE("target registry rejects exact and canonical aliases") +{ + spud::detail::target_registry registry; + const spud::detail::target_owner first = { + .requested_address = 0x1000, + .canonical_address = 0x2000, + .owner_token = 0x3000, + .replacement_address = 0x4000, + }; + + REQUIRE(registry.claim(first).status == spud::detail::target_claim_status::claimed); + REQUIRE(registry.size() == 2); + REQUIRE(registry.claim(first).status == spud::detail::target_claim_status::already_owned); + + const auto exact = registry.claim({ + .requested_address = first.requested_address, + .canonical_address = 0x5000, + .owner_token = 0x6000, + .replacement_address = 0x7000, + }); + REQUIRE(exact.status == spud::detail::target_claim_status::conflict); + REQUIRE(exact.incumbent.owner_token == first.owner_token); + + const auto alias = registry.claim({ + .requested_address = 0x8000, + .canonical_address = first.canonical_address, + .owner_token = 0x9000, + .replacement_address = 0xA000, + }); + REQUIRE(alias.status == spud::detail::target_claim_status::conflict); + REQUIRE(alias.incumbent.owner_token == first.owner_token); + + registry.release(first); + REQUIRE(registry.size() == 0); +} + +TEST_CASE("target registry admits exactly one concurrent owner") +{ + spud::detail::target_registry registry; + std::barrier start(3); + std::atomic_size_t claimed = 0; + std::atomic_size_t conflicts = 0; + + const auto contender = [&](uintptr_t owner) { + start.arrive_and_wait(); + const auto result = registry.claim({ + .requested_address = 0x1000, + .canonical_address = 0x2000, + .owner_token = owner, + .replacement_address = owner + 1, + }); + if (result.status == spud::detail::target_claim_status::claimed) { + claimed.fetch_add(1, std::memory_order_relaxed); + } else if (result.status == spud::detail::target_claim_status::conflict) { + conflicts.fetch_add(1, std::memory_order_relaxed); + } + }; + + std::thread first(contender, 0x3000); + std::thread second(contender, 0x4000); + start.arrive_and_wait(); + first.join(); + second.join(); + + REQUIRE(claimed.load(std::memory_order_relaxed) == 1); + REQUIRE(conflicts.load(std::memory_order_relaxed) == 1); + REQUIRE(registry.size() == 2); +} + +TEST_CASE("duplicate detour preserves the first installed hook") +{ + int (*volatile call_target)(int) = duplicate_target; + REQUIRE(call_target(1) == 2); + + { + auto first = spud::create_detour(&duplicate_target, &add_ten); + first.install(); + REQUIRE(first.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 12); + + first.install(); + REQUIRE(first.last_install_status() == spud::detour_install_status::already_installed); + REQUIRE(call_target(1) == 12); + + auto second = spud::create_detour(&duplicate_target, &add_twenty); + second.install(); + REQUIRE(second.last_install_status() == spud::detour_install_status::duplicate_target); + REQUIRE(second.trampoline() == nullptr); + REQUIRE(call_target(1) == 12); + } + REQUIRE(call_target(1) == 2); +} + +TEST_CASE("moving an installed detour preserves ownership and cleanup") +{ + int (*volatile call_target)(int) = move_target; + REQUIRE(call_target(1) == 3); + { + auto source = spud::create_detour(&move_target, &add_ten); + source.install(); + auto moved = std::move(source); + REQUIRE(moved.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 13); + } + REQUIRE(call_target(1) == 3); +} + +TEST_CASE("normal destruction releases a target for later installation") +{ + int (*volatile call_target)(int) = reinstall_target; + { + auto first = spud::create_detour(&reinstall_target, &add_ten); + first.install(); + REQUIRE(first.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 14); + } + REQUIRE(call_target(1) == 4); + { + auto second = spud::create_detour(&reinstall_target, &add_twenty); + second.install(); + REQUIRE(second.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 24); + } + REQUIRE(call_target(1) == 4); +} + +TEST_CASE("detached hooks retain their target claim") +{ + int (*volatile call_target)(int) = detached_target; + { + auto first = spud::create_detour(&detached_target, &add_ten); + first.install().detach(); + REQUIRE(call_target(1) == 15); + } + + auto second = spud::create_detour(&detached_target, &add_twenty); + second.install(); + REQUIRE(second.last_install_status() == spud::detour_install_status::duplicate_target); + REQUIRE(call_target(1) == 15); +} diff --git a/xmake-requires.lock b/xmake-requires.lock index cad21781a..c10fec04b 100644 --- a/xmake-requires.lock +++ b/xmake-requires.lock @@ -126,11 +126,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-3#f56260b5"] = { + ["spud v0.2.0-4#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-3" + version = "v0.2.0-4" }, ["toml++#f56260b5"] = { repo = { @@ -273,11 +273,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-3#f56260b5"] = { + ["spud v0.2.0-4#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-3" + version = "v0.2.0-4" }, ["toml++#f56260b5"] = { repo = { @@ -384,11 +384,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-3#f56260b5"] = { + ["spud v0.2.0-4#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-3" + version = "v0.2.0-4" }, ["toml++#f56260b5"] = { repo = { @@ -407,4 +407,4 @@ version = "v1.3.2" } } -} \ No newline at end of file +} diff --git a/xmake/dependencies/common.lua b/xmake/dependencies/common.lua index 879f03a4d..17ffdc99d 100644 --- a/xmake/dependencies/common.lua +++ b/xmake/dependencies/common.lua @@ -20,7 +20,7 @@ add_requires("toml++") add_requires("nlohmann_json") add_requires("protobuf 35.1") add_requires("cpr", {system = false}) -add_requires("spud v0.2.0-3") +add_requires("spud v0.2.0-4") add_requires("libil2cpp") add_requires("simdutf", {system = false}) From 9a3d297685c32861ef804e001099e27be0df8125 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 28 Aug 2026 23:58:02 -0500 Subject: [PATCH 19/80] feat: restore double-tap Escape exit --- example_community_patch_settings_da.toml | 5 ++++- example_community_patch_settings_de.toml | 5 ++++- example_community_patch_settings_en-GB-x-cockney.toml | 5 ++++- example_community_patch_settings_en-x-minionese.toml | 5 ++++- example_community_patch_settings_en.toml | 5 ++++- example_community_patch_settings_es.toml | 5 ++++- example_community_patch_settings_fr.toml | 5 ++++- example_community_patch_settings_nl.toml | 5 ++++- example_community_patch_settings_ru.toml | 5 ++++- example_community_patch_settings_tlh.toml | 5 ++++- mods/src/config.cc | 2 ++ mods/src/config.h | 1 + mods/src/defaultconfig.h | 3 +++ mods/src/patches/parts/hotkeys.cc | 11 ++++++++++- 14 files changed, 56 insertions(+), 11 deletions(-) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 4e4003d2f..8af12adb1 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -596,6 +596,10 @@ auto_open_bulk_claim_flyout = false # Forhindr "Escape" i at spørge, om spillet skal afsluttes disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EKSPERIMENTELT: Forhindr den første pop op-reklame i at blive vist disable_first_popup = false @@ -829,4 +833,3 @@ verify_ssl = true # <[========================================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 2dda59892..85da94bdf 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -596,6 +596,10 @@ auto_open_bulk_claim_flyout = false # Verhindert, dass die Escape-Taste zum Beenden des Spiels auffordert disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPERIMENTELL: Verhindert das Erscheinen des ersten Werbe-Pop-ups disable_first_popup = false @@ -829,4 +833,3 @@ verify_ssl = true # <[===================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 0ccf711ca..829d0ea82 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -596,6 +596,10 @@ auto_open_bulk_claim_flyout = false # Prevent "escape" from prompting to exit the game disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPERIMENTAL: Prevent the first popup advert from appearing disable_first_popup = false @@ -829,4 +833,3 @@ verify_ssl = true # <[========================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 9f1e1e3a6..daafdf57c 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -596,6 +596,10 @@ auto_open_bulk_claim_flyout = false # Prevent "escape" from prompting to exit the game disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPERIMENTAL: Prevent the first popup advert from appearing disable_first_popup = false @@ -829,4 +833,3 @@ verify_ssl = true # <[=============================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 9ffe927fe..6f0d17a87 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -596,6 +596,10 @@ auto_open_bulk_claim_flyout = false # Prevent "escape" from prompting to exit the game disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPERIMENTAL: Prevent the first popup advert from appearing disable_first_popup = false @@ -829,4 +833,3 @@ verify_ssl = true # <[========================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index b89c9a2e8..3d18d6aa3 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -596,6 +596,10 @@ auto_open_bulk_claim_flyout = false # Impide que "Escape" pregunte si deseas salir del juego disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPERIMENTAL: Impide que aparezca el primer anuncio emergente disable_first_popup = false @@ -829,4 +833,3 @@ verify_ssl = true # <[==========================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index fdbc5f79d..e9736b90b 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -596,6 +596,10 @@ auto_open_bulk_claim_flyout = false # Empêcher "échap" de proposer de quitter le jeu disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPÉRIMENTAL : Empêcher la première publicité pop-up d'apparaître disable_first_popup = false @@ -829,4 +833,3 @@ verify_ssl = true # <[==========================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index b2b39b208..a3c338ea2 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -596,6 +596,10 @@ auto_open_bulk_claim_flyout = false # Zorgt ervoor dat het gebruiken van "escape" het spel niet sluit disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ EXPERIMENTEEL: Zorgt ervoor dat de eerste advertentie popup niet toont disable_first_popup = false @@ -829,4 +833,3 @@ verify_ssl = true # <[===========================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index ad3db036a..88cc9e203 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -596,6 +596,10 @@ auto_open_bulk_claim_flyout = false # Не показывать запрос выхода из игры при нажатии Escape disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ ЭКСПЕРИМЕНТАЛЬНО: Не показывать первое всплывающее рекламное окно disable_first_popup = false @@ -829,4 +833,3 @@ verify_ssl = true # <[=====================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index b9bce7597..cd3049116 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -596,6 +596,10 @@ auto_open_bulk_claim_flyout = false # Prevent "escape" vo' prompting Daq exit game disable_escape_exit = true +# Max gap in ms for double-tap Escape to show the exit prompt. +# 0 = disabled; only applies when disable_escape_exit is true. +escape_exit_timer = 0 + # ⚠️ ngong: Prevent wa'DIch popup advert vo' appearing disable_first_popup = false @@ -829,4 +833,3 @@ verify_ssl = true # <[======================================================================================================]> [sync.targets.spocksclub] - diff --git a/mods/src/config.cc b/mods/src/config.cc index 95deab836..a64d29bc6 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -929,6 +929,8 @@ void Config::Load() this->disable_escape_exit = get_config_or_default(config, parsed, "ui", "disable_escape_exit", DCU::disable_escape_exit, write_config); + this->escape_exit_timer = + get_config_or_default(config, parsed, "ui", "escape_exit_timer", DCU::escape_exit_timer, write_config); this->disable_preview_locate = get_config_or_default(config, parsed, "ui", "disable_preview_locate", DCU::disable_preview_locate, write_config); this->disable_preview_recall = diff --git a/mods/src/config.h b/mods/src/config.h index 43f5c3913..a81a60a81 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -190,6 +190,7 @@ class Config final bool disable_preview_locate; bool disable_preview_recall; bool disable_escape_exit; + int escape_exit_timer; bool disable_galaxy_chat; bool disable_veil_chat; bool disable_first_popup; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index b22cad5b8..d0731443f 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -222,6 +222,9 @@ namespace UI constexpr const char* daily_bulk_claim_factions = ""; constexpr bool daily_bulk_claim_toggle_default_on = false; constexpr bool disable_escape_exit = true; + // Maximum gap between Escape presses that opens the exit prompt. + // 0 disables double-tap and preserves the existing blocked behavior. + constexpr auto escape_exit_timer = 0; constexpr bool disable_first_popup = false; constexpr bool disable_galaxy_chat = false; constexpr bool disable_move_keys = false; diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index f04ddafb1..d4043745a 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -590,7 +590,16 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) } if (config->disable_escape_exit && Key::Pressed(KeyCode::Escape)) { - return; + // A second unhandled Escape inside the configured window falls through to + // the game's original handler and opens its normal exit prompt. + static auto escape_clock = std::chrono::steady_clock::time_point{}; + const auto escape_now = std::chrono::steady_clock::now(); + const auto escape_diff = std::chrono::duration_cast(escape_now - escape_clock); + escape_clock = escape_now; + + if (config->escape_exit_timer <= 0 || escape_diff > std::chrono::milliseconds(config->escape_exit_timer)) { + return; + } } // config->Load(); From ac12ed0bdb6586273f47e08728e2d03855def31e Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 01:15:47 -0500 Subject: [PATCH 20/80] Add cross-platform notification audio cues --- example_community_patch_settings_da.toml | 8 + example_community_patch_settings_de.toml | 8 + ...munity_patch_settings_en-GB-x-cockney.toml | 8 + ...mmunity_patch_settings_en-x-minionese.toml | 8 + example_community_patch_settings_en.toml | 8 + example_community_patch_settings_es.toml | 8 + example_community_patch_settings_fr.toml | 8 + example_community_patch_settings_nl.toml | 8 + example_community_patch_settings_ru.toml | 8 + example_community_patch_settings_tlh.toml | 8 + mods/src/config.cc | 28 +++ mods/src/config.h | 7 + mods/src/defaultconfig.h | 5 + mods/src/notification_audio_mac.mm | 27 ++ mods/src/patches/notification_audio.cc | 236 ++++++++++++++++++ mods/src/patches/notification_audio.h | 23 ++ .../src/patches/notification_audio_platform.h | 6 + mods/src/patches/notification_service.cc | 26 +- mods/xmake.lua | 1 + 19 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 mods/src/notification_audio_mac.mm create mode 100644 mods/src/patches/notification_audio.cc create mode 100644 mods/src/patches/notification_audio.h create mode 100644 mods/src/patches/notification_audio_platform.h diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 4e4003d2f..ff8585a4e 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[======================================================================================================]> # # ***** * * * diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 2dda59892..2a8fc320b 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 0ccf711ca..e16874cd2 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 9f1e1e3a6..20d316196 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 9ffe927fe..22eeb26c2 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index b89c9a2e8..6ea0664d8 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=====================================================]> # # * * * diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index fdbc5f79d..66441cb22 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index b2b39b208..00361a2ae 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index ad3db036a..120e2c2a1 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[============================================================]> # # diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index b9bce7597..fbe4b4606 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=====================================================]> # # * * **** * * diff --git a/mods/src/config.cc b/mods/src/config.cc index 95deab836..e11aaca62 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -328,6 +328,25 @@ T get_config_or_default(toml::table& config, toml::table& new_config, std::strin return (T)final_value; } +NotificationSound get_notification_sound(toml::table& config, toml::table& new_config, std::string_view item, + std::string_view default_value, bool write_log) +{ + const auto value = get_config_or_default(config, new_config, "audio", item, + std::string(default_value), false); + auto sound = notification_sound_from_name(StripAsciiWhitespace(value)); + if (!sound.has_value()) { + spdlog::warn("invalid config value audio.{}: '{}'; using {}", item, value, default_value); + sound = notification_sound_from_name(default_value); + } + + const auto result = sound.value_or(NotificationSound::None); + new_config["audio"].as_table()->insert_or_assign(item, notification_sound_name(result)); + if (write_log) { + spdlog::debug("config value audio.{} value: {}", item, notification_sound_name(result)); + } + return result; +} + std::string_view to_string(MissionHudVisibility visibility) { switch (visibility) { @@ -951,6 +970,15 @@ void Config::Load() } } this->installAudioEventHooks = this->trace_audio_events || !this->disabled_audio_events.empty(); + this->alert_victory = + get_notification_sound(config, parsed, "alert_victory", DCA::alert_victory, write_config); + this->alert_defeat = get_notification_sound(config, parsed, "alert_defeat", DCA::alert_defeat, write_config); + this->alert_armada_created = + get_notification_sound(config, parsed, "alert_armada_created", DCA::alert_armada_created, write_config); + this->alert_armada_battle_won = + get_notification_sound(config, parsed, "alert_armada_battle_won", DCA::alert_armada_battle_won, write_config); + this->alert_armada_battle_lost = + get_notification_sound(config, parsed, "alert_armada_battle_lost", DCA::alert_armada_battle_lost, write_config); this->auto_open_bulk_claim_flyout = get_config_or_default(config, parsed, "ui", "auto_open_bulk_claim_flyout", DCU::auto_open_bulk_claim_flyout, write_config); diff --git a/mods/src/config.h b/mods/src/config.h index 43f5c3913..93264d756 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -8,6 +8,8 @@ #include +#include "patches/notification_audio.h" + #if _WIN32 #include #endif @@ -196,6 +198,11 @@ class Config final bool disable_toast_banners; bool trace_audio_events; std::vector disabled_audio_events; + NotificationSound alert_victory; + NotificationSound alert_defeat; + NotificationSound alert_armada_created; + NotificationSound alert_armada_battle_won; + NotificationSound alert_armada_battle_lost; bool auto_open_bulk_claim_flyout; bool auto_confirm_ft_upgrade; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index b22cad5b8..efe3530e2 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -11,6 +11,11 @@ namespace Audio { constexpr const char* disabled_events = ""; constexpr bool trace_events = false; + constexpr const char* alert_victory = "none"; + constexpr const char* alert_defeat = "none"; + constexpr const char* alert_armada_created = "none"; + constexpr const char* alert_armada_battle_won = "none"; + constexpr const char* alert_armada_battle_lost = "none"; } // namespace Audio namespace SystemConfig diff --git a/mods/src/notification_audio_mac.mm b/mods/src/notification_audio_mac.mm new file mode 100644 index 000000000..cae47a9eb --- /dev/null +++ b/mods/src/notification_audio_mac.mm @@ -0,0 +1,27 @@ +#ifdef __APPLE__ + +#include "patches/notification_audio_platform.h" + +#import + +bool notification_audio_platform_play(const uint8_t* data, size_t size) +{ + @autoreleasepool { + static NSSound* current_sound = nil; + + NSData* sound_data = [NSData dataWithBytesNoCopy:const_cast(data) length:size freeWhenDone:NO]; + NSSound* sound = [[NSSound alloc] initWithData:sound_data]; + if (!sound) return false; + + [current_sound stop]; +#if __has_feature(objc_arc) + current_sound = sound; +#else + [current_sound release]; + current_sound = sound; +#endif + return [current_sound play]; + } +} + +#endif diff --git a/mods/src/patches/notification_audio.cc b/mods/src/patches/notification_audio.cc new file mode 100644 index 000000000..ebdd87c3c --- /dev/null +++ b/mods/src/patches/notification_audio.cc @@ -0,0 +1,236 @@ +#include "patches/notification_audio.h" + +#include "patches/notification_audio_platform.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if _WIN32 +#include +#include +#endif + +namespace +{ +struct ToneSegment { + double frequency_hz; + int duration_ms; +}; + +constexpr int kSampleRate = 44100; +constexpr double kTwoPi = 6.28318530717958647692; +constexpr double kAmplitude = 0.26; + +constexpr std::array kDefaultPattern{{{740.0, 70}, {0.0, 22}, {880.0, 85}, {0.0, 18}}}; +constexpr std::array kInfoPattern{{{659.0, 80}, {0.0, 24}, {880.0, 110}}}; +constexpr std::array kSuccessPattern{{{587.0, 70}, {0.0, 18}, {740.0, 70}, {0.0, 18}, {988.0, 120}}}; +constexpr std::array kWarningPattern{{{622.0, 90}, {0.0, 36}, {466.0, 110}, {0.0, 28}, {466.0, 90}}}; +constexpr std::array kAlarmPattern{{{880.0, 90}, {0.0, 42}, {880.0, 90}, {0.0, 42}, {698.0, 160}}}; +constexpr std::array kArrivalPattern{{{523.0, 65}, {0.0, 18}, {659.0, 70}, {0.0, 18}, {1046.0, 125}}}; +constexpr std::array kSoftPattern{{{523.0, 90}, {0.0, 26}, {659.0, 110}}}; +constexpr std::array kPingPattern{{{1046.0, 95}}}; +constexpr std::array kRepairPattern{{{440.0, 70}, {0.0, 18}, {554.0, 70}, {0.0, 18}, {740.0, 140}}}; + +std::once_flag s_sound_buffers_once; +std::array, static_cast(NotificationSound::Count)> s_sound_buffers; + +std::string normalize_sound_name(std::string_view value) +{ + std::string normalized; + normalized.reserve(value.size()); + for (const auto ch : value) { + if (ch >= 'A' && ch <= 'Z') { + normalized.push_back(static_cast(ch - 'A' + 'a')); + } else if (ch == '-' || ch == ' ') { + normalized.push_back('_'); + } else { + normalized.push_back(ch); + } + } + return normalized; +} + +std::span sound_pattern(NotificationSound sound) +{ + switch (sound) { + case NotificationSound::Default: + return kDefaultPattern; + case NotificationSound::Info: + return kInfoPattern; + case NotificationSound::Success: + return kSuccessPattern; + case NotificationSound::Warning: + return kWarningPattern; + case NotificationSound::Alarm: + return kAlarmPattern; + case NotificationSound::Arrival: + return kArrivalPattern; + case NotificationSound::Soft: + return kSoftPattern; + case NotificationSound::Ping: + return kPingPattern; + case NotificationSound::Repair: + return kRepairPattern; + default: + return {}; + } +} + +void append_u16(std::vector& buffer, uint16_t value) +{ + buffer.push_back(static_cast(value & 0xff)); + buffer.push_back(static_cast((value >> 8) & 0xff)); +} + +void append_u32(std::vector& buffer, uint32_t value) +{ + buffer.push_back(static_cast(value & 0xff)); + buffer.push_back(static_cast((value >> 8) & 0xff)); + buffer.push_back(static_cast((value >> 16) & 0xff)); + buffer.push_back(static_cast((value >> 24) & 0xff)); +} + +void append_ascii(std::vector& buffer, std::string_view value) +{ buffer.insert(buffer.end(), value.begin(), value.end()); } + +std::vector build_wav(std::span pattern) +{ + uint32_t sample_count = 0; + for (const auto& segment : pattern) { + sample_count += static_cast((static_cast(segment.duration_ms) * kSampleRate) / 1000); + } + + constexpr uint16_t channels = 1; + constexpr uint16_t bits_per_sample = 16; + const uint32_t data_bytes = sample_count * channels * (bits_per_sample / 8); + + std::vector buffer; + buffer.reserve(44 + data_bytes); + append_ascii(buffer, "RIFF"); + append_u32(buffer, 36 + data_bytes); + append_ascii(buffer, "WAVE"); + append_ascii(buffer, "fmt "); + append_u32(buffer, 16); + append_u16(buffer, 1); + append_u16(buffer, channels); + append_u32(buffer, kSampleRate); + append_u32(buffer, kSampleRate * channels * (bits_per_sample / 8)); + append_u16(buffer, channels * (bits_per_sample / 8)); + append_u16(buffer, bits_per_sample); + append_ascii(buffer, "data"); + append_u32(buffer, data_bytes); + + double phase = 0.0; + for (const auto& segment : pattern) { + const auto segment_samples = static_cast((static_cast(segment.duration_ms) * kSampleRate) / 1000); + const auto step = segment.frequency_hz > 0.0 ? kTwoPi * segment.frequency_hz / kSampleRate : 0.0; + for (int index = 0; index < segment_samples; ++index) { + const auto fade_in = std::min(1.0, static_cast(index) / 80.0); + const auto fade_out = std::min(1.0, static_cast(segment_samples - index) / 120.0); + const auto sample = segment.frequency_hz > 0.0 ? std::sin(phase) * kAmplitude * fade_in * fade_out : 0.0; + append_u16(buffer, static_cast(static_cast(sample * 32767.0))); + phase += step; + if (phase > kTwoPi) { + phase -= kTwoPi; + } + } + } + return buffer; +} + +void initialize_sound_buffers() +{ + for (size_t index = 0; index < s_sound_buffers.size(); ++index) { + const auto pattern = sound_pattern(static_cast(index)); + if (!pattern.empty()) { + s_sound_buffers[index] = build_wav(pattern); + } + } +} +} // namespace + +const char* notification_sound_name(NotificationSound sound) +{ + switch (sound) { + case NotificationSound::None: + return "none"; + case NotificationSound::Default: + return "default"; + case NotificationSound::Info: + return "info"; + case NotificationSound::Success: + return "success"; + case NotificationSound::Warning: + return "warning"; + case NotificationSound::Alarm: + return "alarm"; + case NotificationSound::Arrival: + return "arrival"; + case NotificationSound::Soft: + return "soft"; + case NotificationSound::Ping: + return "ping"; + case NotificationSound::Repair: + return "repair"; + default: + return "none"; + } +} + +std::optional notification_sound_from_name(std::string_view name) +{ + const auto normalized = normalize_sound_name(name); + if (normalized == "none" || normalized == "off" || normalized == "silent") + return NotificationSound::None; + if (normalized == "default") + return NotificationSound::Default; + if (normalized == "info") + return NotificationSound::Info; + if (normalized == "success" || normalized == "victory") + return NotificationSound::Success; + if (normalized == "warning" || normalized == "warn") + return NotificationSound::Warning; + if (normalized == "alarm" || normalized == "attack") + return NotificationSound::Alarm; + if (normalized == "arrival" || normalized == "arrive") + return NotificationSound::Arrival; + if (normalized == "soft" || normalized == "quiet") + return NotificationSound::Soft; + if (normalized == "ping") + return NotificationSound::Ping; + if (normalized == "repair" || normalized == "repaired") + return NotificationSound::Repair; + return std::nullopt; +} + +void notification_audio_play(NotificationSound sound) +{ + if (sound == NotificationSound::None) + return; + + std::call_once(s_sound_buffers_once, initialize_sound_buffers); + const auto index = static_cast(sound); + if (index >= s_sound_buffers.size() || s_sound_buffers[index].empty()) + return; + + const auto& buffer = s_sound_buffers[index]; + if (!notification_audio_platform_play(buffer.data(), buffer.size())) { + spdlog::warn("[NotifyAudio] Failed to play '{}' cue", notification_sound_name(sound)); + } +} + +#if _WIN32 +bool notification_audio_platform_play(const uint8_t* data, size_t) +{ return PlaySoundW(reinterpret_cast(data), nullptr, SND_ASYNC | SND_MEMORY | SND_NODEFAULT) != FALSE; } +#elif !defined(__APPLE__) +bool notification_audio_platform_play(const uint8_t*, size_t) +{ return false; } +#endif diff --git a/mods/src/patches/notification_audio.h b/mods/src/patches/notification_audio.h new file mode 100644 index 000000000..8afd5347a --- /dev/null +++ b/mods/src/patches/notification_audio.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +enum class NotificationSound : uint8_t { + None = 0, + Default, + Info, + Success, + Warning, + Alarm, + Arrival, + Soft, + Ping, + Repair, + Count, +}; + +[[nodiscard]] const char* notification_sound_name(NotificationSound sound); +[[nodiscard]] std::optional notification_sound_from_name(std::string_view name); +void notification_audio_play(NotificationSound sound); diff --git a/mods/src/patches/notification_audio_platform.h b/mods/src/patches/notification_audio_platform.h new file mode 100644 index 000000000..3ee8a973c --- /dev/null +++ b/mods/src/patches/notification_audio_platform.h @@ -0,0 +1,6 @@ +#pragma once + +#include +#include + +[[nodiscard]] bool notification_audio_platform_play(const uint8_t* data, size_t size); diff --git a/mods/src/patches/notification_service.cc b/mods/src/patches/notification_service.cc index 46109b8c4..189fabe42 100644 --- a/mods/src/patches/notification_service.cc +++ b/mods/src/patches/notification_service.cc @@ -1,5 +1,6 @@ #include "patches/notification_service.h" #include "patches/battle_notify_parser.h" +#include "patches/notification_audio.h" #include "config.h" #include "str_utils.h" @@ -93,6 +94,19 @@ static const char* toast_state_title(int state) } } +static NotificationSound toast_state_sound(int state) +{ + const auto& config = Config::Get(); + switch (state) { + case Victory: return config.alert_victory; + case Defeat: return config.alert_defeat; + case ArmadaCreated: return config.alert_armada_created; + case ArmadaBattleWon: return config.alert_armada_battle_won; + case ArmadaBattleLost: return config.alert_armada_battle_lost; + default: return NotificationSound::None; + } +} + // --------------------------------------------------------------------------- // Platform notification delivery // --------------------------------------------------------------------------- @@ -477,17 +491,23 @@ void notification_init() } catch (...) { spdlog::warn("[Notify] Windows notification service failed (unknown error)"); } +#else +#if __APPLE__ + spdlog::info("[Notify] macOS audio notification service ready"); #else spdlog::info("[Notify] Notification service: platform not supported (no-op)"); #endif +#endif } void notification_handle_toast(Toast* toast) { -#if !_WIN32 - return; // No notification delivery on non-Windows platforms yet -#else + if (!toast) return; + auto state = toast->get_State(); + notification_audio_play(toast_state_sound(state)); + +#if _WIN32 // Check if this toast type is in the user's notify list const auto& notify_types = Config::Get().notify_banner_types; diff --git a/mods/xmake.lua b/mods/xmake.lua index 1a80fc8ab..aee96c5a2 100644 --- a/mods/xmake.lua +++ b/mods/xmake.lua @@ -116,6 +116,7 @@ do if is_plat("windows") then add_cxflags("/bigobj") add_linkdirs("src/il2cpp") + add_syslinks("winmm") elseif is_plat("macosx") then add_cxflags("-fms-extensions") -- Add Objective-C++ source From 801d19d00849fe6409ed71e562ed55267f1c6991 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 01:53:24 -0500 Subject: [PATCH 21/80] Fix held Escape counting as double tap --- mods/src/patches/parts/hotkeys.cc | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index d4043745a..31ac1da33 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -590,16 +590,25 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) } if (config->disable_escape_exit && Key::Pressed(KeyCode::Escape)) { - // A second unhandled Escape inside the configured window falls through to - // the game's original handler and opens its normal exit prompt. - static auto escape_clock = std::chrono::steady_clock::time_point{}; - const auto escape_now = std::chrono::steady_clock::now(); - const auto escape_diff = std::chrono::duration_cast(escape_now - escape_clock); - escape_clock = escape_now; - - if (config->escape_exit_timer <= 0 || escape_diff > std::chrono::milliseconds(config->escape_exit_timer)) { + // Keep suppressing a held key. Only distinct key-down edges participate in + // the double-tap window. + if (config->escape_exit_timer <= 0 || !Key::Down(KeyCode::Escape)) { return; } + + static auto previous_escape_down = std::chrono::steady_clock::time_point{}; + const auto escape_now = std::chrono::steady_clock::now(); + const auto escape_diff = + std::chrono::duration_cast(escape_now - previous_escape_down); + + if (previous_escape_down == std::chrono::steady_clock::time_point{} + || escape_diff > std::chrono::milliseconds(config->escape_exit_timer)) { + previous_escape_down = escape_now; + return; + } + + // Consume the completed pair so a rapid third press starts a new one. + previous_escape_down = {}; } // config->Load(); From 05daae5b8624e290fb36c6210b0c39eae7fe7b2a Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 01:55:16 -0500 Subject: [PATCH 22/80] Warn when audio alerts lack toast hooks --- example_community_patch_settings_da.toml | 2 +- example_community_patch_settings_de.toml | 2 +- example_community_patch_settings_en-GB-x-cockney.toml | 2 +- example_community_patch_settings_en-x-minionese.toml | 2 +- example_community_patch_settings_en.toml | 2 +- example_community_patch_settings_es.toml | 2 +- example_community_patch_settings_fr.toml | 2 +- example_community_patch_settings_nl.toml | 2 +- example_community_patch_settings_ru.toml | 2 +- example_community_patch_settings_tlh.toml | 2 +- mods/src/config.cc | 7 +++++++ 11 files changed, 17 insertions(+), 10 deletions(-) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index ff8585a4e..8906a3d17 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[========================================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 2a8fc320b..29fd63105 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[===================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index e16874cd2..68196debe 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[========================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 20d316196..80171600b 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[=============================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 22eeb26c2..44fbed0d6 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[========================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 6ea0664d8..5e07467e3 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[==========================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index 66441cb22..c80c17447 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[==========================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 00361a2ae..64be505a7 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[===========================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 120e2c2a1..693344ba4 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[=====================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index fbe4b4606..a30e387c6 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[======================================================================================================]> [sync.targets.spocksclub] - diff --git a/mods/src/config.cc b/mods/src/config.cc index e11aaca62..424eef469 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -979,6 +979,13 @@ void Config::Load() get_notification_sound(config, parsed, "alert_armada_battle_won", DCA::alert_armada_battle_won, write_config); this->alert_armada_battle_lost = get_notification_sound(config, parsed, "alert_armada_battle_lost", DCA::alert_armada_battle_lost, write_config); + if (!this->installToastBannerHooks + && (this->alert_victory != NotificationSound::None || this->alert_defeat != NotificationSound::None + || this->alert_armada_created != NotificationSound::None + || this->alert_armada_battle_won != NotificationSound::None + || this->alert_armada_battle_lost != NotificationSound::None)) { + spdlog::warn("audio alerts require patches.toastbannerhooks = true"); + } this->auto_open_bulk_claim_flyout = get_config_or_default(config, parsed, "ui", "auto_open_bulk_claim_flyout", DCU::auto_open_bulk_claim_flyout, write_config); From 1159e9c832edd8f1c3565baaf5ca24a0adea6bac Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 18:36:34 -0500 Subject: [PATCH 23/80] ci: use semver tags for workflow actions --- .github/workflows/ci.yaml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ad98661b4..b0dcc191b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@v6.1.0 with: fetch-depth: 1 submodules: false @@ -63,7 +63,7 @@ jobs: key: xmake-tool-${{ runner.os }}-${{ runner.arch }}-${{ env.XMAKE_VERSION }}-${{ env.XMAKE_CACHE_SCHEMA }} - name: Setup XMake - uses: xmake-io/github-action-setup-xmake@3a1a5dddfc7fa625d9a698738334bf55655a861a # v1.2.5 + uses: xmake-io/github-action-setup-xmake@v1.2.5 with: xmake-version: ${{ env.XMAKE_VERSION }} @@ -171,7 +171,7 @@ jobs: - name: Upload configure diagnostics if: ${{ failure() && steps.configure_windows.outcome == 'failure' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@v7.0.1 with: name: xmake-configure-diagnostics-windows path: ~/.xmake/cache/packages/**/installdir.failed/logs/install.txt @@ -213,12 +213,12 @@ jobs: github.repository == 'netniV/stfc-mod' && github.ref_protected && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 + uses: actions/attest@v4.2.1 with: subject-path: version.dll - name: Upload - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@v7.0.1 with: name: stfc-community-mod path: version.dll @@ -277,7 +277,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@v6.1.0 with: fetch-depth: 1 submodules: "recursive" @@ -294,7 +294,7 @@ jobs: key: xmake-tool-${{ runner.os }}-${{ runner.arch }}-${{ env.XMAKE_VERSION }}-${{ env.XMAKE_CACHE_SCHEMA }} - name: Setup XMake - uses: xmake-io/github-action-setup-xmake@3a1a5dddfc7fa625d9a698738334bf55655a861a # v1.2.5 + uses: xmake-io/github-action-setup-xmake@v1.2.5 with: xmake-version: ${{ env.XMAKE_VERSION }} @@ -404,7 +404,7 @@ jobs: - name: Upload configure diagnostics if: ${{ failure() && steps.configure.outcome == 'failure' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@v7.0.1 with: name: xmake-configure-diagnostics-macos-${{ matrix.arch }} path: ~/.xmake/cache/packages/**/installdir.failed/logs/install.txt @@ -513,7 +513,7 @@ jobs: fi - name: Upload intermediate build - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@v7.0.1 with: name: ${{ matrix.artifact }} path: macos-${{ matrix.arch }}-build.tar @@ -570,20 +570,20 @@ jobs: steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@v6.1.0 with: fetch-depth: 1 submodules: false show-progress: false - name: Download ARM64 build - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@v8.0.1 with: name: macos-arm64-build path: .artifacts/arm64 - name: Download x86_64 build - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@v8.0.1 with: name: macos-x86_64-build path: .artifacts/x86_64 @@ -785,7 +785,7 @@ jobs: "$DMG_SOURCE/" - name: Upload installer - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@v7.0.1 with: name: stfc-community-mod-installer path: stfc-community-mod-installer.dmg @@ -793,7 +793,7 @@ jobs: if-no-files-found: error - name: Upload macOS mod archive - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@v7.0.1 with: name: stfc-community-mod-macos-universal path: stfc-community-mod-macos-universal From 15904a6205e8f6d1c6881754a1f2cba53c3ecca4 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 18:50:57 -0500 Subject: [PATCH 24/80] ci: isolate artifact attestation permissions --- .github/workflows/ci.yaml | 56 ++++++++++++++++++++++++++-------- docs/WINDOWS_DLL_PROVENANCE.md | 12 ++++++-- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 361c02a7b..4625c52c7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,8 +36,6 @@ jobs: permissions: actions: read contents: read - id-token: write - attestations: write env: STFC_MSVC_SCCACHE: "1" WINDOWS_SCCACHE_SCHEMA: "v1" @@ -207,16 +205,6 @@ jobs: } Write-Host "Verified DLL provenance: $comments" - - name: Attest Windows DLL - if: >- - github.event_name == 'push' && - github.repository == 'netniV/stfc-mod' && - github.ref_protected && - (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') - uses: actions/attest@v4.2.1 - with: - subject-path: version.dll - - name: Upload uses: actions/upload-artifact@v7.0.1 with: @@ -253,6 +241,50 @@ jobs: path: ${{ runner.tool_cache }}/xmake key: ${{ steps.restore_xmake.outputs.cache-primary-key }} + attest-win: + name: attest-win + needs: build-win + if: >- + github.event_name == 'push' && + github.repository == 'netniV/stfc-mod' && + github.ref_protected && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + runs-on: windows-2025-vs2026 + permissions: + actions: read + contents: read + id-token: write + attestations: write + + steps: + - name: Download Windows DLL + uses: actions/download-artifact@v8.0.1 + with: + name: stfc-community-mod + path: .artifacts/windows + + - name: Verify DLL provenance + shell: pwsh + run: | + $dll = (Resolve-Path -LiteralPath '.artifacts/windows/version.dll').Path + $commit = '${{ github.sha }}' + if ($commit -notmatch '^[0-9a-f]{40}$') { + throw "Unable to resolve the workflow commit for DLL provenance." + } + + $expected = "stfc-identity-v1;distribution=netniv.stfc-community-mod;source=git:$commit;" + + "base=$commit;build=github:${{ github.run_id }}:${{ github.run_attempt }}:windows;mode=release;channel=ci" + $comments = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dll).Comments + if (-not [string]::Equals($comments, $expected, [System.StringComparison]::Ordinal)) { + throw "DLL provenance mismatch. Expected '$expected'; found '$comments'." + } + Write-Host "Verified DLL provenance: $comments" + + - name: Attest Windows DLL + uses: actions/attest@v4.2.1 + with: + subject-path: .artifacts/windows/version.dll + build-mac: name: build-mac (${{ matrix.arch }}) diff --git a/docs/WINDOWS_DLL_PROVENANCE.md b/docs/WINDOWS_DLL_PROVENANCE.md index b15ab5072..b47dc91f4 100644 --- a/docs/WINDOWS_DLL_PROVENANCE.md +++ b/docs/WINDOWS_DLL_PROVENANCE.md @@ -8,15 +8,21 @@ For protected `main` and `dev` pushes in `netniV/stfc-mod`, the upstream Build w build-provenance attestation for the exact `version.dll` bytes after verifying the embedded marker. Pull requests, fork runs, unprotected refs, tags, and other repositories do not create authoritative attestations. -After downloading and extracting an attested DLL, verify its byte integrity and expected upstream workflow identity -with a current GitHub CLI: +After downloading and extracting an attested DLL, verify its byte integrity, expected upstream workflow identity, +protected source ref, and reviewed source commit with a current GitHub CLI. Replace `` with the +40-character commit for the artifact: ```shell gh attestation verify version.dll \ --repo netniV/stfc-mod \ - --signer-workflow netniV/stfc-mod/.github/workflows/ci.yaml + --signer-workflow netniV/stfc-mod/.github/workflows/ci.yaml \ + --source-ref refs/heads/main \ + --source-digest ``` +For a reviewed `dev` artifact, use `--source-ref refs/heads/dev` instead. Do not omit the source ref or digest when +making a trust decision. + Successful verification establishes that the exact file digest is covered by an attestation issued for the named repository and signer workflow, including source commit provenance. It does not establish that the source, dependencies, build environment, or resulting behavior are free from malicious or unsafe behavior. It also does not, From ebaf7b29e5c9f8ed15ec770c376bdaff1d5d6924 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 18:55:06 -0500 Subject: [PATCH 25/80] ci: retain producer identity for attestation retries --- .github/workflows/ci.yaml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4625c52c7..baa77ebd0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,6 +33,8 @@ env: jobs: build-win: runs-on: windows-2025-vs2026 + outputs: + expected_identity: ${{ steps.configure_windows.outputs.expected_identity }} permissions: actions: read contents: read @@ -134,6 +136,7 @@ jobs: $identity = "stfc-identity-v1;distribution=netniv.stfc-community-mod;source=$sourceState;" + "base=$commit;build=$buildInvocation;mode=release;channel=ci" "STFC_EXPECTED_IDENTITY=$identity" >> $env:GITHUB_ENV + "expected_identity=$identity" >> $env:GITHUB_OUTPUT $xmakeArgs = @( 'f', '-m', 'release', '-y', '-v', @@ -265,15 +268,15 @@ jobs: - name: Verify DLL provenance shell: pwsh + env: + STFC_EXPECTED_IDENTITY: ${{ needs.build-win.outputs.expected_identity }} run: | $dll = (Resolve-Path -LiteralPath '.artifacts/windows/version.dll').Path - $commit = '${{ github.sha }}' - if ($commit -notmatch '^[0-9a-f]{40}$') { - throw "Unable to resolve the workflow commit for DLL provenance." + $expected = $env:STFC_EXPECTED_IDENTITY + if (-not $expected) { + throw "The producing build did not provide its expected DLL identity." } - $expected = "stfc-identity-v1;distribution=netniv.stfc-community-mod;source=git:$commit;" + - "base=$commit;build=github:${{ github.run_id }}:${{ github.run_attempt }}:windows;mode=release;channel=ci" $comments = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dll).Comments if (-not [string]::Equals($comments, $expected, [System.StringComparison]::Ordinal)) { throw "DLL provenance mismatch. Expected '$expected'; found '$comments'." From 71a311e058092a00b3f4eaf0a3124e50e377f0b0 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 18:59:14 -0500 Subject: [PATCH 26/80] ci: reconstruct attestation identity from trusted context --- .github/workflows/ci.yaml | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index baa77ebd0..c4db85207 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,7 +34,7 @@ jobs: build-win: runs-on: windows-2025-vs2026 outputs: - expected_identity: ${{ steps.configure_windows.outputs.expected_identity }} + run_attempt: ${{ steps.build_identity.outputs.run_attempt }} permissions: actions: read contents: read @@ -45,6 +45,12 @@ jobs: SCCACHE_CACHE_SIZE: "2G" steps: + - name: Capture build identity + id: build_identity + shell: pwsh + run: | + "run_attempt=${{ github.run_attempt }}" >> $env:GITHUB_OUTPUT + - name: Checkout uses: actions/checkout@v7 with: @@ -136,7 +142,6 @@ jobs: $identity = "stfc-identity-v1;distribution=netniv.stfc-community-mod;source=$sourceState;" + "base=$commit;build=$buildInvocation;mode=release;channel=ci" "STFC_EXPECTED_IDENTITY=$identity" >> $env:GITHUB_ENV - "expected_identity=$identity" >> $env:GITHUB_OUTPUT $xmakeArgs = @( 'f', '-m', 'release', '-y', '-v', @@ -269,14 +274,27 @@ jobs: - name: Verify DLL provenance shell: pwsh env: - STFC_EXPECTED_IDENTITY: ${{ needs.build-win.outputs.expected_identity }} + STFC_SOURCE_COMMIT: ${{ github.sha }} + STFC_RUN_ID: ${{ github.run_id }} + STFC_BUILD_RUN_ATTEMPT: ${{ needs.build-win.outputs.run_attempt }} run: | $dll = (Resolve-Path -LiteralPath '.artifacts/windows/version.dll').Path - $expected = $env:STFC_EXPECTED_IDENTITY - if (-not $expected) { - throw "The producing build did not provide its expected DLL identity." + $commit = $env:STFC_SOURCE_COMMIT + $runId = $env:STFC_RUN_ID + $buildAttempt = $env:STFC_BUILD_RUN_ATTEMPT + + if ($commit -notmatch '^[0-9a-f]{40}$') { + throw "Unable to resolve the workflow commit for DLL provenance." + } + if ($runId -notmatch '^[1-9][0-9]*$') { + throw "Unable to resolve the workflow run ID for DLL provenance." + } + if ($buildAttempt -notmatch '^[1-9][0-9]*$') { + throw "The producing build did not provide a valid run attempt." } + $expected = "stfc-identity-v1;distribution=netniv.stfc-community-mod;source=git:$commit;" + + "base=$commit;build=github:${runId}:${buildAttempt}:windows;mode=release;channel=ci" $comments = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dll).Comments if (-not [string]::Equals($comments, $expected, [System.StringComparison]::Ordinal)) { throw "DLL provenance mismatch. Expected '$expected'; found '$comments'." From d835b52b9203001895762c5b361e160c6eb92abb Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 19:29:19 -0500 Subject: [PATCH 27/80] feat: add native navigation shortcuts --- example_community_patch_settings_da.toml | 6 +++ example_community_patch_settings_de.toml | 6 +++ ...munity_patch_settings_en-GB-x-cockney.toml | 6 +++ ...mmunity_patch_settings_en-x-minionese.toml | 6 +++ example_community_patch_settings_en.toml | 6 +++ example_community_patch_settings_es.toml | 6 +++ example_community_patch_settings_fr.toml | 6 +++ example_community_patch_settings_nl.toml | 6 +++ example_community_patch_settings_ru.toml | 6 +++ example_community_patch_settings_tlh.toml | 6 +++ mods/src/config.cc | 4 ++ mods/src/defaultconfig.h | 2 + mods/src/patches/gamefunctions.h | 2 + mods/src/patches/parts/hotkey_ownership.h | 31 ++++++++++++ mods/src/patches/parts/hotkeys.cc | 50 +++++++++++++++++++ mods/src/patches/parts/zoom.cc | 8 ++- 16 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 mods/src/patches/parts/hotkey_ownership.h diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 4e4003d2f..d85668a1e 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Gå til begivenheder show_events = "SHIFT-E" +# Scopely-native Go to Events action +show_events_native = "F5" + # Gå til Exocomp show_exocomp = "X" @@ -464,6 +467,9 @@ show_factions = "F" # Gå til galaksen show_galaxy = "G" +# Scopely-native Galaxy action +show_galaxy_native = "E" + # Gå til gaver show_gifts = "/" diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 2dda59892..1d3a29ea0 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Zu den Events show_events = "SHIFT-E" +# Scopely-native Go to Events action +show_events_native = "F5" + # Zu den Exocomps show_exocomp = "X" @@ -464,6 +467,9 @@ show_factions = "F" # Zur Galaxie show_galaxy = "G" +# Scopely-native Galaxy action +show_galaxy_native = "E" + # Zu den Geschenken show_gifts = "/" diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 0ccf711ca..f26a643a5 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Go to Events show_events = "SHIFT-E" +# Scopely-native Go to Events action +show_events_native = "F5" + # Go to Exocomp show_exocomp = "X" @@ -464,6 +467,9 @@ show_factions = "F" # Go to Galaxy show_galaxy = "G" +# Scopely-native Galaxy action +show_galaxy_native = "E" + # Go to Gifts show_gifts = "/" diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 9f1e1e3a6..b72dce46d 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Go to Events show_events = "SHIFT-E" +# Scopely-native Go to Events action +show_events_native = "F5" + # Go to Exocomp show_exocomp = "X" @@ -464,6 +467,9 @@ show_factions = "F" # Go to Galaxy show_galaxy = "G" +# Scopely-native Galaxy action +show_galaxy_native = "E" + # Go to Gifts show_gifts = "/" diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 9ffe927fe..6f5b351e8 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Go to Events show_events = "SHIFT-E" +# Scopely-native Go to Events action +show_events_native = "F5" + # Go to Exocomp show_exocomp = "X" @@ -464,6 +467,9 @@ show_factions = "F" # Go to Galaxy show_galaxy = "G" +# Scopely-native Galaxy action +show_galaxy_native = "E" + # Go to Gifts show_gifts = "/" diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index b89c9a2e8..869bab2d3 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Ir a eventos show_events = "SHIFT-E" +# Scopely-native Go to Events action +show_events_native = "F5" + # Ir a Exocomp show_exocomp = "X" @@ -464,6 +467,9 @@ show_factions = "F" # Ir a la galaxia show_galaxy = "G" +# Scopely-native Galaxy action +show_galaxy_native = "E" + # Ir a regalos show_gifts = "/" diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index fdbc5f79d..9b362845d 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Aller aux événements show_events = "SHIFT-E" +# Scopely-native Go to Events action +show_events_native = "F5" + # Aller aux Exocomps show_exocomp = "X" @@ -464,6 +467,9 @@ show_factions = "F" # Aller à la galaxie show_galaxy = "G" +# Scopely-native Galaxy action +show_galaxy_native = "E" + # Aller aux cadeaux show_gifts = "/" diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index b2b39b208..ac7dd837d 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Ga naar Evenementen show_events = "SHIFT-E" +# Scopely-native Go to Events action +show_events_native = "F5" + # Ga naar Exocomp show_exocomp = "X" @@ -464,6 +467,9 @@ show_factions = "F" # Ga naar Sterrenstelsel show_galaxy = "G" +# Scopely-native Galaxy action +show_galaxy_native = "E" + # Ga naar Cadeaus show_gifts = "/" diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index ad3db036a..4ef20e0f8 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Перейти к событиям show_events = "SHIFT-E" +# Scopely-native Go to Events action +show_events_native = "F5" + # Перейти к экзокомпу show_exocomp = "X" @@ -464,6 +467,9 @@ show_factions = "F" # Перейти к галактике show_galaxy = "G" +# Scopely-native Galaxy action +show_galaxy_native = "E" + # Перейти к подаркам show_gifts = "/" diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index b9bce7597..9298f33f0 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Go Daq Events show_events = "SHIFT-E" +# Scopely-native Go to Events action +show_events_native = "F5" + # Go Daq Exocomp show_exocomp = "X" @@ -464,6 +467,9 @@ show_factions = "F" # Go Daq Galaxy show_galaxy = "G" +# Scopely-native Galaxy action +show_galaxy_native = "E" + # Go Daq Gifts show_gifts = "/" diff --git a/mods/src/config.cc b/mods/src/config.cc index 95deab836..f9b5ae58d 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1226,6 +1226,8 @@ void Config::Load() parse_config_shortcut(config, parsed, "show_chatside1", GameFunction::ShowChatSide1, DCSH::show_chatside1); parse_config_shortcut(config, parsed, "show_chatside2", GameFunction::ShowChatSide2, DCSH::show_chatside2); parse_config_shortcut(config, parsed, "show_galaxy", GameFunction::ShowGalaxy, DCSH::show_galaxy); + parse_config_shortcut(config, parsed, "show_galaxy_native", GameFunction::ShowGalaxyNative, + DCSH::show_galaxy_native); parse_config_shortcut(config, parsed, "show_system", GameFunction::ShowSystem, DCSH::show_system); parse_config_shortcut(config, parsed, "zoom_preset1", GameFunction::ZoomPreset1, DCSH::zoom_preset1); parse_config_shortcut(config, parsed, "zoom_preset2", GameFunction::ZoomPreset2, DCSH::zoom_preset2); @@ -1261,6 +1263,8 @@ void Config::Load() parse_config_shortcut(config, parsed, "show_commander", GameFunction::ShowCommander, DCSH::show_commander); parse_config_shortcut(config, parsed, "show_daily", GameFunction::ShowDaily, DCSH::show_daily); parse_config_shortcut(config, parsed, "show_events", GameFunction::ShowEvents, DCSH::show_events); + parse_config_shortcut(config, parsed, "show_events_native", GameFunction::ShowEventsNative, + DCSH::show_events_native); parse_config_shortcut(config, parsed, "show_exocomp", GameFunction::ShowExoComp, DCSH::show_exocomp); parse_config_shortcut(config, parsed, "show_factions", GameFunction::ShowFactions, DCSH::show_factions); parse_config_shortcut(config, parsed, "show_inventory", GameFunction::ShowInventory, DCSH::show_inventory); diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index b22cad5b8..c61412f12 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -141,9 +141,11 @@ namespace Shortcuts constexpr const char* show_commander = "O"; constexpr const char* show_daily = "Z"; constexpr const char* show_events = "SHIFT-E"; + constexpr const char* show_events_native = "F5"; constexpr const char* show_exocomp = "X"; constexpr const char* show_factions = "F"; constexpr const char* show_galaxy = "G"; + constexpr const char* show_galaxy_native = "E"; constexpr const char* show_gifts = "/"; constexpr const char* show_inventory = "I"; constexpr const char* show_lookup = "L"; diff --git a/mods/src/patches/gamefunctions.h b/mods/src/patches/gamefunctions.h index bd4b0fb50..469a013a2 100644 --- a/mods/src/patches/gamefunctions.h +++ b/mods/src/patches/gamefunctions.h @@ -40,11 +40,13 @@ enum GameFunction { ShowStationInterior, ShoWStationExterior, ShowGalaxy, + ShowGalaxyNative, ShowSystem, ShowChat, ShowChatSide1, ShowChatSide2, ShowEvents, + ShowEventsNative, ShowSettings, ZoomPreset1, ZoomPreset2, diff --git a/mods/src/patches/parts/hotkey_ownership.h b/mods/src/patches/parts/hotkey_ownership.h new file mode 100644 index 000000000..ec37fd5ef --- /dev/null +++ b/mods/src/patches/parts/hotkey_ownership.h @@ -0,0 +1,31 @@ +#pragma once + +#include "patches/gamefunctions.h" +#include "patches/mapkey.h" + +#include + +// Screen navigation and camera shortcuts run from separate Unity update hooks. Give screen navigation ownership of a +// shared chord so the result does not depend on which hook Unity calls first. +inline bool IsScreenNavigationShortcutPressed() +{ + static constexpr std::array screen_navigation_actions{ + GameFunction::ShowAlliance, GameFunction::ShowAllianceArmada, GameFunction::ShowAllianceHelp, + GameFunction::ShowArtifacts, GameFunction::ShowAwayTeam, GameFunction::ShowBookmarks, + GameFunction::ShowCommander, GameFunction::ShowDaily, GameFunction::ShowEvents, + GameFunction::ShowEventsNative, GameFunction::ShowExoComp, GameFunction::ShowFactions, + GameFunction::ShowGalaxy, GameFunction::ShowGalaxyNative, GameFunction::ShowGifts, + GameFunction::ShowInventory, GameFunction::ShowLookup, GameFunction::ShowMissions, + GameFunction::ShowOfficers, GameFunction::ShowQTrials, GameFunction::ShowRefinery, + GameFunction::ShowResearch, GameFunction::ShowScrapYard, GameFunction::ShowSettings, + GameFunction::ShowShips, GameFunction::ShowStationInterior, GameFunction::ShoWStationExterior, + GameFunction::ShowSystem, + }; + + for (const auto action : screen_navigation_actions) { + if (MapKey::IsPressed(action)) { + return true; + } + } + return false; +} diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index f04ddafb1..5009cb495 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -42,6 +42,7 @@ #include +#include #include #include @@ -52,6 +53,38 @@ static bool reset_focus_next_frame = false; static int show_info_pending = 0; +static void* shortcuts_manager_instance = nullptr; +static const MethodInfo* on_galaxy_action = nullptr; +static const MethodInfo* on_events_action = nullptr; + +struct InputActionCallbackContext { + void* state; + int32_t action_index; +}; + +static_assert(sizeof(InputActionCallbackContext) == 16); + +bool InvokeNativeShortcut(const MethodInfo* method, const char* action_name) +{ + if (!shortcuts_manager_instance || !method) { + spdlog::warn("[Hotkeys] native {} shortcut is unavailable", action_name); + return false; + } + + // These callbacks do not inspect CallbackContext, but runtime_invoke still requires storage for the value-type + // argument. Using the managed invoker also avoids platform-specific by-value ABI assumptions. + InputActionCallbackContext context{}; + void* args[]{&context}; + Il2CppException* exception = nullptr; + il2cpp_runtime_invoke(method, shortcuts_manager_instance, args, &exception); + if (exception) { + spdlog::warn("[Hotkeys] native {} shortcut raised exception={}", action_name, static_cast(exception)); + return false; + } + + return true; +} + bool force_space_action_next_frame = false; void ChangeNavigationSection(SectionID sectionID); @@ -399,6 +432,9 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) return GotoSection(SectionID::Shop_MainFactions); } else if (MapKey::IsDown(GameFunction::ShoWStationExterior)) { return GotoSection(SectionID::Starbase_Exterior); + } else if (MapKey::IsDown(GameFunction::ShowGalaxyNative)) { + InvokeNativeShortcut(on_galaxy_action, "Galaxy"); + return; } else if (MapKey::IsDown(GameFunction::ShowGalaxy)) { return ChangeNavigationSection(SectionID::Navigation_Galaxy); } else if (MapKey::IsDown(GameFunction::ShowStationInterior)) { @@ -423,6 +459,9 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) return GotoSection(SectionID::FleetCommander_Management); } else if (MapKey::IsDown(GameFunction::ShowAwayTeam)) { return GotoSection(SectionID::Missions_AwayTeamsList); + } else if (MapKey::IsDown(GameFunction::ShowEventsNative)) { + InvokeNativeShortcut(on_events_action, "Events"); + return; } else if (MapKey::IsDown(GameFunction::ShowEvents)) { return GotoSection(SectionID::Tournament_Group_Selection); } else if (MapKey::IsDown(GameFunction::ShowExoComp)) { @@ -958,6 +997,7 @@ void ChatMessageListLocalViewController_AboutToShow_Hook(ChatMessageListLocalVie void InitializeActions_Hook(auto original, void* _this) { + shortcuts_manager_instance = _this; if (Config::Get().use_scopely_hotkeys) { return original(_this); } @@ -1034,6 +1074,16 @@ void InstallHotkeyHooks() if (!shortcuts_manager_helper.isValidHelper()) { ErrorMsg::MissingHelper("GameInput", "ShortcutsManager"); } else { + on_galaxy_action = shortcuts_manager_helper.GetMethodInfo("OnGalaxyAction", 1); + if (on_galaxy_action == nullptr) { + ErrorMsg::MissingMethod("ShortcutsManager", "OnGalaxyAction"); + } + + on_events_action = shortcuts_manager_helper.GetMethodInfo("OnEventsAction", 1); + if (on_events_action == nullptr) { + ErrorMsg::MissingMethod("ShortcutsManager", "OnEventsAction"); + } + auto ptr_can_user_shortcuts = shortcuts_manager_helper.GetMethod("InitializeActions"); if (ptr_can_user_shortcuts == nullptr) { ErrorMsg::MissingMethod("ShortcutsManager", "InitializeActions"); diff --git a/mods/src/patches/parts/zoom.cc b/mods/src/patches/parts/zoom.cc index c19de3af1..0449592b9 100644 --- a/mods/src/patches/parts/zoom.cc +++ b/mods/src/patches/parts/zoom.cc @@ -2,9 +2,11 @@ #include "errormsg.h" #include +#include #include +#include #include #include #include @@ -145,7 +147,11 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) EnsureSystemZoomRange(_this); - if (!Key::IsInputFocused()) { + const auto navigation_owns_chord = + config->hotkeys_enabled && !config->use_scopely_hotkeys && !Hub::IsInChat() + && IsScreenNavigationShortcutPressed(); + + if (!Key::IsInputFocused() && !navigation_owns_chord) { if (MapKey::IsDown(GameFunction::SetZoomPreset1)) { return StoreZoom("System Preset 1", config->system_zoom_preset_1, _this); } else if (MapKey::IsDown(GameFunction::SetZoomPreset2)) { From 2fca4d2aba110049ed68b3e3e5569f534fffe68f Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 19:38:20 -0500 Subject: [PATCH 28/80] fix: scope native shortcut to galaxy --- README.md | 9 +- example_community_patch_settings_da.toml | 7 +- example_community_patch_settings_de.toml | 7 +- ...munity_patch_settings_en-GB-x-cockney.toml | 7 +- ...mmunity_patch_settings_en-x-minionese.toml | 7 +- example_community_patch_settings_en.toml | 7 +- example_community_patch_settings_es.toml | 7 +- example_community_patch_settings_fr.toml | 7 +- example_community_patch_settings_nl.toml | 7 +- example_community_patch_settings_ru.toml | 7 +- example_community_patch_settings_tlh.toml | 7 +- mods/src/config.cc | 2 - mods/src/defaultconfig.h | 3 +- mods/src/patches/gamefunctions.h | 1 - mods/src/patches/parts/hotkey_ownership.h | 19 ++-- mods/src/patches/parts/hotkeys.cc | 9 -- mods/src/patches/parts/zoom.cc | 91 ++++++++++--------- 17 files changed, 82 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index 79b566f7d..801fe08e6 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,8 @@ been applied. Valid values for any short can be found in [KEYMAPPING.md](KEYMAPP | -----------: | -------------------------------- | | F10 | Bug fixer (exits game) | | F1-F5 | Zoom presets | -| Q | Zoom Out | -| E | Zoom In | +| Q | Zoom In | +| E | Zoom Out | | MINUS | Zoom (min) | | EQUALS | Zoom (default) | | BACKSPACE | Zoom (max) | @@ -109,9 +109,11 @@ action items such as: | Key | Shortcut | | ------: | ---------------- | -| T | Events | +| T | Away Teams | | G | Galaxy | +| Ctrl-G | Galaxy (native) | | H | System | +| Shift-E | Events | | Shift-G | Exterior View | | Shift-H | Interior View | | B | Bookmarks | @@ -125,7 +127,6 @@ action items such as: | O | Command Center | | Shift-O | Officers | | Shift-Q | Q-Trials | -| Shift-T | Away Teams | | X | ExoComp | | Z | Daily Missions | diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index d85668a1e..5c0499957 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -455,9 +455,6 @@ show_daily = "Z" # Gå til begivenheder show_events = "SHIFT-E" -# Scopely-native Go to Events action -show_events_native = "F5" - # Gå til Exocomp show_exocomp = "X" @@ -467,8 +464,8 @@ show_factions = "F" # Gå til galaksen show_galaxy = "G" -# Scopely-native Galaxy action -show_galaxy_native = "E" +# Scopely shortcut-native Galaxy Action +show_galaxy_native = "CTRL-G" # Gå til gaver show_gifts = "/" diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 1d3a29ea0..90d4daada 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -455,9 +455,6 @@ show_daily = "Z" # Zu den Events show_events = "SHIFT-E" -# Scopely-native Go to Events action -show_events_native = "F5" - # Zu den Exocomps show_exocomp = "X" @@ -467,8 +464,8 @@ show_factions = "F" # Zur Galaxie show_galaxy = "G" -# Scopely-native Galaxy action -show_galaxy_native = "E" +# Scopely shortcut-native Galaxy Action +show_galaxy_native = "CTRL-G" # Zu den Geschenken show_gifts = "/" diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index f26a643a5..d00195fae 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -455,9 +455,6 @@ show_daily = "Z" # Go to Events show_events = "SHIFT-E" -# Scopely-native Go to Events action -show_events_native = "F5" - # Go to Exocomp show_exocomp = "X" @@ -467,8 +464,8 @@ show_factions = "F" # Go to Galaxy show_galaxy = "G" -# Scopely-native Galaxy action -show_galaxy_native = "E" +# Scopely shortcut-native Galaxy Action +show_galaxy_native = "CTRL-G" # Go to Gifts show_gifts = "/" diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index b72dce46d..6d96c31f5 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -455,9 +455,6 @@ show_daily = "Z" # Go to Events show_events = "SHIFT-E" -# Scopely-native Go to Events action -show_events_native = "F5" - # Go to Exocomp show_exocomp = "X" @@ -467,8 +464,8 @@ show_factions = "F" # Go to Galaxy show_galaxy = "G" -# Scopely-native Galaxy action -show_galaxy_native = "E" +# Scopely shortcut-native Galaxy Action +show_galaxy_native = "CTRL-G" # Go to Gifts show_gifts = "/" diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 6f5b351e8..9e7bc39db 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -455,9 +455,6 @@ show_daily = "Z" # Go to Events show_events = "SHIFT-E" -# Scopely-native Go to Events action -show_events_native = "F5" - # Go to Exocomp show_exocomp = "X" @@ -467,8 +464,8 @@ show_factions = "F" # Go to Galaxy show_galaxy = "G" -# Scopely-native Galaxy action -show_galaxy_native = "E" +# Scopely shortcut-native Galaxy Action +show_galaxy_native = "CTRL-G" # Go to Gifts show_gifts = "/" diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 869bab2d3..baa3caf9b 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -455,9 +455,6 @@ show_daily = "Z" # Ir a eventos show_events = "SHIFT-E" -# Scopely-native Go to Events action -show_events_native = "F5" - # Ir a Exocomp show_exocomp = "X" @@ -467,8 +464,8 @@ show_factions = "F" # Ir a la galaxia show_galaxy = "G" -# Scopely-native Galaxy action -show_galaxy_native = "E" +# Scopely shortcut-native Galaxy Action +show_galaxy_native = "CTRL-G" # Ir a regalos show_gifts = "/" diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index 9b362845d..a107f23ce 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -455,9 +455,6 @@ show_daily = "Z" # Aller aux événements show_events = "SHIFT-E" -# Scopely-native Go to Events action -show_events_native = "F5" - # Aller aux Exocomps show_exocomp = "X" @@ -467,8 +464,8 @@ show_factions = "F" # Aller à la galaxie show_galaxy = "G" -# Scopely-native Galaxy action -show_galaxy_native = "E" +# Scopely shortcut-native Galaxy Action +show_galaxy_native = "CTRL-G" # Aller aux cadeaux show_gifts = "/" diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index ac7dd837d..ac72b48df 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -455,9 +455,6 @@ show_daily = "Z" # Ga naar Evenementen show_events = "SHIFT-E" -# Scopely-native Go to Events action -show_events_native = "F5" - # Ga naar Exocomp show_exocomp = "X" @@ -467,8 +464,8 @@ show_factions = "F" # Ga naar Sterrenstelsel show_galaxy = "G" -# Scopely-native Galaxy action -show_galaxy_native = "E" +# Scopely shortcut-native Galaxy Action +show_galaxy_native = "CTRL-G" # Ga naar Cadeaus show_gifts = "/" diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 4ef20e0f8..f227910f2 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -455,9 +455,6 @@ show_daily = "Z" # Перейти к событиям show_events = "SHIFT-E" -# Scopely-native Go to Events action -show_events_native = "F5" - # Перейти к экзокомпу show_exocomp = "X" @@ -467,8 +464,8 @@ show_factions = "F" # Перейти к галактике show_galaxy = "G" -# Scopely-native Galaxy action -show_galaxy_native = "E" +# Scopely shortcut-native Galaxy Action +show_galaxy_native = "CTRL-G" # Перейти к подаркам show_gifts = "/" diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 9298f33f0..89eff1157 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -455,9 +455,6 @@ show_daily = "Z" # Go Daq Events show_events = "SHIFT-E" -# Scopely-native Go to Events action -show_events_native = "F5" - # Go Daq Exocomp show_exocomp = "X" @@ -467,8 +464,8 @@ show_factions = "F" # Go Daq Galaxy show_galaxy = "G" -# Scopely-native Galaxy action -show_galaxy_native = "E" +# Scopely shortcut-native Galaxy Action +show_galaxy_native = "CTRL-G" # Go Daq Gifts show_gifts = "/" diff --git a/mods/src/config.cc b/mods/src/config.cc index f9b5ae58d..6aa4c5b56 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1263,8 +1263,6 @@ void Config::Load() parse_config_shortcut(config, parsed, "show_commander", GameFunction::ShowCommander, DCSH::show_commander); parse_config_shortcut(config, parsed, "show_daily", GameFunction::ShowDaily, DCSH::show_daily); parse_config_shortcut(config, parsed, "show_events", GameFunction::ShowEvents, DCSH::show_events); - parse_config_shortcut(config, parsed, "show_events_native", GameFunction::ShowEventsNative, - DCSH::show_events_native); parse_config_shortcut(config, parsed, "show_exocomp", GameFunction::ShowExoComp, DCSH::show_exocomp); parse_config_shortcut(config, parsed, "show_factions", GameFunction::ShowFactions, DCSH::show_factions); parse_config_shortcut(config, parsed, "show_inventory", GameFunction::ShowInventory, DCSH::show_inventory); diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index c61412f12..089cb246a 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -141,11 +141,10 @@ namespace Shortcuts constexpr const char* show_commander = "O"; constexpr const char* show_daily = "Z"; constexpr const char* show_events = "SHIFT-E"; - constexpr const char* show_events_native = "F5"; constexpr const char* show_exocomp = "X"; constexpr const char* show_factions = "F"; constexpr const char* show_galaxy = "G"; - constexpr const char* show_galaxy_native = "E"; + constexpr const char* show_galaxy_native = "CTRL-G"; constexpr const char* show_gifts = "/"; constexpr const char* show_inventory = "I"; constexpr const char* show_lookup = "L"; diff --git a/mods/src/patches/gamefunctions.h b/mods/src/patches/gamefunctions.h index 469a013a2..c99ebf970 100644 --- a/mods/src/patches/gamefunctions.h +++ b/mods/src/patches/gamefunctions.h @@ -46,7 +46,6 @@ enum GameFunction { ShowChatSide1, ShowChatSide2, ShowEvents, - ShowEventsNative, ShowSettings, ZoomPreset1, ZoomPreset2, diff --git a/mods/src/patches/parts/hotkey_ownership.h b/mods/src/patches/parts/hotkey_ownership.h index ec37fd5ef..b4fbfdfbc 100644 --- a/mods/src/patches/parts/hotkey_ownership.h +++ b/mods/src/patches/parts/hotkey_ownership.h @@ -10,16 +10,15 @@ inline bool IsScreenNavigationShortcutPressed() { static constexpr std::array screen_navigation_actions{ - GameFunction::ShowAlliance, GameFunction::ShowAllianceArmada, GameFunction::ShowAllianceHelp, - GameFunction::ShowArtifacts, GameFunction::ShowAwayTeam, GameFunction::ShowBookmarks, - GameFunction::ShowCommander, GameFunction::ShowDaily, GameFunction::ShowEvents, - GameFunction::ShowEventsNative, GameFunction::ShowExoComp, GameFunction::ShowFactions, - GameFunction::ShowGalaxy, GameFunction::ShowGalaxyNative, GameFunction::ShowGifts, - GameFunction::ShowInventory, GameFunction::ShowLookup, GameFunction::ShowMissions, - GameFunction::ShowOfficers, GameFunction::ShowQTrials, GameFunction::ShowRefinery, - GameFunction::ShowResearch, GameFunction::ShowScrapYard, GameFunction::ShowSettings, - GameFunction::ShowShips, GameFunction::ShowStationInterior, GameFunction::ShoWStationExterior, - GameFunction::ShowSystem, + GameFunction::ShowAlliance, GameFunction::ShowAllianceArmada, GameFunction::ShowAllianceHelp, + GameFunction::ShowArtifacts, GameFunction::ShowAwayTeam, GameFunction::ShowBookmarks, + GameFunction::ShowCommander, GameFunction::ShowDaily, GameFunction::ShowEvents, + GameFunction::ShowExoComp, GameFunction::ShowFactions, GameFunction::ShowGalaxy, + GameFunction::ShowGalaxyNative, GameFunction::ShowGifts, GameFunction::ShowInventory, + GameFunction::ShowLookup, GameFunction::ShowMissions, GameFunction::ShowOfficers, + GameFunction::ShowQTrials, GameFunction::ShowRefinery, GameFunction::ShowResearch, + GameFunction::ShowScrapYard, GameFunction::ShowSettings, GameFunction::ShowShips, + GameFunction::ShowStationInterior, GameFunction::ShoWStationExterior, GameFunction::ShowSystem, }; for (const auto action : screen_navigation_actions) { diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index 5009cb495..f5bab7c5c 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -55,7 +55,6 @@ static int show_info_pending = 0; static void* shortcuts_manager_instance = nullptr; static const MethodInfo* on_galaxy_action = nullptr; -static const MethodInfo* on_events_action = nullptr; struct InputActionCallbackContext { void* state; @@ -459,9 +458,6 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) return GotoSection(SectionID::FleetCommander_Management); } else if (MapKey::IsDown(GameFunction::ShowAwayTeam)) { return GotoSection(SectionID::Missions_AwayTeamsList); - } else if (MapKey::IsDown(GameFunction::ShowEventsNative)) { - InvokeNativeShortcut(on_events_action, "Events"); - return; } else if (MapKey::IsDown(GameFunction::ShowEvents)) { return GotoSection(SectionID::Tournament_Group_Selection); } else if (MapKey::IsDown(GameFunction::ShowExoComp)) { @@ -1079,11 +1075,6 @@ void InstallHotkeyHooks() ErrorMsg::MissingMethod("ShortcutsManager", "OnGalaxyAction"); } - on_events_action = shortcuts_manager_helper.GetMethodInfo("OnEventsAction", 1); - if (on_events_action == nullptr) { - ErrorMsg::MissingMethod("ShortcutsManager", "OnEventsAction"); - } - auto ptr_can_user_shortcuts = shortcuts_manager_helper.GetMethod("InitializeActions"); if (ptr_can_user_shortcuts == nullptr) { ErrorMsg::MissingMethod("ShortcutsManager", "InitializeActions"); diff --git a/mods/src/patches/parts/zoom.cc b/mods/src/patches/parts/zoom.cc index 0449592b9..a9cbeb16c 100644 --- a/mods/src/patches/parts/zoom.cc +++ b/mods/src/patches/parts/zoom.cc @@ -147,51 +147,54 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) EnsureSystemZoomRange(_this); + const auto mod_camera_shortcuts_enabled = config->hotkeys_enabled && !config->use_scopely_hotkeys; const auto navigation_owns_chord = - config->hotkeys_enabled && !config->use_scopely_hotkeys && !Hub::IsInChat() - && IsScreenNavigationShortcutPressed(); - - if (!Key::IsInputFocused() && !navigation_owns_chord) { - if (MapKey::IsDown(GameFunction::SetZoomPreset1)) { - return StoreZoom("System Preset 1", config->system_zoom_preset_1, _this); - } else if (MapKey::IsDown(GameFunction::SetZoomPreset2)) { - return StoreZoom("System Preset 2", config->system_zoom_preset_2, _this); - } else if (MapKey::IsDown(GameFunction::SetZoomPreset3)) { - return StoreZoom("System Preset 3", config->system_zoom_preset_3, _this); - } else if (MapKey::IsDown(GameFunction::SetZoomPreset4)) { - return StoreZoom("System Preset 4", config->system_zoom_preset_4, _this); - } else if (MapKey::IsDown(GameFunction::SetZoomPreset5)) { - return StoreZoom("System Preset 5", config->system_zoom_preset_5, _this); - } else if (MapKey::IsDown(GameFunction::SetZoomDefault)) { - return StoreZoom("System Default", config->default_system_zoom, _this); - } + mod_camera_shortcuts_enabled && !Hub::IsInChat() && IsScreenNavigationShortcutPressed(); + const auto camera_shortcuts_enabled = mod_camera_shortcuts_enabled && !navigation_owns_chord; + + if (!Key::IsInputFocused()) { + if (camera_shortcuts_enabled) { + if (MapKey::IsDown(GameFunction::SetZoomPreset1)) { + return StoreZoom("System Preset 1", config->system_zoom_preset_1, _this); + } else if (MapKey::IsDown(GameFunction::SetZoomPreset2)) { + return StoreZoom("System Preset 2", config->system_zoom_preset_2, _this); + } else if (MapKey::IsDown(GameFunction::SetZoomPreset3)) { + return StoreZoom("System Preset 3", config->system_zoom_preset_3, _this); + } else if (MapKey::IsDown(GameFunction::SetZoomPreset4)) { + return StoreZoom("System Preset 4", config->system_zoom_preset_4, _this); + } else if (MapKey::IsDown(GameFunction::SetZoomPreset5)) { + return StoreZoom("System Preset 5", config->system_zoom_preset_5, _this); + } else if (MapKey::IsDown(GameFunction::SetZoomDefault)) { + return StoreZoom("System Default", config->default_system_zoom, _this); + } - do_absolute_zoom = true; - if (MapKey::IsDown(GameFunction::ZoomPreset1)) { - zoomDelta = config->system_zoom_preset_1; - do_store_zoom = true; - } else if (MapKey::IsDown(GameFunction::ZoomPreset2)) { - zoomDelta = config->system_zoom_preset_2; - do_store_zoom = true; - } else if (MapKey::IsDown(GameFunction::ZoomPreset3)) { - zoomDelta = config->system_zoom_preset_3; - do_store_zoom = true; - } else if (MapKey::IsDown(GameFunction::ZoomPreset4)) { - zoomDelta = config->system_zoom_preset_4; - do_store_zoom = true; - } else if (MapKey::IsDown(GameFunction::ZoomPreset5)) { - zoomDelta = config->system_zoom_preset_5; - do_store_zoom = true; - } + do_absolute_zoom = true; + if (MapKey::IsDown(GameFunction::ZoomPreset1)) { + zoomDelta = config->system_zoom_preset_1; + do_store_zoom = true; + } else if (MapKey::IsDown(GameFunction::ZoomPreset2)) { + zoomDelta = config->system_zoom_preset_2; + do_store_zoom = true; + } else if (MapKey::IsDown(GameFunction::ZoomPreset3)) { + zoomDelta = config->system_zoom_preset_3; + do_store_zoom = true; + } else if (MapKey::IsDown(GameFunction::ZoomPreset4)) { + zoomDelta = config->system_zoom_preset_4; + do_store_zoom = true; + } else if (MapKey::IsDown(GameFunction::ZoomPreset5)) { + zoomDelta = config->system_zoom_preset_5; + do_store_zoom = true; + } - if (config->hotkeys_extended) { - if (MapKey::IsDown(GameFunction::ZoomReset)) { - do_absolute_zoom = false; - do_default_zoom = true; - } else if (MapKey::IsDown(GameFunction::ZoomMin)) { - zoomDelta = config->zoom; - } else if (MapKey::IsDown(GameFunction::ZoomMax)) { - zoomDelta = 100; + if (config->hotkeys_extended) { + if (MapKey::IsDown(GameFunction::ZoomReset)) { + do_absolute_zoom = false; + do_default_zoom = true; + } else if (MapKey::IsDown(GameFunction::ZoomMin)) { + zoomDelta = config->zoom; + } else if (MapKey::IsDown(GameFunction::ZoomMax)) { + zoomDelta = 100; + } } } @@ -205,7 +208,7 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) zoomDelta = config->keyboard_zoom_speed * dt; } - if (MapKey::IsPressed(GameFunction::ZoomIn) || do_absolute_zoom) { + if ((camera_shortcuts_enabled && MapKey::IsPressed(GameFunction::ZoomIn)) || do_absolute_zoom) { vec3 mousePos; GetMousePosition(&mousePos); _this->_zoomLocation = vec2{.x = mousePos.x, .y = mousePos.y}; @@ -219,7 +222,7 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) auto worldPos = GetMouseWorldPos(_this->_sceneCamera, &mousePos); _this->_worldPoint = worldPos; _this->ZoomCameraAtWorldPoint(); - } else if (MapKey::IsPressed(GameFunction::ZoomOut) && !Key::IsInputFocused()) { + } else if (camera_shortcuts_enabled && MapKey::IsPressed(GameFunction::ZoomOut)) { vec3 mousePos; GetMousePosition(&mousePos); _this->_zoomLocation = vec2{.x = mousePos.x, .y = mousePos.y}; From 4e17dcd82c9a100dd737f06440aa446dc056dbab Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 19:42:30 -0500 Subject: [PATCH 29/80] fix: resolve live shortcut manager --- mods/src/patches/parts/hotkeys.cc | 8 ++++---- mods/src/patches/parts/zoom.cc | 4 +--- mods/src/prime/ShortcutsManager.h | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 mods/src/prime/ShortcutsManager.h diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index f5bab7c5c..f2293c6f1 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -34,6 +34,7 @@ #include "prime/ScanEngageButtonsWidget.h" #include "prime/ScreenManager.h" #include "prime/SelectableList.h" +#include "prime/ShortcutsManager.h" #include "patches/key.h" #include "patches/mapkey.h" @@ -53,7 +54,6 @@ static bool reset_focus_next_frame = false; static int show_info_pending = 0; -static void* shortcuts_manager_instance = nullptr; static const MethodInfo* on_galaxy_action = nullptr; struct InputActionCallbackContext { @@ -65,7 +65,8 @@ static_assert(sizeof(InputActionCallbackContext) == 16); bool InvokeNativeShortcut(const MethodInfo* method, const char* action_name) { - if (!shortcuts_manager_instance || !method) { + auto* shortcuts_manager = ShortcutsManager::Instance(); + if (!shortcuts_manager || !method) { spdlog::warn("[Hotkeys] native {} shortcut is unavailable", action_name); return false; } @@ -75,7 +76,7 @@ bool InvokeNativeShortcut(const MethodInfo* method, const char* action_name) InputActionCallbackContext context{}; void* args[]{&context}; Il2CppException* exception = nullptr; - il2cpp_runtime_invoke(method, shortcuts_manager_instance, args, &exception); + il2cpp_runtime_invoke(method, shortcuts_manager, args, &exception); if (exception) { spdlog::warn("[Hotkeys] native {} shortcut raised exception={}", action_name, static_cast(exception)); return false; @@ -993,7 +994,6 @@ void ChatMessageListLocalViewController_AboutToShow_Hook(ChatMessageListLocalVie void InitializeActions_Hook(auto original, void* _this) { - shortcuts_manager_instance = _this; if (Config::Get().use_scopely_hotkeys) { return original(_this); } diff --git a/mods/src/patches/parts/zoom.cc b/mods/src/patches/parts/zoom.cc index a9cbeb16c..a7823f1e6 100644 --- a/mods/src/patches/parts/zoom.cc +++ b/mods/src/patches/parts/zoom.cc @@ -6,7 +6,6 @@ #include -#include #include #include #include @@ -148,8 +147,7 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) EnsureSystemZoomRange(_this); const auto mod_camera_shortcuts_enabled = config->hotkeys_enabled && !config->use_scopely_hotkeys; - const auto navigation_owns_chord = - mod_camera_shortcuts_enabled && !Hub::IsInChat() && IsScreenNavigationShortcutPressed(); + const auto navigation_owns_chord = mod_camera_shortcuts_enabled && IsScreenNavigationShortcutPressed(); const auto camera_shortcuts_enabled = mod_camera_shortcuts_enabled && !navigation_owns_chord; if (!Key::IsInputFocused()) { diff --git a/mods/src/prime/ShortcutsManager.h b/mods/src/prime/ShortcutsManager.h new file mode 100644 index 000000000..5529506f2 --- /dev/null +++ b/mods/src/prime/ShortcutsManager.h @@ -0,0 +1,14 @@ +#pragma once + +#include "MonoSingleton.h" + +struct ShortcutsManager : MonoSingleton { + friend struct MonoSingleton; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameInput", "ShortcutsManager"); + return class_helper; + } +}; From dbdc22f92036ee7cc1a5ecb9ea2090fd7e6c0d10 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 22:27:57 -0500 Subject: [PATCH 30/80] fix: rely on configured zoom shortcuts --- mods/src/patches/parts/hotkey_ownership.h | 30 ----------------------- mods/src/patches/parts/zoom.cc | 5 +--- 2 files changed, 1 insertion(+), 34 deletions(-) delete mode 100644 mods/src/patches/parts/hotkey_ownership.h diff --git a/mods/src/patches/parts/hotkey_ownership.h b/mods/src/patches/parts/hotkey_ownership.h deleted file mode 100644 index b4fbfdfbc..000000000 --- a/mods/src/patches/parts/hotkey_ownership.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include "patches/gamefunctions.h" -#include "patches/mapkey.h" - -#include - -// Screen navigation and camera shortcuts run from separate Unity update hooks. Give screen navigation ownership of a -// shared chord so the result does not depend on which hook Unity calls first. -inline bool IsScreenNavigationShortcutPressed() -{ - static constexpr std::array screen_navigation_actions{ - GameFunction::ShowAlliance, GameFunction::ShowAllianceArmada, GameFunction::ShowAllianceHelp, - GameFunction::ShowArtifacts, GameFunction::ShowAwayTeam, GameFunction::ShowBookmarks, - GameFunction::ShowCommander, GameFunction::ShowDaily, GameFunction::ShowEvents, - GameFunction::ShowExoComp, GameFunction::ShowFactions, GameFunction::ShowGalaxy, - GameFunction::ShowGalaxyNative, GameFunction::ShowGifts, GameFunction::ShowInventory, - GameFunction::ShowLookup, GameFunction::ShowMissions, GameFunction::ShowOfficers, - GameFunction::ShowQTrials, GameFunction::ShowRefinery, GameFunction::ShowResearch, - GameFunction::ShowScrapYard, GameFunction::ShowSettings, GameFunction::ShowShips, - GameFunction::ShowStationInterior, GameFunction::ShoWStationExterior, GameFunction::ShowSystem, - }; - - for (const auto action : screen_navigation_actions) { - if (MapKey::IsPressed(action)) { - return true; - } - } - return false; -} diff --git a/mods/src/patches/parts/zoom.cc b/mods/src/patches/parts/zoom.cc index a7823f1e6..e4b08f588 100644 --- a/mods/src/patches/parts/zoom.cc +++ b/mods/src/patches/parts/zoom.cc @@ -2,7 +2,6 @@ #include "errormsg.h" #include -#include #include @@ -146,9 +145,7 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) EnsureSystemZoomRange(_this); - const auto mod_camera_shortcuts_enabled = config->hotkeys_enabled && !config->use_scopely_hotkeys; - const auto navigation_owns_chord = mod_camera_shortcuts_enabled && IsScreenNavigationShortcutPressed(); - const auto camera_shortcuts_enabled = mod_camera_shortcuts_enabled && !navigation_owns_chord; + const auto camera_shortcuts_enabled = config->hotkeys_enabled && !config->use_scopely_hotkeys; if (!Key::IsInputFocused()) { if (camera_shortcuts_enabled) { From abbe39cba36a6324e66a395873ce66ffd2b18ca8 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 22:34:15 -0500 Subject: [PATCH 31/80] docs: use valid equals shortcut token --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 801fe08e6..de1db1f3e 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ been applied. Valid values for any short can be found in [KEYMAPPING.md](KEYMAPP | Q | Zoom In | | E | Zoom Out | | MINUS | Zoom (min) | -| EQUALS | Zoom (default) | +| = | Zoom (default) | | BACKSPACE | Zoom (max) | | C | Open/Focus Chat - Full Screen | | Alt-C | Open/Focus Chat - Side of Screen | From b32349b851498682b68a483a3dac35741f862e32 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 22:52:27 -0500 Subject: [PATCH 32/80] feat: add native Events shortcut action --- README.md | 1 + example_community_patch_settings_da.toml | 5 ++++- example_community_patch_settings_de.toml | 5 ++++- ...le_community_patch_settings_en-GB-x-cockney.toml | 5 ++++- ...ple_community_patch_settings_en-x-minionese.toml | 5 ++++- example_community_patch_settings_en.toml | 5 ++++- example_community_patch_settings_es.toml | 5 ++++- example_community_patch_settings_fr.toml | 5 ++++- example_community_patch_settings_nl.toml | 5 ++++- example_community_patch_settings_ru.toml | 5 ++++- example_community_patch_settings_tlh.toml | 5 ++++- mods/src/config.cc | 6 ++++-- mods/src/defaultconfig.h | 5 ++++- mods/src/patches/gamefunctions.h | 3 ++- mods/src/patches/parts/hotkeys.cc | 13 +++++++++++-- 15 files changed, 62 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index de1db1f3e..29281264b 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ action items such as: | Ctrl-G | Galaxy (native) | | H | System | | Shift-E | Events | +| Ctrl-E | Events (native) | | Shift-G | Exterior View | | Shift-H | Interior View | | B | Bookmarks | diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 5c0499957..98c078e80 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Gå til begivenheder show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Gå til Exocomp show_exocomp = "X" @@ -465,7 +468,7 @@ show_factions = "F" show_galaxy = "G" # Scopely shortcut-native Galaxy Action -show_galaxy_native = "CTRL-G" +native_shortcut_galaxy = "CTRL-G" # Gå til gaver show_gifts = "/" diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 90d4daada..e185936c6 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Zu den Events show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Zu den Exocomps show_exocomp = "X" @@ -465,7 +468,7 @@ show_factions = "F" show_galaxy = "G" # Scopely shortcut-native Galaxy Action -show_galaxy_native = "CTRL-G" +native_shortcut_galaxy = "CTRL-G" # Zu den Geschenken show_gifts = "/" diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index d00195fae..4969e2e40 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Go to Events show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Go to Exocomp show_exocomp = "X" @@ -465,7 +468,7 @@ show_factions = "F" show_galaxy = "G" # Scopely shortcut-native Galaxy Action -show_galaxy_native = "CTRL-G" +native_shortcut_galaxy = "CTRL-G" # Go to Gifts show_gifts = "/" diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 6d96c31f5..e0facf124 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Go to Events show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Go to Exocomp show_exocomp = "X" @@ -465,7 +468,7 @@ show_factions = "F" show_galaxy = "G" # Scopely shortcut-native Galaxy Action -show_galaxy_native = "CTRL-G" +native_shortcut_galaxy = "CTRL-G" # Go to Gifts show_gifts = "/" diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 9e7bc39db..9e31340b9 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Go to Events show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Go to Exocomp show_exocomp = "X" @@ -465,7 +468,7 @@ show_factions = "F" show_galaxy = "G" # Scopely shortcut-native Galaxy Action -show_galaxy_native = "CTRL-G" +native_shortcut_galaxy = "CTRL-G" # Go to Gifts show_gifts = "/" diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index baa3caf9b..5750f6711 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Ir a eventos show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Ir a Exocomp show_exocomp = "X" @@ -465,7 +468,7 @@ show_factions = "F" show_galaxy = "G" # Scopely shortcut-native Galaxy Action -show_galaxy_native = "CTRL-G" +native_shortcut_galaxy = "CTRL-G" # Ir a regalos show_gifts = "/" diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index a107f23ce..c415cc260 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Aller aux événements show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Aller aux Exocomps show_exocomp = "X" @@ -465,7 +468,7 @@ show_factions = "F" show_galaxy = "G" # Scopely shortcut-native Galaxy Action -show_galaxy_native = "CTRL-G" +native_shortcut_galaxy = "CTRL-G" # Aller aux cadeaux show_gifts = "/" diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index ac72b48df..bf1bdde48 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Ga naar Evenementen show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Ga naar Exocomp show_exocomp = "X" @@ -465,7 +468,7 @@ show_factions = "F" show_galaxy = "G" # Scopely shortcut-native Galaxy Action -show_galaxy_native = "CTRL-G" +native_shortcut_galaxy = "CTRL-G" # Ga naar Cadeaus show_gifts = "/" diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index f227910f2..92e084d7b 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Перейти к событиям show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Перейти к экзокомпу show_exocomp = "X" @@ -465,7 +468,7 @@ show_factions = "F" show_galaxy = "G" # Scopely shortcut-native Galaxy Action -show_galaxy_native = "CTRL-G" +native_shortcut_galaxy = "CTRL-G" # Перейти к подаркам show_gifts = "/" diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 89eff1157..3e7be9c88 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -455,6 +455,9 @@ show_daily = "Z" # Go Daq Events show_events = "SHIFT-E" +# Scopely shortcut-native Events Action +native_shortcut_events = "CTRL-E" + # Go Daq Exocomp show_exocomp = "X" @@ -465,7 +468,7 @@ show_factions = "F" show_galaxy = "G" # Scopely shortcut-native Galaxy Action -show_galaxy_native = "CTRL-G" +native_shortcut_galaxy = "CTRL-G" # Go Daq Gifts show_gifts = "/" diff --git a/mods/src/config.cc b/mods/src/config.cc index 6aa4c5b56..ca3721ecb 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1226,8 +1226,8 @@ void Config::Load() parse_config_shortcut(config, parsed, "show_chatside1", GameFunction::ShowChatSide1, DCSH::show_chatside1); parse_config_shortcut(config, parsed, "show_chatside2", GameFunction::ShowChatSide2, DCSH::show_chatside2); parse_config_shortcut(config, parsed, "show_galaxy", GameFunction::ShowGalaxy, DCSH::show_galaxy); - parse_config_shortcut(config, parsed, "show_galaxy_native", GameFunction::ShowGalaxyNative, - DCSH::show_galaxy_native); + parse_config_shortcut(config, parsed, "native_shortcut_galaxy", GameFunction::NativeShortcutGalaxy, + DCSH::native_shortcut_galaxy); parse_config_shortcut(config, parsed, "show_system", GameFunction::ShowSystem, DCSH::show_system); parse_config_shortcut(config, parsed, "zoom_preset1", GameFunction::ZoomPreset1, DCSH::zoom_preset1); parse_config_shortcut(config, parsed, "zoom_preset2", GameFunction::ZoomPreset2, DCSH::zoom_preset2); @@ -1263,6 +1263,8 @@ void Config::Load() parse_config_shortcut(config, parsed, "show_commander", GameFunction::ShowCommander, DCSH::show_commander); parse_config_shortcut(config, parsed, "show_daily", GameFunction::ShowDaily, DCSH::show_daily); parse_config_shortcut(config, parsed, "show_events", GameFunction::ShowEvents, DCSH::show_events); + parse_config_shortcut(config, parsed, "native_shortcut_events", GameFunction::NativeShortcutEvents, + DCSH::native_shortcut_events); parse_config_shortcut(config, parsed, "show_exocomp", GameFunction::ShowExoComp, DCSH::show_exocomp); parse_config_shortcut(config, parsed, "show_factions", GameFunction::ShowFactions, DCSH::show_factions); parse_config_shortcut(config, parsed, "show_inventory", GameFunction::ShowInventory, DCSH::show_inventory); diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 089cb246a..edd136053 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -129,6 +129,10 @@ namespace Shortcuts constexpr const char* set_zoom_preset3 = "SHIFT-F3"; constexpr const char* set_zoom_preset4 = "SHIFT-F4"; constexpr const char* set_zoom_preset5 = "SHIFT-F5"; + + constexpr const char* native_shortcut_events = "CTRL-E"; + constexpr const char* native_shortcut_galaxy = "CTRL-G"; + constexpr const char* show_alliance = "ALT-'"; constexpr const char* show_alliance_armada = "CTRL-'"; constexpr const char* show_alliance_help = "SHIFT-'"; @@ -144,7 +148,6 @@ namespace Shortcuts constexpr const char* show_exocomp = "X"; constexpr const char* show_factions = "F"; constexpr const char* show_galaxy = "G"; - constexpr const char* show_galaxy_native = "CTRL-G"; constexpr const char* show_gifts = "/"; constexpr const char* show_inventory = "I"; constexpr const char* show_lookup = "L"; diff --git a/mods/src/patches/gamefunctions.h b/mods/src/patches/gamefunctions.h index c99ebf970..3ad39fd59 100644 --- a/mods/src/patches/gamefunctions.h +++ b/mods/src/patches/gamefunctions.h @@ -40,12 +40,13 @@ enum GameFunction { ShowStationInterior, ShoWStationExterior, ShowGalaxy, - ShowGalaxyNative, + NativeShortcutGalaxy, ShowSystem, ShowChat, ShowChatSide1, ShowChatSide2, ShowEvents, + NativeShortcutEvents, ShowSettings, ZoomPreset1, ZoomPreset2, diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index f2293c6f1..e5e8a6143 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -54,7 +54,8 @@ static bool reset_focus_next_frame = false; static int show_info_pending = 0; -static const MethodInfo* on_galaxy_action = nullptr; +static const MethodInfo* on_events_action = nullptr; +static const MethodInfo* on_galaxy_action = nullptr; struct InputActionCallbackContext { void* state; @@ -432,7 +433,7 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) return GotoSection(SectionID::Shop_MainFactions); } else if (MapKey::IsDown(GameFunction::ShoWStationExterior)) { return GotoSection(SectionID::Starbase_Exterior); - } else if (MapKey::IsDown(GameFunction::ShowGalaxyNative)) { + } else if (MapKey::IsDown(GameFunction::NativeShortcutGalaxy)) { InvokeNativeShortcut(on_galaxy_action, "Galaxy"); return; } else if (MapKey::IsDown(GameFunction::ShowGalaxy)) { @@ -459,6 +460,9 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) return GotoSection(SectionID::FleetCommander_Management); } else if (MapKey::IsDown(GameFunction::ShowAwayTeam)) { return GotoSection(SectionID::Missions_AwayTeamsList); + } else if (MapKey::IsDown(GameFunction::NativeShortcutEvents)) { + InvokeNativeShortcut(on_events_action, "Events"); + return; } else if (MapKey::IsDown(GameFunction::ShowEvents)) { return GotoSection(SectionID::Tournament_Group_Selection); } else if (MapKey::IsDown(GameFunction::ShowExoComp)) { @@ -1070,6 +1074,11 @@ void InstallHotkeyHooks() if (!shortcuts_manager_helper.isValidHelper()) { ErrorMsg::MissingHelper("GameInput", "ShortcutsManager"); } else { + on_events_action = shortcuts_manager_helper.GetMethodInfo("OnEventsAction", 1); + if (on_events_action == nullptr) { + ErrorMsg::MissingMethod("ShortcutsManager", "OnEventsAction"); + } + on_galaxy_action = shortcuts_manager_helper.GetMethodInfo("OnGalaxyAction", 1); if (on_galaxy_action == nullptr) { ErrorMsg::MissingMethod("ShortcutsManager", "OnGalaxyAction"); From f7111e6e0fc78aef79b7548b7c0a014739e958e0 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 22:57:01 -0500 Subject: [PATCH 33/80] fix: preserve native Galaxy shortcut alias --- mods/src/config.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mods/src/config.cc b/mods/src/config.cc index ca3721ecb..654110174 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1226,8 +1226,8 @@ void Config::Load() parse_config_shortcut(config, parsed, "show_chatside1", GameFunction::ShowChatSide1, DCSH::show_chatside1); parse_config_shortcut(config, parsed, "show_chatside2", GameFunction::ShowChatSide2, DCSH::show_chatside2); parse_config_shortcut(config, parsed, "show_galaxy", GameFunction::ShowGalaxy, DCSH::show_galaxy); - parse_config_shortcut(config, parsed, "native_shortcut_galaxy", GameFunction::NativeShortcutGalaxy, - DCSH::native_shortcut_galaxy); + parse_config_shortcut_aliases(config, parsed, "native_shortcut_galaxy", GameFunction::NativeShortcutGalaxy, + DCSH::native_shortcut_galaxy, {"show_galaxy_native"}); parse_config_shortcut(config, parsed, "show_system", GameFunction::ShowSystem, DCSH::show_system); parse_config_shortcut(config, parsed, "zoom_preset1", GameFunction::ZoomPreset1, DCSH::zoom_preset1); parse_config_shortcut(config, parsed, "zoom_preset2", GameFunction::ZoomPreset2, DCSH::zoom_preset2); From 7213776daa2fc2020a893159cd268f56981dc60c Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 23:22:58 -0500 Subject: [PATCH 34/80] feat: add efficient fleet transition notifications --- example_community_patch_settings_da.toml | 6 + example_community_patch_settings_de.toml | 6 + ...munity_patch_settings_en-GB-x-cockney.toml | 6 + ...mmunity_patch_settings_en-x-minionese.toml | 6 + example_community_patch_settings_en.toml | 6 + example_community_patch_settings_es.toml | 6 + example_community_patch_settings_fr.toml | 6 + example_community_patch_settings_nl.toml | 6 + example_community_patch_settings_ru.toml | 6 + example_community_patch_settings_tlh.toml | 6 + mods/src/config.cc | 41 ++ mods/src/config.h | 4 + mods/src/defaultconfig.h | 2 + mods/src/patches/fleet_notification_types.h | 47 ++ mods/src/patches/fleet_watch.cc | 588 ++++++++++++++++++ mods/src/patches/fleet_watch.h | 13 + mods/src/patches/notification_service.cc | 19 + mods/src/patches/notification_service.h | 5 + mods/src/patches/parts/fleet_watch.cc | 74 +++ mods/src/patches/parts/hotkeys.cc | 37 +- mods/src/patches/patches.cc | 2 + mods/src/patches/screen_update_hook.h | 5 + mods/src/prime/CargoHoldData.h | 25 + mods/src/prime/FleetPlayerData.h | 50 +- mods/src/prime/MiningSlot.h | 26 + mods/src/prime/ProgressData.h | 24 + mods/src/prime/ResourceSpec.h | 23 + mods/src/prime/SpecManager.h | 35 ++ 28 files changed, 1061 insertions(+), 19 deletions(-) create mode 100644 mods/src/patches/fleet_notification_types.h create mode 100644 mods/src/patches/fleet_watch.cc create mode 100644 mods/src/patches/fleet_watch.h create mode 100644 mods/src/patches/parts/fleet_watch.cc create mode 100644 mods/src/patches/screen_update_hook.h create mode 100644 mods/src/prime/CargoHoldData.h create mode 100644 mods/src/prime/MiningSlot.h create mode 100644 mods/src/prime/ProgressData.h create mode 100644 mods/src/prime/ResourceSpec.h create mode 100644 mods/src/prime/SpecManager.h diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 98c078e80..a556e58b6 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -235,6 +235,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -638,6 +639,11 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Windows system notifications to send for fleet events (comma separated). +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index e185936c6..c4629c33e 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -235,6 +235,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -638,6 +639,11 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Windows system notifications to send for fleet events (comma separated). +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 4969e2e40..cd792396d 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -235,6 +235,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -638,6 +639,11 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Windows system notifications to send for fleet events (comma separated). +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index e0facf124..b4436c79f 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -235,6 +235,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -638,6 +639,11 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Windows system notifications to send for fleet events (comma separated). +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 9e31340b9..3024f4373 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -235,6 +235,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -638,6 +639,11 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Windows system notifications to send for fleet events (comma separated). +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 5750f6711..c69d7715a 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -235,6 +235,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -638,6 +639,11 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Windows system notifications to send for fleet events (comma separated). +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index c415cc260..e611998b2 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -235,6 +235,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -638,6 +639,11 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Windows system notifications to send for fleet events (comma separated). +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index bf1bdde48..422986135 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -235,6 +235,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -638,6 +639,11 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Windows system notifications to send for fleet events (comma separated). +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 92e084d7b..49d252530 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -235,6 +235,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -638,6 +639,11 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Windows system notifications to send for fleet events (comma separated). +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 3e7be9c88..58d3ecc5c 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -235,6 +235,7 @@ freeresizehooks = true game_version = true giftsbulkclaimhooks = true hotkeyhooks = true +fleetwatchhooks = true loadingscreenhooks = true miscpatches = true objecttracker = true @@ -638,6 +639,11 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Windows system notifications to send for fleet events (comma separated). +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, +# Docked, RepairComplete, MinerOPC, All +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/mods/src/config.cc b/mods/src/config.cc index 654110174..19adc7c07 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -825,6 +825,8 @@ void Config::Load() this->installPanHooks = get_config_or_default(config, parsed, "patches", "panhooks", DCP::panhooks, write_config); this->installHotkeyHooks = get_config_or_default(config, parsed, "patches", "hotkeyhooks", DCP::hotkeyhooks, write_config); + this->installFleetWatchHooks = + get_config_or_default(config, parsed, "patches", "fleetwatchhooks", DCP::fleetwatchhooks, write_config); this->installFreeResizeHooks = get_config_or_default(config, parsed, "patches", "freeresizehooks", DCP::freeresizehooks, write_config); this->installTempCrashFixes = @@ -1185,6 +1187,45 @@ void Config::Load() spdlog::debug("Final notify banner types: {}", notifyString); parsed["ui"].as_table()->insert_or_assign("notify_banner_types", notifyString); + auto notify_fleet_events_str = get_config_or_default(config, parsed, "ui", "notify_fleet_events", + DCU::notify_fleet_events, write_log); + this->notify_fleet_events = 0; + for (const auto& event : StrSplit(notify_fleet_events_str, ',')) { + const std::string trimmed{StripAsciiWhitespace(event)}; + if (trimmed.empty()) { + continue; + } + const auto normalized = AsciiStrToUpper(trimmed); + if (normalized == "ALL") { + this->notify_fleet_events = kAllFleetNotifications; + break; + } + bool matched = false; + for (const auto& entry : kFleetNotificationCatalog) { + if (normalized == AsciiStrToUpper(entry.config_name)) { + this->notify_fleet_events |= fleet_notification_bit(entry.kind); + matched = true; + break; + } + } + if (!matched) { + spdlog::warn("Unknown fleet notification event '{}'; ignoring it", trimmed); + } + } + + std::string fleet_events_string; + for (const auto& entry : kFleetNotificationCatalog) { + if ((this->notify_fleet_events & fleet_notification_bit(entry.kind)) == 0) { + continue; + } + if (!fleet_events_string.empty()) { + fleet_events_string.append(", "); + } + fleet_events_string.append(entry.config_name); + } + spdlog::debug("Final fleet notification events: {}", fleet_events_string); + parsed["ui"].as_table()->insert_or_assign("notify_fleet_events", fleet_events_string); + spdlog::debug(""); parse_config_shortcut(config, parsed, "move_left", GameFunction::MoveLeft, DCSH::move_left); diff --git a/mods/src/config.h b/mods/src/config.h index 43f5c3913..b32e32419 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -1,5 +1,7 @@ #pragma once +#include "patches/fleet_notification_types.h" + #include #include #include @@ -182,6 +184,7 @@ class Config final bool borderless_fullscreen; std::vector disabled_banner_types; std::vector notify_banner_types; + FleetNotificationMask notify_fleet_events = 0; int extend_chest_purchase_max; int extend_donation_max; @@ -240,6 +243,7 @@ class Config final bool installToastBannerHooks; bool installPanHooks; bool installHotkeyHooks; + bool installFleetWatchHooks; bool installFreeResizeHooks; bool installTempCrashFixes; bool installTestPatches; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index edd136053..11a1a2a27 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -84,6 +84,7 @@ namespace Patches constexpr bool giftsbulkclaimhooks = true; constexpr bool dailyfactionbulkclaimhooks = true; constexpr bool focussearch = true; + constexpr bool fleetwatchhooks = true; constexpr bool cargoformathooks = true; // on by default: cargo number precision override constexpr bool officersorthooks = true; // restore Below Deck Ability sort option constexpr bool pinnedshiphooks = true; // pin configured ships to front of fleet dock sort @@ -246,6 +247,7 @@ namespace UI constexpr const char* instant_warp_always_ask = ""; constexpr const char* pinned_ships = ""; constexpr const char* notify_banner_types = ""; + constexpr const char* notify_fleet_events = ""; constexpr auto extend_chest_purchase_max = 160; constexpr auto extend_donation_max = 80; constexpr bool extend_donation_slider = true; diff --git a/mods/src/patches/fleet_notification_types.h b/mods/src/patches/fleet_notification_types.h new file mode 100644 index 000000000..a95450a5e --- /dev/null +++ b/mods/src/patches/fleet_notification_types.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include + +enum class FleetNotificationKind : uint8_t { + ArrivedInSystem = 0, + ArrivedAtDestination, + StartedMining, + NodeDepleted, + Docked, + RepairComplete, + MinerOpc, + Count, +}; + +using FleetNotificationMask = uint32_t; + +struct FleetNotificationCatalogEntry { + FleetNotificationKind kind; + std::string_view config_name; +}; + +inline constexpr std::array kFleetNotificationCatalog{ + FleetNotificationCatalogEntry{FleetNotificationKind::ArrivedInSystem, "ArrivedInSystem"}, + FleetNotificationCatalogEntry{FleetNotificationKind::ArrivedAtDestination, "ArrivedAtDestination"}, + FleetNotificationCatalogEntry{FleetNotificationKind::StartedMining, "StartedMining"}, + FleetNotificationCatalogEntry{FleetNotificationKind::NodeDepleted, "NodeDepleted"}, + FleetNotificationCatalogEntry{FleetNotificationKind::Docked, "Docked"}, + FleetNotificationCatalogEntry{FleetNotificationKind::RepairComplete, "RepairComplete"}, + FleetNotificationCatalogEntry{FleetNotificationKind::MinerOpc, "MinerOPC"}, +}; + +static_assert(kFleetNotificationCatalog.size() == static_cast(FleetNotificationKind::Count)); + +constexpr FleetNotificationMask fleet_notification_bit(FleetNotificationKind kind) +{ return FleetNotificationMask{1} << static_cast(kind); } + +constexpr FleetNotificationMask kAllFleetNotifications = [] { + FleetNotificationMask result = 0; + for (const auto& entry : kFleetNotificationCatalog) { + result |= fleet_notification_bit(entry.kind); + } + return result; +}(); diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc new file mode 100644 index 000000000..60f825cf0 --- /dev/null +++ b/mods/src/patches/fleet_watch.cc @@ -0,0 +1,588 @@ +#include "patches/fleet_watch.h" + +#include "config.h" +#include "errormsg.h" +#include "patches/fleet_notification_types.h" +#include "patches/notification_service.h" +#include "str_utils.h" + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int kFleetSlotCount = 10; +constexpr int64_t kFollowThroughIntervalMs = 250; +constexpr int64_t kFollowThroughBackoffMs = 5'000; +constexpr int64_t kFollowThroughFastPeriodMs = 30'000; +constexpr int64_t kFollowThroughLifetimeMs = 24LL * 60 * 60 * 1'000; +constexpr int64_t kMiningWatchIntervalMs = 5'000; +constexpr int64_t kSeedFastIntervalMs = 1'000; +constexpr int64_t kSeedBackoffIntervalMs = 5'000; +constexpr int64_t kSeedFastPeriodMs = 30'000; +constexpr int64_t kSeedLifetimeMs = 120'000; +constexpr int64_t kDuplicateSuppressionMs = 2'000; + +constexpr std::size_t kFleetNotificationCount = static_cast(FleetNotificationKind::Count); + +struct FleetSnapshot { + uint64_t fleet_id = 0; + int64_t resource_id = 0; + FleetState state = FleetState::Unknown; + bool occupied = false; + bool opc_known = false; + bool opc = false; + int64_t follow_through_started_ms = 0; +}; + +using TransitionPredicate = bool (*)(FleetState, FleetState); + +struct FleetTransitionRule { + FleetNotificationKind kind; + TransitionPredicate matches; +}; + +std::array s_slots{}; +std::array, kFleetSlotCount> s_last_emitted_ms{}; +FleetNotificationMask s_enabled_notifications = 0; +bool s_state_observation_enabled = false; +bool s_transition_follow_enabled = false; +bool s_opc_entry_follow_enabled = false; +bool s_seed_pending = false; +int64_t s_last_follow_through_scan_ms = 0; +int64_t s_last_mining_scan_ms = 0; +int64_t s_last_seed_attempt_ms = 0; +int64_t s_seed_started_ms = 0; +int s_mining_watch_count = 0; +int s_follow_through_watch_count = 0; + +int64_t now_milliseconds() +{ + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +bool notification_enabled(FleetNotificationKind kind) +{ return (s_enabled_notifications & fleet_notification_bit(kind)) != 0; } + +bool allow_notification(int slot_index, FleetNotificationKind kind) +{ + if (slot_index < 0 || slot_index >= kFleetSlotCount) { + return true; + } + + const auto now = now_milliseconds(); + auto& last = s_last_emitted_ms[slot_index][static_cast(kind)]; + if (last != 0 && now - last < kDuplicateSuppressionMs) { + return false; + } + last = now; + return true; +} + +constexpr bool state_is_follow_through(FleetState state) +{ + switch (state) { + case FleetState::TieringUp: + case FleetState::Repairing: + case FleetState::Battling: + case FleetState::WarpCharging: + case FleetState::Warping: + case FleetState::Impulsing: + case FleetState::Capturing: + return true; + default: + return false; + } +} + +constexpr bool state_is_opc_entry_follow_through(FleetState state) +{ return state == FleetState::WarpCharging || state == FleetState::Warping || state == FleetState::Impulsing; } + +bool state_requires_enabled_follow_through(FleetState state) +{ + return (s_transition_follow_enabled && state_is_follow_through(state)) + || (s_opc_entry_follow_enabled && state_is_opc_entry_follow_through(state)); +} + +constexpr bool state_is_destination(FleetState state) +{ + switch (state) { + case FleetState::IdleInSpace: + case FleetState::CanRecall: + case FleetState::CanEngage: + case FleetState::CanLocate: + case FleetState::Deployed: + case FleetState::Capturing: + return true; + default: + return false; + } +} + +constexpr bool state_can_dock_from_space(FleetState state) +{ + switch (state) { + case FleetState::IdleInSpace: + case FleetState::Mining: + case FleetState::Battling: + case FleetState::WarpCharging: + case FleetState::Warping: + case FleetState::Impulsing: + case FleetState::Capturing: + case FleetState::CanRecall: + case FleetState::CanEngage: + case FleetState::Deployed: + case FleetState::CanLocate: + return true; + default: + return false; + } +} + +constexpr bool arrived_in_system(FleetState old_state, FleetState new_state) +{ return old_state == FleetState::Warping && new_state == FleetState::Impulsing; } + +constexpr bool arrived_at_destination(FleetState old_state, FleetState new_state) +{ return old_state == FleetState::Impulsing && state_is_destination(new_state); } + +constexpr bool started_mining(FleetState old_state, FleetState new_state) +{ return old_state != FleetState::Mining && new_state == FleetState::Mining; } + +constexpr bool docked(FleetState old_state, FleetState new_state) +{ + return old_state != FleetState::Repairing && new_state == FleetState::Docked && state_can_dock_from_space(old_state); +} + +constexpr bool repair_complete(FleetState old_state, FleetState new_state) +{ return old_state == FleetState::Repairing && new_state == FleetState::Docked; } + +constexpr std::array kTransitionRules{ + FleetTransitionRule{FleetNotificationKind::ArrivedInSystem, arrived_in_system}, + FleetTransitionRule{FleetNotificationKind::ArrivedAtDestination, arrived_at_destination}, + FleetTransitionRule{FleetNotificationKind::StartedMining, started_mining}, + FleetTransitionRule{FleetNotificationKind::Docked, docked}, + FleetTransitionRule{FleetNotificationKind::RepairComplete, repair_complete}, +}; + +static_assert(arrived_in_system(FleetState::Warping, FleetState::Impulsing)); +static_assert(arrived_at_destination(FleetState::Impulsing, FleetState::IdleInSpace)); +static_assert(started_mining(FleetState::IdleInSpace, FleetState::Mining)); +static_assert(docked(FleetState::Impulsing, FleetState::Docked)); +static_assert(!docked(FleetState::Repairing, FleetState::Docked)); +static_assert(repair_complete(FleetState::Repairing, FleetState::Docked)); + +std::string normalize_name(std::string name) +{ + constexpr std::string_view live_suffix = "_LIVE"; + if (name.ends_with(live_suffix)) { + name.erase(name.size() - live_suffix.size()); + } + + for (auto& character : name) { + if (character == '_') { + character = ' '; + } + } + return name; +} + +std::string ship_name(FleetPlayerData* fleet) +{ + auto* hull = fleet ? fleet->Hull : nullptr; + return hull && hull->Name ? normalize_name(to_string(hull->Name)) : std::string{}; +} + +std::string resource_name(int64_t resource_id) +{ + if (resource_id == 0) { + return {}; + } + + auto* manager = SpecManager::Instance(); + auto* spec = manager ? manager->GetResourceSpec(resource_id) : nullptr; + auto name = spec && spec->Name ? normalize_name(to_string(spec->Name)) : std::string{}; + constexpr std::string_view resource_prefix = "Resource "; + if (name.starts_with(resource_prefix)) { + name.erase(0, resource_prefix.size()); + } + return name; +} + +std::string notification_subject(FleetPlayerData* fleet) +{ + auto name = ship_name(fleet); + return name.empty() ? "fleet" : name; +} + +std::string cargo_text(FleetPlayerData* fleet) +{ + const auto fill = fleet ? fleet->CargoResourceFillLevel : -1.0f; + if (!std::isfinite(fill) || fill < 0.0f) { + return {}; + } + const auto percent = static_cast(std::lround(std::clamp(fill, 0.0f, 1.0f) * 100.0f)); + return " Current cargo: " + std::to_string(percent) + "%."; +} + +FleetPlayerData* fleet_for_slot(int slot_index) +{ + auto* manager = FleetsManager::Instance(); + return manager && slot_index >= 0 && slot_index < kFleetSlotCount ? manager->GetFleetPlayerData(slot_index) : nullptr; +} + +FleetPlayerData* fleet_for_snapshot(int slot_index, uint64_t fleet_id) +{ + auto* fleet = fleet_for_slot(slot_index); + return fleet && fleet->Id == fleet_id ? fleet : nullptr; +} + +void emit_transition(FleetNotificationKind kind, FleetPlayerData* fleet, int64_t resource_id) +{ + const auto subject = notification_subject(fleet); + switch (kind) { + case FleetNotificationKind::ArrivedInSystem: + notification_emit("Fleet Arrived", "Your " + subject + " has arrived in-system"); + break; + case FleetNotificationKind::ArrivedAtDestination: + notification_emit("Fleet Arrived", "Your " + subject + " has reached its destination"); + break; + case FleetNotificationKind::StartedMining: { + const auto resource = resource_name(resource_id); + const auto detail = resource.empty() ? std::string{} : " " + resource; + notification_emit("Fleet Mining", "Your " + subject + " started mining" + detail); + break; + } + case FleetNotificationKind::Docked: + notification_emit("Fleet Docked", "Your " + subject + " docked"); + break; + case FleetNotificationKind::RepairComplete: + notification_emit("Repair Complete", "Your " + subject + " finished repairs"); + break; + default: + break; + } +} + +bool read_opc(FleetPlayerData* fleet, bool& known) +{ + known = false; + auto* cargo_hold = fleet ? fleet->CargoHoldData : nullptr; + auto* unprotected = cargo_hold ? cargo_hold->UnprotectedCargoProgress : nullptr; + if (!unprotected) { + return false; + } + + known = true; + return unprotected->CurrentValue > 0.0; +} + +int resolve_slot(FleetPlayerData* fleet, int requested_slot) +{ + if (!fleet) { + return -1; + } + if (requested_slot >= 0 && requested_slot < kFleetSlotCount) { + return requested_slot; + } + + for (int index = 0; index < kFleetSlotCount; ++index) { + if (s_slots[index].occupied && s_slots[index].fleet_id == fleet->Id) { + return index; + } + } + + const auto model_slot = fleet->Index; + if (model_slot >= 0 && model_slot < kFleetSlotCount) { + return model_slot; + } + return -1; +} + +void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifications, bool sample_opc, + std::string_view source) +{ + const auto slot_index = resolve_slot(fleet, requested_slot); + if (!fleet || slot_index < 0) { + return; + } + + auto& previous = s_slots[slot_index]; + const auto fleet_id = fleet->Id; + const auto current = fleet->CurrentState; + const bool same_fleet = previous.occupied && previous.fleet_id == fleet_id; + const bool resource_context_enabled = notification_enabled(FleetNotificationKind::StartedMining) + || notification_enabled(FleetNotificationKind::NodeDepleted); + auto* mining_data = resource_context_enabled && current == FleetState::Mining ? fleet->MiningData : nullptr; + const auto resource_id = mining_data ? mining_data->ResourceId : (same_fleet ? previous.resource_id : 0); + const bool miner_opc_enabled = notification_enabled(FleetNotificationKind::MinerOpc); + bool opc_known = same_fleet && previous.state == FleetState::Mining ? previous.opc_known : false; + bool opc = same_fleet && previous.state == FleetState::Mining ? previous.opc : false; + if (miner_opc_enabled && current == FleetState::Mining + && (sample_opc || !same_fleet || previous.state != FleetState::Mining)) { + opc = read_opc(fleet, opc_known); + } else if (current != FleetState::Mining) { + opc_known = false; + opc = false; + } + + const bool previously_watched = + miner_opc_enabled && previous.occupied && previous.state == FleetState::Mining && !previous.opc; + const bool previously_following = previous.occupied && previous.follow_through_started_ms != 0; + if (!same_fleet) { + s_last_emitted_ms[slot_index] = {}; + } + if (same_fleet && allow_notifications && previous.state != current) { + for (const auto& rule : kTransitionRules) { + if (notification_enabled(rule.kind) && rule.matches(previous.state, current)) { + if (!allow_notification(slot_index, rule.kind)) { + spdlog::debug("[FleetWatch] source={} suppressed duplicate event={} slot={} fleet={}", source, + kFleetNotificationCatalog[static_cast(rule.kind)].config_name, slot_index, + fleet_id); + continue; + } + spdlog::debug("[FleetWatch] source={} event={} slot={} fleet={} oldState={} newState={}", source, + kFleetNotificationCatalog[static_cast(rule.kind)].config_name, slot_index, fleet_id, + static_cast(previous.state), static_cast(current)); + emit_transition(rule.kind, fleet, resource_id); + } + } + } + + if (same_fleet && allow_notifications && miner_opc_enabled && previous.state == FleetState::Mining + && current == FleetState::Mining && opc_known && !previous.opc && opc) { + if (!allow_notification(slot_index, FleetNotificationKind::MinerOpc)) { + spdlog::debug("[FleetWatch] source={} suppressed duplicate event=MinerOPC slot={} fleet={}", source, slot_index, + fleet_id); + } else { + const auto subject = notification_subject(fleet); + spdlog::debug("[FleetWatch] source={} event=MinerOPC slot={} fleet={}", source, slot_index, fleet_id); + notification_emit("Miner Is OPC", "Your " + subject + " is now over protected cargo." + cargo_text(fleet)); + } + } + + int64_t follow_through_started_ms = 0; + if (state_requires_enabled_follow_through(current)) { + if (same_fleet && state_requires_enabled_follow_through(previous.state)) { + follow_through_started_ms = previous.follow_through_started_ms; + } else { + follow_through_started_ms = now_milliseconds(); + s_last_follow_through_scan_ms = 0; + spdlog::debug("[FleetWatch] follow-through scan armed source={} slot={} fleet={}", source, slot_index, fleet_id); + } + } + + previous = FleetSnapshot{fleet_id, resource_id, current, true, opc_known, opc, follow_through_started_ms}; + const bool currently_watched = miner_opc_enabled && current == FleetState::Mining && !opc; + if (previously_watched != currently_watched) { + if (currently_watched && s_mining_watch_count == 0) { + s_last_mining_scan_ms = now_milliseconds(); + } + s_mining_watch_count += currently_watched ? 1 : -1; + } + const bool currently_following = follow_through_started_ms != 0; + if (previously_following != currently_following) { + s_follow_through_watch_count += currently_following ? 1 : -1; + } +} + +struct ScanResult { + int observed_count = 0; +}; + +ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_view source) +{ + ScanResult result; + auto* manager = FleetsManager::Instance(); + if (!manager) { + return result; + } + + for (int index = 0; index < kFleetSlotCount; ++index) { + auto* fleet = manager->GetFleetPlayerData(index); + if (!fleet) { + if (notification_enabled(FleetNotificationKind::MinerOpc) && s_slots[index].occupied + && s_slots[index].state == FleetState::Mining && !s_slots[index].opc) { + --s_mining_watch_count; + } + if (s_slots[index].occupied && s_slots[index].follow_through_started_ms != 0) { + --s_follow_through_watch_count; + } + s_slots[index] = {}; + s_last_emitted_ms[index] = {}; + continue; + } + + observe_fleet(fleet, index, allow_notifications, sample_opc, source); + ++result.observed_count; + } + return result; +} +} // namespace + +bool fleet_watch_uses_state_observation() +{ return s_state_observation_enabled; } + +bool fleet_watch_uses_node_depleted_hook() +{ return notification_enabled(FleetNotificationKind::NodeDepleted); } + +void fleet_watch_init() +{ + constexpr auto follow_through_events = fleet_notification_bit(FleetNotificationKind::ArrivedInSystem) + | fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination) + | fleet_notification_bit(FleetNotificationKind::StartedMining) + | fleet_notification_bit(FleetNotificationKind::Docked) + | fleet_notification_bit(FleetNotificationKind::RepairComplete); + constexpr auto state_events = follow_through_events | fleet_notification_bit(FleetNotificationKind::MinerOpc); + s_enabled_notifications = Config::Get().notify_fleet_events; + s_state_observation_enabled = (s_enabled_notifications & state_events) != 0; + s_transition_follow_enabled = (s_enabled_notifications & follow_through_events) != 0; + s_opc_entry_follow_enabled = notification_enabled(FleetNotificationKind::MinerOpc); + s_slots = {}; + s_last_emitted_ms = {}; + s_seed_pending = fleet_watch_uses_state_observation(); + s_last_follow_through_scan_ms = 0; + s_last_mining_scan_ms = 0; + s_last_seed_attempt_ms = 0; + s_seed_started_ms = 0; + s_mining_watch_count = 0; + s_follow_through_watch_count = 0; +} + +void fleet_watch_observe_widget(FleetPlayerData* fleet) +{ + if (!fleet || !fleet_watch_uses_state_observation()) { + return; + } + + observe_fleet(fleet, -1, true, false, "fleet-state-widget"); +} + +void fleet_watch_observe_node_depleted(int64_t fleet_id) +{ + if (!fleet_watch_uses_node_depleted_hook()) { + return; + } + + for (int index = 0; index < kFleetSlotCount; ++index) { + const auto& snapshot = s_slots[index]; + if (!snapshot.occupied || snapshot.fleet_id != static_cast(fleet_id)) { + continue; + } + + auto* fleet = fleet_for_snapshot(index, snapshot.fleet_id); + const auto subject = notification_subject(fleet); + const auto resource = resource_name(snapshot.resource_id); + const auto detail = resource.empty() ? std::string{" its node"} : " its " + resource + " node"; + if (allow_notification(index, FleetNotificationKind::NodeDepleted)) { + notification_emit("Node Depleted", subject + " depleted" + detail + "." + cargo_text(fleet)); + } + return; + } + + for (int index = 0; index < kFleetSlotCount; ++index) { + auto* fleet = fleet_for_slot(index); + if (!fleet || fleet->Id != static_cast(fleet_id)) { + continue; + } + + auto* mining_data = fleet->MiningData; + const auto resource = resource_name(mining_data ? mining_data->ResourceId : 0); + const auto detail = resource.empty() ? std::string{" its node"} : " its " + resource + " node"; + if (allow_notification(index, FleetNotificationKind::NodeDepleted)) { + notification_emit("Node Depleted", notification_subject(fleet) + " depleted" + detail + "." + cargo_text(fleet)); + } + return; + } + + notification_emit("Node Depleted", "A mining fleet depleted its node."); +} + +void fleet_watch_tick() +{ + if (!fleet_watch_uses_state_observation()) { + return; + } + if (!s_seed_pending && s_follow_through_watch_count == 0 && s_mining_watch_count == 0) { + return; + } + + const auto now_ms = now_milliseconds(); + if (s_seed_pending) { + if (s_seed_started_ms == 0) { + s_seed_started_ms = now_ms; + } + const auto seed_lifetime = now_ms - s_seed_started_ms; + if (seed_lifetime >= kSeedLifetimeMs) { + s_seed_pending = false; + spdlog::warn("[FleetWatch] initial seed stopped after {}ms without observing a fleet", kSeedLifetimeMs); + } else { + const auto seed_interval = seed_lifetime < kSeedFastPeriodMs ? kSeedFastIntervalMs : kSeedBackoffIntervalMs; + if (s_last_seed_attempt_ms == 0 || now_ms - s_last_seed_attempt_ms >= seed_interval) { + s_last_seed_attempt_ms = now_ms; + const auto seed = scan_fleets(false, true, "initial-seed"); + if (seed.observed_count > 0) { + s_seed_pending = false; + spdlog::debug("[FleetWatch] seeded {} fleet slots", seed.observed_count); + if (s_follow_through_watch_count > 0) { + s_last_follow_through_scan_ms = now_ms; + } + if (s_mining_watch_count > 0) { + s_last_mining_scan_ms = now_ms; + } + } + } + } + } + + bool follow_through_due = false; + bool follow_through_fast = false; + for (int index = 0; index < kFleetSlotCount; ++index) { + auto& slot = s_slots[index]; + if (!slot.occupied || slot.follow_through_started_ms == 0) { + continue; + } + const auto lifetime = now_ms - slot.follow_through_started_ms; + if (lifetime >= kFollowThroughLifetimeMs) { + slot.follow_through_started_ms = 0; + --s_follow_through_watch_count; + spdlog::warn("[FleetWatch] follow-through scan expired slot={} fleet={} after 24 hours", index, slot.fleet_id); + } else if (lifetime < kFollowThroughFastPeriodMs) { + follow_through_fast = true; + } + } + if (s_follow_through_watch_count > 0) { + const auto interval = follow_through_fast ? kFollowThroughIntervalMs : kFollowThroughBackoffMs; + follow_through_due = s_last_follow_through_scan_ms == 0 || now_ms - s_last_follow_through_scan_ms >= interval; + } + + const bool mining_due = s_mining_watch_count > 0 + && (s_last_mining_scan_ms == 0 || now_ms - s_last_mining_scan_ms >= kMiningWatchIntervalMs); + if (!follow_through_due && !mining_due) { + return; + } + + const bool had_follow_through = s_follow_through_watch_count > 0; + scan_fleets(true, mining_due, follow_through_due ? "follow-through" : "mining-watch"); + if (follow_through_due) { + s_last_follow_through_scan_ms = now_ms; + if (had_follow_through && s_follow_through_watch_count == 0) { + spdlog::debug("[FleetWatch] follow-through scan settled"); + } + } + if (mining_due) { + s_last_mining_scan_ms = now_ms; + } +} diff --git a/mods/src/patches/fleet_watch.h b/mods/src/patches/fleet_watch.h new file mode 100644 index 000000000..932f0ac27 --- /dev/null +++ b/mods/src/patches/fleet_watch.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +struct FleetPlayerData; + +void fleet_watch_init(); +void fleet_watch_tick(); +void fleet_watch_observe_widget(FleetPlayerData* fleet); +void fleet_watch_observe_node_depleted(int64_t fleet_id); + +bool fleet_watch_uses_state_observation(); +bool fleet_watch_uses_node_depleted_hook(); diff --git a/mods/src/patches/notification_service.cc b/mods/src/patches/notification_service.cc index 46109b8c4..53717ca1f 100644 --- a/mods/src/patches/notification_service.cc +++ b/mods/src/patches/notification_service.cc @@ -11,6 +11,7 @@ #include #include +#include #if _WIN32 #include @@ -24,6 +25,7 @@ static const MethodInfo* s_localize_ltc = nullptr; // LanguageManager.Localize(out string, LocaleTextContext) — instance static const MethodInfo* s_locale_utils_localize = nullptr; // LocaleUtilities.Localize(LocaleTextContext, bool, bool) — static static const MethodInfo* s_object_tostring = nullptr; +static bool s_initialized = false; // --------------------------------------------------------------------------- // Toast state → human-readable title @@ -417,6 +419,11 @@ static std::string strip_unity_rich_text(const std::string& s) void notification_init() { + if (s_initialized) { + return; + } + s_initialized = true; + // Resolve LanguageManager::Localize(out string, LocaleTextContext) — the // 2-parameter overload that takes an LTC and returns a localized string. auto lm_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.Localization", "LanguageManager"); @@ -482,6 +489,18 @@ void notification_init() #endif } +void notification_emit(std::string_view title, std::string_view body) +{ +#if _WIN32 + const std::string owned_title{title}; + const std::string owned_body{body}; + show_system_notification(owned_title.c_str(), owned_body.c_str()); +#else + (void)title; + (void)body; +#endif +} + void notification_handle_toast(Toast* toast) { #if !_WIN32 diff --git a/mods/src/patches/notification_service.h b/mods/src/patches/notification_service.h index 2ebad087a..df37b87b3 100644 --- a/mods/src/patches/notification_service.h +++ b/mods/src/patches/notification_service.h @@ -1,10 +1,15 @@ #pragma once +#include + struct Toast; // Initialize the notification service (resolve IL2CPP methods, init platform). // Call once during InstallToastBannerHooks(). void notification_init(); +// Deliver a notification created by a mod feature rather than a Scopely toast. +void notification_emit(std::string_view title, std::string_view body); + // Called from toast hooks — checks config, formats, and delivers notification. void notification_handle_toast(Toast* toast); diff --git a/mods/src/patches/parts/fleet_watch.cc b/mods/src/patches/parts/fleet_watch.cc new file mode 100644 index 000000000..f02b2967f --- /dev/null +++ b/mods/src/patches/parts/fleet_watch.cc @@ -0,0 +1,74 @@ +#include "patches/fleet_watch.h" + +#include "errormsg.h" +#include "patches/notification_service.h" +#include "patches/screen_update_hook.h" + +#include +#include + +#include + +#include + +namespace +{ +FleetPlayerData* fleet_widget_context(void* self) +{ + if (!self) { + return nullptr; + } + + auto helper = IL2CppClassHelper{reinterpret_cast(self)->klass}; + auto get_context = helper.GetMethod("get_Context", 0); + return get_context ? get_context(self) : nullptr; +} + +void FleetStateWidget_SetWidgetData_Hook(auto original, void* self) +{ + fleet_watch_observe_widget(fleet_widget_context(self)); + original(self); +} + +void ToastFleetObserver_HandleMiningDepleted_Hook(auto original, void* self, int64_t fleet_id) +{ + original(self, fleet_id); + fleet_watch_observe_node_depleted(fleet_id); +} +} // namespace + +void InstallFleetWatchHooks() +{ +#if !_WIN32 + return; +#endif + + fleet_watch_init(); + if (!fleet_watch_uses_state_observation() && !fleet_watch_uses_node_depleted_hook()) { + return; + } + notification_init(); + + if (fleet_watch_uses_state_observation()) { + install_screen_manager_update_hook(); + auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.HUD", "FleetStateWidget"); + if (!helper.isValidHelper()) { + ErrorMsg::MissingHelper("HUD", "FleetStateWidget"); + } else if (auto method = helper.GetMethod("SetWidgetData"); method) { + SPUD_STATIC_DETOUR(method, FleetStateWidget_SetWidgetData_Hook); + } else { + ErrorMsg::MissingMethod("FleetStateWidget", "SetWidgetData"); + } + } + + if (fleet_watch_uses_node_depleted_hook()) { + auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.HUD", "ToastFleetObserver"); + if (!helper.isValidHelper()) { + ErrorMsg::MissingHelper("HUD", "ToastFleetObserver"); + } else if (auto method = helper.GetMethod("HandleMiningDepleted", 1); method) { + SPUD_STATIC_DETOUR(method, ToastFleetObserver_HandleMiningDepleted_Hook); + } else { + ErrorMsg::MissingMethod("ToastFleetObserver", "HandleMiningDepleted"); + } + } +} diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index e5e8a6143..1408e6d98 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -37,7 +37,9 @@ #include "prime/ShortcutsManager.h" #include "patches/key.h" +#include "patches/fleet_watch.h" #include "patches/mapkey.h" +#include "patches/screen_update_hook.h" #include "patches/parts/daily_faction_bulk_claim.h" #include "patches/parts/focus_search.h" @@ -222,6 +224,11 @@ bool MoveShipSelectionInDock(bool goLeft) void ScreenManager_Update_Hook(auto original, ScreenManager* _this) { + fleet_watch_tick(); + if (!Config::Get().installHotkeyHooks) { + return original(_this); + } + // This function is called every frame to update the screen manager. // Create a global clock to detect time elapsed static std::chrono::time_point select_clock = std::chrono::steady_clock::now(); @@ -1067,6 +1074,24 @@ void ShowWithFleet_Hook(auto original, PreScanTargetWidget* _this, void* a1) } } +void install_screen_manager_update_hook() +{ + static bool installed = false; + if (installed) { + return; + } + + auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "ScreenManager"); + if (!helper.isValidHelper()) { + ErrorMsg::MissingHelper("UI", "ScreenManager"); + } else if (auto update = helper.GetMethod("Update"); update) { + SPUD_STATIC_DETOUR(update, ScreenManager_Update_Hook); + installed = true; + } else { + ErrorMsg::MissingMethod("ScreenManager", "Update"); + } +} + void InstallHotkeyHooks() { auto shortcuts_manager_helper = @@ -1092,17 +1117,7 @@ void InstallHotkeyHooks() } } - auto screen_manager_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "ScreenManager"); - if (!screen_manager_helper.isValidHelper()) { - ErrorMsg::MissingHelper("UI", "ScreenManager"); - } else { - auto ptr_update = screen_manager_helper.GetMethod("Update"); - if (ptr_update == nullptr) { - ErrorMsg::MissingMethod("ScreenManager", "Update"); - } else { - SPUD_STATIC_DETOUR(ptr_update, ScreenManager_Update_Hook); - } - } + install_screen_manager_update_hook(); static auto rewards_button_widget = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Combat", "RewardsButtonWidget"); diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 9579b9692..f516b8764 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -27,6 +27,7 @@ void InstallFreeResizeHooks(); void InstallToastBannerHooks(); void InstallPanHooks(); void InstallHotkeyHooks(); +void InstallFleetWatchHooks(); void InstallGiftsBulkClaimHooks(); void InstallDailyFactionBulkClaimHooks(); @@ -127,6 +128,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"ToastBannerHooks", {InstallToastBannerHooks, &cfg.installToastBannerHooks}}, {"PanHooks", {InstallPanHooks, &cfg.installPanHooks}}, {"HotkeyHooks", {InstallHotkeyHooks, &cfg.installHotkeyHooks}}, + {"FleetWatchHooks", {InstallFleetWatchHooks, &cfg.installFleetWatchHooks}}, {"GiftsBulkClaimHooks", {InstallGiftsBulkClaimHooks, &cfg.installGiftsBulkClaimHooks}}, {"DailyFactionBulkClaimHooks", {InstallDailyFactionBulkClaimHooks, &cfg.installDailyFactionBulkClaimHooks}}, diff --git a/mods/src/patches/screen_update_hook.h b/mods/src/patches/screen_update_hook.h new file mode 100644 index 000000000..6fe3ebe46 --- /dev/null +++ b/mods/src/patches/screen_update_hook.h @@ -0,0 +1,5 @@ +#pragma once + +// ScreenManager.Update has one detour owner shared by HotkeyHooks and FleetWatchHooks. +// Installation is idempotent so either patch can request the dispatcher. +void install_screen_manager_update_hook(); diff --git a/mods/src/prime/CargoHoldData.h b/mods/src/prime/CargoHoldData.h new file mode 100644 index 000000000..a43486d1d --- /dev/null +++ b/mods/src/prime/CargoHoldData.h @@ -0,0 +1,25 @@ +#pragma once + +#include "ProgressData.h" + +#include + +struct CargoHoldData { +public: + __declspec(property(get = __get_UnprotectedCargoProgress)) ProgressData* UnprotectedCargoProgress; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "CargoHoldData"); + return class_helper; + } + +public: + ProgressData* __get_UnprotectedCargoProgress() + { + static auto property = get_class_helper().GetProperty("UnprotectedCargoProgress"); + return property.GetRaw(this); + } +}; diff --git a/mods/src/prime/FleetPlayerData.h b/mods/src/prime/FleetPlayerData.h index 05c5c0fa0..ba031eed4 100644 --- a/mods/src/prime/FleetPlayerData.h +++ b/mods/src/prime/FleetPlayerData.h @@ -1,7 +1,9 @@ #pragma once #include "BattleTargetData.h" +#include "CargoHoldData.h" #include "HullSpec.h" +#include "MiningSlot.h" #include "RecallRequirement.h" #include "CanRepairRequirement.h" @@ -15,19 +17,25 @@ enum class FleetState { Destroyed = 8, TieringUp = 16, Repairing = 32, - CannotLaunch = 56, + CanReplaceOfficers = 18, Battling = 64, WarpCharging = 128, Warping = 256, - CanRemove = 384, - CannotMove = 504, Impulsing = 512, - CanManage = 899, Capturing = 1024, - CanRecall = 1541, - CanEngage = 1543, - Deployed = 1989, - CanLocate = 1991 + AutoHunting = 2048, + Outposting = 4096, + CannotLaunch = 56, + CannotMove = 2552, + CanRecall = 5637, + CanRemove = 384, + CanManage = 2947, + CanLocate = 8135, + Deployed = 8133, + CanEngage = 3591, + CanActivateAbility = 513, + CanBeTargetedByAbility = 3589, + CanDisco = 515 }; struct FleetPlayerData { @@ -36,6 +44,10 @@ struct FleetPlayerData { __declspec(property(get = __get_PreviousState)) FleetState PreviousState; __declspec(property(get = __get_Id)) uint64_t Id; __declspec(property(get = __get_Hull)) HullSpec* Hull; + __declspec(property(get = __get_Index)) int Index; + __declspec(property(get = __get_MiningData)) MiningSlot* MiningData; + __declspec(property(get = __get_CargoHoldData)) ::CargoHoldData* CargoHoldData; + __declspec(property(get = __get_CargoResourceFillLevel)) float CargoResourceFillLevel; __declspec(property(get = __get_Address)) void* Address; __declspec(property(get = __get_Level)) int64_t Level; __declspec(property(get = __get_HasShip)) bool HasShip; @@ -54,6 +66,28 @@ struct FleetPlayerData { static auto field = get_class_helper().GetProperty("Hull"); return field.GetRaw(this); } + int __get_Index() + { + static auto property = get_class_helper().GetProperty("Index"); + auto* value = property.Get(this); + return value ? *value : -1; + } + MiningSlot* __get_MiningData() + { + static auto property = get_class_helper().GetProperty("MiningData"); + return property.GetRaw(this); + } + ::CargoHoldData* __get_CargoHoldData() + { + static auto property = get_class_helper().GetProperty("CargoHoldData"); + return property.GetRaw(this); + } + float __get_CargoResourceFillLevel() + { + static auto property = get_class_helper().GetProperty("CargoResourceFillLevel"); + auto* value = property.Get(this); + return value ? *value : -1.0f; + } void* __get_Address() { static auto field = get_class_helper().GetProperty("Address"); diff --git a/mods/src/prime/MiningSlot.h b/mods/src/prime/MiningSlot.h new file mode 100644 index 000000000..ea9df59b6 --- /dev/null +++ b/mods/src/prime/MiningSlot.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include + +struct MiningSlot { +public: + __declspec(property(get = __get_ResourceId)) int64_t ResourceId; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "MiningSlot"); + return class_helper; + } + +public: + int64_t __get_ResourceId() + { + static auto property = get_class_helper().GetProperty("ResourceId"); + auto* value = property.Get(this); + return value ? *value : 0; + } +}; diff --git a/mods/src/prime/ProgressData.h b/mods/src/prime/ProgressData.h new file mode 100644 index 000000000..00c90aee9 --- /dev/null +++ b/mods/src/prime/ProgressData.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +struct ProgressData { +public: + __declspec(property(get = __get_CurrentValue)) double CurrentValue; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "ProgressData"); + return class_helper; + } + +public: + double __get_CurrentValue() + { + static auto property = get_class_helper().GetProperty("CurrentValue"); + auto* value = property.Get(this); + return value ? *value : 0.0; + } +}; diff --git a/mods/src/prime/ResourceSpec.h b/mods/src/prime/ResourceSpec.h new file mode 100644 index 000000000..fd3935914 --- /dev/null +++ b/mods/src/prime/ResourceSpec.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +struct ResourceSpec { +public: + __declspec(property(get = __get_Name)) Il2CppString* Name; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "ResourceSpec"); + return class_helper; + } + +public: + Il2CppString* __get_Name() + { + static auto property = get_class_helper().GetProperty("Name"); + return property.GetRaw(this); + } +}; diff --git a/mods/src/prime/SpecManager.h b/mods/src/prime/SpecManager.h new file mode 100644 index 000000000..1187d385e --- /dev/null +++ b/mods/src/prime/SpecManager.h @@ -0,0 +1,35 @@ +#pragma once + +#include "MonoSingleton.h" +#include "ResourceSpec.h" +#include "errormsg.h" + +#include + +#include + +struct SpecManager : MonoSingleton { + friend struct MonoSingleton; + + ResourceSpec* GetResourceSpec(int64_t id) + { + static auto method = get_class_helper().GetMethod("GetResourceSpec"); + static bool warn = true; + if (method) { + return method(this, id); + } + if (warn) { + warn = false; + ErrorMsg::MissingMethod("SpecManager", "GetResourceSpec"); + } + return nullptr; + } + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation.Managers", "SpecManager"); + return class_helper; + } +}; From 7d7e6e8024405e6ad18626cdf53704b4683e0d95 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 23:35:02 -0500 Subject: [PATCH 35/80] fix: harden fleet notification transitions --- mods/src/patches/fleet_watch.cc | 99 ++++++++++++++++++++++++--------- mods/src/prime/ProgressData.h | 11 +++- 2 files changed, 83 insertions(+), 27 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index 60f825cf0..fc0af956d 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -47,6 +47,12 @@ struct FleetSnapshot { int64_t follow_through_started_ms = 0; }; +struct FleetNotificationStamp { + uint64_t fleet_id = 0; + int64_t last_emitted_ms = 0; + bool occupied = false; +}; + using TransitionPredicate = bool (*)(FleetState, FleetState); struct FleetTransitionRule { @@ -56,9 +62,9 @@ struct FleetTransitionRule { std::array s_slots{}; std::array, kFleetSlotCount> s_last_emitted_ms{}; +std::array s_node_depleted_stamps{}; FleetNotificationMask s_enabled_notifications = 0; bool s_state_observation_enabled = false; -bool s_transition_follow_enabled = false; bool s_opc_entry_follow_enabled = false; bool s_seed_pending = false; int64_t s_last_follow_through_scan_ms = 0; @@ -92,31 +98,32 @@ bool allow_notification(int slot_index, FleetNotificationKind kind) return true; } -constexpr bool state_is_follow_through(FleetState state) +bool any_notification_enabled(FleetNotificationMask mask) +{ return (s_enabled_notifications & mask) != 0; } + +bool state_requires_enabled_follow_through(FleetState state) { + constexpr auto arrival_events = fleet_notification_bit(FleetNotificationKind::ArrivedInSystem) + | fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination); + constexpr auto movement_completion_events = arrival_events + | fleet_notification_bit(FleetNotificationKind::StartedMining) + | fleet_notification_bit(FleetNotificationKind::Docked); + switch (state) { - case FleetState::TieringUp: - case FleetState::Repairing: - case FleetState::Battling: case FleetState::WarpCharging: case FleetState::Warping: case FleetState::Impulsing: + return any_notification_enabled(movement_completion_events) || s_opc_entry_follow_enabled; + case FleetState::Battling: case FleetState::Capturing: - return true; + return notification_enabled(FleetNotificationKind::Docked); + case FleetState::Repairing: + return notification_enabled(FleetNotificationKind::RepairComplete); default: return false; } } -constexpr bool state_is_opc_entry_follow_through(FleetState state) -{ return state == FleetState::WarpCharging || state == FleetState::Warping || state == FleetState::Impulsing; } - -bool state_requires_enabled_follow_through(FleetState state) -{ - return (s_transition_follow_enabled && state_is_follow_through(state)) - || (s_opc_entry_follow_enabled && state_is_opc_entry_follow_through(state)); -} - constexpr bool state_is_destination(FleetState state) { switch (state) { @@ -276,6 +283,13 @@ void emit_transition(FleetNotificationKind kind, FleetPlayerData* fleet, int64_t } } +constexpr bool cargo_is_opc(double current_value, double protected_limit) +{ return current_value > protected_limit; } + +static_assert(!cargo_is_opc(99.0, 100.0)); +static_assert(!cargo_is_opc(100.0, 100.0)); +static_assert(cargo_is_opc(101.0, 100.0)); + bool read_opc(FleetPlayerData* fleet, bool& known) { known = false; @@ -285,8 +299,39 @@ bool read_opc(FleetPlayerData* fleet, bool& known) return false; } + const auto current_value = unprotected->CurrentValue; + const auto protected_limit = unprotected->MinValue; + if (!std::isfinite(current_value) || !std::isfinite(protected_limit)) { + return false; + } + known = true; - return unprotected->CurrentValue > 0.0; + return cargo_is_opc(current_value, protected_limit); +} + +bool allow_node_depleted_notification(uint64_t fleet_id) +{ + const auto now = now_milliseconds(); + FleetNotificationStamp* oldest = &s_node_depleted_stamps.front(); + for (auto& stamp : s_node_depleted_stamps) { + if (stamp.occupied && stamp.fleet_id == fleet_id) { + if (now - stamp.last_emitted_ms < kDuplicateSuppressionMs) { + return false; + } + stamp.last_emitted_ms = now; + return true; + } + if (!stamp.occupied) { + oldest = &stamp; + break; + } + if (stamp.last_emitted_ms < oldest->last_emitted_ms) { + oldest = &stamp; + } + } + + *oldest = FleetNotificationStamp{fleet_id, now, true}; + return true; } int resolve_slot(FleetPlayerData* fleet, int requested_slot) @@ -362,7 +407,7 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifi } if (same_fleet && allow_notifications && miner_opc_enabled && previous.state == FleetState::Mining - && current == FleetState::Mining && opc_known && !previous.opc && opc) { + && current == FleetState::Mining && previous.opc_known && opc_known && !previous.opc && opc) { if (!allow_notification(slot_index, FleetNotificationKind::MinerOpc)) { spdlog::debug("[FleetWatch] source={} suppressed duplicate event=MinerOPC slot={} fleet={}", source, slot_index, fleet_id); @@ -448,10 +493,10 @@ void fleet_watch_init() constexpr auto state_events = follow_through_events | fleet_notification_bit(FleetNotificationKind::MinerOpc); s_enabled_notifications = Config::Get().notify_fleet_events; s_state_observation_enabled = (s_enabled_notifications & state_events) != 0; - s_transition_follow_enabled = (s_enabled_notifications & follow_through_events) != 0; s_opc_entry_follow_enabled = notification_enabled(FleetNotificationKind::MinerOpc); s_slots = {}; s_last_emitted_ms = {}; + s_node_depleted_stamps = {}; s_seed_pending = fleet_watch_uses_state_observation(); s_last_follow_through_scan_ms = 0; s_last_mining_scan_ms = 0; @@ -476,9 +521,15 @@ void fleet_watch_observe_node_depleted(int64_t fleet_id) return; } + const auto fleet_key = static_cast(fleet_id); + if (!allow_node_depleted_notification(fleet_key)) { + spdlog::debug("[FleetWatch] suppressed duplicate event=NodeDepleted fleet={}", fleet_key); + return; + } + for (int index = 0; index < kFleetSlotCount; ++index) { const auto& snapshot = s_slots[index]; - if (!snapshot.occupied || snapshot.fleet_id != static_cast(fleet_id)) { + if (!snapshot.occupied || snapshot.fleet_id != fleet_key) { continue; } @@ -486,24 +537,20 @@ void fleet_watch_observe_node_depleted(int64_t fleet_id) const auto subject = notification_subject(fleet); const auto resource = resource_name(snapshot.resource_id); const auto detail = resource.empty() ? std::string{" its node"} : " its " + resource + " node"; - if (allow_notification(index, FleetNotificationKind::NodeDepleted)) { - notification_emit("Node Depleted", subject + " depleted" + detail + "." + cargo_text(fleet)); - } + notification_emit("Node Depleted", subject + " depleted" + detail + "." + cargo_text(fleet)); return; } for (int index = 0; index < kFleetSlotCount; ++index) { auto* fleet = fleet_for_slot(index); - if (!fleet || fleet->Id != static_cast(fleet_id)) { + if (!fleet || fleet->Id != fleet_key) { continue; } auto* mining_data = fleet->MiningData; const auto resource = resource_name(mining_data ? mining_data->ResourceId : 0); const auto detail = resource.empty() ? std::string{" its node"} : " its " + resource + " node"; - if (allow_notification(index, FleetNotificationKind::NodeDepleted)) { - notification_emit("Node Depleted", notification_subject(fleet) + " depleted" + detail + "." + cargo_text(fleet)); - } + notification_emit("Node Depleted", notification_subject(fleet) + " depleted" + detail + "." + cargo_text(fleet)); return; } diff --git a/mods/src/prime/ProgressData.h b/mods/src/prime/ProgressData.h index 00c90aee9..94049700d 100644 --- a/mods/src/prime/ProgressData.h +++ b/mods/src/prime/ProgressData.h @@ -2,9 +2,12 @@ #include +#include + struct ProgressData { public: __declspec(property(get = __get_CurrentValue)) double CurrentValue; + __declspec(property(get = __get_MinValue)) double MinValue; private: static IL2CppClassHelper& get_class_helper() @@ -19,6 +22,12 @@ struct ProgressData { { static auto property = get_class_helper().GetProperty("CurrentValue"); auto* value = property.Get(this); - return value ? *value : 0.0; + return value ? *value : std::numeric_limits::quiet_NaN(); + } + double __get_MinValue() + { + static auto property = get_class_helper().GetProperty("MinValue"); + auto* value = property.Get(this); + return value ? *value : std::numeric_limits::quiet_NaN(); } }; From fd0790eee883b78b745d2b01a2c6af9ae9c0fb12 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 23:42:16 -0500 Subject: [PATCH 36/80] fix: preserve fleet follow-through coverage --- mods/src/patches/fleet_watch.cc | 56 ++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index fc0af956d..a847e19a1 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -65,7 +65,6 @@ std::array, kFleetSlotCount> s_last std::array s_node_depleted_stamps{}; FleetNotificationMask s_enabled_notifications = 0; bool s_state_observation_enabled = false; -bool s_opc_entry_follow_enabled = false; bool s_seed_pending = false; int64_t s_last_follow_through_scan_ms = 0; int64_t s_last_mining_scan_ms = 0; @@ -98,32 +97,55 @@ bool allow_notification(int slot_index, FleetNotificationKind kind) return true; } -bool any_notification_enabled(FleetNotificationMask mask) -{ return (s_enabled_notifications & mask) != 0; } - -bool state_requires_enabled_follow_through(FleetState state) +constexpr bool state_requires_follow_through(FleetNotificationMask enabled, FleetState state) { - constexpr auto arrival_events = fleet_notification_bit(FleetNotificationKind::ArrivedInSystem) - | fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination); - constexpr auto movement_completion_events = arrival_events - | fleet_notification_bit(FleetNotificationKind::StartedMining) - | fleet_notification_bit(FleetNotificationKind::Docked); + constexpr auto arrival_events = fleet_notification_bit(FleetNotificationKind::ArrivedInSystem) + | fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination); + constexpr auto post_warp_events = arrival_events | fleet_notification_bit(FleetNotificationKind::StartedMining) + | fleet_notification_bit(FleetNotificationKind::Docked) + | fleet_notification_bit(FleetNotificationKind::MinerOpc); + constexpr auto post_impulse_events = fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination) + | fleet_notification_bit(FleetNotificationKind::StartedMining) + | fleet_notification_bit(FleetNotificationKind::Docked) + | fleet_notification_bit(FleetNotificationKind::MinerOpc); + constexpr auto post_activity_events = fleet_notification_bit(FleetNotificationKind::StartedMining) + | fleet_notification_bit(FleetNotificationKind::Docked) + | fleet_notification_bit(FleetNotificationKind::MinerOpc); switch (state) { case FleetState::WarpCharging: case FleetState::Warping: + return (enabled & post_warp_events) != 0; case FleetState::Impulsing: - return any_notification_enabled(movement_completion_events) || s_opc_entry_follow_enabled; + return (enabled & post_impulse_events) != 0; case FleetState::Battling: case FleetState::Capturing: - return notification_enabled(FleetNotificationKind::Docked); + return (enabled & post_activity_events) != 0; case FleetState::Repairing: - return notification_enabled(FleetNotificationKind::RepairComplete); + return (enabled & fleet_notification_bit(FleetNotificationKind::RepairComplete)) != 0; default: return false; } } +bool state_requires_enabled_follow_through(FleetState state) +{ return state_requires_follow_through(s_enabled_notifications, state); } + +static_assert(state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::ArrivedInSystem), + FleetState::Warping)); +static_assert(!state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::ArrivedInSystem), + FleetState::Impulsing)); +static_assert(state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination), + FleetState::Impulsing)); +static_assert(state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::StartedMining), + FleetState::Battling)); +static_assert(state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::MinerOpc), + FleetState::Battling)); +static_assert(!state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::ArrivedInSystem), + FleetState::Battling)); +static_assert(state_requires_follow_through(fleet_notification_bit(FleetNotificationKind::RepairComplete), + FleetState::Repairing)); + constexpr bool state_is_destination(FleetState state) { switch (state) { @@ -377,7 +399,12 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifi bool opc = same_fleet && previous.state == FleetState::Mining ? previous.opc : false; if (miner_opc_enabled && current == FleetState::Mining && (sample_opc || !same_fleet || previous.state != FleetState::Mining)) { - opc = read_opc(fleet, opc_known); + bool sampled_known = false; + const bool sampled_opc = read_opc(fleet, sampled_known); + if (sampled_known) { + opc_known = true; + opc = sampled_opc; + } } else if (current != FleetState::Mining) { opc_known = false; opc = false; @@ -493,7 +520,6 @@ void fleet_watch_init() constexpr auto state_events = follow_through_events | fleet_notification_bit(FleetNotificationKind::MinerOpc); s_enabled_notifications = Config::Get().notify_fleet_events; s_state_observation_enabled = (s_enabled_notifications & state_events) != 0; - s_opc_entry_follow_enabled = notification_enabled(FleetNotificationKind::MinerOpc); s_slots = {}; s_last_emitted_ms = {}; s_node_depleted_stamps = {}; From ac52649fb5141b319fb0b13983c3c5f585b24aa8 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 23:49:57 -0500 Subject: [PATCH 37/80] fix: stabilize fleet state before notifications --- mods/src/patches/fleet_watch.cc | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index a847e19a1..e0e2ec0fb 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -33,6 +33,7 @@ constexpr int64_t kSeedFastIntervalMs = 1'000; constexpr int64_t kSeedBackoffIntervalMs = 5'000; constexpr int64_t kSeedFastPeriodMs = 30'000; constexpr int64_t kSeedLifetimeMs = 120'000; +constexpr int64_t kSeedStabilizationMs = 5'000; constexpr int64_t kDuplicateSuppressionMs = 2'000; constexpr std::size_t kFleetNotificationCount = static_cast(FleetNotificationKind::Count); @@ -70,6 +71,7 @@ int64_t s_last int64_t s_last_mining_scan_ms = 0; int64_t s_last_seed_attempt_ms = 0; int64_t s_seed_started_ms = 0; +int64_t s_seed_first_observed_ms = 0; int s_mining_watch_count = 0; int s_follow_through_watch_count = 0; @@ -528,6 +530,7 @@ void fleet_watch_init() s_last_mining_scan_ms = 0; s_last_seed_attempt_ms = 0; s_seed_started_ms = 0; + s_seed_first_observed_ms = 0; s_mining_watch_count = 0; s_follow_through_watch_count = 0; } @@ -538,7 +541,7 @@ void fleet_watch_observe_widget(FleetPlayerData* fleet) return; } - observe_fleet(fleet, -1, true, false, "fleet-state-widget"); + observe_fleet(fleet, -1, !s_seed_pending, false, "fleet-state-widget"); } void fleet_watch_observe_node_depleted(int64_t fleet_id) @@ -607,19 +610,29 @@ void fleet_watch_tick() s_last_seed_attempt_ms = now_ms; const auto seed = scan_fleets(false, true, "initial-seed"); if (seed.observed_count > 0) { - s_seed_pending = false; - spdlog::debug("[FleetWatch] seeded {} fleet slots", seed.observed_count); - if (s_follow_through_watch_count > 0) { - s_last_follow_through_scan_ms = now_ms; - } - if (s_mining_watch_count > 0) { - s_last_mining_scan_ms = now_ms; + if (s_seed_first_observed_ms == 0) { + s_seed_first_observed_ms = now_ms; + spdlog::debug("[FleetWatch] observed {} fleet slots; stabilizing initial state", seed.observed_count); + } else if (now_ms - s_seed_first_observed_ms >= kSeedStabilizationMs) { + s_seed_pending = false; + spdlog::debug("[FleetWatch] seeded {} fleet slots after {}ms stabilization", seed.observed_count, + kSeedStabilizationMs); + if (s_follow_through_watch_count > 0) { + s_last_follow_through_scan_ms = now_ms; + } + if (s_mining_watch_count > 0) { + s_last_mining_scan_ms = now_ms; + } } } } } } + if (s_seed_pending) { + return; + } + bool follow_through_due = false; bool follow_through_fast = false; for (int index = 0; index < kFleetSlotCount; ++index) { From 2804dfd3cf93f16cde82b0dea856fba8dffa14b2 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 23:54:54 -0500 Subject: [PATCH 38/80] fix: require quiet fleet seed window --- mods/src/patches/fleet_watch.cc | 50 ++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index e0e2ec0fb..3e9e81216 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -67,11 +67,12 @@ std::array s_node FleetNotificationMask s_enabled_notifications = 0; bool s_state_observation_enabled = false; bool s_seed_pending = false; +bool s_seed_has_observation = false; int64_t s_last_follow_through_scan_ms = 0; int64_t s_last_mining_scan_ms = 0; int64_t s_last_seed_attempt_ms = 0; int64_t s_seed_started_ms = 0; -int64_t s_seed_first_observed_ms = 0; +int64_t s_seed_last_change_ms = 0; int s_mining_watch_count = 0; int s_follow_through_watch_count = 0; @@ -415,6 +416,8 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifi const bool previously_watched = miner_opc_enabled && previous.occupied && previous.state == FleetState::Mining && !previous.opc; const bool previously_following = previous.occupied && previous.follow_through_started_ms != 0; + const bool seed_state_changed = !same_fleet || previous.state != current || previous.resource_id != resource_id + || previous.opc_known != opc_known || previous.opc != opc; if (!same_fleet) { s_last_emitted_ms[slot_index] = {}; } @@ -470,10 +473,14 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifi if (previously_following != currently_following) { s_follow_through_watch_count += currently_following ? 1 : -1; } + if (s_seed_pending && s_seed_has_observation && seed_state_changed) { + s_seed_last_change_ms = now_milliseconds(); + } } struct ScanResult { - int observed_count = 0; + int observed_count = 0; + bool manager_available = false; }; ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_view source) @@ -483,10 +490,14 @@ ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_vi if (!manager) { return result; } + result.manager_available = true; for (int index = 0; index < kFleetSlotCount; ++index) { auto* fleet = manager->GetFleetPlayerData(index); if (!fleet) { + if (s_seed_pending && s_seed_has_observation && s_slots[index].occupied) { + s_seed_last_change_ms = now_milliseconds(); + } if (notification_enabled(FleetNotificationKind::MinerOpc) && s_slots[index].occupied && s_slots[index].state == FleetState::Mining && !s_slots[index].opc) { --s_mining_watch_count; @@ -526,11 +537,12 @@ void fleet_watch_init() s_last_emitted_ms = {}; s_node_depleted_stamps = {}; s_seed_pending = fleet_watch_uses_state_observation(); + s_seed_has_observation = false; s_last_follow_through_scan_ms = 0; s_last_mining_scan_ms = 0; s_last_seed_attempt_ms = 0; s_seed_started_ms = 0; - s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; s_mining_watch_count = 0; s_follow_through_watch_count = 0; } @@ -601,21 +613,33 @@ void fleet_watch_tick() s_seed_started_ms = now_ms; } const auto seed_lifetime = now_ms - s_seed_started_ms; - if (seed_lifetime >= kSeedLifetimeMs) { - s_seed_pending = false; - spdlog::warn("[FleetWatch] initial seed stopped after {}ms without observing a fleet", kSeedLifetimeMs); + if (seed_lifetime >= kSeedLifetimeMs && !s_seed_has_observation) { + s_seed_pending = false; + s_slots = {}; + s_last_emitted_ms = {}; + s_last_follow_through_scan_ms = 0; + s_last_mining_scan_ms = 0; + s_mining_watch_count = 0; + s_follow_through_watch_count = 0; + spdlog::warn("[FleetWatch] initial seed stopped after {}ms without a stable fleet baseline", kSeedLifetimeMs); } else { - const auto seed_interval = seed_lifetime < kSeedFastPeriodMs ? kSeedFastIntervalMs : kSeedBackoffIntervalMs; + const auto seed_interval = + s_seed_has_observation || seed_lifetime < kSeedFastPeriodMs ? kSeedFastIntervalMs : kSeedBackoffIntervalMs; if (s_last_seed_attempt_ms == 0 || now_ms - s_last_seed_attempt_ms >= seed_interval) { s_last_seed_attempt_ms = now_ms; const auto seed = scan_fleets(false, true, "initial-seed"); - if (seed.observed_count > 0) { - if (s_seed_first_observed_ms == 0) { - s_seed_first_observed_ms = now_ms; - spdlog::debug("[FleetWatch] observed {} fleet slots; stabilizing initial state", seed.observed_count); - } else if (now_ms - s_seed_first_observed_ms >= kSeedStabilizationMs) { + if (!seed.manager_available || seed.observed_count == 0) { + s_seed_has_observation = false; + s_seed_last_change_ms = 0; + } else { + if (!s_seed_has_observation) { + s_seed_has_observation = true; + s_seed_last_change_ms = now_ms; + spdlog::debug("[FleetWatch] observed {} fleet slots; waiting for stable initial state", + seed.observed_count); + } else if (now_ms - s_seed_last_change_ms >= kSeedStabilizationMs) { s_seed_pending = false; - spdlog::debug("[FleetWatch] seeded {} fleet slots after {}ms stabilization", seed.observed_count, + spdlog::debug("[FleetWatch] seeded {} stable fleet slots after {}ms quiet window", seed.observed_count, kSeedStabilizationMs); if (s_follow_through_watch_count > 0) { s_last_follow_through_scan_ms = now_ms; From c810462dc07d7ed6971c86d70593e51a8bbf1f96 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 00:00:11 -0500 Subject: [PATCH 39/80] fix: finalize stable fleet baselines safely --- mods/src/patches/fleet_watch.cc | 89 ++++++++++++++++++++++++------- mods/src/patches/fleet_watch.h | 1 + mods/src/patches/parts/hotkeys.cc | 12 +++-- 3 files changed, 81 insertions(+), 21 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index 3e9e81216..5178e3b0d 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -34,6 +34,7 @@ constexpr int64_t kSeedBackoffIntervalMs = 5'000; constexpr int64_t kSeedFastPeriodMs = 30'000; constexpr int64_t kSeedLifetimeMs = 120'000; constexpr int64_t kSeedStabilizationMs = 5'000; +constexpr int64_t kSeedMaxStabilizationMs = 30'000; constexpr int64_t kDuplicateSuppressionMs = 2'000; constexpr std::size_t kFleetNotificationCount = static_cast(FleetNotificationKind::Count); @@ -68,11 +69,15 @@ FleetNotificationMask s_enab bool s_state_observation_enabled = false; bool s_seed_pending = false; bool s_seed_has_observation = false; +bool s_seed_ready_candidate = false; +bool s_seed_candidate_forced = false; int64_t s_last_follow_through_scan_ms = 0; int64_t s_last_mining_scan_ms = 0; int64_t s_last_seed_attempt_ms = 0; int64_t s_seed_started_ms = 0; +int64_t s_seed_first_observed_ms = 0; int64_t s_seed_last_change_ms = 0; +int s_seed_candidate_count = 0; int s_mining_watch_count = 0; int s_follow_through_watch_count = 0; @@ -389,10 +394,26 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifi return; } - auto& previous = s_slots[slot_index]; - const auto fleet_id = fleet->Id; - const auto current = fleet->CurrentState; - const bool same_fleet = previous.occupied && previous.fleet_id == fleet_id; + auto& previous = s_slots[slot_index]; + const auto fleet_id = fleet->Id; + const auto current = fleet->CurrentState; + const bool same_fleet = previous.occupied && previous.fleet_id == fleet_id; + if (!same_fleet && !s_seed_pending) { + const auto now_ms = now_milliseconds(); + s_seed_pending = true; + s_seed_has_observation = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_last_seed_attempt_ms = 0; + s_seed_started_ms = now_ms; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_seed_candidate_count = 0; + allow_notifications = false; + spdlog::debug("[FleetWatch] new fleet observed; restarting initial-state stabilization slot={} fleet={}", + slot_index, fleet_id); + } + allow_notifications = allow_notifications && !s_seed_pending; const bool resource_context_enabled = notification_enabled(FleetNotificationKind::StartedMining) || notification_enabled(FleetNotificationKind::NodeDepleted); auto* mining_data = resource_context_enabled && current == FleetState::Mining ? fleet->MiningData : nullptr; @@ -476,6 +497,9 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifi if (s_seed_pending && s_seed_has_observation && seed_state_changed) { s_seed_last_change_ms = now_milliseconds(); } + if (s_seed_pending && seed_state_changed) { + s_seed_ready_candidate = false; + } } struct ScanResult { @@ -496,7 +520,8 @@ ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_vi auto* fleet = manager->GetFleetPlayerData(index); if (!fleet) { if (s_seed_pending && s_seed_has_observation && s_slots[index].occupied) { - s_seed_last_change_ms = now_milliseconds(); + s_seed_last_change_ms = now_milliseconds(); + s_seed_ready_candidate = false; } if (notification_enabled(FleetNotificationKind::MinerOpc) && s_slots[index].occupied && s_slots[index].state == FleetState::Mining && !s_slots[index].opc) { @@ -538,11 +563,15 @@ void fleet_watch_init() s_node_depleted_stamps = {}; s_seed_pending = fleet_watch_uses_state_observation(); s_seed_has_observation = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; s_last_follow_through_scan_ms = 0; s_last_mining_scan_ms = 0; s_last_seed_attempt_ms = 0; s_seed_started_ms = 0; + s_seed_first_observed_ms = 0; s_seed_last_change_ms = 0; + s_seed_candidate_count = 0; s_mining_watch_count = 0; s_follow_through_watch_count = 0; } @@ -615,6 +644,7 @@ void fleet_watch_tick() const auto seed_lifetime = now_ms - s_seed_started_ms; if (seed_lifetime >= kSeedLifetimeMs && !s_seed_has_observation) { s_seed_pending = false; + s_seed_ready_candidate = false; s_slots = {}; s_last_emitted_ms = {}; s_last_follow_through_scan_ms = 0; @@ -629,23 +659,26 @@ void fleet_watch_tick() s_last_seed_attempt_ms = now_ms; const auto seed = scan_fleets(false, true, "initial-seed"); if (!seed.manager_available || seed.observed_count == 0) { - s_seed_has_observation = false; - s_seed_last_change_ms = 0; + s_seed_has_observation = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_seed_candidate_count = 0; } else { if (!s_seed_has_observation) { - s_seed_has_observation = true; - s_seed_last_change_ms = now_ms; + s_seed_has_observation = true; + s_seed_first_observed_ms = now_ms; + s_seed_last_change_ms = now_ms; spdlog::debug("[FleetWatch] observed {} fleet slots; waiting for stable initial state", seed.observed_count); - } else if (now_ms - s_seed_last_change_ms >= kSeedStabilizationMs) { - s_seed_pending = false; - spdlog::debug("[FleetWatch] seeded {} stable fleet slots after {}ms quiet window", seed.observed_count, - kSeedStabilizationMs); - if (s_follow_through_watch_count > 0) { - s_last_follow_through_scan_ms = now_ms; - } - if (s_mining_watch_count > 0) { - s_last_mining_scan_ms = now_ms; + } else { + const bool quiet = now_ms - s_seed_last_change_ms >= kSeedStabilizationMs; + const bool forced = now_ms - s_seed_first_observed_ms >= kSeedMaxStabilizationMs; + if (quiet || forced) { + s_seed_ready_candidate = true; + s_seed_candidate_forced = forced && !quiet; + s_seed_candidate_count = seed.observed_count; } } } @@ -696,3 +729,23 @@ void fleet_watch_tick() s_last_mining_scan_ms = now_ms; } } + +void fleet_watch_after_update() +{ + if (!s_seed_pending || !s_seed_ready_candidate) { + return; + } + + const auto now_ms = now_milliseconds(); + s_seed_pending = false; + s_seed_ready_candidate = false; + s_last_follow_through_scan_ms = s_follow_through_watch_count > 0 ? now_ms : 0; + s_last_mining_scan_ms = s_mining_watch_count > 0 ? now_ms : 0; + if (s_seed_candidate_forced) { + spdlog::warn("[FleetWatch] seeded {} fleet slots after {}ms without a quiet window", s_seed_candidate_count, + kSeedMaxStabilizationMs); + } else { + spdlog::debug("[FleetWatch] seeded {} stable fleet slots after {}ms quiet window", s_seed_candidate_count, + kSeedStabilizationMs); + } +} diff --git a/mods/src/patches/fleet_watch.h b/mods/src/patches/fleet_watch.h index 932f0ac27..49d43e168 100644 --- a/mods/src/patches/fleet_watch.h +++ b/mods/src/patches/fleet_watch.h @@ -6,6 +6,7 @@ struct FleetPlayerData; void fleet_watch_init(); void fleet_watch_tick(); +void fleet_watch_after_update(); void fleet_watch_observe_widget(FleetPlayerData* fleet); void fleet_watch_observe_node_depleted(int64_t fleet_id); diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index 1408e6d98..ca26f8a15 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -226,7 +226,9 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) { fleet_watch_tick(); if (!Config::Get().installHotkeyHooks) { - return original(_this); + original(_this); + fleet_watch_after_update(); + return; } // This function is called every frame to update the screen manager. @@ -247,10 +249,13 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) } if (Config::Get().use_scopely_hotkeys && Config::Get().hotkeys_enabled) { - return original(_this); + original(_this); + fleet_watch_after_update(); + return; } if (!Config::Get().hotkeys_enabled) { + fleet_watch_after_update(); return; } @@ -642,7 +647,8 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) // config->Load(); - return original(_this); + original(_this); + fleet_watch_after_update(); } // NOTE: If you change this loop functionality, also change DoHideViewersOfType template From df01eaec42ac51f78456a176ccdaae4dfe2fc79b Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 00:05:31 -0500 Subject: [PATCH 40/80] fix: close fleet seed edge cases --- mods/src/patches/fleet_watch.cc | 78 ++++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index 5178e3b0d..362b39ab2 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -71,6 +71,7 @@ bool s_seed bool s_seed_has_observation = false; bool s_seed_ready_candidate = false; bool s_seed_candidate_forced = false; +bool s_seed_restart_disabled = false; int64_t s_last_follow_through_scan_ms = 0; int64_t s_last_mining_scan_ms = 0; int64_t s_last_seed_attempt_ms = 0; @@ -81,6 +82,24 @@ int s_seed int s_mining_watch_count = 0; int s_follow_through_watch_count = 0; +int64_t now_milliseconds(); + +void restart_seed_stabilization(int slot_index, uint64_t fleet_id) +{ + const auto now_ms = now_milliseconds(); + s_seed_pending = true; + s_seed_has_observation = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_last_seed_attempt_ms = 0; + s_seed_started_ms = now_ms; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_seed_candidate_count = 0; + spdlog::debug("[FleetWatch] new fleet observed; restarting initial-state stabilization slot={} fleet={}", slot_index, + fleet_id); +} + int64_t now_milliseconds() { return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) @@ -398,20 +417,8 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifi const auto fleet_id = fleet->Id; const auto current = fleet->CurrentState; const bool same_fleet = previous.occupied && previous.fleet_id == fleet_id; - if (!same_fleet && !s_seed_pending) { - const auto now_ms = now_milliseconds(); - s_seed_pending = true; - s_seed_has_observation = false; - s_seed_ready_candidate = false; - s_seed_candidate_forced = false; - s_last_seed_attempt_ms = 0; - s_seed_started_ms = now_ms; - s_seed_first_observed_ms = 0; - s_seed_last_change_ms = 0; - s_seed_candidate_count = 0; - allow_notifications = false; - spdlog::debug("[FleetWatch] new fleet observed; restarting initial-state stabilization slot={} fleet={}", - slot_index, fleet_id); + if (!same_fleet && !s_seed_pending && !s_seed_restart_disabled) { + restart_seed_stabilization(slot_index, fleet_id); } allow_notifications = allow_notifications && !s_seed_pending; const bool resource_context_enabled = notification_enabled(FleetNotificationKind::StartedMining) @@ -515,9 +522,30 @@ ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_vi return result; } result.manager_available = true; + s_seed_restart_disabled = false; + std::array fleets{}; for (int index = 0; index < kFleetSlotCount; ++index) { - auto* fleet = manager->GetFleetPlayerData(index); + fleets[index] = manager->GetFleetPlayerData(index); + } + + if (allow_notifications && !s_seed_pending) { + for (int index = 0; index < kFleetSlotCount; ++index) { + auto* fleet = fleets[index]; + if (!fleet) { + continue; + } + const auto fleet_id = fleet->Id; + if (!s_slots[index].occupied || s_slots[index].fleet_id != fleet_id) { + restart_seed_stabilization(index, fleet_id); + allow_notifications = false; + break; + } + } + } + + for (int index = 0; index < kFleetSlotCount; ++index) { + auto* fleet = fleets[index]; if (!fleet) { if (s_seed_pending && s_seed_has_observation && s_slots[index].occupied) { s_seed_last_change_ms = now_milliseconds(); @@ -565,6 +593,7 @@ void fleet_watch_init() s_seed_has_observation = false; s_seed_ready_candidate = false; s_seed_candidate_forced = false; + s_seed_restart_disabled = false; s_last_follow_through_scan_ms = 0; s_last_mining_scan_ms = 0; s_last_seed_attempt_ms = 0; @@ -645,6 +674,7 @@ void fleet_watch_tick() if (seed_lifetime >= kSeedLifetimeMs && !s_seed_has_observation) { s_seed_pending = false; s_seed_ready_candidate = false; + s_seed_restart_disabled = true; s_slots = {}; s_last_emitted_ms = {}; s_last_follow_through_scan_ms = 0; @@ -736,12 +766,28 @@ void fleet_watch_after_update() return; } + const bool forced = s_seed_candidate_forced; + const auto seed = scan_fleets(false, true, "seed-finalize"); + if (!seed.manager_available || seed.observed_count == 0) { + s_seed_has_observation = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_seed_candidate_count = 0; + return; + } + if (!forced && !s_seed_ready_candidate) { + return; + } + const auto now_ms = now_milliseconds(); s_seed_pending = false; s_seed_ready_candidate = false; + s_seed_candidate_count = seed.observed_count; s_last_follow_through_scan_ms = s_follow_through_watch_count > 0 ? now_ms : 0; s_last_mining_scan_ms = s_mining_watch_count > 0 ? now_ms : 0; - if (s_seed_candidate_forced) { + if (forced) { spdlog::warn("[FleetWatch] seeded {} fleet slots after {}ms without a quiet window", s_seed_candidate_count, kSeedMaxStabilizationMs); } else { From 81f1f680df360ce93ac59d80fbc086aeb97b0c9d Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 00:16:35 -0500 Subject: [PATCH 41/80] fix: stabilize fleet baseline sources --- mods/src/patches/fleet_watch.cc | 302 +++++++++++++++++++++----------- 1 file changed, 195 insertions(+), 107 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index 362b39ab2..bb7865f3c 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -65,39 +65,46 @@ struct FleetTransitionRule { std::array s_slots{}; std::array, kFleetSlotCount> s_last_emitted_ms{}; std::array s_node_depleted_stamps{}; -FleetNotificationMask s_enabled_notifications = 0; -bool s_state_observation_enabled = false; -bool s_seed_pending = false; -bool s_seed_has_observation = false; -bool s_seed_ready_candidate = false; -bool s_seed_candidate_forced = false; -bool s_seed_restart_disabled = false; -int64_t s_last_follow_through_scan_ms = 0; -int64_t s_last_mining_scan_ms = 0; -int64_t s_last_seed_attempt_ms = 0; -int64_t s_seed_started_ms = 0; -int64_t s_seed_first_observed_ms = 0; -int64_t s_seed_last_change_ms = 0; -int s_seed_candidate_count = 0; -int s_mining_watch_count = 0; -int s_follow_through_watch_count = 0; +FleetNotificationMask s_enabled_notifications = 0; +bool s_state_observation_enabled = false; +bool s_seed_pending = false; +bool s_seed_has_observation = false; +bool s_seed_manager_backed = false; +bool s_seed_ready_candidate = false; +bool s_seed_candidate_forced = false; +bool s_seed_candidate_manager_backed = false; +int64_t s_last_follow_through_scan_ms = 0; +int64_t s_last_mining_scan_ms = 0; +int64_t s_last_seed_attempt_ms = 0; +int64_t s_seed_started_ms = 0; +int64_t s_seed_first_observed_ms = 0; +int64_t s_seed_last_change_ms = 0; +int s_seed_candidate_count = 0; +int s_mining_watch_count = 0; +int s_follow_through_watch_count = 0; int64_t now_milliseconds(); void restart_seed_stabilization(int slot_index, uint64_t fleet_id) { - const auto now_ms = now_milliseconds(); - s_seed_pending = true; - s_seed_has_observation = false; - s_seed_ready_candidate = false; - s_seed_candidate_forced = false; - s_last_seed_attempt_ms = 0; - s_seed_started_ms = now_ms; - s_seed_first_observed_ms = 0; - s_seed_last_change_ms = 0; - s_seed_candidate_count = 0; - spdlog::debug("[FleetWatch] new fleet observed; restarting initial-state stabilization slot={} fleet={}", slot_index, - fleet_id); + const auto now_ms = now_milliseconds(); + s_slots = {}; + s_last_emitted_ms = {}; + s_seed_pending = true; + s_seed_has_observation = false; + s_seed_manager_backed = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_last_seed_attempt_ms = 0; + s_seed_started_ms = now_ms; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_seed_candidate_count = 0; + s_mining_watch_count = 0; + s_follow_through_watch_count = 0; + spdlog::debug("[FleetWatch] fleet topology changed; restarting initial-state stabilization slot={} fleet={}", + slot_index, fleet_id); } int64_t now_milliseconds() @@ -417,7 +424,7 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifi const auto fleet_id = fleet->Id; const auto current = fleet->CurrentState; const bool same_fleet = previous.occupied && previous.fleet_id == fleet_id; - if (!same_fleet && !s_seed_pending && !s_seed_restart_disabled) { + if (!same_fleet && (!s_seed_pending || s_seed_has_observation)) { restart_seed_stabilization(slot_index, fleet_id); } allow_notifications = allow_notifications && !s_seed_pending; @@ -509,9 +516,31 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifi } } +int occupied_snapshot_count() +{ + return static_cast( + std::count_if(s_slots.begin(), s_slots.end(), [](const auto& slot) { return slot.occupied; })); +} + +void begin_seed_observation(int observed_count, bool manager_backed, int64_t now_ms, std::string_view source) +{ + s_seed_has_observation = true; + s_seed_manager_backed = manager_backed; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_seed_first_observed_ms = now_ms; + s_seed_last_change_ms = now_ms; + s_seed_candidate_count = 0; + spdlog::debug("[FleetWatch] observed {} fleet slots via {}; waiting for stable initial state", observed_count, + source); +} + struct ScanResult { - int observed_count = 0; - bool manager_available = false; + int observed_count = 0; + int first_changed_slot = -1; + uint64_t first_changed_fleet = 0; + bool topology_changed = false; }; ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_view source) @@ -521,27 +550,36 @@ ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_vi if (!manager) { return result; } - result.manager_available = true; - s_seed_restart_disabled = false; - std::array fleets{}; for (int index = 0; index < kFleetSlotCount; ++index) { fleets[index] = manager->GetFleetPlayerData(index); + if (fleets[index]) { + ++result.observed_count; + } + const bool now_occupied = fleets[index] != nullptr; + const auto fleet_id = now_occupied ? fleets[index]->Id : 0; + const bool changed = + s_slots[index].occupied != now_occupied || (now_occupied && s_slots[index].fleet_id != fleet_id); + if (changed && !result.topology_changed) { + result.topology_changed = true; + result.first_changed_slot = index; + result.first_changed_fleet = fleet_id; + } } - if (allow_notifications && !s_seed_pending) { - for (int index = 0; index < kFleetSlotCount; ++index) { - auto* fleet = fleets[index]; - if (!fleet) { - continue; - } - const auto fleet_id = fleet->Id; - if (!s_slots[index].occupied || s_slots[index].fleet_id != fleet_id) { - restart_seed_stabilization(index, fleet_id); - allow_notifications = false; - break; - } - } + if (s_seed_pending && result.observed_count == 0) { + result.topology_changed = false; + result.first_changed_slot = -1; + result.first_changed_fleet = 0; + return result; + } + + const bool topology_requires_restart = + result.topology_changed + && ((s_seed_pending && s_seed_has_observation) || (!s_seed_pending && allow_notifications)); + if (topology_requires_restart) { + restart_seed_stabilization(result.first_changed_slot, result.first_changed_fleet); + allow_notifications = false; } for (int index = 0; index < kFleetSlotCount; ++index) { @@ -564,7 +602,6 @@ ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_vi } observe_fleet(fleet, index, allow_notifications, sample_opc, source); - ++result.observed_count; } return result; } @@ -583,26 +620,27 @@ void fleet_watch_init() | fleet_notification_bit(FleetNotificationKind::StartedMining) | fleet_notification_bit(FleetNotificationKind::Docked) | fleet_notification_bit(FleetNotificationKind::RepairComplete); - constexpr auto state_events = follow_through_events | fleet_notification_bit(FleetNotificationKind::MinerOpc); - s_enabled_notifications = Config::Get().notify_fleet_events; - s_state_observation_enabled = (s_enabled_notifications & state_events) != 0; - s_slots = {}; - s_last_emitted_ms = {}; - s_node_depleted_stamps = {}; - s_seed_pending = fleet_watch_uses_state_observation(); - s_seed_has_observation = false; - s_seed_ready_candidate = false; - s_seed_candidate_forced = false; - s_seed_restart_disabled = false; - s_last_follow_through_scan_ms = 0; - s_last_mining_scan_ms = 0; - s_last_seed_attempt_ms = 0; - s_seed_started_ms = 0; - s_seed_first_observed_ms = 0; - s_seed_last_change_ms = 0; - s_seed_candidate_count = 0; - s_mining_watch_count = 0; - s_follow_through_watch_count = 0; + constexpr auto state_events = follow_through_events | fleet_notification_bit(FleetNotificationKind::MinerOpc); + s_enabled_notifications = Config::Get().notify_fleet_events; + s_state_observation_enabled = (s_enabled_notifications & state_events) != 0; + s_slots = {}; + s_last_emitted_ms = {}; + s_node_depleted_stamps = {}; + s_seed_pending = fleet_watch_uses_state_observation(); + s_seed_has_observation = false; + s_seed_manager_backed = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_last_follow_through_scan_ms = 0; + s_last_mining_scan_ms = 0; + s_last_seed_attempt_ms = 0; + s_seed_started_ms = 0; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_seed_candidate_count = 0; + s_mining_watch_count = 0; + s_follow_through_watch_count = 0; } void fleet_watch_observe_widget(FleetPlayerData* fleet) @@ -612,6 +650,12 @@ void fleet_watch_observe_widget(FleetPlayerData* fleet) } observe_fleet(fleet, -1, !s_seed_pending, false, "fleet-state-widget"); + if (s_seed_pending && !s_seed_has_observation) { + const auto observed_count = occupied_snapshot_count(); + if (observed_count > 0) { + begin_seed_observation(observed_count, false, now_milliseconds(), "fleet-state-widget"); + } + } } void fleet_watch_observe_node_depleted(int64_t fleet_id) @@ -672,43 +716,68 @@ void fleet_watch_tick() } const auto seed_lifetime = now_ms - s_seed_started_ms; if (seed_lifetime >= kSeedLifetimeMs && !s_seed_has_observation) { - s_seed_pending = false; - s_seed_ready_candidate = false; - s_seed_restart_disabled = true; - s_slots = {}; - s_last_emitted_ms = {}; - s_last_follow_through_scan_ms = 0; - s_last_mining_scan_ms = 0; - s_mining_watch_count = 0; - s_follow_through_watch_count = 0; + s_seed_pending = false; + s_seed_manager_backed = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_slots = {}; + s_last_emitted_ms = {}; + s_last_follow_through_scan_ms = 0; + s_last_mining_scan_ms = 0; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_seed_candidate_count = 0; + s_mining_watch_count = 0; + s_follow_through_watch_count = 0; spdlog::warn("[FleetWatch] initial seed stopped after {}ms without a stable fleet baseline", kSeedLifetimeMs); } else { const auto seed_interval = s_seed_has_observation || seed_lifetime < kSeedFastPeriodMs ? kSeedFastIntervalMs : kSeedBackoffIntervalMs; if (s_last_seed_attempt_ms == 0 || now_ms - s_last_seed_attempt_ms >= seed_interval) { - s_last_seed_attempt_ms = now_ms; - const auto seed = scan_fleets(false, true, "initial-seed"); - if (!seed.manager_available || seed.observed_count == 0) { - s_seed_has_observation = false; - s_seed_ready_candidate = false; - s_seed_candidate_forced = false; - s_seed_first_observed_ms = 0; - s_seed_last_change_ms = 0; - s_seed_candidate_count = 0; + s_last_seed_attempt_ms = now_ms; + const auto seed = scan_fleets(false, true, "initial-seed"); + const bool manager_backed = seed.observed_count > 0; + const bool widget_backed = !s_seed_has_observation || !s_seed_manager_backed; + const int observed_count = + manager_backed ? seed.observed_count : (widget_backed ? occupied_snapshot_count() : 0); + if (observed_count == 0) { + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_seed_candidate_count = 0; + if (s_seed_has_observation && !s_seed_manager_backed) { + s_seed_has_observation = false; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + } + if (seed_lifetime >= kSeedLifetimeMs) { + s_seed_pending = false; + s_seed_has_observation = false; + s_seed_manager_backed = false; + s_slots = {}; + s_last_emitted_ms = {}; + s_last_follow_through_scan_ms = 0; + s_last_mining_scan_ms = 0; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_mining_watch_count = 0; + s_follow_through_watch_count = 0; + spdlog::warn("[FleetWatch] initial seed stopped after {}ms without an available fleet baseline", + kSeedLifetimeMs); + } } else { - if (!s_seed_has_observation) { - s_seed_has_observation = true; - s_seed_first_observed_ms = now_ms; - s_seed_last_change_ms = now_ms; - spdlog::debug("[FleetWatch] observed {} fleet slots; waiting for stable initial state", - seed.observed_count); + if (!s_seed_has_observation || s_seed_manager_backed != manager_backed) { + begin_seed_observation(observed_count, manager_backed, now_ms, + manager_backed ? "fleet-manager" : "fleet-state-widget"); } else { const bool quiet = now_ms - s_seed_last_change_ms >= kSeedStabilizationMs; const bool forced = now_ms - s_seed_first_observed_ms >= kSeedMaxStabilizationMs; if (quiet || forced) { - s_seed_ready_candidate = true; - s_seed_candidate_forced = forced && !quiet; - s_seed_candidate_count = seed.observed_count; + s_seed_ready_candidate = true; + s_seed_candidate_forced = forced && !quiet; + s_seed_candidate_manager_backed = manager_backed; + s_seed_candidate_count = observed_count; } } } @@ -762,29 +831,48 @@ void fleet_watch_tick() void fleet_watch_after_update() { - if (!s_seed_pending || !s_seed_ready_candidate) { + if (!s_seed_pending || (!s_seed_ready_candidate && !s_seed_candidate_forced)) { return; } - const bool forced = s_seed_candidate_forced; - const auto seed = scan_fleets(false, true, "seed-finalize"); - if (!seed.manager_available || seed.observed_count == 0) { - s_seed_has_observation = false; - s_seed_ready_candidate = false; - s_seed_candidate_forced = false; - s_seed_first_observed_ms = 0; - s_seed_last_change_ms = 0; - s_seed_candidate_count = 0; + const bool forced = s_seed_candidate_forced; + int observed_count = occupied_snapshot_count(); + const auto seed = scan_fleets(false, true, "seed-finalize"); + if (seed.topology_changed) { return; } - if (!forced && !s_seed_ready_candidate) { + if (seed.observed_count > 0) { + observed_count = seed.observed_count; + if (!forced && !s_seed_ready_candidate) { + return; + } + } else if (s_seed_candidate_manager_backed) { + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_seed_candidate_count = 0; return; + } else { + if (observed_count == 0) { + s_seed_has_observation = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_seed_candidate_count = 0; + return; + } + if (!forced && !s_seed_ready_candidate) { + return; + } } const auto now_ms = now_milliseconds(); s_seed_pending = false; s_seed_ready_candidate = false; - s_seed_candidate_count = seed.observed_count; + s_seed_candidate_forced = false; + s_seed_candidate_count = observed_count; s_last_follow_through_scan_ms = s_follow_through_watch_count > 0 ? now_ms : 0; s_last_mining_scan_ms = s_mining_watch_count > 0 ? now_ms : 0; if (forced) { From b37977cd8cd8be579214dbaabaf3ecdf98edbaa1 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 00:19:36 -0500 Subject: [PATCH 42/80] fix: observe hydrated fleet widget state --- mods/src/patches/parts/fleet_watch.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mods/src/patches/parts/fleet_watch.cc b/mods/src/patches/parts/fleet_watch.cc index f02b2967f..2a951e541 100644 --- a/mods/src/patches/parts/fleet_watch.cc +++ b/mods/src/patches/parts/fleet_watch.cc @@ -26,8 +26,8 @@ FleetPlayerData* fleet_widget_context(void* self) void FleetStateWidget_SetWidgetData_Hook(auto original, void* self) { - fleet_watch_observe_widget(fleet_widget_context(self)); original(self); + fleet_watch_observe_widget(fleet_widget_context(self)); } void ToastFleetObserver_HandleMiningDepleted_Hook(auto original, void* self, int64_t fleet_id) From 59be3cc7d41527d2d7eadd6e36ca862065aca337 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 00:23:22 -0500 Subject: [PATCH 43/80] fix: accumulate widget seed frames --- mods/src/patches/fleet_watch.cc | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index bb7865f3c..34fe14481 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -650,12 +650,6 @@ void fleet_watch_observe_widget(FleetPlayerData* fleet) } observe_fleet(fleet, -1, !s_seed_pending, false, "fleet-state-widget"); - if (s_seed_pending && !s_seed_has_observation) { - const auto observed_count = occupied_snapshot_count(); - if (observed_count > 0) { - begin_seed_observation(observed_count, false, now_milliseconds(), "fleet-state-widget"); - } - } } void fleet_watch_observe_node_depleted(int64_t fleet_id) @@ -831,7 +825,17 @@ void fleet_watch_tick() void fleet_watch_after_update() { - if (!s_seed_pending || (!s_seed_ready_candidate && !s_seed_candidate_forced)) { + if (!s_seed_pending) { + return; + } + if (!s_seed_has_observation) { + const auto observed_count = occupied_snapshot_count(); + if (observed_count > 0) { + begin_seed_observation(observed_count, false, now_milliseconds(), "fleet-state-widget"); + } + return; + } + if (!s_seed_ready_candidate && !s_seed_candidate_forced) { return; } From ad2396fe150e65e1b8d13eaa8509208f5c46b004 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 00:26:07 -0500 Subject: [PATCH 44/80] fix: preserve staggered widget baselines --- mods/src/patches/fleet_watch.cc | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index 34fe14481..ac6735878 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -85,11 +85,15 @@ int s_foll int64_t now_milliseconds(); -void restart_seed_stabilization(int slot_index, uint64_t fleet_id) +void restart_seed_stabilization(int slot_index, uint64_t fleet_id, bool preserve_snapshots = false) { - const auto now_ms = now_milliseconds(); - s_slots = {}; - s_last_emitted_ms = {}; + const auto now_ms = now_milliseconds(); + if (!preserve_snapshots) { + s_slots = {}; + s_last_emitted_ms = {}; + s_mining_watch_count = 0; + s_follow_through_watch_count = 0; + } s_seed_pending = true; s_seed_has_observation = false; s_seed_manager_backed = false; @@ -101,10 +105,9 @@ void restart_seed_stabilization(int slot_index, uint64_t fleet_id) s_seed_first_observed_ms = 0; s_seed_last_change_ms = 0; s_seed_candidate_count = 0; - s_mining_watch_count = 0; - s_follow_through_watch_count = 0; - spdlog::debug("[FleetWatch] fleet topology changed; restarting initial-state stabilization slot={} fleet={}", - slot_index, fleet_id); + spdlog::debug( + "[FleetWatch] fleet topology changed; restarting initial-state stabilization slot={} fleet={} preserve={}", + slot_index, fleet_id, preserve_snapshots); } int64_t now_milliseconds() @@ -415,7 +418,8 @@ int resolve_slot(FleetPlayerData* fleet, int requested_slot) void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifications, bool sample_opc, std::string_view source) { - const auto slot_index = resolve_slot(fleet, requested_slot); + const bool widget_observation = requested_slot < 0; + const auto slot_index = resolve_slot(fleet, requested_slot); if (!fleet || slot_index < 0) { return; } @@ -425,7 +429,7 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifi const auto current = fleet->CurrentState; const bool same_fleet = previous.occupied && previous.fleet_id == fleet_id; if (!same_fleet && (!s_seed_pending || s_seed_has_observation)) { - restart_seed_stabilization(slot_index, fleet_id); + restart_seed_stabilization(slot_index, fleet_id, widget_observation); } allow_notifications = allow_notifications && !s_seed_pending; const bool resource_context_enabled = notification_enabled(FleetNotificationKind::StartedMining) From 177fbb145a99cf79b4158a252f21561e27225860 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 00:28:51 -0500 Subject: [PATCH 45/80] fix: reset fleet seed after manager loss --- mods/src/patches/fleet_watch.cc | 42 ++++++++++++++++----------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index ac6735878..b5b52652d 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -85,6 +85,22 @@ int s_foll int64_t now_milliseconds(); +void invalidate_seed_observation() +{ + s_slots = {}; + s_last_emitted_ms = {}; + s_seed_has_observation = false; + s_seed_manager_backed = false; + s_seed_ready_candidate = false; + s_seed_candidate_forced = false; + s_seed_candidate_manager_backed = false; + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_seed_candidate_count = 0; + s_mining_watch_count = 0; + s_follow_through_watch_count = 0; +} + void restart_seed_stabilization(int slot_index, uint64_t fleet_id, bool preserve_snapshots = false) { const auto now_ms = now_milliseconds(); @@ -552,6 +568,9 @@ ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_vi ScanResult result; auto* manager = FleetsManager::Instance(); if (!manager) { + if (allow_notifications && !s_seed_pending) { + restart_seed_stabilization(-1, 0); + } return result; } std::array fleets{}; @@ -740,27 +759,11 @@ void fleet_watch_tick() const int observed_count = manager_backed ? seed.observed_count : (widget_backed ? occupied_snapshot_count() : 0); if (observed_count == 0) { - s_seed_ready_candidate = false; - s_seed_candidate_forced = false; - s_seed_candidate_manager_backed = false; - s_seed_candidate_count = 0; - if (s_seed_has_observation && !s_seed_manager_backed) { - s_seed_has_observation = false; - s_seed_first_observed_ms = 0; - s_seed_last_change_ms = 0; - } + invalidate_seed_observation(); if (seed_lifetime >= kSeedLifetimeMs) { s_seed_pending = false; - s_seed_has_observation = false; - s_seed_manager_backed = false; - s_slots = {}; - s_last_emitted_ms = {}; s_last_follow_through_scan_ms = 0; s_last_mining_scan_ms = 0; - s_seed_first_observed_ms = 0; - s_seed_last_change_ms = 0; - s_mining_watch_count = 0; - s_follow_through_watch_count = 0; spdlog::warn("[FleetWatch] initial seed stopped after {}ms without an available fleet baseline", kSeedLifetimeMs); } @@ -855,10 +858,7 @@ void fleet_watch_after_update() return; } } else if (s_seed_candidate_manager_backed) { - s_seed_ready_candidate = false; - s_seed_candidate_forced = false; - s_seed_candidate_manager_backed = false; - s_seed_candidate_count = 0; + invalidate_seed_observation(); return; } else { if (observed_count == 0) { From 4b14dfed392f34cd0f2db7e8f69c2848551e012f Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 00:32:38 -0500 Subject: [PATCH 46/80] fix: retain fleet baseline provenance --- mods/src/patches/fleet_watch.cc | 49 +++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index b5b52652d..f9f0ce888 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -82,6 +82,7 @@ int64_t s_seed int s_seed_candidate_count = 0; int s_mining_watch_count = 0; int s_follow_through_watch_count = 0; +int s_manager_probe_slot = 0; int64_t now_milliseconds(); @@ -99,6 +100,7 @@ void invalidate_seed_observation() s_seed_candidate_count = 0; s_mining_watch_count = 0; s_follow_through_watch_count = 0; + s_manager_probe_slot = 0; } void restart_seed_stabilization(int slot_index, uint64_t fleet_id, bool preserve_snapshots = false) @@ -121,6 +123,7 @@ void restart_seed_stabilization(int slot_index, uint64_t fleet_id, bool preserve s_seed_first_observed_ms = 0; s_seed_last_change_ms = 0; s_seed_candidate_count = 0; + s_manager_probe_slot = 0; spdlog::debug( "[FleetWatch] fleet topology changed; restarting initial-state stabilization slot={} fleet={} preserve={}", slot_index, fleet_id, preserve_snapshots); @@ -445,7 +448,8 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifi const auto current = fleet->CurrentState; const bool same_fleet = previous.occupied && previous.fleet_id == fleet_id; if (!same_fleet && (!s_seed_pending || s_seed_has_observation)) { - restart_seed_stabilization(slot_index, fleet_id, widget_observation); + const bool preserve_snapshots = widget_observation && !s_seed_manager_backed; + restart_seed_stabilization(slot_index, fleet_id, preserve_snapshots); } allow_notifications = allow_notifications && !s_seed_pending; const bool resource_context_enabled = notification_enabled(FleetNotificationKind::StartedMining) @@ -542,6 +546,33 @@ int occupied_snapshot_count() std::count_if(s_slots.begin(), s_slots.end(), [](const auto& slot) { return slot.occupied; })); } +bool manager_baseline_matches(int& changed_slot, uint64_t& changed_fleet) +{ + auto* manager = FleetsManager::Instance(); + if (!manager) { + changed_slot = -1; + changed_fleet = 0; + return false; + } + + for (int offset = 0; offset < kFleetSlotCount; ++offset) { + const int index = (s_manager_probe_slot + offset) % kFleetSlotCount; + if (!s_slots[index].occupied) { + continue; + } + + auto* fleet = manager->GetFleetPlayerData(index); + changed_slot = index; + changed_fleet = fleet ? fleet->Id : 0; + s_manager_probe_slot = (index + 1) % kFleetSlotCount; + return fleet && fleet->Id == s_slots[index].fleet_id; + } + + changed_slot = -1; + changed_fleet = 0; + return false; +} + void begin_seed_observation(int observed_count, bool manager_backed, int64_t now_ms, std::string_view source) { s_seed_has_observation = true; @@ -568,7 +599,7 @@ ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_vi ScanResult result; auto* manager = FleetsManager::Instance(); if (!manager) { - if (allow_notifications && !s_seed_pending) { + if (allow_notifications && !s_seed_pending && s_seed_manager_backed) { restart_seed_stabilization(-1, 0); } return result; @@ -590,7 +621,7 @@ ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_vi } } - if (s_seed_pending && result.observed_count == 0) { + if (result.observed_count == 0 && (s_seed_pending || !s_seed_manager_backed)) { result.topology_changed = false; result.first_changed_slot = -1; result.first_changed_fleet = 0; @@ -626,6 +657,9 @@ ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_vi observe_fleet(fleet, index, allow_notifications, sample_opc, source); } + if (result.observed_count > 0) { + s_seed_manager_backed = true; + } return result; } } // namespace @@ -664,6 +698,7 @@ void fleet_watch_init() s_seed_candidate_count = 0; s_mining_watch_count = 0; s_follow_through_watch_count = 0; + s_manager_probe_slot = 0; } void fleet_watch_observe_widget(FleetPlayerData* fleet) @@ -722,6 +757,14 @@ void fleet_watch_tick() if (!fleet_watch_uses_state_observation()) { return; } + if (!s_seed_pending && s_seed_manager_backed) { + int changed_slot = -1; + uint64_t changed_fleet = 0; + if (!manager_baseline_matches(changed_slot, changed_fleet)) { + restart_seed_stabilization(changed_slot, changed_fleet); + return; + } + } if (!s_seed_pending && s_follow_through_watch_count == 0 && s_mining_watch_count == 0) { return; } From b4ebffd011a6e721328a01978e463981c7e78b73 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 00:34:54 -0500 Subject: [PATCH 47/80] fix: stabilize fleet source handoff --- mods/src/patches/fleet_watch.cc | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index f9f0ce888..27a5dcef1 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -628,12 +628,22 @@ ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_vi return result; } - const bool topology_requires_restart = - result.topology_changed - && ((s_seed_pending && s_seed_has_observation) || (!s_seed_pending && allow_notifications)); - if (topology_requires_restart) { + const bool observed_baseline = s_seed_has_observation || !s_seed_pending; + const bool topology_requires_restart = result.topology_changed && observed_baseline; + const bool source_requires_restart = !s_seed_manager_backed && observed_baseline; + if (topology_requires_restart || source_requires_restart) { + if (result.first_changed_slot < 0) { + for (int index = 0; index < kFleetSlotCount; ++index) { + if (fleets[index]) { + result.first_changed_slot = index; + result.first_changed_fleet = fleets[index]->Id; + break; + } + } + } restart_seed_stabilization(result.first_changed_slot, result.first_changed_fleet); - allow_notifications = false; + result.topology_changed = true; + allow_notifications = false; } for (int index = 0; index < kFleetSlotCount; ++index) { @@ -881,7 +891,8 @@ void fleet_watch_after_update() if (!s_seed_has_observation) { const auto observed_count = occupied_snapshot_count(); if (observed_count > 0) { - begin_seed_observation(observed_count, false, now_milliseconds(), "fleet-state-widget"); + begin_seed_observation(observed_count, s_seed_manager_backed, now_milliseconds(), + s_seed_manager_backed ? "fleet-manager" : "fleet-state-widget"); } return; } From 9ea4fa788be57f1c80e322954d5d2541793a0250 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 00:39:14 -0500 Subject: [PATCH 48/80] fix: validate fleet source before events --- mods/src/patches/fleet_watch.cc | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index 27a5dcef1..146fd6c64 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -35,6 +35,7 @@ constexpr int64_t kSeedFastPeriodMs = 30'000; constexpr int64_t kSeedLifetimeMs = 120'000; constexpr int64_t kSeedStabilizationMs = 5'000; constexpr int64_t kSeedMaxStabilizationMs = 30'000; +constexpr int64_t kManagerProbeIntervalMs = 250; constexpr int64_t kDuplicateSuppressionMs = 2'000; constexpr std::size_t kFleetNotificationCount = static_cast(FleetNotificationKind::Count); @@ -79,6 +80,7 @@ int64_t s_last int64_t s_seed_started_ms = 0; int64_t s_seed_first_observed_ms = 0; int64_t s_seed_last_change_ms = 0; +int64_t s_last_manager_probe_ms = 0; int s_seed_candidate_count = 0; int s_mining_watch_count = 0; int s_follow_through_watch_count = 0; @@ -97,6 +99,7 @@ void invalidate_seed_observation() s_seed_candidate_manager_backed = false; s_seed_first_observed_ms = 0; s_seed_last_change_ms = 0; + s_last_manager_probe_ms = 0; s_seed_candidate_count = 0; s_mining_watch_count = 0; s_follow_through_watch_count = 0; @@ -122,6 +125,7 @@ void restart_seed_stabilization(int slot_index, uint64_t fleet_id, bool preserve s_seed_started_ms = now_ms; s_seed_first_observed_ms = 0; s_seed_last_change_ms = 0; + s_last_manager_probe_ms = 0; s_seed_candidate_count = 0; s_manager_probe_slot = 0; spdlog::debug( @@ -573,6 +577,14 @@ bool manager_baseline_matches(int& changed_slot, uint64_t& changed_fleet) return false; } +bool manager_slot_matches(int slot_index, uint64_t fleet_id) +{ + auto* manager = FleetsManager::Instance(); + auto* fleet = + manager && slot_index >= 0 && slot_index < kFleetSlotCount ? manager->GetFleetPlayerData(slot_index) : nullptr; + return fleet && fleet->Id == fleet_id; +} + void begin_seed_observation(int observed_count, bool manager_backed, int64_t now_ms, std::string_view source) { s_seed_has_observation = true; @@ -705,6 +717,7 @@ void fleet_watch_init() s_seed_started_ms = 0; s_seed_first_observed_ms = 0; s_seed_last_change_ms = 0; + s_last_manager_probe_ms = 0; s_seed_candidate_count = 0; s_mining_watch_count = 0; s_follow_through_watch_count = 0; @@ -717,6 +730,10 @@ void fleet_watch_observe_widget(FleetPlayerData* fleet) return; } + const auto slot_index = resolve_slot(fleet, -1); + if (!s_seed_pending && s_seed_manager_backed && slot_index >= 0 && !manager_slot_matches(slot_index, fleet->Id)) { + restart_seed_stabilization(slot_index, fleet->Id); + } observe_fleet(fleet, -1, !s_seed_pending, false, "fleet-state-widget"); } @@ -768,11 +785,15 @@ void fleet_watch_tick() return; } if (!s_seed_pending && s_seed_manager_backed) { - int changed_slot = -1; - uint64_t changed_fleet = 0; - if (!manager_baseline_matches(changed_slot, changed_fleet)) { - restart_seed_stabilization(changed_slot, changed_fleet); - return; + const auto now_ms = now_milliseconds(); + if (s_last_manager_probe_ms == 0 || now_ms - s_last_manager_probe_ms >= kManagerProbeIntervalMs) { + s_last_manager_probe_ms = now_ms; + int changed_slot = -1; + uint64_t changed_fleet = 0; + if (!manager_baseline_matches(changed_slot, changed_fleet)) { + restart_seed_stabilization(changed_slot, changed_fleet); + return; + } } } if (!s_seed_pending && s_follow_through_watch_count == 0 && s_mining_watch_count == 0) { From 5ff4120f2cb417259985c026826c0628124951ee Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 00:42:28 -0500 Subject: [PATCH 49/80] fix: cover fleet topology and widget opc --- mods/src/patches/fleet_watch.cc | 42 ++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index 146fd6c64..45e6754d1 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -39,6 +39,7 @@ constexpr int64_t kManagerProbeIntervalMs = 250; constexpr int64_t kDuplicateSuppressionMs = 2'000; constexpr std::size_t kFleetNotificationCount = static_cast(FleetNotificationKind::Count); +constexpr uint16_t kAllFleetSlotsMask = (1U << kFleetSlotCount) - 1; struct FleetSnapshot { uint64_t fleet_id = 0; @@ -85,6 +86,7 @@ int s_seed int s_mining_watch_count = 0; int s_follow_through_watch_count = 0; int s_manager_probe_slot = 0; +uint16_t s_widget_opc_sample_mask = 0; int64_t now_milliseconds(); @@ -104,6 +106,7 @@ void invalidate_seed_observation() s_mining_watch_count = 0; s_follow_through_watch_count = 0; s_manager_probe_slot = 0; + s_widget_opc_sample_mask = 0; } void restart_seed_stabilization(int slot_index, uint64_t fleet_id, bool preserve_snapshots = false) @@ -128,6 +131,7 @@ void restart_seed_stabilization(int slot_index, uint64_t fleet_id, bool preserve s_last_manager_probe_ms = 0; s_seed_candidate_count = 0; s_manager_probe_slot = 0; + s_widget_opc_sample_mask = 0; spdlog::debug( "[FleetWatch] fleet topology changed; restarting initial-state stabilization slot={} fleet={} preserve={}", slot_index, fleet_id, preserve_snapshots); @@ -559,22 +563,13 @@ bool manager_baseline_matches(int& changed_slot, uint64_t& changed_fleet) return false; } - for (int offset = 0; offset < kFleetSlotCount; ++offset) { - const int index = (s_manager_probe_slot + offset) % kFleetSlotCount; - if (!s_slots[index].occupied) { - continue; - } - - auto* fleet = manager->GetFleetPlayerData(index); - changed_slot = index; - changed_fleet = fleet ? fleet->Id : 0; - s_manager_probe_slot = (index + 1) % kFleetSlotCount; - return fleet && fleet->Id == s_slots[index].fleet_id; - } - - changed_slot = -1; - changed_fleet = 0; - return false; + const int index = s_manager_probe_slot; + auto* fleet = manager->GetFleetPlayerData(index); + changed_slot = index; + changed_fleet = fleet ? fleet->Id : 0; + s_manager_probe_slot = (index + 1) % kFleetSlotCount; + const bool now_occupied = fleet != nullptr; + return s_slots[index].occupied == now_occupied && (!now_occupied || s_slots[index].fleet_id == changed_fleet); } bool manager_slot_matches(int slot_index, uint64_t fleet_id) @@ -680,7 +675,8 @@ ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_vi observe_fleet(fleet, index, allow_notifications, sample_opc, source); } if (result.observed_count > 0) { - s_seed_manager_backed = true; + s_seed_manager_backed = true; + s_widget_opc_sample_mask = 0; } return result; } @@ -722,6 +718,7 @@ void fleet_watch_init() s_mining_watch_count = 0; s_follow_through_watch_count = 0; s_manager_probe_slot = 0; + s_widget_opc_sample_mask = 0; } void fleet_watch_observe_widget(FleetPlayerData* fleet) @@ -734,7 +731,11 @@ void fleet_watch_observe_widget(FleetPlayerData* fleet) if (!s_seed_pending && s_seed_manager_backed && slot_index >= 0 && !manager_slot_matches(slot_index, fleet->Id)) { restart_seed_stabilization(slot_index, fleet->Id); } - observe_fleet(fleet, -1, !s_seed_pending, false, "fleet-state-widget"); + const bool sample_opc = slot_index >= 0 && (s_widget_opc_sample_mask & (1U << slot_index)) != 0; + observe_fleet(fleet, -1, !s_seed_pending, sample_opc, "fleet-state-widget"); + if (sample_opc) { + s_widget_opc_sample_mask &= ~(1U << slot_index); + } } void fleet_watch_observe_node_depleted(int64_t fleet_id) @@ -892,7 +893,7 @@ void fleet_watch_tick() } const bool had_follow_through = s_follow_through_watch_count > 0; - scan_fleets(true, mining_due, follow_through_due ? "follow-through" : "mining-watch"); + const auto scan = scan_fleets(true, mining_due, follow_through_due ? "follow-through" : "mining-watch"); if (follow_through_due) { s_last_follow_through_scan_ms = now_ms; if (had_follow_through && s_follow_through_watch_count == 0) { @@ -900,6 +901,9 @@ void fleet_watch_tick() } } if (mining_due) { + if (scan.observed_count == 0 && !s_seed_manager_backed) { + s_widget_opc_sample_mask = kAllFleetSlotsMask; + } s_last_mining_scan_ms = now_ms; } } From 19564c865929a1f6a330b4bcc8893af4eee846bc Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 00:47:51 -0500 Subject: [PATCH 50/80] perf: avoid redundant fleet widget lookups --- mods/src/patches/fleet_watch.cc | 10 +++++++--- mods/src/patches/parts/fleet_watch.cc | 5 +++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index 45e6754d1..d091996ed 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -727,11 +727,15 @@ void fleet_watch_observe_widget(FleetPlayerData* fleet) return; } - const auto slot_index = resolve_slot(fleet, -1); - if (!s_seed_pending && s_seed_manager_backed && slot_index >= 0 && !manager_slot_matches(slot_index, fleet->Id)) { + const auto slot_index = resolve_slot(fleet, -1); + const bool sample_opc = slot_index >= 0 && (s_widget_opc_sample_mask & (1U << slot_index)) != 0; + const bool transition_candidate = slot_index >= 0 && s_slots[slot_index].occupied + && s_slots[slot_index].fleet_id == fleet->Id + && s_slots[slot_index].state != fleet->CurrentState; + if (!s_seed_pending && s_seed_manager_backed && (transition_candidate || sample_opc) + && !manager_slot_matches(slot_index, fleet->Id)) { restart_seed_stabilization(slot_index, fleet->Id); } - const bool sample_opc = slot_index >= 0 && (s_widget_opc_sample_mask & (1U << slot_index)) != 0; observe_fleet(fleet, -1, !s_seed_pending, sample_opc, "fleet-state-widget"); if (sample_opc) { s_widget_opc_sample_mask &= ~(1U << slot_index); diff --git a/mods/src/patches/parts/fleet_watch.cc b/mods/src/patches/parts/fleet_watch.cc index 2a951e541..8ceee3d2f 100644 --- a/mods/src/patches/parts/fleet_watch.cc +++ b/mods/src/patches/parts/fleet_watch.cc @@ -19,8 +19,9 @@ FleetPlayerData* fleet_widget_context(void* self) return nullptr; } - auto helper = IL2CppClassHelper{reinterpret_cast(self)->klass}; - auto get_context = helper.GetMethod("get_Context", 0); + static auto get_context = + IL2CppClassHelper{reinterpret_cast(self)->klass}.GetMethod("get_Context", + 0); return get_context ? get_context(self) : nullptr; } From 192ced56e41e759fe339e22a6fc337bc3cf7bdc0 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 01:38:10 -0500 Subject: [PATCH 51/80] feat: add configurable fleet audio alerts --- example_community_patch_settings_da.toml | 10 +++++ example_community_patch_settings_de.toml | 10 +++++ ...munity_patch_settings_en-GB-x-cockney.toml | 10 +++++ ...mmunity_patch_settings_en-x-minionese.toml | 10 +++++ example_community_patch_settings_en.toml | 10 +++++ example_community_patch_settings_es.toml | 10 +++++ example_community_patch_settings_fr.toml | 10 +++++ example_community_patch_settings_nl.toml | 10 +++++ example_community_patch_settings_ru.toml | 10 +++++ example_community_patch_settings_tlh.toml | 10 +++++ mods/src/config.cc | 10 ++++- mods/src/config.h | 1 + mods/src/defaultconfig.h | 17 ++++++--- mods/src/patches/fleet_notification_types.h | 23 ++++++++---- mods/src/patches/fleet_watch.cc | 37 ++++++++++++++----- mods/src/patches/notification_audio.cc | 5 ++- 16 files changed, 168 insertions(+), 25 deletions(-) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 436968924..10ed9df4f 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -46,6 +46,16 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" +# Optional Fleet Watch cues; "none" disables an individual alert. +# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[======================================================================================================]> # # ***** * * * diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 3f53858a6..135c0e184 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -46,6 +46,16 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" +# Optional Fleet Watch cues; "none" disables an individual alert. +# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 21af6ebcb..de1c0866b 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -46,6 +46,16 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" +# Optional Fleet Watch cues; "none" disables an individual alert. +# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index d38139da2..6d8d5df86 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -46,6 +46,16 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" +# Optional Fleet Watch cues; "none" disables an individual alert. +# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index a9dc6156e..fe19d35a6 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -46,6 +46,16 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" +# Optional Fleet Watch cues; "none" disables an individual alert. +# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 6aa2fc586..057a83829 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -46,6 +46,16 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" +# Optional Fleet Watch cues; "none" disables an individual alert. +# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=====================================================]> # # * * * diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index cc213ec20..24f045655 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -46,6 +46,16 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" +# Optional Fleet Watch cues; "none" disables an individual alert. +# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index c83890b6b..e30ead854 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -46,6 +46,16 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" +# Optional Fleet Watch cues; "none" disables an individual alert. +# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 2756f1f00..e4ca1dbf8 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -46,6 +46,16 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" +# Optional Fleet Watch cues; "none" disables an individual alert. +# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[============================================================]> # # diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 8f5b7af16..2fd4a66d7 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -46,6 +46,16 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" +# Optional Fleet Watch cues; "none" disables an individual alert. +# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=====================================================]> # # * * **** * * diff --git a/mods/src/config.cc b/mods/src/config.cc index 6ffae9d24..980651c44 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -335,8 +335,8 @@ NotificationSound get_notification_sound(toml::table& config, toml::table& new_c std::string(default_value), false); auto sound = notification_sound_from_name(StripAsciiWhitespace(value)); if (!sound.has_value()) { - spdlog::warn("invalid config value audio.{}: '{}'; using {}", item, value, default_value); - sound = notification_sound_from_name(default_value); + spdlog::warn("invalid config value audio.{}: '{}'; disabling this alert", item, value); + sound = NotificationSound::None; } const auto result = sound.value_or(NotificationSound::None); @@ -981,6 +981,12 @@ void Config::Load() get_notification_sound(config, parsed, "alert_armada_battle_won", DCA::alert_armada_battle_won, write_config); this->alert_armada_battle_lost = get_notification_sound(config, parsed, "alert_armada_battle_lost", DCA::alert_armada_battle_lost, write_config); + static_assert(DCA::alert_fleet_events.size() == kFleetNotificationCatalog.size()); + for (const auto& entry : kFleetNotificationCatalog) { + const auto index = fleet_notification_index(entry.kind); + this->fleet_notification_sounds[index] = + get_notification_sound(config, parsed, entry.audio_config_name, DCA::alert_fleet_events[index], write_config); + } if (!this->installToastBannerHooks && (this->alert_victory != NotificationSound::None || this->alert_defeat != NotificationSound::None || this->alert_armada_created != NotificationSound::None diff --git a/mods/src/config.h b/mods/src/config.h index 2f2618fcf..291001888 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -187,6 +187,7 @@ class Config final std::vector disabled_banner_types; std::vector notify_banner_types; FleetNotificationMask notify_fleet_events = 0; + std::array(FleetNotificationKind::Count)> fleet_notification_sounds{}; int extend_chest_purchase_max; int extend_donation_max; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index dc426f4bb..3d9aa5612 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace DefaultConfig { namespace Buffs @@ -9,13 +11,16 @@ namespace Buffs namespace Audio { - constexpr const char* disabled_events = ""; - constexpr bool trace_events = false; - constexpr const char* alert_victory = "none"; - constexpr const char* alert_defeat = "none"; - constexpr const char* alert_armada_created = "none"; - constexpr const char* alert_armada_battle_won = "none"; + constexpr const char* disabled_events = ""; + constexpr bool trace_events = false; + constexpr const char* alert_victory = "none"; + constexpr const char* alert_defeat = "none"; + constexpr const char* alert_armada_created = "none"; + constexpr const char* alert_armada_battle_won = "none"; constexpr const char* alert_armada_battle_lost = "none"; + constexpr std::array alert_fleet_events{ + "none", "none", "none", "none", "none", "none", "none", + }; } // namespace Audio namespace SystemConfig diff --git a/mods/src/patches/fleet_notification_types.h b/mods/src/patches/fleet_notification_types.h index a95450a5e..269a92426 100644 --- a/mods/src/patches/fleet_notification_types.h +++ b/mods/src/patches/fleet_notification_types.h @@ -21,16 +21,22 @@ using FleetNotificationMask = uint32_t; struct FleetNotificationCatalogEntry { FleetNotificationKind kind; std::string_view config_name; + std::string_view audio_config_name; }; inline constexpr std::array kFleetNotificationCatalog{ - FleetNotificationCatalogEntry{FleetNotificationKind::ArrivedInSystem, "ArrivedInSystem"}, - FleetNotificationCatalogEntry{FleetNotificationKind::ArrivedAtDestination, "ArrivedAtDestination"}, - FleetNotificationCatalogEntry{FleetNotificationKind::StartedMining, "StartedMining"}, - FleetNotificationCatalogEntry{FleetNotificationKind::NodeDepleted, "NodeDepleted"}, - FleetNotificationCatalogEntry{FleetNotificationKind::Docked, "Docked"}, - FleetNotificationCatalogEntry{FleetNotificationKind::RepairComplete, "RepairComplete"}, - FleetNotificationCatalogEntry{FleetNotificationKind::MinerOpc, "MinerOPC"}, + FleetNotificationCatalogEntry{FleetNotificationKind::ArrivedInSystem, "ArrivedInSystem", + "alert_fleet_arrived_in_system"}, + FleetNotificationCatalogEntry{FleetNotificationKind::ArrivedAtDestination, "ArrivedAtDestination", + "alert_fleet_arrived_at_destination"}, + FleetNotificationCatalogEntry{FleetNotificationKind::StartedMining, "StartedMining", + "alert_fleet_started_mining"}, + FleetNotificationCatalogEntry{FleetNotificationKind::NodeDepleted, "NodeDepleted", + "alert_fleet_node_depleted"}, + FleetNotificationCatalogEntry{FleetNotificationKind::Docked, "Docked", "alert_fleet_docked"}, + FleetNotificationCatalogEntry{FleetNotificationKind::RepairComplete, "RepairComplete", + "alert_fleet_repair_complete"}, + FleetNotificationCatalogEntry{FleetNotificationKind::MinerOpc, "MinerOPC", "alert_fleet_miner_opc"}, }; static_assert(kFleetNotificationCatalog.size() == static_cast(FleetNotificationKind::Count)); @@ -38,6 +44,9 @@ static_assert(kFleetNotificationCatalog.size() == static_cast(Fleet constexpr FleetNotificationMask fleet_notification_bit(FleetNotificationKind kind) { return FleetNotificationMask{1} << static_cast(kind); } +constexpr std::size_t fleet_notification_index(FleetNotificationKind kind) +{ return static_cast(kind); } + constexpr FleetNotificationMask kAllFleetNotifications = [] { FleetNotificationMask result = 0; for (const auto& entry : kFleetNotificationCatalog) { diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index d091996ed..e4675a3cc 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -3,6 +3,7 @@ #include "config.h" #include "errormsg.h" #include "patches/fleet_notification_types.h" +#include "patches/notification_audio.h" #include "patches/notification_service.h" #include "str_utils.h" @@ -342,27 +343,42 @@ FleetPlayerData* fleet_for_snapshot(int slot_index, uint64_t fleet_id) return fleet && fleet->Id == fleet_id ? fleet : nullptr; } +void emit_notification(FleetNotificationKind kind, std::string_view title, std::string body) +{ + notification_emit(title, body); + + const auto index = fleet_notification_index(kind); + const auto sound = Config::Get().fleet_notification_sounds[index]; + if (sound == NotificationSound::None) { + return; + } + + spdlog::debug("[FleetWatch] audio event={} sound={}", kFleetNotificationCatalog[index].config_name, + notification_sound_name(sound)); + notification_audio_play(sound); +} + void emit_transition(FleetNotificationKind kind, FleetPlayerData* fleet, int64_t resource_id) { const auto subject = notification_subject(fleet); switch (kind) { case FleetNotificationKind::ArrivedInSystem: - notification_emit("Fleet Arrived", "Your " + subject + " has arrived in-system"); + emit_notification(kind, "Fleet Arrived", "Your " + subject + " has arrived in-system"); break; case FleetNotificationKind::ArrivedAtDestination: - notification_emit("Fleet Arrived", "Your " + subject + " has reached its destination"); + emit_notification(kind, "Fleet Arrived", "Your " + subject + " has reached its destination"); break; case FleetNotificationKind::StartedMining: { const auto resource = resource_name(resource_id); const auto detail = resource.empty() ? std::string{} : " " + resource; - notification_emit("Fleet Mining", "Your " + subject + " started mining" + detail); + emit_notification(kind, "Fleet Mining", "Your " + subject + " started mining" + detail); break; } case FleetNotificationKind::Docked: - notification_emit("Fleet Docked", "Your " + subject + " docked"); + emit_notification(kind, "Fleet Docked", "Your " + subject + " docked"); break; case FleetNotificationKind::RepairComplete: - notification_emit("Repair Complete", "Your " + subject + " finished repairs"); + emit_notification(kind, "Repair Complete", "Your " + subject + " finished repairs"); break; default: break; @@ -513,7 +529,8 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool allow_notifi } else { const auto subject = notification_subject(fleet); spdlog::debug("[FleetWatch] source={} event=MinerOPC slot={} fleet={}", source, slot_index, fleet_id); - notification_emit("Miner Is OPC", "Your " + subject + " is now over protected cargo." + cargo_text(fleet)); + emit_notification(FleetNotificationKind::MinerOpc, "Miner Is OPC", + "Your " + subject + " is now over protected cargo." + cargo_text(fleet)); } } @@ -764,7 +781,8 @@ void fleet_watch_observe_node_depleted(int64_t fleet_id) const auto subject = notification_subject(fleet); const auto resource = resource_name(snapshot.resource_id); const auto detail = resource.empty() ? std::string{" its node"} : " its " + resource + " node"; - notification_emit("Node Depleted", subject + " depleted" + detail + "." + cargo_text(fleet)); + emit_notification(FleetNotificationKind::NodeDepleted, "Node Depleted", + subject + " depleted" + detail + "." + cargo_text(fleet)); return; } @@ -777,11 +795,12 @@ void fleet_watch_observe_node_depleted(int64_t fleet_id) auto* mining_data = fleet->MiningData; const auto resource = resource_name(mining_data ? mining_data->ResourceId : 0); const auto detail = resource.empty() ? std::string{" its node"} : " its " + resource + " node"; - notification_emit("Node Depleted", notification_subject(fleet) + " depleted" + detail + "." + cargo_text(fleet)); + emit_notification(FleetNotificationKind::NodeDepleted, "Node Depleted", + notification_subject(fleet) + " depleted" + detail + "." + cargo_text(fleet)); return; } - notification_emit("Node Depleted", "A mining fleet depleted its node."); + emit_notification(FleetNotificationKind::NodeDepleted, "Node Depleted", "A mining fleet depleted its node."); } void fleet_watch_tick() diff --git a/mods/src/patches/notification_audio.cc b/mods/src/patches/notification_audio.cc index ebdd87c3c..fc4ac8941 100644 --- a/mods/src/patches/notification_audio.cc +++ b/mods/src/patches/notification_audio.cc @@ -188,7 +188,8 @@ const char* notification_sound_name(NotificationSound sound) std::optional notification_sound_from_name(std::string_view name) { const auto normalized = normalize_sound_name(name); - if (normalized == "none" || normalized == "off" || normalized == "silent") + if (normalized == "none" || normalized == "false" || normalized == "off" || normalized == "disabled" + || normalized == "silent" || normalized == "0") return NotificationSound::None; if (normalized == "default") return NotificationSound::Default; @@ -224,7 +225,9 @@ void notification_audio_play(NotificationSound sound) const auto& buffer = s_sound_buffers[index]; if (!notification_audio_platform_play(buffer.data(), buffer.size())) { spdlog::warn("[NotifyAudio] Failed to play '{}' cue", notification_sound_name(sound)); + return; } + spdlog::debug("[NotifyAudio] Played '{}' cue", notification_sound_name(sound)); } #if _WIN32 From 2fab58bc6ef63a4916c689685ab17d015e24eee6 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 01:44:14 -0500 Subject: [PATCH 52/80] fix: fail closed on fleet audio config --- example_community_patch_settings_da.toml | 2 +- example_community_patch_settings_de.toml | 2 +- ...ommunity_patch_settings_en-GB-x-cockney.toml | 2 +- ...community_patch_settings_en-x-minionese.toml | 2 +- example_community_patch_settings_en.toml | 2 +- example_community_patch_settings_es.toml | 2 +- example_community_patch_settings_fr.toml | 2 +- example_community_patch_settings_nl.toml | 2 +- example_community_patch_settings_ru.toml | 2 +- example_community_patch_settings_tlh.toml | 2 +- mods/src/config.cc | 17 +++++++++++++++-- mods/src/patches/notification_service.h | 2 +- 12 files changed, 26 insertions(+), 13 deletions(-) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 10ed9df4f..a579377d1 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Fleet Watch cues; "none" disables an individual alert. +# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 135c0e184..3bccda1f4 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Fleet Watch cues; "none" disables an individual alert. +# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index de1c0866b..7dd533cd3 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Fleet Watch cues; "none" disables an individual alert. +# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 6d8d5df86..6e3ce21b5 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Fleet Watch cues; "none" disables an individual alert. +# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index fe19d35a6..16bd25462 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Fleet Watch cues; "none" disables an individual alert. +# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 057a83829..7a3599149 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Fleet Watch cues; "none" disables an individual alert. +# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index 24f045655..f3b38a473 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Fleet Watch cues; "none" disables an individual alert. +# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index e30ead854..50b38e520 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Fleet Watch cues; "none" disables an individual alert. +# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index e4ca1dbf8..821d9765a 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Fleet Watch cues; "none" disables an individual alert. +# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 2fd4a66d7..a6976e1cb 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Fleet Watch cues; "none" disables an individual alert. +# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/mods/src/config.cc b/mods/src/config.cc index 980651c44..2d9af06c8 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -331,8 +331,21 @@ T get_config_or_default(toml::table& config, toml::table& new_config, std::strin NotificationSound get_notification_sound(toml::table& config, toml::table& new_config, std::string_view item, std::string_view default_value, bool write_log) { - const auto value = get_config_or_default(config, new_config, "audio", item, - std::string(default_value), false); + new_config.emplace("audio", toml::table()); + + std::string value{default_value}; + const auto configured = config["audio"][item]; + if (configured) { + if (const auto string_value = configured.value()) { + value = *string_value; + } else if (const auto bool_value = configured.value(); bool_value.has_value() && !*bool_value) { + value = "none"; + } else { + spdlog::warn("invalid config value audio.{}; expected a sound name or false; disabling this alert", item); + value = "none"; + } + } + auto sound = notification_sound_from_name(StripAsciiWhitespace(value)); if (!sound.has_value()) { spdlog::warn("invalid config value audio.{}: '{}'; disabling this alert", item, value); diff --git a/mods/src/patches/notification_service.h b/mods/src/patches/notification_service.h index df37b87b3..11fd5dcd9 100644 --- a/mods/src/patches/notification_service.h +++ b/mods/src/patches/notification_service.h @@ -5,7 +5,7 @@ struct Toast; // Initialize the notification service (resolve IL2CPP methods, init platform). -// Call once during InstallToastBannerHooks(). +// Idempotent; call from each feature installer that emits notifications. void notification_init(); // Deliver a notification created by a mod feature rather than a Scopely toast. From 680ea00b3f9b9507441de4101e09396cba756f38 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 01:47:42 -0500 Subject: [PATCH 53/80] feat: enable fleet audio on macOS --- example_community_patch_settings_da.toml | 2 +- example_community_patch_settings_de.toml | 2 +- example_community_patch_settings_en-GB-x-cockney.toml | 2 +- example_community_patch_settings_en-x-minionese.toml | 2 +- example_community_patch_settings_en.toml | 2 +- example_community_patch_settings_es.toml | 2 +- example_community_patch_settings_fr.toml | 2 +- example_community_patch_settings_nl.toml | 2 +- example_community_patch_settings_ru.toml | 2 +- example_community_patch_settings_tlh.toml | 2 +- mods/src/patches/fleet_watch.cc | 9 +++++++++ mods/src/patches/parts/fleet_watch.cc | 2 +- 12 files changed, 20 insertions(+), 11 deletions(-) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index a579377d1..9ddd20ac9 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 3bccda1f4..648c3c802 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 7dd533cd3..5b037658a 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 6e3ce21b5..c51bd8b4a 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 16bd25462..156784c91 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 7a3599149..cd50c9eb2 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index f3b38a473..c59a3ff5c 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 50b38e520..ce3176441 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 821d9765a..09b4e9b9a 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index a6976e1cb..de36937ad 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -46,7 +46,7 @@ alert_armada_created = "none" alert_armada_battle_won = "none" alert_armada_battle_lost = "none" -# Optional Windows-only Fleet Watch cues; "none" disables an individual alert. +# Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. # A cue plays only when its matching [ui].notify_fleet_events entry is enabled. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index e4675a3cc..a059382c2 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -714,6 +714,15 @@ void fleet_watch_init() | fleet_notification_bit(FleetNotificationKind::RepairComplete); constexpr auto state_events = follow_through_events | fleet_notification_bit(FleetNotificationKind::MinerOpc); s_enabled_notifications = Config::Get().notify_fleet_events; +#if defined(__APPLE__) + FleetNotificationMask audible_notifications = 0; + for (const auto& entry : kFleetNotificationCatalog) { + if (Config::Get().fleet_notification_sounds[fleet_notification_index(entry.kind)] != NotificationSound::None) { + audible_notifications |= fleet_notification_bit(entry.kind); + } + } + s_enabled_notifications &= audible_notifications; +#endif s_state_observation_enabled = (s_enabled_notifications & state_events) != 0; s_slots = {}; s_last_emitted_ms = {}; diff --git a/mods/src/patches/parts/fleet_watch.cc b/mods/src/patches/parts/fleet_watch.cc index 8ceee3d2f..14c6aefcd 100644 --- a/mods/src/patches/parts/fleet_watch.cc +++ b/mods/src/patches/parts/fleet_watch.cc @@ -40,7 +40,7 @@ void ToastFleetObserver_HandleMiningDepleted_Hook(auto original, void* self, int void InstallFleetWatchHooks() { -#if !_WIN32 +#if !defined(_WIN32) && !defined(__APPLE__) return; #endif From 7b6911d4488a97cb78132a81b272826544fba8f5 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 01:50:33 -0500 Subject: [PATCH 54/80] docs: clarify cross-platform fleet audio gating --- example_community_patch_settings_da.toml | 3 ++- example_community_patch_settings_de.toml | 3 ++- example_community_patch_settings_en-GB-x-cockney.toml | 3 ++- example_community_patch_settings_en-x-minionese.toml | 3 ++- example_community_patch_settings_en.toml | 3 ++- example_community_patch_settings_es.toml | 3 ++- example_community_patch_settings_fr.toml | 3 ++- example_community_patch_settings_nl.toml | 3 ++- example_community_patch_settings_ru.toml | 3 ++- example_community_patch_settings_tlh.toml | 3 ++- 10 files changed, 20 insertions(+), 10 deletions(-) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 9ddd20ac9..008c2ddff 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -658,7 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Windows system notifications to send for fleet events (comma separated). +# Fleet Watch events to observe (comma separated). +# Windows sends system notifications; Windows and macOS can play matching [audio] cues. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 648c3c802..030eeb950 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -658,7 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Windows system notifications to send for fleet events (comma separated). +# Fleet Watch events to observe (comma separated). +# Windows sends system notifications; Windows and macOS can play matching [audio] cues. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 5b037658a..d3cac12b5 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -658,7 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Windows system notifications to send for fleet events (comma separated). +# Fleet Watch events to observe (comma separated). +# Windows sends system notifications; Windows and macOS can play matching [audio] cues. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index c51bd8b4a..f47d3d03a 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -658,7 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Windows system notifications to send for fleet events (comma separated). +# Fleet Watch events to observe (comma separated). +# Windows sends system notifications; Windows and macOS can play matching [audio] cues. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 156784c91..d1d724f84 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -658,7 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Windows system notifications to send for fleet events (comma separated). +# Fleet Watch events to observe (comma separated). +# Windows sends system notifications; Windows and macOS can play matching [audio] cues. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index cd50c9eb2..f61085df5 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -658,7 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Windows system notifications to send for fleet events (comma separated). +# Fleet Watch events to observe (comma separated). +# Windows sends system notifications; Windows and macOS can play matching [audio] cues. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index c59a3ff5c..a173893df 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -658,7 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Windows system notifications to send for fleet events (comma separated). +# Fleet Watch events to observe (comma separated). +# Windows sends system notifications; Windows and macOS can play matching [audio] cues. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index ce3176441..220a1da80 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -658,7 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Windows system notifications to send for fleet events (comma separated). +# Fleet Watch events to observe (comma separated). +# Windows sends system notifications; Windows and macOS can play matching [audio] cues. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 09b4e9b9a..b70292af0 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -658,7 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Windows system notifications to send for fleet events (comma separated). +# Fleet Watch events to observe (comma separated). +# Windows sends system notifications; Windows and macOS can play matching [audio] cues. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index de36937ad..2c9241251 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -658,7 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Windows system notifications to send for fleet events (comma separated). +# Fleet Watch events to observe (comma separated). +# Windows sends system notifications; Windows and macOS can play matching [audio] cues. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" From ee7f7f514ffb7c7ae033aad6403974a946f3345e Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 02:56:26 -0500 Subject: [PATCH 55/80] fix: reopen bulk claim flyout on shop return --- .../patches/parts/open_bulk_claim_gifts.cc | 273 ++++-------------- 1 file changed, 55 insertions(+), 218 deletions(-) diff --git a/mods/src/patches/parts/open_bulk_claim_gifts.cc b/mods/src/patches/parts/open_bulk_claim_gifts.cc index 28d5fd40b..d488ec370 100644 --- a/mods/src/patches/parts/open_bulk_claim_gifts.cc +++ b/mods/src/patches/parts/open_bulk_claim_gifts.cc @@ -1,24 +1,13 @@ #include "config.h" #include "errormsg.h" -#include "prime/Hub.h" -#include "str_utils.h" #include #include #include -#include -#include namespace { -constexpr auto kGiftsCategoryKey = "chests"; -constexpr int kAutoOpenRetryFrames = 300; - -bool g_pending_auto_open = false; -bool g_auto_open_attempted = false; -int g_retry_frames_left = 0; - class DrawerContext { private: @@ -31,9 +20,7 @@ class DrawerContext public: static IL2CppClassHelper& ClassHelper() - { - return get_class_helper(); - } + { return get_class_helper(); } bool Enabled() { @@ -54,9 +41,7 @@ class DrawerWidget public: static IL2CppClassHelper& ClassHelper() - { - return get_class_helper(); - } + { return get_class_helper(); } DrawerContext* Context() { @@ -65,12 +50,6 @@ class DrawerWidget return field.GetRaw(this); } - bool ContextEnabled() - { - auto context = Context(); - return context != nullptr && context->Enabled(); - } - void OpenViaButtonCallback() { static auto method = get_class_helper().GetMethod("OnOpenButtonClicked"); @@ -80,68 +59,6 @@ class DrawerWidget } }; -class ShopCategory -{ -private: - static IL2CppClassHelper& get_class_helper() - { - static auto class_helper = - il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.Prime.Shop", "ShopCategory"); - return class_helper; - } - -public: - static IL2CppClassHelper& ClassHelper() - { - return get_class_helper(); - } - - static std::string KeyForValue(int value) - { - static auto method = get_class_helper().GetMethod("EnumToKey"); - auto key = method != nullptr ? method(value) : nullptr; - return key != nullptr ? to_string(key) : std::string{}; - } -}; - -class BundleGroupConfig -{ -private: - static IL2CppClassHelper& get_class_helper() - { - static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Shop", "BundleGroupConfig"); - return class_helper; - } - -public: - static IL2CppClassHelper& ClassHelper() - { - return get_class_helper(); - } - - int Category() - { - static auto field = get_class_helper().GetField("_category"); - return field.isValidHelper() ? *(int*)((ptrdiff_t)this + field.offset()) : -1; - } -}; - -class ShopSectionContext -{ -private: - static IL2CppClassHelper& get_class_helper() - { - static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Shop", "ShopSectionContext"); - return class_helper; - } - -public: - static IL2CppClassHelper& ClassHelper() - { - return get_class_helper(); - } -}; - class ShopListScrollerViewController { private: @@ -154,130 +71,72 @@ class ShopListScrollerViewController public: static IL2CppClassHelper& ClassHelper() - { - return get_class_helper(); - } + { return get_class_helper(); } DrawerWidget* SelectionDrawer() { - // Lower Gifts << drawer; scroller category can describe upper shop chrome such as latinum. static auto field = get_class_helper().GetField("_selectionDrawerWidget"); return field.isValidHelper() ? *(DrawerWidget**)((ptrdiff_t)this + field.offset()) : nullptr; } }; -bool IsClassNamed(void* object, const char* namespaze, const char* name) -{ - if (object == nullptr) { - return false; - } - - const auto klass = ((Il2CppObject*)object)->klass; - if (klass == nullptr) { - return false; - } - - const auto object_namespace = klass->namespaze != nullptr ? klass->namespaze : ""; - return strcmp(object_namespace, namespaze) == 0 && strcmp(klass->name, name) == 0; -} - -void ClearAutoOpen() -{ - g_pending_auto_open = false; - g_retry_frames_left = 0; -} - -void ArmAutoOpen() -{ - g_pending_auto_open = true; - g_auto_open_attempted = false; - g_retry_frames_left = kAutoOpenRetryFrames; -} - -bool IsGiftsShopPayload(int section, void* args) -{ - if (section != (int)SectionID::Shop_List || !IsClassNamed(args, "Digit.Prime.Shop", "BundleGroupConfig")) { - return false; - } - - const auto category = ((BundleGroupConfig*)args)->Category(); - return ShopCategory::KeyForValue(category) == kGiftsCategoryKey; -} - -bool IsBulkClaimTabSection(int section) -{ - return section == (int)SectionID::Shop_List || section == (int)SectionID::Shop_AllianceChests - || section == (int)SectionID::Consumables || section == (int)SectionID::Missions_AwayTeamsList; -} +ShopListScrollerViewController* g_active_scroller = nullptr; +bool g_opened_for_landing = false; void TryAutoOpenDrawer(ShopListScrollerViewController* controller) { - if (!Config::Get().auto_open_bulk_claim_flyout || !g_pending_auto_open || g_auto_open_attempted - || controller == nullptr) { + if (!Config::Get().auto_open_bulk_claim_flyout || controller == nullptr || g_opened_for_landing) { return; } - if (g_retry_frames_left-- <= 0) { - ClearAutoOpen(); + auto* drawer = controller->SelectionDrawer(); + auto* context = drawer != nullptr ? drawer->Context() : nullptr; + if (context == nullptr || !context->Enabled()) { return; } - auto drawer = controller->SelectionDrawer(); - if (drawer == nullptr || !drawer->ContextEnabled()) { - return; - } - - g_auto_open_attempted = true; - ClearAutoOpen(); + // Mark first so a native callback cannot cause a nested lifecycle update to open the drawer twice. + g_opened_for_landing = true; drawer->OpenViaButtonCallback(); } -void ShopListViewController_AboutToHide(auto original, void* _this) +void DrawerWidget_OnDidBindContext(auto original, DrawerWidget* self) { - ClearAutoOpen(); - original(_this); + original(self); + + if (g_active_scroller != nullptr && g_active_scroller->SelectionDrawer() == self) { + // A new native selection-drawer binding represents a new in-place tab landing. + g_opened_for_landing = false; + TryAutoOpenDrawer(g_active_scroller); + } } -void ShopListScrollerViewController_Update(auto original, ShopListScrollerViewController* _this) +void ShopListScrollerViewController_AboutToShow(auto original, ShopListScrollerViewController* self) { - original(_this); - TryAutoOpenDrawer(_this); + g_active_scroller = self; + g_opened_for_landing = false; + + original(self); + TryAutoOpenDrawer(self); } -void ShopSectionContext_InjectTabData(auto original, ShopSectionContext* _this, int section, - Il2CppArraySize* tab_locale_contexts, - Il2CppArraySize* additional_tab_locale_contexts, - Il2CppArraySize* tab_icon_identifiers, Il2CppArraySize* pip_types, - Il2CppArraySize* hide_if_no_content, bool set_current_section, - bool override_existing_tabs) +void ShopListScrollerViewController_AboutToHide(auto original, ShopListScrollerViewController* self) { - original(_this, section, tab_locale_contexts, additional_tab_locale_contexts, tab_icon_identifiers, pip_types, - hide_if_no_content, set_current_section, override_existing_tabs); - - if (!Config::Get().auto_open_bulk_claim_flyout || !set_current_section) { - return; + if (g_active_scroller == self) { + g_active_scroller = nullptr; + g_opened_for_landing = false; } - if (IsBulkClaimTabSection(section)) { - ArmAutoOpen(); - } else { - ClearAutoOpen(); - } + original(self); } -bool SectionManager_TriggerSectionChange(auto original, - void* _this, - int next_section, - void* args, - bool forced_section_change, - bool is_go_back_step, - bool allow_same_section) +void ShopListScrollerViewController_UpdateDrawer(auto original, ShopListScrollerViewController* self) { - const auto changed = original(_this, next_section, args, forced_section_change, is_go_back_step, allow_same_section); - if (changed && Config::Get().auto_open_bulk_claim_flyout && IsGiftsShopPayload(next_section, args)) { - ArmAutoOpen(); + original(self); + + if (g_active_scroller == self) { + TryAutoOpenDrawer(self); } - return changed; } } // namespace @@ -301,60 +160,38 @@ void InstallGiftsBulkClaimHooks() if (drawer_widget.GetMethod("OnOpenButtonClicked") == nullptr) { ErrorMsg::MissingMethod("DrawerWidget", "OnOpenButtonClicked"); } + if (auto ptr = drawer_widget.GetMethod("OnDidBindContext"); ptr == nullptr) { + ErrorMsg::MissingMethod("DrawerWidget", "OnDidBindContext"); + } else { + SPUD_STATIC_DETOUR(ptr, DrawerWidget_OnDidBindContext); + } } - auto shop_category = ShopCategory::ClassHelper(); - if (!shop_category.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Prime.Shop", "ShopCategory"); - } else if (shop_category.GetMethod("EnumToKey") == nullptr) { - ErrorMsg::MissingMethod("ShopCategory", "EnumToKey"); - } - - auto bundle_group_config = BundleGroupConfig::ClassHelper(); - if (!bundle_group_config.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Prime.Shop", "BundleGroupConfig"); - } else if (!bundle_group_config.GetField("_category").isValidHelper()) { - ErrorMsg::MissingMethod("BundleGroupConfig", "_category"); + auto shop_list_scroller = ShopListScrollerViewController::ClassHelper(); + if (!shop_list_scroller.isValidHelper()) { + ErrorMsg::MissingHelper("Digit.Prime.Shop", "ShopListScrollerViewController"); + return; } - auto shop_section_context = ShopSectionContext::ClassHelper(); - if (!shop_section_context.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Prime.Shop", "ShopSectionContext"); - } else if (auto ptr = shop_section_context.GetMethod("InjectTabData"); ptr == nullptr) { - ErrorMsg::MissingMethod("ShopSectionContext", "InjectTabData"); - } else { - SPUD_STATIC_DETOUR(ptr, ShopSectionContext_InjectTabData); + if (!shop_list_scroller.GetField("_selectionDrawerWidget").isValidHelper()) { + ErrorMsg::MissingMethod("ShopListScrollerViewController", "_selectionDrawerWidget"); } - auto shop_list_controller = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Shop", "ShopListViewController"); - if (!shop_list_controller.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Prime.Shop", "ShopListViewController"); - } else if (auto ptr = shop_list_controller.GetMethod("AboutToHide"); ptr == nullptr) { - ErrorMsg::MissingMethod("ShopListViewController", "AboutToHide"); + if (auto ptr = shop_list_scroller.GetMethod("AboutToShow"); ptr == nullptr) { + ErrorMsg::MissingMethod("ShopListScrollerViewController", "AboutToShow"); } else { - SPUD_STATIC_DETOUR(ptr, ShopListViewController_AboutToHide); + SPUD_STATIC_DETOUR(ptr, ShopListScrollerViewController_AboutToShow); } - auto shop_list_scroller = ShopListScrollerViewController::ClassHelper(); - if (!shop_list_scroller.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Prime.Shop", "ShopListScrollerViewController"); + if (auto ptr = shop_list_scroller.GetMethod("AboutToHide"); ptr == nullptr) { + ErrorMsg::MissingMethod("ShopListScrollerViewController", "AboutToHide"); } else { - if (!shop_list_scroller.GetField("_selectionDrawerWidget").isValidHelper()) { - ErrorMsg::MissingMethod("ShopListScrollerViewController", "_selectionDrawerWidget"); - } - if (auto ptr = shop_list_scroller.GetMethod("Update"); ptr == nullptr) { - ErrorMsg::MissingMethod("ShopListScrollerViewController", "Update"); - } else { - SPUD_STATIC_DETOUR(ptr, ShopListScrollerViewController_Update); - } + SPUD_STATIC_DETOUR(ptr, ShopListScrollerViewController_AboutToHide); } - auto section_manager = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.Sections", "SectionManager"); - if (!section_manager.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Client.Sections", "SectionManager"); - } else if (auto ptr = section_manager.GetMethod("TriggerSectionChange", 5); ptr == nullptr) { - ErrorMsg::MissingMethod("SectionManager", "TriggerSectionChange(section,args)"); + if (auto ptr = shop_list_scroller.GetMethod("UpdateDrawer"); ptr == nullptr) { + ErrorMsg::MissingMethod("ShopListScrollerViewController", "UpdateDrawer"); } else { - SPUD_STATIC_DETOUR(ptr, SectionManager_TriggerSectionChange); + SPUD_STATIC_DETOUR(ptr, ShopListScrollerViewController_UpdateDrawer); } } From 4f045c0d1cf399d8266d1d6ec48cd011bfe594e4 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 03:10:35 -0500 Subject: [PATCH 56/80] fix: fail closed on bulk flyout hook drift --- .../patches/parts/open_bulk_claim_gifts.cc | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/mods/src/patches/parts/open_bulk_claim_gifts.cc b/mods/src/patches/parts/open_bulk_claim_gifts.cc index d488ec370..ded9379ea 100644 --- a/mods/src/patches/parts/open_bulk_claim_gifts.cc +++ b/mods/src/patches/parts/open_bulk_claim_gifts.cc @@ -100,17 +100,6 @@ void TryAutoOpenDrawer(ShopListScrollerViewController* controller) drawer->OpenViaButtonCallback(); } -void DrawerWidget_OnDidBindContext(auto original, DrawerWidget* self) -{ - original(self); - - if (g_active_scroller != nullptr && g_active_scroller->SelectionDrawer() == self) { - // A new native selection-drawer binding represents a new in-place tab landing. - g_opened_for_landing = false; - TryAutoOpenDrawer(g_active_scroller); - } -} - void ShopListScrollerViewController_AboutToShow(auto original, ShopListScrollerViewController* self) { g_active_scroller = self; @@ -142,28 +131,30 @@ void ShopListScrollerViewController_UpdateDrawer(auto original, ShopListScroller void InstallGiftsBulkClaimHooks() { + bool can_install = true; + auto drawer_context = DrawerContext::ClassHelper(); if (!drawer_context.isValidHelper()) { ErrorMsg::MissingHelper("Prime.SharedFeatures.Scripts.UI.Widgets", "DrawerContext"); + can_install = false; } else if (!drawer_context.GetField("Enabled").isValidHelper()) { ErrorMsg::MissingMethod("DrawerContext", "Enabled"); + can_install = false; } auto drawer_widget = DrawerWidget::ClassHelper(); if (!drawer_widget.isValidHelper()) { ErrorMsg::MissingHelper("Prime.SharedFeatures.Scripts.UI.Widgets", "DrawerWidget"); + can_install = false; } else { auto widget_base = drawer_widget.GetParent("Widget`1"); if (!widget_base.isValidHelper() || !widget_base.GetProperty("Context").isValidHelper()) { ErrorMsg::MissingMethod("DrawerWidget", "Widget.Context"); + can_install = false; } if (drawer_widget.GetMethod("OnOpenButtonClicked") == nullptr) { ErrorMsg::MissingMethod("DrawerWidget", "OnOpenButtonClicked"); - } - if (auto ptr = drawer_widget.GetMethod("OnDidBindContext"); ptr == nullptr) { - ErrorMsg::MissingMethod("DrawerWidget", "OnDidBindContext"); - } else { - SPUD_STATIC_DETOUR(ptr, DrawerWidget_OnDidBindContext); + can_install = false; } } @@ -175,23 +166,32 @@ void InstallGiftsBulkClaimHooks() if (!shop_list_scroller.GetField("_selectionDrawerWidget").isValidHelper()) { ErrorMsg::MissingMethod("ShopListScrollerViewController", "_selectionDrawerWidget"); + can_install = false; } - if (auto ptr = shop_list_scroller.GetMethod("AboutToShow"); ptr == nullptr) { + auto about_to_show = shop_list_scroller.GetMethod("AboutToShow"); + if (about_to_show == nullptr) { ErrorMsg::MissingMethod("ShopListScrollerViewController", "AboutToShow"); - } else { - SPUD_STATIC_DETOUR(ptr, ShopListScrollerViewController_AboutToShow); + can_install = false; } - if (auto ptr = shop_list_scroller.GetMethod("AboutToHide"); ptr == nullptr) { + auto about_to_hide = shop_list_scroller.GetMethod("AboutToHide"); + if (about_to_hide == nullptr) { ErrorMsg::MissingMethod("ShopListScrollerViewController", "AboutToHide"); - } else { - SPUD_STATIC_DETOUR(ptr, ShopListScrollerViewController_AboutToHide); + can_install = false; } - if (auto ptr = shop_list_scroller.GetMethod("UpdateDrawer"); ptr == nullptr) { + auto update_drawer = shop_list_scroller.GetMethod("UpdateDrawer"); + if (update_drawer == nullptr) { ErrorMsg::MissingMethod("ShopListScrollerViewController", "UpdateDrawer"); - } else { - SPUD_STATIC_DETOUR(ptr, ShopListScrollerViewController_UpdateDrawer); + can_install = false; } + + if (!can_install) { + return; + } + + SPUD_STATIC_DETOUR(about_to_show, ShopListScrollerViewController_AboutToShow); + SPUD_STATIC_DETOUR(about_to_hide, ShopListScrollerViewController_AboutToHide); + SPUD_STATIC_DETOUR(update_drawer, ShopListScrollerViewController_UpdateDrawer); } From 3c832634b0b8a785dd50b5f2403df0477d6ad631 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 18:15:11 -0500 Subject: [PATCH 57/80] Add configurable fleet label detail --- example_community_patch_settings_da.toml | 11 +- example_community_patch_settings_de.toml | 11 +- ...munity_patch_settings_en-GB-x-cockney.toml | 11 +- ...mmunity_patch_settings_en-x-minionese.toml | 11 +- example_community_patch_settings_en.toml | 11 +- example_community_patch_settings_es.toml | 11 +- example_community_patch_settings_fr.toml | 11 +- example_community_patch_settings_nl.toml | 11 +- example_community_patch_settings_ru.toml | 11 +- example_community_patch_settings_tlh.toml | 11 +- mods/src/config.cc | 79 +++++- mods/src/config.h | 9 + mods/src/defaultconfig.h | 2 + mods/src/patches/parts/zoom.cc | 250 +++++++++++++++++- mods/src/prime/NavigationFleetWidget.h | 27 ++ mods/src/prime/NavigationLOD.h | 22 ++ mods/src/prime/NavigationZoom.h | 15 ++ mods/src/prime/ZoomLevels.h | 8 + 18 files changed, 504 insertions(+), 18 deletions(-) create mode 100644 mods/src/prime/NavigationFleetWidget.h create mode 100644 mods/src/prime/NavigationLOD.h create mode 100644 mods/src/prime/ZoomLevels.h diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 4e4003d2f..61634d6b5 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -121,6 +121,16 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Detaljegrad for flådeetiketter i systemvisning: +# "native" - brug Scopelys Near/Middle/Far-adfærd +# "expanded" - vis altid lange flådeetiketter +# "compact" - vis altid kompakte flådeetiketter +# "threshold" - skift fra lange til kompakte etiketter ved fleet_label_zoom_threshold +fleet_label_detail = "native" + +# Normaliseret zoomgrænse for "threshold" (0.0 = altid kompakt, 1.0 = altid udvidet) +fleet_label_zoom_threshold = 0.5 + # Tiden mellem overgange; øg værdien for at gøre overgangene langsommere transition_time = 0.01 @@ -829,4 +839,3 @@ verify_ssl = true # <[========================================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 2dda59892..560bd5c49 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -121,6 +121,16 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Detailgrad der Flottenbeschriftungen in der Systemansicht: +# "native" - Scopelys Near/Middle/Far-Verhalten verwenden +# "expanded" - lange Flottenbeschriftungen immer anzeigen +# "compact" - kompakte Flottenbeschriftungen immer anzeigen +# "threshold" - bei fleet_label_zoom_threshold von erweitert zu kompakt wechseln +fleet_label_detail = "native" + +# Normalisierte Zoomgrenze für "threshold" (0.0 = immer kompakt, 1.0 = immer erweitert) +fleet_label_zoom_threshold = 0.5 + # Dauer zwischen Übergängen; erhöhen, um Übergänge zu verlangsamen transition_time = 0.01 @@ -829,4 +839,3 @@ verify_ssl = true # <[===================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 0ccf711ca..ed856977f 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -121,6 +121,16 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Fleet label detail in system view: +# "native" - use Scopely's Near/Middle/Far behaviour +# "expanded" - always show long fleet labels +# "compact" - always show compact fleet labels +# "threshold" - switch from expanded to compact at fleet_label_zoom_threshold +fleet_label_detail = "native" + +# Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) +fleet_label_zoom_threshold = 0.5 + # Amount of time to spend between transitions, increase to slow transitions down transition_time = 0.01 @@ -829,4 +839,3 @@ verify_ssl = true # <[========================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 9f1e1e3a6..2cebc8033 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -121,6 +121,16 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Fleet label detail in system view: +# "native" - use Scopely's Near/Middle/Far behaviour +# "expanded" - always show long fleet labels +# "compact" - always show compact fleet labels +# "threshold" - switch from expanded to compact at fleet_label_zoom_threshold +fleet_label_detail = "native" + +# Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) +fleet_label_zoom_threshold = 0.5 + # Amount of time to spend between transitions, increase to slow transitions down transition_time = 0.01 @@ -829,4 +839,3 @@ verify_ssl = true # <[=============================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 9ffe927fe..a4c299452 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -121,6 +121,16 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Fleet label detail in system view: +# "native" - use Scopely's Near/Middle/Far behaviour +# "expanded" - always show long fleet labels +# "compact" - always show compact fleet labels +# "threshold" - switch from expanded to compact at fleet_label_zoom_threshold +fleet_label_detail = "native" + +# Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) +fleet_label_zoom_threshold = 0.5 + # Amount of time to spend between transitions, increase to slow transitions down transition_time = 0.01 @@ -829,4 +839,3 @@ verify_ssl = true # <[========================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index b89c9a2e8..9d02cfa1b 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -121,6 +121,16 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Nivel de detalle de las etiquetas de flota en la vista del sistema: +# "native" - usar el comportamiento Near/Middle/Far de Scopely +# "expanded" - mostrar siempre las etiquetas largas +# "compact" - mostrar siempre las etiquetas compactas +# "threshold" - cambiar de expandido a compacto en fleet_label_zoom_threshold +fleet_label_detail = "native" + +# Umbral de zoom normalizado para "threshold" (0.0 = siempre compacto, 1.0 = siempre expandido) +fleet_label_zoom_threshold = 0.5 + # Tiempo entre transiciones; aumenta el valor para ralentizarlas transition_time = 0.01 @@ -829,4 +839,3 @@ verify_ssl = true # <[==========================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index fdbc5f79d..14187e872 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -121,6 +121,16 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Niveau de détail des étiquettes de flotte dans la vue système : +# "native" - utiliser le comportement Near/Middle/Far de Scopely +# "expanded" - toujours afficher les étiquettes longues +# "compact" - toujours afficher les étiquettes compactes +# "threshold" - passer du format étendu au format compact à fleet_label_zoom_threshold +fleet_label_detail = "native" + +# Seuil de zoom normalisé pour "threshold" (0.0 = toujours compact, 1.0 = toujours étendu) +fleet_label_zoom_threshold = 0.5 + # Temps entre les transitions, augmenter pour ralentir les transitions transition_time = 0.01 @@ -829,4 +839,3 @@ verify_ssl = true # <[==========================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index b2b39b208..c64fce52b 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -121,6 +121,16 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Detailniveau van vlootlabels in de systeemweergave: +# "native" - gebruik Scopely's Near/Middle/Far-gedrag +# "expanded" - toon altijd lange vlootlabels +# "compact" - toon altijd compacte vlootlabels +# "threshold" - schakel bij fleet_label_zoom_threshold van uitgebreid naar compact +fleet_label_detail = "native" + +# Genormaliseerde zoomgrens voor "threshold" (0.0 = altijd compact, 1.0 = altijd uitgebreid) +fleet_label_zoom_threshold = 0.5 + # Bepaald de tijd tussen transities, hoe hoger het getal des te trager de transitie transition_time = 0.01 @@ -829,4 +839,3 @@ verify_ssl = true # <[===========================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index ad3db036a..7583dd753 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -121,6 +121,16 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Детализация меток флотов в системном виде: +# "native" - использовать поведение Scopely Near/Middle/Far +# "expanded" - всегда показывать длинные метки флотов +# "compact" - всегда показывать компактные метки флотов +# "threshold" - переключать расширенные метки на компактные при fleet_label_zoom_threshold +fleet_label_detail = "native" + +# Нормализованный порог масштаба для "threshold" (0.0 = всегда компактно, 1.0 = всегда расширенно) +fleet_label_zoom_threshold = 0.5 + # Время между переходами; увеличьте значение, чтобы замедлить переходы transition_time = 0.01 @@ -829,4 +839,3 @@ verify_ssl = true # <[=====================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index b9bce7597..70658974e 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -121,6 +121,16 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 +# Fleet label detail in system view: +# "native" - use Scopely's Near/Middle/Far behaviour +# "expanded" - always show long fleet labels +# "compact" - always show compact fleet labels +# "threshold" - switch from expanded to compact at fleet_label_zoom_threshold +fleet_label_detail = "native" + +# Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) +fleet_label_zoom_threshold = 0.5 + # Amount of poH Daq spend between transitions, increase Daq slow transitions bIng transition_time = 0.01 @@ -829,4 +839,3 @@ verify_ssl = true # <[======================================================================================================]> [sync.targets.spocksclub] - diff --git a/mods/src/config.cc b/mods/src/config.cc index 95deab836..87967e2de 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -13,6 +13,7 @@ #include "defaultconfig.h" #include #include +#include #include #include #include @@ -423,6 +424,77 @@ InstantWarpConfirmation get_auto_confirm_instant_warp(toml::table& config, toml: return confirmation; } +std::string_view to_string(FleetLabelDetail detail) +{ + switch (detail) { + case FleetLabelDetail::Expanded: + return "expanded"; + case FleetLabelDetail::Compact: + return "compact"; + case FleetLabelDetail::Threshold: + return "threshold"; + case FleetLabelDetail::Native: + default: + return "native"; + } +} + +FleetLabelDetail parse_fleet_label_detail(std::string_view value) +{ + const auto normalized = AsciiStrToUpper(StripAsciiWhitespace(value)); + + if (normalized == "EXPANDED" || normalized == "ALWAYS") { + return FleetLabelDetail::Expanded; + } + if (normalized == "COMPACT" || normalized == "NEVER") { + return FleetLabelDetail::Compact; + } + if (normalized == "THRESHOLD" || normalized == "CUSTOM") { + return FleetLabelDetail::Threshold; + } + if (normalized == "NATIVE" || normalized == "AUTO" || normalized == "NONE" || normalized == "OFF" + || normalized.empty()) { + return FleetLabelDetail::Native; + } + + spdlog::warn("invalid config value graphics.fleet_label_detail: '{}'; using native", value); + return FleetLabelDetail::Native; +} + +FleetLabelDetail get_fleet_label_detail(toml::table& config, toml::table& new_config, std::string_view default_value, + bool write_log) +{ + const auto value = config["graphics"]["fleet_label_detail"].value().value_or(std::string(default_value)); + const auto detail = parse_fleet_label_detail(value); + + new_config.emplace("graphics", toml::table()); + new_config["graphics"].as_table()->insert_or_assign("fleet_label_detail", std::string(to_string(detail))); + + if (write_log) { + spdlog::debug("config value graphics.fleet_label_detail value: {}", to_string(detail)); + } + + return detail; +} + +float get_fleet_label_zoom_threshold(toml::table& config, toml::table& new_config, float default_value, bool write_log) +{ + auto threshold = config["graphics"]["fleet_label_zoom_threshold"].value().value_or(default_value); + if (!std::isfinite(threshold) || threshold < 0.0f || threshold > 1.0f) { + spdlog::warn("invalid config value graphics.fleet_label_zoom_threshold: {}; using {}", threshold, default_value); + threshold = default_value; + } + + new_config.emplace("graphics", toml::table()); + new_config["graphics"].as_table()->insert_or_assign("fleet_label_zoom_threshold", threshold); + + if (write_log) { + spdlog::debug("config value graphics.fleet_label_zoom_threshold value: {}", threshold); + } + + return threshold; +} + void parse_ship_filter(std::string_view value, std::vector& names, bool& match_all) { names.clear(); @@ -881,8 +953,11 @@ void Config::Load() get_config_or_default(config, parsed, "graphics", "ui_scale_ship", DCG::ui_scale_ship, write_config); this->ui_scale_viewer = get_config_or_default(config, parsed, "graphics", "ui_scale_viewer", DCG::ui_scale_viewer, write_config); - this->zoom = get_config_or_default(config, parsed, "graphics", "zoom", DCG::zoom, write_config); - this->fr_scale = get_config_or_default(config, parsed, "graphics", "fr_scale", DCG::fr_scale, write_config); + this->zoom = get_config_or_default(config, parsed, "graphics", "zoom", DCG::zoom, write_config); + this->fr_scale = get_config_or_default(config, parsed, "graphics", "fr_scale", DCG::fr_scale, write_config); + this->fleet_label_detail = get_fleet_label_detail(config, parsed, DCG::fleet_label_detail, write_config); + this->fleet_label_zoom_threshold = + get_fleet_label_zoom_threshold(config, parsed, DCG::fleet_label_zoom_threshold, write_config); this->free_resize = get_config_or_default(config, parsed, "graphics", "free_resize", DCG::free_resize, write_config); this->allow_cursor = get_config_or_default(config, parsed, "graphics", "allow_cursor", DCG::allow_cursor, write_config); diff --git a/mods/src/config.h b/mods/src/config.h index 43f5c3913..4c2afd3ef 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -116,6 +116,13 @@ enum class InstantWarpConfirmation { Jump, }; +enum class FleetLabelDetail { + Native, + Expanded, + Compact, + Threshold, +}; + // Part of UI Scale void ApplyUiShipScaleToLoadedShips(float old_multiplier, float new_multiplier); @@ -153,6 +160,8 @@ class Config final float ui_scale_viewer; float zoom; float fr_scale; + FleetLabelDetail fleet_label_detail; + float fleet_label_zoom_threshold; bool allow_cursor; bool free_resize; bool adjust_scale_res; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index b22cad5b8..202764e2a 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -33,6 +33,8 @@ namespace Graphics { constexpr bool borderless_fullscreen = true; constexpr bool allow_cursor = true; + constexpr const char* fleet_label_detail = "native"; + constexpr auto fleet_label_zoom_threshold = 0.5; constexpr auto default_system_zoom = 1750; constexpr bool free_resize = true; constexpr auto keyboard_zoom_speed = 350; diff --git a/mods/src/patches/parts/zoom.cc b/mods/src/patches/parts/zoom.cc index c19de3af1..7627b6cfa 100644 --- a/mods/src/patches/parts/zoom.cc +++ b/mods/src/patches/parts/zoom.cc @@ -5,6 +5,8 @@ #include +#include +#include #include #include #include @@ -13,6 +15,112 @@ #include #include +#include +#include + +namespace +{ +std::unordered_set fleet_label_lods; +bool fleet_label_hooks_installed = false; +uintptr_t active_system_zoom_id = 0; +bool system_zoom_state_valid = false; +ZoomLevels system_zoom_level = ZoomLevels::None; +float system_normalized_zoom = 0.0f; +bool threshold_state_valid = false; +bool threshold_state_expanded = false; + +ZoomLevels ExpandedFleetLabelZoomLevel(ZoomLevels native_level) +{ return native_level == ZoomLevels::Near ? ZoomLevels::Near : ZoomLevels::Middle; } + +bool FleetLabelsExpandedAt(float normalized_zoom) +{ + const auto threshold = Config::Get().fleet_label_zoom_threshold; + if (threshold <= 0.0f) { + return false; + } + if (threshold >= 1.0f) { + return true; + } + return normalized_zoom <= threshold; +} + +ZoomLevels FleetLabelZoomLevel(ZoomLevels native_level) +{ + const auto detail = Config::Get().fleet_label_detail; + switch (detail) { + case FleetLabelDetail::Expanded: + return ExpandedFleetLabelZoomLevel(native_level); + case FleetLabelDetail::Compact: + return ZoomLevels::Far; + case FleetLabelDetail::Threshold: + if (!system_zoom_state_valid) { + return native_level; + } + return FleetLabelsExpandedAt(system_normalized_zoom) ? ExpandedFleetLabelZoomLevel(native_level) + : ZoomLevels::Far; + case FleetLabelDetail::Native: + default: + return native_level; + } +} + +void ApplyFleetLabelDetail(NavigationLOD *lod, ZoomLevels native_level) +{ + if (!lod) { + return; + } + + const auto detail = Config::Get().fleet_label_detail; + if (detail == FleetLabelDetail::Native || (detail == FleetLabelDetail::Threshold && !system_zoom_state_valid)) { + return; + } + + lod->OnZoomChanged(FleetLabelZoomLevel(native_level)); +} + +void ResetFleetLabelSystemState() +{ + active_system_zoom_id = 0; + system_zoom_state_valid = false; + system_zoom_level = ZoomLevels::None; + system_normalized_zoom = 0.0f; + threshold_state_valid = false; +} + +void BeginFleetLabelSystemState(NavigationZoom *zoom) +{ + const auto zoom_id = reinterpret_cast(zoom); + if (active_system_zoom_id != zoom_id) { + ResetFleetLabelSystemState(); + active_system_zoom_id = zoom_id; + } +} + +void UpdateFleetLabelThreshold() +{ + if (!system_zoom_state_valid || Config::Get().fleet_label_detail != FleetLabelDetail::Threshold) { + threshold_state_valid = false; + return; + } + + const auto expanded = FleetLabelsExpandedAt(system_normalized_zoom); + if (threshold_state_valid && expanded == threshold_state_expanded) { + return; + } + + threshold_state_valid = true; + threshold_state_expanded = expanded; + for (auto *lod : fleet_label_lods) { + ApplyFleetLabelDetail(lod, system_zoom_level); + } + +#ifdef _MODDBG + spdlog::info("Fleet label detail threshold: {} at normalized zoom {:.4f} (configured {:.4f})", + expanded ? "expanded" : "compact", system_normalized_zoom, Config::Get().fleet_label_zoom_threshold); +#endif +} +} // namespace + vec3 GetMouseWorldPos(void *cam, vec3 *pos) { static auto class_helper = il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.Client.Core", "MathUtils"); @@ -106,7 +214,7 @@ static void ScaleFR(void *fr) return; } - static auto comp_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Component"); + static auto comp_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Component"); if (!comp_helper.isValidHelper()) { return; } @@ -137,6 +245,15 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) il2cpp_resolve_icall_typed("UnityEngine.Input::get_mousePosition_Injected(UnityEngine.Vector3&)"); static auto GetDeltaTime = il2cpp_resolve_icall_typed("UnityEngine.Time::get_deltaTime()"); + if (fleet_label_hooks_installed) { + const auto zoom_id = reinterpret_cast(_this); + if (_this->_depth == NodeDepth::SolarSystem) { + BeginFleetLabelSystemState(_this); + } else if (active_system_zoom_id == zoom_id) { + ResetFleetLabelSystemState(); + } + } + const auto dt = GetDeltaTime(); auto zoomDelta = 0.0f; bool do_absolute_zoom = false; @@ -234,6 +351,46 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) original(_this); EnsureSystemZoomRange(_this); + if (fleet_label_hooks_installed && _this->_depth == NodeDepth::SolarSystem) { + BeginFleetLabelSystemState(_this); + system_zoom_level = _this->_zoomLevel; + system_normalized_zoom = _this->NormalizedZoom; + system_zoom_state_valid = true; + if (config->fleet_label_detail == FleetLabelDetail::Threshold) { + UpdateFleetLabelThreshold(); + } + } +} + +void NavigationLOD_OnZoomChanged_Hook(auto original, NavigationLOD *_this, ZoomLevels level) +{ + const auto effective_level = fleet_label_lods.contains(_this) ? FleetLabelZoomLevel(level) : level; + original(_this, effective_level); +} + +void NavigationFleetWidget_OnEnable_Hook(auto original, NavigationFleetWidget *_this) +{ + original(_this); + if (!_this) { + return; + } + + auto *lod = _this->_lod; + if (lod) { + fleet_label_lods.insert(lod); + if (Config::Get().fleet_label_detail == FleetLabelDetail::Threshold && !system_zoom_state_valid) { + threshold_state_valid = false; + } + ApplyFleetLabelDetail(lod, system_zoom_level); + } +} + +void NavigationFleetWidget_OnDisable_Hook(auto original, NavigationFleetWidget *_this) +{ + if (_this) { + fleet_label_lods.erase(_this->_lod); + } + original(_this); } void PlanetViewUtils_CameraZoomedEventHandler_Hook(auto original, PlanetViewUtils *_this, float zoomDistance, @@ -249,6 +406,14 @@ void PlanetViewUtils_CameraZoomedEventHandler_Hook(auto original, PlanetViewUtil void NavigationZoom_SetViewParameters_Hook(auto original, NavigationZoom *_this, float radius, NodeDepth depth) { + if (fleet_label_hooks_installed && depth != NodeDepth::SolarSystem) { + if (active_system_zoom_id == reinterpret_cast(_this)) { + ResetFleetLabelSystemState(); + } + } else if (fleet_label_hooks_installed) { + BeginFleetLabelSystemState(_this); + } + if (depth == NodeDepth::SolarSystem) { ApplySystemZoomRange(_this, radius); SetSceneCameraFarClip(_this); @@ -264,6 +429,14 @@ void NavigationZoom_SetViewParameters_Hook(auto original, NavigationZoom *_this, void NavigationZoom_SetDepth_Hook(auto original, NavigationZoom *_this, NodeDepth depth) { + if (fleet_label_hooks_installed && depth != NodeDepth::SolarSystem) { + if (active_system_zoom_id == reinterpret_cast(_this)) { + ResetFleetLabelSystemState(); + } + } else if (fleet_label_hooks_installed) { + BeginFleetLabelSystemState(_this); + } + if (depth == NodeDepth::SolarSystem) { ApplySystemZoomRange(_this, _this->_viewRadius); SetSceneCameraFarClip(_this); @@ -290,6 +463,73 @@ void *PlanetViewUtils_get_FlatRenderable_Hook(auto original, PlanetViewUtils *_t void InstallZoomHooks() { + auto navigation_zoom_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationZoom"); + auto ptr_update = navigation_zoom_helper.isValidHelper() ? navigation_zoom_helper.GetMethod("Update") : nullptr; + auto *navigation_zoom_class = navigation_zoom_helper.get_cls(); + auto *zoom_level_field = navigation_zoom_class != nullptr + ? il2cpp_class_get_field_from_name(navigation_zoom_class, "_zoomLevel") + : nullptr; + auto *normalized_zoom_property = navigation_zoom_class != nullptr + ? il2cpp_class_get_property_from_name(navigation_zoom_class, "NormalizedZoom") + : nullptr; + if (Config::Get().fleet_label_detail != FleetLabelDetail::Native) { + auto lod_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationLOD"); + auto fleet_widget_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationFleetWidget"); + auto ptr_on_zoom_changed = lod_helper.isValidHelper() ? lod_helper.GetMethod("OnZoomChanged") : nullptr; + auto ptr_on_enable = fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnEnable") : nullptr; + auto ptr_on_disable = fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnDisable") : nullptr; + auto *fleet_widget_class = fleet_widget_helper.get_cls(); + auto *lod_field = + fleet_widget_class != nullptr ? il2cpp_class_get_field_from_name(fleet_widget_class, "_lod") : nullptr; + + if (!lod_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Navigation", "NavigationLOD"); + } else if (ptr_on_zoom_changed == nullptr) { + ErrorMsg::MissingMethod("NavigationLOD", "OnZoomChanged"); + } + if (!fleet_widget_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Navigation", "NavigationFleetWidget"); + } else { + if (ptr_on_enable == nullptr) { + ErrorMsg::MissingMethod("NavigationFleetWidget", "OnEnable"); + } + if (ptr_on_disable == nullptr) { + ErrorMsg::MissingMethod("NavigationFleetWidget", "OnDisable"); + } + if (lod_field == nullptr) { + ErrorMsg::MissingMethod("NavigationFleetWidget", "_lod"); + } + } + if (!navigation_zoom_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Navigation", "NavigationZoom"); + } else { + if (ptr_update == nullptr) { + ErrorMsg::MissingMethod("NavigationZoom", "Update"); + } + if (zoom_level_field == nullptr) { + ErrorMsg::MissingMethod("NavigationZoom", "_zoomLevel"); + } + if (normalized_zoom_property == nullptr) { + ErrorMsg::MissingMethod("NavigationZoom", "NormalizedZoom"); + } + } + + const auto fleet_label_dependencies_valid = lod_helper.isValidHelper() && ptr_on_zoom_changed != nullptr + && fleet_widget_helper.isValidHelper() && ptr_on_enable != nullptr + && ptr_on_disable != nullptr && lod_field != nullptr + && navigation_zoom_helper.isValidHelper() && ptr_update != nullptr + && zoom_level_field != nullptr && normalized_zoom_property != nullptr; + if (fleet_label_dependencies_valid) { + fleet_label_hooks_installed = true; + SPUD_STATIC_DETOUR(ptr_on_zoom_changed, NavigationLOD_OnZoomChanged_Hook); + SPUD_STATIC_DETOUR(ptr_on_enable, NavigationFleetWidget_OnEnable_Hook); + SPUD_STATIC_DETOUR(ptr_on_disable, NavigationFleetWidget_OnDisable_Hook); + } else { + spdlog::error("Fleet label detail hooks were not installed; using native fleet labels"); + } + } + { auto pv_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "PlanetViewUtils"); if (pv_helper.isValidHelper()) { @@ -311,11 +551,9 @@ void InstallZoomHooks() } } - auto screen_manager_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationZoom"); - if (!screen_manager_helper.isValidHelper()) { + if (!navigation_zoom_helper.isValidHelper()) { ErrorMsg::MissingHelper("Navigation", "NavigationZoom"); } else { - auto ptr_update = screen_manager_helper.GetMethod("Update"); if (ptr_update == nullptr) { ErrorMsg::MissingMethod("NavigationZoom", "Update"); } else { @@ -323,7 +561,7 @@ void InstallZoomHooks() } #if _WIN32 - auto ptr_set_depth = screen_manager_helper.GetMethod("SetDepth"); + auto ptr_set_depth = navigation_zoom_helper.GetMethod("SetDepth"); if (ptr_set_depth == nullptr) { ErrorMsg::MissingMethod("NavigationZoom", "SetDepth"); } else { @@ -331,7 +569,7 @@ void InstallZoomHooks() } #endif - auto ptr_set_view_parameters = screen_manager_helper.GetMethod("SetViewParameters"); + auto ptr_set_view_parameters = navigation_zoom_helper.GetMethod("SetViewParameters"); if (ptr_set_view_parameters == nullptr) { ErrorMsg::MissingMethod("NavigationZoom", "SetViewParameters"); } else { diff --git a/mods/src/prime/NavigationFleetWidget.h b/mods/src/prime/NavigationFleetWidget.h new file mode 100644 index 000000000..c6a855fe7 --- /dev/null +++ b/mods/src/prime/NavigationFleetWidget.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include "NavigationLOD.h" + +struct NavigationFleetWidget { + __declspec(property(get = __get__lod)) NavigationLOD* _lod; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationFleetWidget"); + return class_helper; + } + +public: + NavigationLOD* __get__lod() + { + static auto* field = [] { + auto* cls = get_class_helper().get_cls(); + return cls != nullptr ? il2cpp_class_get_field_from_name(cls, "_lod") : nullptr; + }(); + return field != nullptr ? *(NavigationLOD**)((ptrdiff_t)this + field->offset) : nullptr; + } +}; diff --git a/mods/src/prime/NavigationLOD.h b/mods/src/prime/NavigationLOD.h new file mode 100644 index 000000000..21a134449 --- /dev/null +++ b/mods/src/prime/NavigationLOD.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "ZoomLevels.h" + +struct NavigationLOD { + void OnZoomChanged(ZoomLevels level) + { + static auto on_zoom_changed = get_class_helper().GetMethod("OnZoomChanged"); + if (on_zoom_changed != nullptr) { + on_zoom_changed(this, level); + } + } + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationLOD"); + return class_helper; + } +}; diff --git a/mods/src/prime/NavigationZoom.h b/mods/src/prime/NavigationZoom.h index bf88d23e6..22cf2fcd7 100644 --- a/mods/src/prime/NavigationZoom.h +++ b/mods/src/prime/NavigationZoom.h @@ -7,6 +7,7 @@ #include "Camera.h" #include "NavigationPan.h" +#include "ZoomLevels.h" enum class NodeDepth { Galaxy = 1, @@ -42,6 +43,8 @@ struct NavigationZoom { __declspec(property(get = __get__zoomtotal, put = __set__zoomtotal)) float _zoomtotal; __declspec(property(get = __get__minimum, put = __set__minimum)) float _minimum; __declspec(property(get = __get__maximum, put = __set__maximum)) float _maximum; + __declspec(property(get = __get__zoomLevel)) ZoomLevels _zoomLevel; + __declspec(property(get = __get_NormalizedZoom)) float NormalizedZoom; __declspec(property(get = __get__zoomDelta, put = __set__zoomDelta)) float _zoomDelta; __declspec(property(get = __get__viewRadius, put = __set__viewRadius)) float _viewRadius; __declspec(property(get = __get__lastZoomDelta, put = __set__lastZoomDelta)) float _lastZoomDelta; @@ -134,6 +137,18 @@ struct NavigationZoom { *(float*)((ptrdiff_t)this + field.offset()) = delta; } + ZoomLevels __get__zoomLevel() + { + static auto field = get_class_helper().GetField("_zoomLevel"); + return *(ZoomLevels*)((ptrdiff_t)this + field.offset()); + } + + float __get_NormalizedZoom() + { + static auto field = get_class_helper().GetProperty("NormalizedZoom"); + return *field.Get(this); + } + float __get__actualDistance() { static auto field = get_class_helper().GetField("_actualDistance"); diff --git a/mods/src/prime/ZoomLevels.h b/mods/src/prime/ZoomLevels.h new file mode 100644 index 000000000..d5b0f1295 --- /dev/null +++ b/mods/src/prime/ZoomLevels.h @@ -0,0 +1,8 @@ +#pragma once + +enum class ZoomLevels { + None = 0, + Near = 1, + Middle = 2, + Far = 3, +}; From 56f653f8644c564d5846ccd1b0762278976d5eb0 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 18:42:12 -0500 Subject: [PATCH 58/80] Allow fleet audio cues without system notifications --- example_community_patch_settings_da.toml | 6 +-- example_community_patch_settings_de.toml | 6 +-- ...munity_patch_settings_en-GB-x-cockney.toml | 6 +-- ...mmunity_patch_settings_en-x-minionese.toml | 6 +-- example_community_patch_settings_en.toml | 6 +-- example_community_patch_settings_es.toml | 6 +-- example_community_patch_settings_fr.toml | 6 +-- example_community_patch_settings_nl.toml | 6 +-- example_community_patch_settings_ru.toml | 6 +-- example_community_patch_settings_tlh.toml | 6 +-- mods/src/patches/fleet_watch.cc | 39 +++++++++++++------ mods/src/patches/notification_audio.cc | 2 +- 12 files changed, 58 insertions(+), 43 deletions(-) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 008c2ddff..de7157ce3 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -47,7 +47,7 @@ alert_armada_battle_won = "none" alert_armada_battle_lost = "none" # Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. -# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" alert_fleet_started_mining = "none" @@ -658,8 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Fleet Watch events to observe (comma separated). -# Windows sends system notifications; Windows and macOS can play matching [audio] cues. +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 030eeb950..90451c72d 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -47,7 +47,7 @@ alert_armada_battle_won = "none" alert_armada_battle_lost = "none" # Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. -# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" alert_fleet_started_mining = "none" @@ -658,8 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Fleet Watch events to observe (comma separated). -# Windows sends system notifications; Windows and macOS can play matching [audio] cues. +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index d3cac12b5..3ee97893c 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -47,7 +47,7 @@ alert_armada_battle_won = "none" alert_armada_battle_lost = "none" # Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. -# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" alert_fleet_started_mining = "none" @@ -658,8 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Fleet Watch events to observe (comma separated). -# Windows sends system notifications; Windows and macOS can play matching [audio] cues. +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index f47d3d03a..ce6349ee9 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -47,7 +47,7 @@ alert_armada_battle_won = "none" alert_armada_battle_lost = "none" # Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. -# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" alert_fleet_started_mining = "none" @@ -658,8 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Fleet Watch events to observe (comma separated). -# Windows sends system notifications; Windows and macOS can play matching [audio] cues. +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index d1d724f84..01b0ecaa1 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -47,7 +47,7 @@ alert_armada_battle_won = "none" alert_armada_battle_lost = "none" # Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. -# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" alert_fleet_started_mining = "none" @@ -658,8 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Fleet Watch events to observe (comma separated). -# Windows sends system notifications; Windows and macOS can play matching [audio] cues. +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index f61085df5..51fd3ffec 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -47,7 +47,7 @@ alert_armada_battle_won = "none" alert_armada_battle_lost = "none" # Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. -# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" alert_fleet_started_mining = "none" @@ -658,8 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Fleet Watch events to observe (comma separated). -# Windows sends system notifications; Windows and macOS can play matching [audio] cues. +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index a173893df..ac4deba43 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -47,7 +47,7 @@ alert_armada_battle_won = "none" alert_armada_battle_lost = "none" # Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. -# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" alert_fleet_started_mining = "none" @@ -658,8 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Fleet Watch events to observe (comma separated). -# Windows sends system notifications; Windows and macOS can play matching [audio] cues. +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 220a1da80..79bf74530 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -47,7 +47,7 @@ alert_armada_battle_won = "none" alert_armada_battle_lost = "none" # Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. -# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" alert_fleet_started_mining = "none" @@ -658,8 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Fleet Watch events to observe (comma separated). -# Windows sends system notifications; Windows and macOS can play matching [audio] cues. +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index b70292af0..215a5985c 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -47,7 +47,7 @@ alert_armada_battle_won = "none" alert_armada_battle_lost = "none" # Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. -# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" alert_fleet_started_mining = "none" @@ -658,8 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Fleet Watch events to observe (comma separated). -# Windows sends system notifications; Windows and macOS can play matching [audio] cues. +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 2c9241251..b580ec6fd 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -47,7 +47,7 @@ alert_armada_battle_won = "none" alert_armada_battle_lost = "none" # Optional Windows and macOS Fleet Watch cues; "none" disables an individual alert. -# A cue plays only when its matching [ui].notify_fleet_events entry is enabled. +# A non-"none" cue enables observation for that event; [ui].notify_fleet_events is not required for audio. alert_fleet_arrived_in_system = "none" alert_fleet_arrived_at_destination = "none" alert_fleet_started_mining = "none" @@ -658,8 +658,8 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" -# Fleet Watch events to observe (comma separated). -# Windows sends system notifications; Windows and macOS can play matching [audio] cues. +# Fleet Watch events that send Windows system notifications (comma separated). +# Matching [audio] cues are configured independently on Windows and macOS. # Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, # Docked, RepairComplete, MinerOPC, All notify_fleet_events = "" diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index a059382c2..b252714cd 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -147,6 +147,16 @@ int64_t now_milliseconds() bool notification_enabled(FleetNotificationKind kind) { return (s_enabled_notifications & fleet_notification_bit(kind)) != 0; } +constexpr FleetNotificationMask fleet_events_to_observe(FleetNotificationMask system_notifications, + FleetNotificationMask audible_notifications, + bool system_notifications_supported) +{ return audible_notifications | (system_notifications_supported ? system_notifications : 0); } + +constexpr auto kArrivalNotification = fleet_notification_bit(FleetNotificationKind::ArrivedInSystem); +static_assert(fleet_events_to_observe(0, kArrivalNotification, false) == kArrivalNotification); +static_assert(fleet_events_to_observe(kArrivalNotification, 0, true) == kArrivalNotification); +static_assert(fleet_events_to_observe(kArrivalNotification, 0, false) == 0); + bool allow_notification(int slot_index, FleetNotificationKind kind) { if (slot_index < 0 || slot_index >= kFleetSlotCount) { @@ -345,7 +355,9 @@ FleetPlayerData* fleet_for_snapshot(int slot_index, uint64_t fleet_id) void emit_notification(FleetNotificationKind kind, std::string_view title, std::string body) { - notification_emit(title, body); + if ((Config::Get().notify_fleet_events & fleet_notification_bit(kind)) != 0) { + notification_emit(title, body); + } const auto index = fleet_notification_index(kind); const auto sound = Config::Get().fleet_notification_sounds[index]; @@ -353,8 +365,8 @@ void emit_notification(FleetNotificationKind kind, std::string_view title, std:: return; } - spdlog::debug("[FleetWatch] audio event={} sound={}", kFleetNotificationCatalog[index].config_name, - notification_sound_name(sound)); + spdlog::info("[FleetWatch] audio event={} sound={}", kFleetNotificationCatalog[index].config_name, + notification_sound_name(sound)); notification_audio_play(sound); } @@ -707,22 +719,25 @@ bool fleet_watch_uses_node_depleted_hook() void fleet_watch_init() { - constexpr auto follow_through_events = fleet_notification_bit(FleetNotificationKind::ArrivedInSystem) - | fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination) - | fleet_notification_bit(FleetNotificationKind::StartedMining) - | fleet_notification_bit(FleetNotificationKind::Docked) - | fleet_notification_bit(FleetNotificationKind::RepairComplete); - constexpr auto state_events = follow_through_events | fleet_notification_bit(FleetNotificationKind::MinerOpc); - s_enabled_notifications = Config::Get().notify_fleet_events; -#if defined(__APPLE__) + constexpr auto follow_through_events = fleet_notification_bit(FleetNotificationKind::ArrivedInSystem) + | fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination) + | fleet_notification_bit(FleetNotificationKind::StartedMining) + | fleet_notification_bit(FleetNotificationKind::Docked) + | fleet_notification_bit(FleetNotificationKind::RepairComplete); + constexpr auto state_events = follow_through_events | fleet_notification_bit(FleetNotificationKind::MinerOpc); FleetNotificationMask audible_notifications = 0; for (const auto& entry : kFleetNotificationCatalog) { if (Config::Get().fleet_notification_sounds[fleet_notification_index(entry.kind)] != NotificationSound::None) { audible_notifications |= fleet_notification_bit(entry.kind); } } - s_enabled_notifications &= audible_notifications; +#if _WIN32 + constexpr bool system_notifications_supported = true; +#else + constexpr bool system_notifications_supported = false; #endif + s_enabled_notifications = + fleet_events_to_observe(Config::Get().notify_fleet_events, audible_notifications, system_notifications_supported); s_state_observation_enabled = (s_enabled_notifications & state_events) != 0; s_slots = {}; s_last_emitted_ms = {}; diff --git a/mods/src/patches/notification_audio.cc b/mods/src/patches/notification_audio.cc index fc4ac8941..64cdd0368 100644 --- a/mods/src/patches/notification_audio.cc +++ b/mods/src/patches/notification_audio.cc @@ -227,7 +227,7 @@ void notification_audio_play(NotificationSound sound) spdlog::warn("[NotifyAudio] Failed to play '{}' cue", notification_sound_name(sound)); return; } - spdlog::debug("[NotifyAudio] Played '{}' cue", notification_sound_name(sound)); + spdlog::info("[NotifyAudio] Played '{}' cue", notification_sound_name(sound)); } #if _WIN32 From 4eba95726933dee1bfb7cc47d89556fb828b42ed Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 19:53:54 -0500 Subject: [PATCH 59/80] Add All option for disabled audio events --- example_community_patch_settings_da.toml | 2 +- example_community_patch_settings_de.toml | 2 +- ...munity_patch_settings_en-GB-x-cockney.toml | 2 +- ...mmunity_patch_settings_en-x-minionese.toml | 2 +- example_community_patch_settings_en.toml | 2 +- example_community_patch_settings_es.toml | 2 +- example_community_patch_settings_fr.toml | 2 +- example_community_patch_settings_nl.toml | 2 +- example_community_patch_settings_ru.toml | 2 +- example_community_patch_settings_tlh.toml | 2 +- mods/src/config.cc | 8 +++- mods/src/config.h | 1 + mods/src/patches/parts/audio_events.cc | 44 +++++++++++++++++-- 13 files changed, 58 insertions(+), 15 deletions(-) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 8906a3d17..2b04751f9 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 29fd63105..dfb27a400 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 68196debe..7dd78d117 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 80171600b..d33ce82dc 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 44fbed0d6..a91fd28fc 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 5e07467e3..14afc383c 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index c80c17447..8e5f304df 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 64be505a7..6799cf3f1 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 693344ba4..4c89babc4 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index a30e387c6..6f53304aa 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; leave blank to allow all audio events +# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/mods/src/config.cc b/mods/src/config.cc index 424eef469..825e753ba 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -962,14 +962,18 @@ void Config::Load() get_config_or_default(config, parsed, "audio", "trace_events", DCA::trace_events, write_config); auto disabled_audio_events = get_config_or_default( config, parsed, "audio", "disabled_events", DCA::disabled_events, write_config); + this->disable_all_audio_events = false; this->disabled_audio_events.clear(); for (const auto& event : StrSplit(disabled_audio_events, ',')) { auto stripped = StripAsciiWhitespace(event); - if (!stripped.empty()) { + if (AsciiStrToUpper(stripped) == "ALL") { + this->disable_all_audio_events = true; + } else if (!stripped.empty()) { this->disabled_audio_events.emplace_back(stripped); } } - this->installAudioEventHooks = this->trace_audio_events || !this->disabled_audio_events.empty(); + this->installAudioEventHooks = + this->trace_audio_events || this->disable_all_audio_events || !this->disabled_audio_events.empty(); this->alert_victory = get_notification_sound(config, parsed, "alert_victory", DCA::alert_victory, write_config); this->alert_defeat = get_notification_sound(config, parsed, "alert_defeat", DCA::alert_defeat, write_config); diff --git a/mods/src/config.h b/mods/src/config.h index 93264d756..f2f83c4df 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -197,6 +197,7 @@ class Config final bool disable_first_popup; bool disable_toast_banners; bool trace_audio_events; + bool disable_all_audio_events; std::vector disabled_audio_events; NotificationSound alert_victory; NotificationSound alert_defeat; diff --git a/mods/src/patches/parts/audio_events.cc b/mods/src/patches/parts/audio_events.cc index 1d2c2c0e2..2040c7ae2 100644 --- a/mods/src/patches/parts/audio_events.cc +++ b/mods/src/patches/parts/audio_events.cc @@ -13,6 +13,42 @@ struct FabricEventManager { }; +namespace +{ +enum class FabricEventAction : int { + PlaySound = 0, + StopSound = 1, + UnpauseSound = 3, + AdvanceSequence = 16, + StopAll = 21, + UnloadAudio = 23, + PlayScheduled = 33, +}; + +constexpr bool can_start_audio(int event_action) +{ + switch (static_cast(event_action)) { + case FabricEventAction::PlaySound: + case FabricEventAction::UnpauseSound: + case FabricEventAction::AdvanceSequence: + case FabricEventAction::PlayScheduled: + return true; + default: + return false; + } +} + +constexpr bool should_suppress_audio_event(bool disable_all, bool event_is_disabled, int event_action) +{ return (disable_all || event_is_disabled) && can_start_audio(event_action); } + +static_assert(should_suppress_audio_event(true, false, static_cast(FabricEventAction::PlaySound))); +static_assert(should_suppress_audio_event(true, false, static_cast(FabricEventAction::UnpauseSound))); +static_assert(should_suppress_audio_event(false, true, static_cast(FabricEventAction::PlayScheduled))); +static_assert(!should_suppress_audio_event(true, false, static_cast(FabricEventAction::StopSound))); +static_assert(!should_suppress_audio_event(true, false, static_cast(FabricEventAction::StopAll))); +static_assert(!should_suppress_audio_event(true, false, static_cast(FabricEventAction::UnloadAudio))); +} // namespace + // Fabric.EventManager.PostEvent(string, EventAction, object, GameObject, // InitialiseParameters, bool, OnEventNotify) bool FabricEventManager_PostEvent_Hook(auto original, FabricEventManager* _this, Il2CppString* event_name, @@ -29,11 +65,13 @@ bool FabricEventManager_PostEvent_Hook(auto original, FabricEventManager* _this, auto& config = Config::Get(); if (config.trace_audio_events) { - spdlog::info("Audio event: {}", event); + spdlog::info("Audio event: {} (action {})", event, event_action); } - if (std::ranges::find(config.disabled_audio_events, event) != config.disabled_audio_events.end()) { - spdlog::debug("Suppressed audio event: {}", event); + const bool event_is_disabled = + std::ranges::find(config.disabled_audio_events, event) != config.disabled_audio_events.end(); + if (should_suppress_audio_event(config.disable_all_audio_events, event_is_disabled, event_action)) { + spdlog::debug("Suppressed audio event: {} (action {})", event, event_action); return false; } From 6ae3b52f08d12eef1f03ef835131706f6f89257c Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 20:07:06 -0500 Subject: [PATCH 60/80] Cover Fabric event object audio routes --- mods/src/patches/parts/audio_events.cc | 123 ++++++++++++++++++++----- mods/src/str_utils.h | 4 +- 2 files changed, 101 insertions(+), 26 deletions(-) diff --git a/mods/src/patches/parts/audio_events.cc b/mods/src/patches/parts/audio_events.cc index 2040c7ae2..fc0c088c3 100644 --- a/mods/src/patches/parts/audio_events.cc +++ b/mods/src/patches/parts/audio_events.cc @@ -8,11 +8,25 @@ #include #include +#include +#include +#include #include struct FabricEventManager { }; +struct FabricEvent { + Il2CppObject object; + Il2CppString* event_name; + int32_t event_id; + int event_action; +}; + +static_assert(offsetof(FabricEvent, event_name) == 0x10); +static_assert(offsetof(FabricEvent, event_id) == 0x18); +static_assert(offsetof(FabricEvent, event_action) == 0x1C); + namespace { enum class FabricEventAction : int { @@ -39,7 +53,7 @@ constexpr bool can_start_audio(int event_action) } constexpr bool should_suppress_audio_event(bool disable_all, bool event_is_disabled, int event_action) -{ return (disable_all || event_is_disabled) && can_start_audio(event_action); } +{ return event_is_disabled || (disable_all && can_start_audio(event_action)); } static_assert(should_suppress_audio_event(true, false, static_cast(FabricEventAction::PlaySound))); static_assert(should_suppress_audio_event(true, false, static_cast(FabricEventAction::UnpauseSound))); @@ -47,36 +61,77 @@ static_assert(should_suppress_audio_event(false, true, static_cast(FabricEv static_assert(!should_suppress_audio_event(true, false, static_cast(FabricEventAction::StopSound))); static_assert(!should_suppress_audio_event(true, false, static_cast(FabricEventAction::StopAll))); static_assert(!should_suppress_audio_event(true, false, static_cast(FabricEventAction::UnloadAudio))); -} // namespace +static_assert(should_suppress_audio_event(false, true, static_cast(FabricEventAction::StopSound))); -// Fabric.EventManager.PostEvent(string, EventAction, object, GameObject, -// InitialiseParameters, bool, OnEventNotify) -bool FabricEventManager_PostEvent_Hook(auto original, FabricEventManager* _this, Il2CppString* event_name, - int event_action, Il2CppObject* parameter, Il2CppObject* parent_game_object, - Il2CppObject* initialise_parameters, bool add_to_queue, - Il2CppObject* on_event_notify) +bool should_suppress_audio_event(const FabricEvent* event) { - if (event_name == nullptr) { - return original(_this, event_name, event_action, parameter, parent_game_object, initialise_parameters, - add_to_queue, on_event_notify); + if (event == nullptr) { + return false; + } + + auto& config = Config::Get(); + + // `All` is the common high-frequency path. Avoid converting every managed event name when tracing is off. + if (config.disable_all_audio_events && can_start_audio(event->event_action) && !config.trace_audio_events) { + spdlog::debug("Suppressed audio event ID {} (action {})", event->event_id, event->event_action); + return true; + } + + if (!config.trace_audio_events && config.disabled_audio_events.empty()) { + return false; } - const auto event = to_string(event_name); - auto& config = Config::Get(); + std::string event_name; + if (event->event_name != nullptr) { + event_name = to_string(event->event_name); + } if (config.trace_audio_events) { - spdlog::info("Audio event: {} (action {})", event, event_action); + if (event_name.empty()) { + spdlog::info("Audio event ID: {} (action {})", event->event_id, event->event_action); + } else { + spdlog::info("Audio event: {} (ID {}, action {})", event_name, event->event_id, event->event_action); + } } const bool event_is_disabled = - std::ranges::find(config.disabled_audio_events, event) != config.disabled_audio_events.end(); - if (should_suppress_audio_event(config.disable_all_audio_events, event_is_disabled, event_action)) { - spdlog::debug("Suppressed audio event: {} (action {})", event, event_action); + !event_name.empty() + && std::ranges::find(config.disabled_audio_events, event_name) != config.disabled_audio_events.end(); + if (!should_suppress_audio_event(config.disable_all_audio_events, event_is_disabled, event->event_action)) { return false; } - return original(_this, event_name, event_action, parameter, parent_game_object, initialise_parameters, - add_to_queue, on_event_notify); + if (event_name.empty()) { + spdlog::debug("Suppressed audio event ID {} (action {})", event->event_id, event->event_action); + } else { + spdlog::debug("Suppressed audio event: {} (ID {}, action {})", event_name, event->event_id, event->event_action); + } + return true; +} + +bool is_fabric_event_overload(int param_count, const Il2CppType** params, Il2CppClass* event_class) +{ + return param_count == 2 && params != nullptr && params[0] != nullptr && params[1] != nullptr + && il2cpp_class_from_type(params[0]) == event_class && params[1]->type == IL2CPP_TYPE_BOOLEAN; +} +} // namespace + +// Fabric.EventManager.PostEvent(Event, bool). String and EventTrigger callers converge here. +bool FabricEventManager_PostEvent_Hook(auto original, FabricEventManager* _this, FabricEvent* event, bool add_to_queue) +{ + if (should_suppress_audio_event(event)) { + return false; + } + return original(_this, event, add_to_queue); +} + +// Fabric.EventManager.PostEventID(Event, bool). Hashed integer-ID callers converge here. +bool FabricEventManager_PostEventID_Hook(auto original, FabricEventManager* _this, FabricEvent* event, bool add_to_queue) +{ + if (should_suppress_audio_event(event)) { + return false; + } + return original(_this, event, add_to_queue); } void InstallAudioEventHooks() @@ -87,12 +142,32 @@ void InstallAudioEventHooks() return; } - // The shorter PostEvent overloads funnel into this seven-argument overload. - const auto method = helper.GetMethodInfo("PostEvent", 7); - if (method == nullptr || method->methodPointer == nullptr) { - ErrorMsg::MissingMethod("EventManager", "PostEvent"); + auto event_helper = il2cpp_get_class_helper("Fabric.Core", "Fabric", "Event"); + if (!event_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Fabric", "Event"); + return; + } + + auto* event_class = event_helper.get_cls(); + auto event_filter = [event_class](int count, const Il2CppType** params) { + return is_fabric_event_overload(count, params, event_class); + }; + + const auto post_event = helper.GetMethodInfoSpecial("PostEvent", event_filter); + const auto post_event_id = helper.GetMethodInfoSpecial("PostEventID", event_filter); + if (post_event == nullptr || post_event->methodPointer == nullptr) { + ErrorMsg::MissingMethod("EventManager", "PostEvent(Event, bool)"); + return; + } + if (post_event_id == nullptr || post_event_id->methodPointer == nullptr) { + ErrorMsg::MissingMethod("EventManager", "PostEventID(Event, bool)"); + return; + } + if (post_event->methodPointer == post_event_id->methodPointer) { + spdlog::error("Fabric EventManager event and event-ID routes unexpectedly share a hook target"); return; } - SPUD_STATIC_DETOUR(method->methodPointer, FabricEventManager_PostEvent_Hook); + SPUD_STATIC_DETOUR(post_event->methodPointer, FabricEventManager_PostEvent_Hook); + SPUD_STATIC_DETOUR(post_event_id->methodPointer, FabricEventManager_PostEventID_Hook); } diff --git a/mods/src/str_utils.h b/mods/src/str_utils.h index a9fbfb7df..89ec079c2 100644 --- a/mods/src/str_utils.h +++ b/mods/src/str_utils.h @@ -57,14 +57,14 @@ constexpr std::string_view StripPrefix(std::string_view str, const std::string_v constexpr std::string AsciiStrToUpper(const std::string_view s) { - std::string str = s.data(); + std::string str{s}; std::ranges::transform(str, str.begin(), ::toupper); return str; } constexpr std::string AsciiStrToLower(const std::string_view s) { - std::string str = s.data(); + std::string str{s}; std::ranges::transform(str, str.begin(), ::tolower); return str; } From 802e60f769611d85c3094eaca37a7a5d98249394 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 20:15:15 -0500 Subject: [PATCH 61/80] Harden global audio suppression policy --- mods/src/patches/parts/audio_events.cc | 75 +++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/mods/src/patches/parts/audio_events.cc b/mods/src/patches/parts/audio_events.cc index fc0c088c3..5562d8e09 100644 --- a/mods/src/patches/parts/audio_events.cc +++ b/mods/src/patches/parts/audio_events.cc @@ -53,7 +53,7 @@ constexpr bool can_start_audio(int event_action) } constexpr bool should_suppress_audio_event(bool disable_all, bool event_is_disabled, int event_action) -{ return event_is_disabled || (disable_all && can_start_audio(event_action)); } +{ return disable_all ? can_start_audio(event_action) : event_is_disabled; } static_assert(should_suppress_audio_event(true, false, static_cast(FabricEventAction::PlaySound))); static_assert(should_suppress_audio_event(true, false, static_cast(FabricEventAction::UnpauseSound))); @@ -62,6 +62,9 @@ static_assert(!should_suppress_audio_event(true, false, static_cast(FabricE static_assert(!should_suppress_audio_event(true, false, static_cast(FabricEventAction::StopAll))); static_assert(!should_suppress_audio_event(true, false, static_cast(FabricEventAction::UnloadAudio))); static_assert(should_suppress_audio_event(false, true, static_cast(FabricEventAction::StopSound))); +static_assert(!should_suppress_audio_event(true, true, static_cast(FabricEventAction::StopSound))); +static_assert(!should_suppress_audio_event(true, true, static_cast(FabricEventAction::StopAll))); +static_assert(!should_suppress_audio_event(true, true, static_cast(FabricEventAction::UnloadAudio))); bool should_suppress_audio_event(const FabricEvent* event) { @@ -72,9 +75,12 @@ bool should_suppress_audio_event(const FabricEvent* event) auto& config = Config::Get(); // `All` is the common high-frequency path. Avoid converting every managed event name when tracing is off. - if (config.disable_all_audio_events && can_start_audio(event->event_action) && !config.trace_audio_events) { - spdlog::debug("Suppressed audio event ID {} (action {})", event->event_id, event->event_action); - return true; + if (config.disable_all_audio_events && !config.trace_audio_events) { + if (can_start_audio(event->event_action)) { + spdlog::debug("Suppressed audio event ID {} (action {})", event->event_id, event->event_action); + return true; + } + return false; } if (!config.trace_audio_events && config.disabled_audio_events.empty()) { @@ -114,6 +120,51 @@ bool is_fabric_event_overload(int param_count, const Il2CppType** params, Il2Cpp return param_count == 2 && params != nullptr && params[0] != nullptr && params[1] != nullptr && il2cpp_class_from_type(params[0]) == event_class && params[1]->type == IL2CPP_TYPE_BOOLEAN; } + +uintptr_t canonical_spud_target(uintptr_t address) +{ +#if defined(__aarch64__) || defined(_M_ARM64) + const auto instruction = *reinterpret_cast(address); + if ((instruction >> 26) != 0b000101) { + return address; + } + const auto offset = static_cast(static_cast(instruction << 6)) >> 4; + return address + offset; +#else + const auto* memory = reinterpret_cast(address); + if (memory[0] == 0x55 && memory[1] == 0x48 && memory[2] == 0x89 && memory[3] == 0xE5 && memory[4] == 0x5D) { + return canonical_spud_target(address + 5); + } + if (memory[0] != 0xE9) { + return address; + } + int32_t offset = 0; + std::memcpy(&offset, memory + 1, sizeof(offset)); + return canonical_spud_target(address + offset + 5); +#endif +} + +bool validate_fabric_event_layout(Il2CppClass* event_class) +{ + struct ExpectedField { + const char* name; + size_t offset; + }; + constexpr ExpectedField expected_fields[] = { + {"_eventName", offsetof(FabricEvent, event_name)}, + {"_eventID", offsetof(FabricEvent, event_id)}, + {"EventAction", offsetof(FabricEvent, event_action)}, + }; + + for (const auto& expected : expected_fields) { + const auto* field = il2cpp_class_get_field_from_name(event_class, expected.name); + if (field == nullptr || static_cast(field->offset) != expected.offset) { + spdlog::error("Fabric.Event field '{}' is missing or has an unexpected layout", expected.name); + return false; + } + } + return true; +} } // namespace // Fabric.EventManager.PostEvent(Event, bool). String and EventTrigger callers converge here. @@ -126,7 +177,8 @@ bool FabricEventManager_PostEvent_Hook(auto original, FabricEventManager* _this, } // Fabric.EventManager.PostEventID(Event, bool). Hashed integer-ID callers converge here. -bool FabricEventManager_PostEventID_Hook(auto original, FabricEventManager* _this, FabricEvent* event, bool add_to_queue) +bool FabricEventManager_PostEventID_Hook(auto original, FabricEventManager* _this, FabricEvent* event, + bool add_to_queue) { if (should_suppress_audio_event(event)) { return false; @@ -149,6 +201,9 @@ void InstallAudioEventHooks() } auto* event_class = event_helper.get_cls(); + if (event_class == nullptr || !validate_fabric_event_layout(event_class)) { + return; + } auto event_filter = [event_class](int count, const Il2CppType** params) { return is_fabric_event_overload(count, params, event_class); }; @@ -163,7 +218,15 @@ void InstallAudioEventHooks() ErrorMsg::MissingMethod("EventManager", "PostEventID(Event, bool)"); return; } - if (post_event->methodPointer == post_event_id->methodPointer) { + if (post_event->return_type == nullptr || post_event->return_type->type != IL2CPP_TYPE_BOOLEAN + || post_event_id->return_type == nullptr || post_event_id->return_type->type != IL2CPP_TYPE_BOOLEAN) { + spdlog::error("Fabric EventManager event routes have an unexpected return type"); + return; + } + + const auto post_event_target = canonical_spud_target(reinterpret_cast(post_event->methodPointer)); + const auto post_event_id_target = canonical_spud_target(reinterpret_cast(post_event_id->methodPointer)); + if (post_event_target == post_event_id_target) { spdlog::error("Fabric EventManager event and event-ID routes unexpectedly share a hook target"); return; } From e116f9efc2679c730da81030b565294c1b6b3b43 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 20:19:42 -0500 Subject: [PATCH 62/80] Match Spud x64 hook target resolution --- mods/src/patches/parts/audio_events.cc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mods/src/patches/parts/audio_events.cc b/mods/src/patches/parts/audio_events.cc index 5562d8e09..5030bb355 100644 --- a/mods/src/patches/parts/audio_events.cc +++ b/mods/src/patches/parts/audio_events.cc @@ -140,7 +140,9 @@ uintptr_t canonical_spud_target(uintptr_t address) } int32_t offset = 0; std::memcpy(&offset, memory + 1, sizeof(offset)); - return canonical_spud_target(address + offset + 5); + // Match Spud's x86_64 resolver exactly: it recursively skips the special prologue above, but an E9 is + // resolved only once because that first-hop destination is the address Spud will actually patch. + return address + offset + 5; #endif } From 6077e3341370c23cd1fe31281cd10967551c24f2 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 21:34:40 -0500 Subject: [PATCH 63/80] Add local officer preset reordering --- example_community_patch_settings_da.toml | 5 + example_community_patch_settings_de.toml | 5 + ...munity_patch_settings_en-GB-x-cockney.toml | 5 + ...mmunity_patch_settings_en-x-minionese.toml | 5 + example_community_patch_settings_en.toml | 5 + example_community_patch_settings_es.toml | 5 + example_community_patch_settings_fr.toml | 5 + example_community_patch_settings_nl.toml | 5 + example_community_patch_settings_ru.toml | 5 + example_community_patch_settings_tlh.toml | 5 + mods/src/config.cc | 2 + mods/src/config.h | 1 + mods/src/defaultconfig.h | 1 + .../patches/parts/officer_preset_reorder.cc | 384 ++++++++++++++++++ mods/src/patches/patches.cc | 2 + 15 files changed, 440 insertions(+) create mode 100644 mods/src/patches/parts/officer_preset_reorder.cc diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 4e4003d2f..a75cc138e 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Forhindr "Escape" i at spørge, om spillet skal afsluttes disable_escape_exit = true diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 2dda59892..ef15dd229 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Verhindert, dass die Escape-Taste zum Beenden des Spiels auffordert disable_escape_exit = true diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 0ccf711ca..3034cc6d2 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Prevent "escape" from prompting to exit the game disable_escape_exit = true diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 9f1e1e3a6..0328cde23 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Prevent "escape" from prompting to exit the game disable_escape_exit = true diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 9ffe927fe..c0433aa7e 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Prevent "escape" from prompting to exit the game disable_escape_exit = true diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index b89c9a2e8..9c48e639c 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Impide que "Escape" pregunte si deseas salir del juego disable_escape_exit = true diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index fdbc5f79d..51f36f198 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Empêcher "échap" de proposer de quitter le jeu disable_escape_exit = true diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index b2b39b208..788a5d949 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Zorgt ervoor dat het gebruiken van "escape" het spel niet sluit disable_escape_exit = true diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index ad3db036a..61ef60edb 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Не показывать запрос выхода из игры при нажатии Escape disable_escape_exit = true diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index b9bce7597..69825164a 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Prevent "escape" vo' prompting Daq exit game disable_escape_exit = true diff --git a/mods/src/config.cc b/mods/src/config.cc index 95deab836..094ce8e66 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -953,6 +953,8 @@ void Config::Load() this->installAudioEventHooks = this->trace_audio_events || !this->disabled_audio_events.empty(); this->auto_open_bulk_claim_flyout = get_config_or_default(config, parsed, "ui", "auto_open_bulk_claim_flyout", DCU::auto_open_bulk_claim_flyout, write_config); + this->allow_officer_preset_reordering = get_config_or_default( + config, parsed, "ui", "allow_officer_preset_reordering", DCU::allow_officer_preset_reordering, write_config); read_daily_bulk_claim_factions(config, parsed, this->daily_bulk_claim_factions, DCU::daily_bulk_claim_factions, write_config); diff --git a/mods/src/config.h b/mods/src/config.h index 43f5c3913..2f29170a8 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -197,6 +197,7 @@ class Config final bool trace_audio_events; std::vector disabled_audio_events; bool auto_open_bulk_claim_flyout; + bool allow_officer_preset_reordering; bool auto_confirm_ft_upgrade; std::vector daily_bulk_claim_factions; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index b22cad5b8..dadaeabd2 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -219,6 +219,7 @@ namespace UI constexpr bool auto_confirm_discovery = true; constexpr bool auto_confirm_ft_upgrade = false; constexpr bool auto_open_bulk_claim_flyout = false; + constexpr bool allow_officer_preset_reordering = false; constexpr const char* daily_bulk_claim_factions = ""; constexpr bool daily_bulk_claim_toggle_default_on = false; constexpr bool disable_escape_exit = true; diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc new file mode 100644 index 000000000..fea4d8eed --- /dev/null +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -0,0 +1,384 @@ +#include "errormsg.h" +#include "file.h" +#include "patches/key.h" +#include "str_utils.h" + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +struct OfficerPresetItemContext { + Il2CppObject object; + void* synthetic_fleet_player_data; + int32_t presentation; + bool is_save_available; + bool is_occupied; + int32_t order_id; + int64_t slot_id; + Il2CppString* preset_name; + Il2CppArray* officers; + Il2CppArray* below_deck_officers; + bool below_deck_slots_unlocked; + void* officer_presets_view_context; +}; + +using ClearAndGenerateContentsFn = void(void*, Il2CppObject*, Il2CppObject*); +using GetScrollPositionFn = float(void*); +using RestoreScrollPositionFn = void(void*, float); + +std::vector session_order; +Il2CppObject* active_controller = nullptr; +ptrdiff_t widget_context_offset = 0; +ptrdiff_t controller_scroller_offset = 0; +ptrdiff_t presets_items_offset = 0; +ClearAndGenerateContentsFn* clear_and_generate = nullptr; +GetScrollPositionFn* get_scroll_position = nullptr; +RestoreScrollPositionFn* restore_scroll_position = nullptr; + +std::filesystem::path state_path() +{ return std::filesystem::path{File::MakePath("community_patch_state.json", true)}; } + +void load_session_order() +{ + const auto path = state_path(); + std::ifstream file(path, std::ios::in | std::ios::binary); + if (!file) { + return; + } + + try { + const auto state = nlohmann::json::parse(file); + const auto order = state.find("officer_preset_order"); + if (order == state.end() || !order->is_array()) { + return; + } + + std::vector loaded_order; + loaded_order.reserve(order->size()); + for (const auto& item : *order) { + int64_t slot_id = -1; + if (item.is_string()) { + const auto& value = item.get_ref(); + const auto result = std::from_chars(value.data(), value.data() + value.size(), slot_id); + if (result.ec != std::errc{} || result.ptr != value.data() + value.size()) { + continue; + } + } else if (item.is_number_integer()) { + slot_id = item.get(); + } else { + continue; + } + + if (slot_id >= 0 && std::find(loaded_order.begin(), loaded_order.end(), slot_id) == loaded_order.end()) { + loaded_order.push_back(slot_id); + } + } + + session_order = std::move(loaded_order); + spdlog::info("[OfficerPresetReorder] loaded {} persisted slot positions", session_order.size()); + } catch (const std::exception& error) { + spdlog::warn("[OfficerPresetReorder] ignored invalid state file '{}': {}", path.string(), error.what()); + } +} + +void save_session_order() +{ + const auto path = state_path(); + nlohmann::json state = nlohmann::json::object(); + try { + std::ifstream existing(path, std::ios::in | std::ios::binary); + if (existing) { + state = nlohmann::json::parse(existing); + if (!state.is_object()) { + state = nlohmann::json::object(); + } + } + } catch (const std::exception& error) { + spdlog::warn("[OfficerPresetReorder] replacing invalid state file '{}': {}", path.string(), error.what()); + state = nlohmann::json::object(); + } + + auto order = nlohmann::json::array(); + for (const auto slot_id : session_order) { + order.push_back(std::to_string(slot_id)); + } + state["version"] = 1; + state["officer_preset_order"] = std::move(order); + + std::ofstream file(path, std::ios::out | std::ios::binary | std::ios::trunc); + if (!file) { + spdlog::warn("[OfficerPresetReorder] unable to write state file '{}'", path.string()); + return; + } + file << state.dump(2) << '\n'; +} + +static_assert(offsetof(OfficerPresetItemContext, presentation) == 0x18); +static_assert(offsetof(OfficerPresetItemContext, is_occupied) == 0x1D); +static_assert(offsetof(OfficerPresetItemContext, order_id) == 0x20); +static_assert(offsetof(OfficerPresetItemContext, slot_id) == 0x28); +static_assert(offsetof(OfficerPresetItemContext, preset_name) == 0x30); + +bool is_reorderable_preset(const OfficerPresetItemContext* context) +{ + return context != nullptr && context->slot_id >= 0 && context->order_id >= 0 && context->preset_name != nullptr + && context->officers != nullptr && reinterpret_cast(context->officers)->max_length > 0; +} + +template T* read_object_field(void* object, ptrdiff_t offset) +{ return object != nullptr ? *reinterpret_cast(reinterpret_cast(object) + offset) : nullptr; } + +void remember_slots(OfficerPresetItemContext** items, il2cpp_array_size_t size) +{ + for (il2cpp_array_size_t index = 0; index < size; ++index) { + const auto* context = items[index]; + if (!is_reorderable_preset(context)) { + continue; + } + if (std::find(session_order.begin(), session_order.end(), context->slot_id) == session_order.end()) { + session_order.push_back(context->slot_id); + } + } +} + +void apply_session_order(OfficerPresetItemContext** items, il2cpp_array_size_t size) +{ + remember_slots(items, size); + if (session_order.empty()) { + return; + } + + std::vector occupied_positions; + std::vector occupied_contexts; + occupied_positions.reserve(size); + occupied_contexts.reserve(size); + for (il2cpp_array_size_t index = 0; index < size; ++index) { + if (is_reorderable_preset(items[index])) { + occupied_positions.push_back(index); + occupied_contexts.push_back(items[index]); + } + } + + std::stable_sort(occupied_contexts.begin(), occupied_contexts.end(), [](const auto* left, const auto* right) { + const auto left_order = std::find(session_order.begin(), session_order.end(), left->slot_id); + const auto right_order = std::find(session_order.begin(), session_order.end(), right->slot_id); + return left_order < right_order; + }); + + std::vector presentations; + presentations.reserve(occupied_positions.size()); + for (const auto position : occupied_positions) { + presentations.push_back(items[position]->presentation); + } + for (size_t index = 0; index < occupied_positions.size(); ++index) { + items[occupied_positions[index]] = occupied_contexts[index]; + occupied_contexts[index]->presentation = presentations[index]; + } +} + +bool try_get_preset_list(void* view_context, Il2CppObject** list, Il2CppArraySize** backing_items, int32_t* size) +{ + *list = read_object_field(view_context, presets_items_offset); + if (*list == nullptr) { + return false; + } + + auto list_helper = IL2CppClassHelper{(*list)->klass}; + auto items_field = list_helper.GetField("_items"); + auto size_field = list_helper.GetField("_size"); + if (!items_field.isValidHelper() || !size_field.isValidHelper()) { + return false; + } + + *backing_items = read_object_field(*list, items_field.offset()); + *size = *reinterpret_cast(reinterpret_cast(*list) + size_field.offset()); + return *backing_items != nullptr && *size >= 0 + && static_cast(*size) <= (*backing_items)->max_length; +} + +bool move_preset(OfficerPresetItemContext* context, int direction) +{ + if (!is_reorderable_preset(context) || context->officer_presets_view_context == nullptr + || active_controller == nullptr || clear_and_generate == nullptr) { + return false; + } + + Il2CppObject* list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (!try_get_preset_list(context->officer_presets_view_context, &list, &backing_items, &size)) { + spdlog::warn("[OfficerPresetReorder] unable to read live preset list"); + return false; + } + + auto** items = reinterpret_cast(backing_items->vector); + int32_t current_index = -1; + for (int32_t index = 0; index < size; ++index) { + if (items[index] == context) { + current_index = index; + break; + } + } + if (current_index < 0) { + return false; + } + + int32_t target_index = current_index + direction; + while (target_index >= 0 && target_index < size && !is_reorderable_preset(items[target_index])) { + target_index += direction; + } + if (target_index < 0 || target_index >= size) { + spdlog::info("[OfficerPresetReorder] slot={} is already at the {}", context->slot_id, + direction < 0 ? "top" : "bottom"); + return true; + } + + auto* target = items[target_index]; + remember_slots(items, static_cast(size)); + const auto current_order = std::find(session_order.begin(), session_order.end(), context->slot_id); + const auto target_order = std::find(session_order.begin(), session_order.end(), target->slot_id); + if (current_order != session_order.end() && target_order != session_order.end()) { + std::iter_swap(current_order, target_order); + save_session_order(); + } + + std::swap(context->presentation, target->presentation); + std::swap(items[current_index], items[target_index]); + + auto* scroller = read_object_field(active_controller, controller_scroller_offset); + if (scroller == nullptr) { + return false; + } + + const auto scroll_position = get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; + spdlog::info("[OfficerPresetReorder] moved slot={} {} across slot={} (persisted locally)", context->slot_id, + direction < 0 ? "up" : "down", target->slot_id); + clear_and_generate(scroller, active_controller, list); + if (restore_scroll_position != nullptr) { + restore_scroll_position(scroller, scroll_position); + } + return true; +} + +bool OfficerManager_TryGetPresetItemContext_Hook(auto original, void* _this, Il2CppArraySize** preset_contexts, + void* view_context) +{ + const bool result = original(_this, preset_contexts, view_context); + if (!result || preset_contexts == nullptr || *preset_contexts == nullptr) { + spdlog::info("[OfficerPresetReorder] preset context load returned result={} array={}", result, + preset_contexts != nullptr ? static_cast(*preset_contexts) : nullptr); + return result; + } + + auto* contexts = *preset_contexts; + auto** items = reinterpret_cast(contexts->vector); + apply_session_order(items, contexts->max_length); + spdlog::debug("[OfficerPresetReorder] applied local ordering to {} preset rows", contexts->max_length); + + return result; +} + +void OfficerPresetItemWidget_OnEditNameButtonClicked_Hook(auto original, void* _this) +{ + const bool move_up = Key::HasShift(); + const bool move_down = Key::HasCtrl(); + if (move_up != move_down) { + auto* context = read_object_field(_this, widget_context_offset); + if (move_preset(context, move_up ? -1 : 1)) { + return; + } + } + original(_this); +} + +void OfficerPresetsViewController_OnDidBindCanvasContext_Hook(auto original, Il2CppObject* _this) +{ + original(_this); + active_controller = _this; +} + +void OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook(auto original, Il2CppObject* _this) +{ + if (active_controller == _this) { + active_controller = nullptr; + } + original(_this); +} +} // namespace + +void InstallOfficerPresetReorderHooks() +{ + auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Officers", "OfficerManager"); + if (!helper.isValidHelper()) { + ErrorMsg::MissingHelper("Digit.Prime.Officers", "OfficerManager"); + return; + } + + const auto method = helper.GetMethodInfo("TryGetPresetItemContext", 2); + if (method == nullptr || method->methodPointer == nullptr) { + ErrorMsg::MissingMethod("OfficerManager", "TryGetPresetItemContext"); + return; + } + + auto widget_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetItemWidget"); + auto controller_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewController"); + auto view_context_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewContext"); + auto scroller_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "SmartScrollerBase"); + if (!widget_helper.isValidHelper() || !controller_helper.isValidHelper() || !view_context_helper.isValidHelper() + || !scroller_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Digit.Prime.OfficerPresets", "reorder UI surface"); + return; + } + + auto context_field = widget_helper.GetField("m_context"); + auto scroller_field = controller_helper.GetField("_smartScroller"); + auto presets_field = view_context_helper.GetField("PresetsItemsContext"); + if (!context_field.isValidHelper() || !scroller_field.isValidHelper() || !presets_field.isValidHelper()) { + ErrorMsg::MissingMethod("OfficerPresetReorder", "required field"); + return; + } + widget_context_offset = context_field.offset(); + controller_scroller_offset = scroller_field.offset(); + presets_items_offset = presets_field.offset(); + + const auto clear_method = scroller_helper.GetMethodInfo("ClearAndGenerateContents", 2); + const auto get_scroll_method = scroller_helper.GetMethodInfo("get_ScrollPosition", 0); + const auto restore_scroll_method = scroller_helper.GetMethodInfo("RestoreScrollPosition", 1); + const auto edit_method = widget_helper.GetMethodInfo("OnEditNameButtonClicked", 0); + const auto bind_method = controller_helper.GetMethodInfo("OnDidBindCanvasContext", 0); + const auto release_method = controller_helper.GetMethodInfo("OnAboutToReleaseCanvasContext", 0); + if (clear_method == nullptr || clear_method->methodPointer == nullptr || get_scroll_method == nullptr + || get_scroll_method->methodPointer == nullptr || restore_scroll_method == nullptr + || restore_scroll_method->methodPointer == nullptr || edit_method == nullptr + || edit_method->methodPointer == nullptr || bind_method == nullptr || bind_method->methodPointer == nullptr + || release_method == nullptr || release_method->methodPointer == nullptr) { + ErrorMsg::MissingMethod("OfficerPresetReorder", "required UI method"); + return; + } + clear_and_generate = reinterpret_cast(clear_method->methodPointer); + get_scroll_position = reinterpret_cast(get_scroll_method->methodPointer); + restore_scroll_position = reinterpret_cast(restore_scroll_method->methodPointer); + + load_session_order(); + + SPUD_STATIC_DETOUR(method->methodPointer, OfficerManager_TryGetPresetItemContext_Hook); + SPUD_STATIC_DETOUR(edit_method->methodPointer, OfficerPresetItemWidget_OnEditNameButtonClicked_Hook); + SPUD_STATIC_DETOUR(bind_method->methodPointer, OfficerPresetsViewController_OnDidBindCanvasContext_Hook); + SPUD_STATIC_DETOUR(release_method->methodPointer, OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook); +} diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 9579b9692..f0dbf27ef 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -48,6 +48,7 @@ void InstallDoubleClickAssignShipHooks(); void InstallInstantWarpConfirmationHooks(); void InstallForbiddenTechConfirmationHooks(); void InstallAudioEventHooks(); +void InstallOfficerPresetReorderHooks(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -151,6 +152,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"InstantWarpConfirm", {InstallInstantWarpConfirmationHooks, &cfg.installInstantWarpConfirmationHooks}}, {"ForbiddenTechConfirm", {InstallForbiddenTechConfirmationHooks, &cfg.auto_confirm_ft_upgrade}}, {"AudioEvents", {InstallAudioEventHooks, &cfg.installAudioEventHooks}}, + {"OfficerPresetReorder", {InstallOfficerPresetReorderHooks, &cfg.allow_officer_preset_reordering}}, }; printf("il2cpp_init_hook(%s)\n", domain_name); From c2a443466848ce1fc52442c5a72d87971f31978d Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 21:42:30 -0500 Subject: [PATCH 64/80] Harden preset reorder input and state writes --- .../patches/parts/officer_preset_reorder.cc | 67 ++++++++++++++++--- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index fea4d8eed..de5f1af36 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -1,6 +1,6 @@ #include "errormsg.h" #include "file.h" -#include "patches/key.h" +#include "prime/KeyCode.h" #include "str_utils.h" #include @@ -94,7 +94,28 @@ void load_session_order() } } -void save_session_order() +bool replace_state_file(const std::filesystem::path& temporary_path, const std::filesystem::path& path) +{ +#if _WIN32 + if (MoveFileExW(temporary_path.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0) { + return true; + } + spdlog::warn("[OfficerPresetReorder] unable to replace state file '{}' (Windows error {})", path.string(), + GetLastError()); +#else + std::error_code error; + std::filesystem::rename(temporary_path, path, error); + if (!error) { + return true; + } + spdlog::warn("[OfficerPresetReorder] unable to replace state file '{}': {}", path.string(), error.message()); +#endif + std::error_code cleanup_error; + std::filesystem::remove(temporary_path, cleanup_error); + return false; +} + +bool save_session_order() { const auto path = state_path(); nlohmann::json state = nlohmann::json::object(); @@ -118,12 +139,24 @@ void save_session_order() state["version"] = 1; state["officer_preset_order"] = std::move(order); - std::ofstream file(path, std::ios::out | std::ios::binary | std::ios::trunc); + auto temporary_path = path; + temporary_path += ".tmp"; + std::ofstream file(temporary_path, std::ios::out | std::ios::binary | std::ios::trunc); if (!file) { - spdlog::warn("[OfficerPresetReorder] unable to write state file '{}'", path.string()); - return; + spdlog::warn("[OfficerPresetReorder] unable to open temporary state file '{}'", temporary_path.string()); + return false; } file << state.dump(2) << '\n'; + file.flush(); + const bool write_succeeded = file.good(); + file.close(); + if (!write_succeeded || file.fail()) { + spdlog::warn("[OfficerPresetReorder] unable to finish temporary state file '{}'", temporary_path.string()); + std::error_code cleanup_error; + std::filesystem::remove(temporary_path, cleanup_error); + return false; + } + return replace_state_file(temporary_path, path); } static_assert(offsetof(OfficerPresetItemContext, presentation) == 0x18); @@ -141,6 +174,18 @@ bool is_reorderable_preset(const OfficerPresetItemContext* context) template T* read_object_field(void* object, ptrdiff_t offset) { return object != nullptr ? *reinterpret_cast(reinterpret_cast(object) + offset) : nullptr; } +bool key_pressed(KeyCode key) +{ + static auto get_key = il2cpp_resolve_icall_typed("UnityEngine.Input::GetKeyInt(UnityEngine.KeyCode)"); + return get_key != nullptr && get_key(key); +} + +bool shift_pressed() +{ return key_pressed(KeyCode::LeftShift) || key_pressed(KeyCode::RightShift); } + +bool control_pressed() +{ return key_pressed(KeyCode::LeftControl) || key_pressed(KeyCode::RightControl); } + void remember_slots(OfficerPresetItemContext** items, il2cpp_array_size_t size) { for (il2cpp_array_size_t index = 0; index < size; ++index) { @@ -250,9 +295,10 @@ bool move_preset(OfficerPresetItemContext* context, int direction) remember_slots(items, static_cast(size)); const auto current_order = std::find(session_order.begin(), session_order.end(), context->slot_id); const auto target_order = std::find(session_order.begin(), session_order.end(), target->slot_id); + bool persisted = false; if (current_order != session_order.end() && target_order != session_order.end()) { std::iter_swap(current_order, target_order); - save_session_order(); + persisted = save_session_order(); } std::swap(context->presentation, target->presentation); @@ -264,8 +310,9 @@ bool move_preset(OfficerPresetItemContext* context, int direction) } const auto scroll_position = get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; - spdlog::info("[OfficerPresetReorder] moved slot={} {} across slot={} (persisted locally)", context->slot_id, - direction < 0 ? "up" : "down", target->slot_id); + spdlog::info("[OfficerPresetReorder] moved slot={} {} across slot={} ({})", context->slot_id, + direction < 0 ? "up" : "down", target->slot_id, + persisted ? "persisted locally" : "local persistence failed"); clear_and_generate(scroller, active_controller, list); if (restore_scroll_position != nullptr) { restore_scroll_position(scroller, scroll_position); @@ -293,8 +340,8 @@ bool OfficerManager_TryGetPresetItemContext_Hook(auto original, void* _this, Il2 void OfficerPresetItemWidget_OnEditNameButtonClicked_Hook(auto original, void* _this) { - const bool move_up = Key::HasShift(); - const bool move_down = Key::HasCtrl(); + const bool move_up = shift_pressed(); + const bool move_down = control_pressed(); if (move_up != move_down) { auto* context = read_object_field(_this, widget_context_offset); if (move_preset(context, move_up ? -1 : 1)) { From 87b95b56e1168938017a2cba45311cc9bfe93700 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 22:00:10 -0500 Subject: [PATCH 65/80] Scope preset state to config profiles --- .../patches/parts/officer_preset_reorder.cc | 117 +++++++++++++++++- 1 file changed, 113 insertions(+), 4 deletions(-) diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index de5f1af36..44edee30e 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -10,14 +10,24 @@ #include #include +#include +#include +#include #include #include #include #include #include #include +#include #include +#if !_WIN32 +#include +#include +#include +#endif + namespace { struct OfficerPresetItemContext { @@ -49,7 +59,101 @@ GetScrollPositionFn* get_scroll_position = nullptr; RestoreScrollPositionFn* restore_scroll_position = nullptr; std::filesystem::path state_path() -{ return std::filesystem::path{File::MakePath("community_patch_state.json", true)}; } +{ + if (File::hasCustomNames()) { + std::filesystem::path config_path{File::Config()}; + auto state_name = config_path.stem(); + state_name += ".state.json"; + return config_path.parent_path() / state_name; + } + return std::filesystem::path{File::MakePath("community_patch_state.json", true)}; +} + +class StateFileLock +{ +public: + explicit StateFileLock(const std::filesystem::path& path) + { + lock_path = path; + lock_path += ".lock"; + + for (int attempt = 0; attempt < 50; ++attempt) { +#if _WIN32 + handle = CreateFileW(lock_path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle != INVALID_HANDLE_VALUE) { + return; + } + const auto error = GetLastError(); + if (error != ERROR_SHARING_VIOLATION && error != ERROR_LOCK_VIOLATION) { + break; + } +#else + descriptor = open(lock_path.c_str(), O_CREAT | O_RDWR, 0600); + if (descriptor < 0) { + break; + } + if (flock(descriptor, LOCK_EX | LOCK_NB) == 0) { + return; + } + const auto error = errno; + close(descriptor); + descriptor = -1; + if (error != EWOULDBLOCK && error != EAGAIN) { + break; + } +#endif + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + + ~StateFileLock() + { +#if _WIN32 + if (handle != INVALID_HANDLE_VALUE) { + CloseHandle(handle); + } +#else + if (descriptor >= 0) { + flock(descriptor, LOCK_UN); + close(descriptor); + } +#endif + } + + StateFileLock(const StateFileLock&) = delete; + StateFileLock& operator=(const StateFileLock&) = delete; + + bool acquired() const + { +#if _WIN32 + return handle != INVALID_HANDLE_VALUE; +#else + return descriptor >= 0; +#endif + } + +private: + std::filesystem::path lock_path; +#if _WIN32 + HANDLE handle = INVALID_HANDLE_VALUE; +#else + int descriptor = -1; +#endif +}; + +std::filesystem::path temporary_state_path(const std::filesystem::path& path) +{ + static std::atomic_uint64_t sequence{0}; + auto temporary_path = path; +#if _WIN32 + const auto process_id = static_cast(GetCurrentProcessId()); +#else + const auto process_id = static_cast(getpid()); +#endif + temporary_path += "." + std::to_string(process_id) + "." + std::to_string(++sequence) + ".tmp"; + return temporary_path; +} void load_session_order() { @@ -117,7 +221,13 @@ bool replace_state_file(const std::filesystem::path& temporary_path, const std:: bool save_session_order() { - const auto path = state_path(); + const auto path = state_path(); + StateFileLock lock(path); + if (!lock.acquired()) { + spdlog::warn("[OfficerPresetReorder] unable to lock state file '{}'", path.string()); + return false; + } + nlohmann::json state = nlohmann::json::object(); try { std::ifstream existing(path, std::ios::in | std::ios::binary); @@ -139,8 +249,7 @@ bool save_session_order() state["version"] = 1; state["officer_preset_order"] = std::move(order); - auto temporary_path = path; - temporary_path += ".tmp"; + const auto temporary_path = temporary_state_path(path); std::ofstream file(temporary_path, std::ios::out | std::ios::binary | std::ios::trunc); if (!file) { spdlog::warn("[OfficerPresetReorder] unable to open temporary state file '{}'", temporary_path.string()); From 223324a5795b5bab601f64c65d5f3c1f207c86fd Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 02:19:15 -0500 Subject: [PATCH 66/80] Add mod shortcut hints to native overlay --- example_community_patch_settings_da.toml | 5 +- example_community_patch_settings_de.toml | 5 +- ...munity_patch_settings_en-GB-x-cockney.toml | 5 +- ...mmunity_patch_settings_en-x-minionese.toml | 5 +- example_community_patch_settings_en.toml | 5 +- example_community_patch_settings_es.toml | 5 +- example_community_patch_settings_fr.toml | 5 +- example_community_patch_settings_nl.toml | 5 +- example_community_patch_settings_ru.toml | 5 +- example_community_patch_settings_tlh.toml | 5 +- mods/src/config.cc | 2 + mods/src/defaultconfig.h | 1 + mods/src/patches/gamefunctions.h | 1 + mods/src/patches/parts/hotkeys.cc | 261 ++++++++++++++++++ mods/src/prime/ShortcutsManager.h | 14 + 15 files changed, 319 insertions(+), 10 deletions(-) create mode 100644 mods/src/prime/ShortcutsManager.h diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 4e4003d2f..6f94d727c 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -491,6 +491,10 @@ show_scrapyard = "Y" # Gå til indstillinger show_settings = "SHIFT-S" +# Slå genvejstips på skærmen til/fra (viser mod-genveje, hvor de understøttes) +toggle_shortcut_hints = "NONE" +# Symboler i mærker bruger AutoHotkey-notation: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Gå til administration af skibe show_ships = "N" @@ -829,4 +833,3 @@ verify_ssl = true # <[========================================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 2dda59892..91b1d95af 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -491,6 +491,10 @@ show_scrapyard = "Y" # Zu den Einstellungen show_settings = "SHIFT-S" +# Bildschirmhinweise für Tastenkürzel umschalten (zeigt unterstützte Mod-Tastenkürzel) +toggle_shortcut_hints = "NONE" +# Hinweissymbole verwenden die AutoHotkey-Schreibweise: ^ Strg, ! Alt, + Umschalt, # Win/Befehl + # Zur Schiffsverwaltung show_ships = "N" @@ -829,4 +833,3 @@ verify_ssl = true # <[===================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 0ccf711ca..246f402b9 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -491,6 +491,10 @@ show_scrapyard = "Y" # Go to Settings show_settings = "SHIFT-S" +# Toggle on-screen shortcut hints (shows mod bindings where supported) +toggle_shortcut_hints = "NONE" +# Badge modifiers use AutoHotkey notation: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Go to Manage Ships show_ships = "N" @@ -829,4 +833,3 @@ verify_ssl = true # <[========================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 9f1e1e3a6..cbdf3574c 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -491,6 +491,10 @@ show_scrapyard = "Y" # Go to Settings show_settings = "SHIFT-S" +# Toggle on-screen shortcut hints (shows mod bindings where supported) +toggle_shortcut_hints = "NONE" +# Badge modifiers use AutoHotkey notation: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Go to Manage Ships show_ships = "N" @@ -829,4 +833,3 @@ verify_ssl = true # <[=============================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 9ffe927fe..e3dfd3a5e 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -491,6 +491,10 @@ show_scrapyard = "Y" # Go to Settings show_settings = "SHIFT-S" +# Toggle on-screen shortcut hints (shows mod bindings where supported) +toggle_shortcut_hints = "NONE" +# Badge modifiers use AutoHotkey notation: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Go to Manage Ships show_ships = "N" @@ -829,4 +833,3 @@ verify_ssl = true # <[========================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index b89c9a2e8..8ce40cd78 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -491,6 +491,10 @@ show_scrapyard = "Y" # Ir a ajustes show_settings = "SHIFT-S" +# Alternar las indicaciones de atajos en pantalla (muestra los atajos compatibles del mod) +toggle_shortcut_hints = "NONE" +# Los modificadores usan la notación de AutoHotkey: ^ Ctrl, ! Alt, + Mayús, # Win/Comando + # Ir a administrar barcos show_ships = "N" @@ -829,4 +833,3 @@ verify_ssl = true # <[==========================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index fdbc5f79d..6b44557c8 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -491,6 +491,10 @@ show_scrapyard = "Y" # Aller aux paramètres show_settings = "SHIFT-S" +# Afficher ou masquer les raccourcis à l’écran (affiche les raccourcis du mod pris en charge) +toggle_shortcut_hints = "NONE" +# Les modificateurs utilisent la notation AutoHotkey : ^ Ctrl, ! Alt, + Maj, # Win/Commande + # Aller aux vaisseaux show_ships = "N" @@ -829,4 +833,3 @@ verify_ssl = true # <[==========================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index b2b39b208..5861c829e 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -491,6 +491,10 @@ show_scrapyard = "Y" # Ga naar Instellingen show_settings = "SHIFT-S" +# Schermtips voor sneltoetsen in- of uitschakelen (toont ondersteunde mod-sneltoetsen) +toggle_shortcut_hints = "NONE" +# Modificatietoetsen gebruiken AutoHotkey-notatie: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Ga naar Scheepsmanagement show_ships = "N" @@ -829,4 +833,3 @@ verify_ssl = true # <[===========================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index ad3db036a..b2aed9338 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -491,6 +491,10 @@ show_scrapyard = "Y" # Перейти к настройкам show_settings = "SHIFT-S" +# Показать или скрыть подсказки клавиш (показывает поддерживаемые сочетания мода) +toggle_shortcut_hints = "NONE" +# Модификаторы используют нотацию AutoHotkey: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Перейти к управлению кораблями show_ships = "N" @@ -829,4 +833,3 @@ verify_ssl = true # <[=====================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index b9bce7597..e25274d23 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -491,6 +491,10 @@ show_scrapyard = "Y" # Go Daq ghun show_settings = "SHIFT-S" +# Toggle on-screen shortcut hints (shows mod bindings where supported) +toggle_shortcut_hints = "NONE" +# Badge modifiers use AutoHotkey notation: ^ Ctrl, ! Alt, + Shift, # Win/Command + # Go Daq Manage Ships show_ships = "N" @@ -829,4 +833,3 @@ verify_ssl = true # <[======================================================================================================]> [sync.targets.spocksclub] - diff --git a/mods/src/config.cc b/mods/src/config.cc index 95deab836..16c7fb5b7 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1268,6 +1268,8 @@ void Config::Load() parse_config_shortcut(config, parsed, "show_research", GameFunction::ShowResearch, DCSH::show_research); parse_config_shortcut(config, parsed, "show_scrapyard", GameFunction::ShowScrapYard, DCSH::show_scrapyard); parse_config_shortcut(config, parsed, "show_settings", GameFunction::ShowSettings, DCSH::show_settings); + parse_config_shortcut(config, parsed, "toggle_shortcut_hints", GameFunction::ToggleShortcutHints, + DCSH::toggle_shortcut_hints); parse_config_shortcut(config, parsed, "show_officers", GameFunction::ShowOfficers, DCSH::show_officers); parse_config_shortcut(config, parsed, "show_qtrials", GameFunction::ShowQTrials, DCSH::show_qtrials); parse_config_shortcut(config, parsed, "show_refinery", GameFunction::ShowRefinery, DCSH::show_refinery); diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index b22cad5b8..13b9a8bc2 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -158,6 +158,7 @@ namespace Shortcuts constexpr const char* show_stationexterior = "SHIFT-G"; constexpr const char* show_stationinterior = "SHIFT-H"; constexpr const char* show_system = "H"; + constexpr const char* toggle_shortcut_hints = "NONE"; constexpr const char* toggle_cargo_armada = "ALT-5"; constexpr const char* toggle_cargo_default = "ALT-1"; constexpr const char* toggle_cargo_hostile = "ALT-4"; diff --git a/mods/src/patches/gamefunctions.h b/mods/src/patches/gamefunctions.h index bd4b0fb50..96fbb450e 100644 --- a/mods/src/patches/gamefunctions.h +++ b/mods/src/patches/gamefunctions.h @@ -46,6 +46,7 @@ enum GameFunction { ShowChatSide2, ShowEvents, ShowSettings, + ToggleShortcutHints, ZoomPreset1, ZoomPreset2, ZoomPreset3, diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index f04ddafb1..ff22fb8f1 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -34,11 +34,13 @@ #include "prime/ScanEngageButtonsWidget.h" #include "prime/ScreenManager.h" #include "prime/SelectableList.h" +#include "prime/ShortcutsManager.h" #include "patches/key.h" #include "patches/mapkey.h" #include "patches/parts/daily_faction_bulk_claim.h" #include "patches/parts/focus_search.h" +#include "str_utils.h" #include @@ -52,6 +54,242 @@ static bool reset_focus_next_frame = false; static int show_info_pending = 0; +static const MethodInfo* on_show_keybindings_action = nullptr; + +struct InputActionCallbackContext { + void* state; + int32_t action_index; +}; + +static_assert(sizeof(InputActionCallbackContext) == 16); + +bool InvokeNativeShortcut(const MethodInfo* method, const char* action_name) +{ + auto* shortcuts_manager = ShortcutsManager::Instance(); + if (!shortcuts_manager || !method) { + spdlog::warn("[Hotkeys] native {} shortcut is unavailable", action_name); + return false; + } + + // The callback does not inspect CallbackContext, but runtime_invoke still requires storage for the value-type + // argument. Using the managed invoker also avoids platform-specific by-value ABI assumptions. + InputActionCallbackContext context{}; + void* args[]{&context}; + Il2CppException* exception = nullptr; + il2cpp_runtime_invoke(method, shortcuts_manager, args, &exception); + if (exception) { + spdlog::warn("[Hotkeys] native {} shortcut raised exception={}", action_name, static_cast(exception)); + return false; + } + + return true; +} + +std::string CompactShortcutToken(std::string_view token) +{ + // Use AutoHotkey's established ASCII modifier notation so the native game's limited font can render every badge. + if (token == "SHIFT") + return "+"; + if (token == "CTRL") + return "^"; + if (token == "ALT" || token == "ALTGR") + return "!"; + if (token == "APPLE" || token == "CMD") + return "#"; + if (token == "WIN") + return "#"; + if (token == "SPACE") + return "SPC"; + if (token == "MOUSE1") + return "M1"; + if (token == "MOUSE2") + return "M2"; + if (token == "MOUSE3") + return "M3"; + if (token == "MOUSE4") + return "M4"; + if (token == "MOUSE5") + return "M5"; + if (token == "ENTER" || token == "RETURN") + return "ENT"; + if (token == "ESCAPE") + return "ESC"; + if (token == "TAB") + return "TAB"; + if (token == "BACKSPACE") + return "BS"; + if (token == "DELETE") + return "DEL"; + if (token == "MINUS") + return "-"; + if (token == "EQUAL") + return "="; + if (token == "LEFT") + return "L"; + if (token == "RIGHT") + return "R"; + if (token == "UP") + return "U"; + if (token == "DOWN") + return "D"; + if (token == "PGUP") + return "PU"; + if (token == "PGDOWN") + return "PD"; + if (token == "HOME") + return "HM"; + if (token == "END") + return "END"; + if (token == "NONE") + return "-"; + return std::string(token); +} + +std::string CompactShortcutForHint(std::string_view shortcuts) +{ + if (shortcuts.empty()) { + return "-"; + } + + // A native badge only has room for one chord, so show the first configured alternative. + if (const auto alternative = shortcuts.find(" | "); alternative != std::string_view::npos) { + shortcuts = shortcuts.substr(0, alternative); + } + + std::string compact; + for (size_t start = 0; start <= shortcuts.size();) { + const auto separator = shortcuts.find('-', start); + const auto end = separator == std::string_view::npos ? shortcuts.size() : separator; + compact.append(CompactShortcutToken(shortcuts.substr(start, end - start))); + if (separator == std::string_view::npos) { + break; + } + start = separator + 1; + } + return compact; +} + +GameFunction ModFunctionForNativeAction(std::string_view action_name) +{ + if (action_name == "interior_view") + return GameFunction::ShowStationInterior; + if (action_name == "exterior_view") + return GameFunction::ShoWStationExterior; + if (action_name == "system_view") + return GameFunction::ShowSystem; + if (action_name == "galaxy_view") + return GameFunction::ShowGalaxy; + if (action_name == "events") + return GameFunction::ShowEvents; + if (action_name == "ship_a") + return GameFunction::SelectShip1; + if (action_name == "ship_b") + return GameFunction::SelectShip2; + if (action_name == "ship_c") + return GameFunction::SelectShip3; + if (action_name == "ship_d") + return GameFunction::SelectShip4; + if (action_name == "ship_e") + return GameFunction::SelectShip5; + if (action_name == "ship_f") + return GameFunction::SelectShip6; + if (action_name == "ship_g") + return GameFunction::SelectShip7; + if (action_name == "ship_h") + return GameFunction::SelectShip8; + if (action_name == "alliance") + return GameFunction::ShowAlliance; + if (action_name == "chat") + return GameFunction::ShowChat; + if (action_name == "side_chat") + return GameFunction::ShowChatSide1; + if (action_name == "away_teams") + return GameFunction::ShowAwayTeam; + if (action_name == "missions") + return GameFunction::ShowMissions; + if (action_name == "daily_goals") + return GameFunction::ShowDaily; + if (action_name == "ship_locate") + return GameFunction::SelectCurrent; + if (action_name == "ship_manage") + return GameFunction::ShowShips; + if (action_name == "research") + return GameFunction::ShowResearch; + if (action_name == "consumables") + return GameFunction::ShowExoComp; + if (action_name == "ship_recall") + return GameFunction::ActionRecall; + if (action_name == "show_keybindings") + return GameFunction::ToggleShortcutHints; + if (action_name == "gifts") + return GameFunction::ShowGifts; + if (action_name == "help_alliance") + return GameFunction::ShowAllianceHelp; + if (action_name == "officers") + return GameFunction::ShowOfficers; + if (action_name == "factions") + return GameFunction::ShowFactions; + if (action_name == "items") + return GameFunction::ShowInventory; + if (action_name == "refinery") + return GameFunction::ShowRefinery; + if (action_name == "commanders") + return GameFunction::ShowCommander; + if (action_name == "challenges") + return GameFunction::ShowQTrials; + return GameFunction::Max; +} + +void ShortcutKeybindHint_UpdateText_Hook(auto original, void* _this) +{ + original(_this); + + const auto& config = Config::Get(); + if (!_this || !config.hotkeys_enabled || config.use_scopely_hotkeys) { + return; + } + + static auto hint_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameInput", "ShortcutKeybindHint"); + static auto input_action_field = hint_helper.GetField("_inputAction"); + static auto text_localizer_field = hint_helper.GetField("_keyTextLocalizer"); + if (!input_action_field.isValidHelper() || !text_localizer_field.isValidHelper()) { + return; + } + + auto* input_action_reference = + *reinterpret_cast(reinterpret_cast(_this) + input_action_field.offset()); + auto* text_localizer = *reinterpret_cast(reinterpret_cast(_this) + text_localizer_field.offset()); + if (!input_action_reference || !text_localizer) { + return; + } + + static auto input_action_reference_helper = + il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "InputActionReference"); + static auto input_action_helper = + il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "InputAction"); + static auto text_localizer_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "TextLocalizer"); + static auto get_action = input_action_reference_helper.GetMethod("get_action", 0); + static auto get_name = input_action_helper.GetMethod("get_name", 0); + static auto override_text = text_localizer_helper.GetMethod("OverrideLocalizedText", 1); + if (!get_action || !get_name || !override_text) { + return; + } + + auto* action = get_action(input_action_reference); + auto* name = action ? get_name(action) : nullptr; + if (!name) { + return; + } + + const auto game_function = ModFunctionForNativeAction(to_string(name)); + if (game_function == GameFunction::Max) { + return; + } + + auto shortcut = CompactShortcutForHint(MapKey::GetShortcuts(game_function)); + override_text(text_localizer, il2cpp_string_new(shortcut.c_str())); +} + bool force_space_action_next_frame = false; void ChangeNavigationSection(SectionID sectionID); @@ -213,6 +451,11 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) return; } + if (MapKey::IsDown(GameFunction::ToggleShortcutHints)) { + InvokeNativeShortcut(on_show_keybindings_action, "Show Keybindings"); + return; + } + static auto GetDeltaTime = il2cpp_resolve_icall_typed("UnityEngine.Time::get_deltaTime()"); const auto is_in_chat = Hub::IsInChat(); @@ -1034,6 +1277,11 @@ void InstallHotkeyHooks() if (!shortcuts_manager_helper.isValidHelper()) { ErrorMsg::MissingHelper("GameInput", "ShortcutsManager"); } else { + on_show_keybindings_action = shortcuts_manager_helper.GetMethodInfo("OnShowKeybindingsAction", 1); + if (on_show_keybindings_action == nullptr) { + ErrorMsg::MissingMethod("ShortcutsManager", "OnShowKeybindingsAction"); + } + auto ptr_can_user_shortcuts = shortcuts_manager_helper.GetMethod("InitializeActions"); if (ptr_can_user_shortcuts == nullptr) { ErrorMsg::MissingMethod("ShortcutsManager", "InitializeActions"); @@ -1042,6 +1290,19 @@ void InstallHotkeyHooks() } } + auto shortcut_hint_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameInput", "ShortcutKeybindHint"); + if (!shortcut_hint_helper.isValidHelper()) { + ErrorMsg::MissingHelper("GameInput", "ShortcutKeybindHint"); + } else { + auto update_text = shortcut_hint_helper.GetMethod("UpdateText"); + if (update_text == nullptr) { + ErrorMsg::MissingMethod("ShortcutKeybindHint", "UpdateText"); + } else { + SPUD_STATIC_DETOUR(update_text, ShortcutKeybindHint_UpdateText_Hook); + } + } + auto screen_manager_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "ScreenManager"); if (!screen_manager_helper.isValidHelper()) { ErrorMsg::MissingHelper("UI", "ScreenManager"); diff --git a/mods/src/prime/ShortcutsManager.h b/mods/src/prime/ShortcutsManager.h new file mode 100644 index 000000000..5529506f2 --- /dev/null +++ b/mods/src/prime/ShortcutsManager.h @@ -0,0 +1,14 @@ +#pragma once + +#include "MonoSingleton.h" + +struct ShortcutsManager : MonoSingleton { + friend struct MonoSingleton; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameInput", "ShortcutsManager"); + return class_helper; + } +}; From 8a6807b3727a8c2592f7a4d0b1d6eca25fec9678 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 02:29:30 -0500 Subject: [PATCH 67/80] Harden shortcut hint state transitions --- mods/src/patches/parts/hotkeys.cc | 156 +++++++++++++++++++++--------- 1 file changed, 111 insertions(+), 45 deletions(-) diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index ff22fb8f1..b6a3c909c 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -54,38 +54,48 @@ static bool reset_focus_next_frame = false; static int show_info_pending = 0; -static const MethodInfo* on_show_keybindings_action = nullptr; +using GetShowKeybindingsFn = bool(void*); +using SetShowKeybindingsFn = void(void*, bool); +using CanUseShortcutsFn = bool(); +using ClearTextOverrideFn = void(void*); -struct InputActionCallbackContext { - void* state; - int32_t action_index; -}; +static GetShowKeybindingsFn* get_show_keybindings = nullptr; +static SetShowKeybindingsFn* set_show_keybindings = nullptr; +static CanUseShortcutsFn* can_use_shortcuts = nullptr; +static ClearTextOverrideFn* clear_text_override = nullptr; -static_assert(sizeof(InputActionCallbackContext) == 16); - -bool InvokeNativeShortcut(const MethodInfo* method, const char* action_name) +bool SetNativeShortcutHintsVisible(bool visible) { auto* shortcuts_manager = ShortcutsManager::Instance(); - if (!shortcuts_manager || !method) { - spdlog::warn("[Hotkeys] native {} shortcut is unavailable", action_name); + if (!shortcuts_manager || !get_show_keybindings || !set_show_keybindings) { + spdlog::warn("[Hotkeys] native shortcut hints are unavailable"); return false; } - // The callback does not inspect CallbackContext, but runtime_invoke still requires storage for the value-type - // argument. Using the managed invoker also avoids platform-specific by-value ABI assumptions. - InputActionCallbackContext context{}; - void* args[]{&context}; - Il2CppException* exception = nullptr; - il2cpp_runtime_invoke(method, shortcuts_manager, args, &exception); - if (exception) { - spdlog::warn("[Hotkeys] native {} shortcut raised exception={}", action_name, static_cast(exception)); + if (get_show_keybindings(shortcuts_manager) != visible) + set_show_keybindings(shortcuts_manager, visible); + + return true; +} + +bool ToggleNativeShortcutHints() +{ + auto* shortcuts_manager = ShortcutsManager::Instance(); + if (!shortcuts_manager || !get_show_keybindings || !set_show_keybindings || !can_use_shortcuts) { + spdlog::warn("[Hotkeys] native shortcut hints are unavailable"); return false; } + // Match the native OnShowKeybindingsAction gate while avoiding a fabricated InputAction.CallbackContext. + if (!can_use_shortcuts()) + return false; + + set_show_keybindings(shortcuts_manager, !get_show_keybindings(shortcuts_manager)); + return true; } -std::string CompactShortcutToken(std::string_view token) +std::string CompactShortcutToken(std::string_view token, bool is_primary_key) { // Use AutoHotkey's established ASCII modifier notation so the native game's limited font can render every badge. if (token == "SHIFT") @@ -94,12 +104,36 @@ std::string CompactShortcutToken(std::string_view token) return "^"; if (token == "ALT" || token == "ALTGR") return "!"; - if (token == "APPLE" || token == "CMD") - return "#"; - if (token == "WIN") + if (token == "APPLE" || token == "CMD" || token == "WIN") return "#"; + if (token == "LSHIFT") + return "<+"; + if (token == "RSHIFT") + return ">+"; + if (token == "LCTRL") + return "<^"; + if (token == "RCTRL") + return ">^"; + if (token == "LALT") + return "!"; + if (token == "LAPPLE" || token == "LCOM" || token == "LWIN") + return "<#"; + if (token == "RAPPLE" || token == "RCOM" || token == "RWIN") + return ">#"; + if (is_primary_key && token == "+") + return "PLS"; + if (is_primary_key && token == "^") + return "CAR"; + if (is_primary_key && token == "!") + return "EXC"; + if (is_primary_key && token == "#") + return "HSH"; if (token == "SPACE") return "SPC"; + if (token == "MOUSE0") + return "M0"; if (token == "MOUSE1") return "M1"; if (token == "MOUSE2") @@ -110,6 +144,8 @@ std::string CompactShortcutToken(std::string_view token) return "M4"; if (token == "MOUSE5") return "M5"; + if (token == "MOUSE6") + return "M6"; if (token == "ENTER" || token == "RETURN") return "ENT"; if (token == "ESCAPE") @@ -125,13 +161,13 @@ std::string CompactShortcutToken(std::string_view token) if (token == "EQUAL") return "="; if (token == "LEFT") - return "L"; + return "LT"; if (token == "RIGHT") - return "R"; + return "RT"; if (token == "UP") - return "U"; + return "UP"; if (token == "DOWN") - return "D"; + return "DN"; if (token == "PGUP") return "PU"; if (token == "PGDOWN") @@ -160,7 +196,7 @@ std::string CompactShortcutForHint(std::string_view shortcuts) for (size_t start = 0; start <= shortcuts.size();) { const auto separator = shortcuts.find('-', start); const auto end = separator == std::string_view::npos ? shortcuts.size() : separator; - compact.append(CompactShortcutToken(shortcuts.substr(start, end - start))); + compact.append(CompactShortcutToken(shortcuts.substr(start, end - start), separator == std::string_view::npos)); if (separator == std::string_view::npos) { break; } @@ -242,24 +278,32 @@ GameFunction ModFunctionForNativeAction(std::string_view action_name) void ShortcutKeybindHint_UpdateText_Hook(auto original, void* _this) { - original(_this); - - const auto& config = Config::Get(); - if (!_this || !config.hotkeys_enabled || config.use_scopely_hotkeys) { - return; - } - static auto hint_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameInput", "ShortcutKeybindHint"); static auto input_action_field = hint_helper.GetField("_inputAction"); static auto text_localizer_field = hint_helper.GetField("_keyTextLocalizer"); - if (!input_action_field.isValidHelper() || !text_localizer_field.isValidHelper()) { + if (!_this || !input_action_field.isValidHelper() || !text_localizer_field.isValidHelper() || + !clear_text_override) { + return original(_this); + } + + auto* text_localizer = *reinterpret_cast(reinterpret_cast(_this) + text_localizer_field.offset()); + if (!text_localizer) { + return original(_this); + } + + // UpdateText does not clear an existing localization override. Clear ours first so native, disabled, and unknown + // actions cannot retain a stale mod badge. + clear_text_override(text_localizer); + original(_this); + + const auto& config = Config::Get(); + if (!config.hotkeys_enabled || config.use_scopely_hotkeys) { return; } auto* input_action_reference = *reinterpret_cast(reinterpret_cast(_this) + input_action_field.offset()); - auto* text_localizer = *reinterpret_cast(reinterpret_cast(_this) + text_localizer_field.offset()); - if (!input_action_reference || !text_localizer) { + if (!input_action_reference) { return; } @@ -281,12 +325,19 @@ void ShortcutKeybindHint_UpdateText_Hook(auto original, void* _this) return; } - const auto game_function = ModFunctionForNativeAction(to_string(name)); + const auto action_name = to_string(name); + const auto game_function = ModFunctionForNativeAction(action_name); if (game_function == GameFunction::Max) { + override_text(text_localizer, il2cpp_string_new("-")); return; } - auto shortcut = CompactShortcutForHint(MapKey::GetShortcuts(game_function)); + auto shortcuts = MapKey::GetShortcuts(game_function); + if (action_name == "side_chat" && shortcuts.empty()) { + shortcuts = MapKey::GetShortcuts(GameFunction::ShowChatSide2); + } + + auto shortcut = CompactShortcutForHint(shortcuts); override_text(text_localizer, il2cpp_string_new(shortcut.c_str())); } @@ -434,6 +485,7 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) Key::ResetCache(); if (MapKey::IsDown(GameFunction::DisableHotKeys)) { + SetNativeShortcutHintsVisible(false); Config::Get().hotkeys_enabled = false; spdlog::warn("Setting hotkeys to DISABLED"); return; @@ -452,7 +504,7 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) } if (MapKey::IsDown(GameFunction::ToggleShortcutHints)) { - InvokeNativeShortcut(on_show_keybindings_action, "Show Keybindings"); + ToggleNativeShortcutHints(); return; } @@ -1277,10 +1329,15 @@ void InstallHotkeyHooks() if (!shortcuts_manager_helper.isValidHelper()) { ErrorMsg::MissingHelper("GameInput", "ShortcutsManager"); } else { - on_show_keybindings_action = shortcuts_manager_helper.GetMethodInfo("OnShowKeybindingsAction", 1); - if (on_show_keybindings_action == nullptr) { - ErrorMsg::MissingMethod("ShortcutsManager", "OnShowKeybindingsAction"); - } + get_show_keybindings = shortcuts_manager_helper.GetMethod("get_ShowKeybindings", 0); + set_show_keybindings = shortcuts_manager_helper.GetMethod("set_ShowKeybindings", 1); + can_use_shortcuts = shortcuts_manager_helper.GetMethod("get_CanUseShortcuts", 0); + if (!get_show_keybindings) + ErrorMsg::MissingMethod("ShortcutsManager", "get_ShowKeybindings"); + if (!set_show_keybindings) + ErrorMsg::MissingMethod("ShortcutsManager", "set_ShowKeybindings"); + if (!can_use_shortcuts) + ErrorMsg::MissingMethod("ShortcutsManager", "get_CanUseShortcuts"); auto ptr_can_user_shortcuts = shortcuts_manager_helper.GetMethod("InitializeActions"); if (ptr_can_user_shortcuts == nullptr) { @@ -1290,6 +1347,15 @@ void InstallHotkeyHooks() } } + auto text_localizer_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "TextLocalizer"); + if (!text_localizer_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Digit.Client.UI", "TextLocalizer"); + } else { + clear_text_override = text_localizer_helper.GetMethod("ClearTextOverride", 0); + if (!clear_text_override) + ErrorMsg::MissingMethod("TextLocalizer", "ClearTextOverride"); + } + auto shortcut_hint_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameInput", "ShortcutKeybindHint"); if (!shortcut_hint_helper.isValidHelper()) { @@ -1298,7 +1364,7 @@ void InstallHotkeyHooks() auto update_text = shortcut_hint_helper.GetMethod("UpdateText"); if (update_text == nullptr) { ErrorMsg::MissingMethod("ShortcutKeybindHint", "UpdateText"); - } else { + } else if (clear_text_override) { SPUD_STATIC_DETOUR(update_text, ShortcutKeybindHint_UpdateText_Hook); } } From 05bea0de7a0f1525b25fc27598b2ffe29dd27bd3 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 02:38:33 -0500 Subject: [PATCH 68/80] Refresh shortcut hints when shown --- mods/src/patches/parts/hotkeys.cc | 131 +++++++++++++++++++++--------- 1 file changed, 94 insertions(+), 37 deletions(-) diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index b6a3c909c..66a203139 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -54,15 +54,28 @@ static bool reset_focus_next_frame = false; static int show_info_pending = 0; -using GetShowKeybindingsFn = bool(void*); -using SetShowKeybindingsFn = void(void*, bool); -using CanUseShortcutsFn = bool(); -using ClearTextOverrideFn = void(void*); - -static GetShowKeybindingsFn* get_show_keybindings = nullptr; -static SetShowKeybindingsFn* set_show_keybindings = nullptr; -static CanUseShortcutsFn* can_use_shortcuts = nullptr; -static ClearTextOverrideFn* clear_text_override = nullptr; +using GetShowKeybindingsFn = bool(void*); +using SetShowKeybindingsFn = void(void*, bool); +using CanUseShortcutsFn = bool(); +using ClearTextOverrideFn = void(void*); +using UpdateShortcutHintTextFn = void(void*); +using GetInputActionFn = void*(void*); +using GetInputActionNameFn = Il2CppString*(void*); +using OverrideLocalizedTextFn = void(void*, Il2CppString*); +using KeybindVisibilityChangedFn = void(void*, bool); + +static GetShowKeybindingsFn* get_show_keybindings = nullptr; +static SetShowKeybindingsFn* set_show_keybindings = nullptr; +static CanUseShortcutsFn* can_use_shortcuts = nullptr; +static ClearTextOverrideFn* clear_text_override = nullptr; +static GetInputActionFn* get_input_action = nullptr; +static GetInputActionNameFn* get_input_action_name = nullptr; +static OverrideLocalizedTextFn* override_localized_text = nullptr; +static UpdateShortcutHintTextFn* original_shortcut_hint_update_text = nullptr; +static ptrdiff_t shortcut_hint_input_action_offset = 0; +static ptrdiff_t shortcut_hint_text_localizer_offset = 0; +static bool shortcut_hint_fields_ready = false; +static bool shortcut_hints_ready = false; bool SetNativeShortcutHintsVisible(bool visible) { @@ -81,7 +94,8 @@ bool SetNativeShortcutHintsVisible(bool visible) bool ToggleNativeShortcutHints() { auto* shortcuts_manager = ShortcutsManager::Instance(); - if (!shortcuts_manager || !get_show_keybindings || !set_show_keybindings || !can_use_shortcuts) { + if (!shortcut_hints_ready || !shortcuts_manager || !get_show_keybindings || !set_show_keybindings || + !can_use_shortcuts) { spdlog::warn("[Hotkeys] native shortcut hints are unavailable"); return false; } @@ -278,15 +292,12 @@ GameFunction ModFunctionForNativeAction(std::string_view action_name) void ShortcutKeybindHint_UpdateText_Hook(auto original, void* _this) { - static auto hint_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameInput", "ShortcutKeybindHint"); - static auto input_action_field = hint_helper.GetField("_inputAction"); - static auto text_localizer_field = hint_helper.GetField("_keyTextLocalizer"); - if (!_this || !input_action_field.isValidHelper() || !text_localizer_field.isValidHelper() || - !clear_text_override) { + if (!_this || !shortcut_hints_ready) { return original(_this); } - auto* text_localizer = *reinterpret_cast(reinterpret_cast(_this) + text_localizer_field.offset()); + auto* text_localizer = + *reinterpret_cast(reinterpret_cast(_this) + shortcut_hint_text_localizer_offset); if (!text_localizer) { return original(_this); } @@ -302,25 +313,13 @@ void ShortcutKeybindHint_UpdateText_Hook(auto original, void* _this) } auto* input_action_reference = - *reinterpret_cast(reinterpret_cast(_this) + input_action_field.offset()); + *reinterpret_cast(reinterpret_cast(_this) + shortcut_hint_input_action_offset); if (!input_action_reference) { return; } - static auto input_action_reference_helper = - il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "InputActionReference"); - static auto input_action_helper = - il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "InputAction"); - static auto text_localizer_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "TextLocalizer"); - static auto get_action = input_action_reference_helper.GetMethod("get_action", 0); - static auto get_name = input_action_helper.GetMethod("get_name", 0); - static auto override_text = text_localizer_helper.GetMethod("OverrideLocalizedText", 1); - if (!get_action || !get_name || !override_text) { - return; - } - - auto* action = get_action(input_action_reference); - auto* name = action ? get_name(action) : nullptr; + auto* action = get_input_action(input_action_reference); + auto* name = action ? get_input_action_name(action) : nullptr; if (!name) { return; } @@ -328,7 +327,7 @@ void ShortcutKeybindHint_UpdateText_Hook(auto original, void* _this) const auto action_name = to_string(name); const auto game_function = ModFunctionForNativeAction(action_name); if (game_function == GameFunction::Max) { - override_text(text_localizer, il2cpp_string_new("-")); + override_localized_text(text_localizer, il2cpp_string_new("-")); return; } @@ -338,7 +337,15 @@ void ShortcutKeybindHint_UpdateText_Hook(auto original, void* _this) } auto shortcut = CompactShortcutForHint(shortcuts); - override_text(text_localizer, il2cpp_string_new(shortcut.c_str())); + override_localized_text(text_localizer, il2cpp_string_new(shortcut.c_str())); +} + +void ShortcutKeybindHint_KeybindShowVisibilityChanged_Hook(auto original, void* _this, bool visible) +{ + if (visible && shortcut_hints_ready && original_shortcut_hint_update_text) { + ShortcutKeybindHint_UpdateText_Hook(original_shortcut_hint_update_text, _this); + } + original(_this, visible); } bool force_space_action_next_frame = false; @@ -1352,21 +1359,71 @@ void InstallHotkeyHooks() ErrorMsg::MissingHelper("Digit.Client.UI", "TextLocalizer"); } else { clear_text_override = text_localizer_helper.GetMethod("ClearTextOverride", 0); + override_localized_text = + text_localizer_helper.GetMethod("OverrideLocalizedText", 1); if (!clear_text_override) ErrorMsg::MissingMethod("TextLocalizer", "ClearTextOverride"); + if (!override_localized_text) + ErrorMsg::MissingMethod("TextLocalizer", "OverrideLocalizedText"); } + UpdateShortcutHintTextFn* shortcut_hint_update_text = nullptr; + KeybindVisibilityChangedFn* keybind_show_visibility_changed = nullptr; auto shortcut_hint_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameInput", "ShortcutKeybindHint"); if (!shortcut_hint_helper.isValidHelper()) { ErrorMsg::MissingHelper("GameInput", "ShortcutKeybindHint"); } else { - auto update_text = shortcut_hint_helper.GetMethod("UpdateText"); - if (update_text == nullptr) { + auto input_action_field = shortcut_hint_helper.GetField("_inputAction"); + auto text_localizer_field = shortcut_hint_helper.GetField("_keyTextLocalizer"); + if (!input_action_field.isValidHelper()) { + spdlog::error("Unable to find field 'ShortcutKeybindHint->_inputAction'"); + } + if (!text_localizer_field.isValidHelper()) { + spdlog::error("Unable to find field 'ShortcutKeybindHint->_keyTextLocalizer'"); + } + if (input_action_field.isValidHelper() && text_localizer_field.isValidHelper()) { + shortcut_hint_input_action_offset = input_action_field.offset(); + shortcut_hint_text_localizer_offset = text_localizer_field.offset(); + shortcut_hint_fields_ready = true; + } + + shortcut_hint_update_text = shortcut_hint_helper.GetMethod("UpdateText", 0); + keybind_show_visibility_changed = + shortcut_hint_helper.GetMethod("KeybindShowVisibilityChanged", 1); + if (!shortcut_hint_update_text) { ErrorMsg::MissingMethod("ShortcutKeybindHint", "UpdateText"); - } else if (clear_text_override) { - SPUD_STATIC_DETOUR(update_text, ShortcutKeybindHint_UpdateText_Hook); } + if (!keybind_show_visibility_changed) + ErrorMsg::MissingMethod("ShortcutKeybindHint", "KeybindShowVisibilityChanged"); + } + + auto input_action_reference_helper = + il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "InputActionReference"); + auto input_action_helper = + il2cpp_get_class_helper("Unity.InputSystem", "UnityEngine.InputSystem", "InputAction"); + if (!input_action_reference_helper.isValidHelper()) { + ErrorMsg::MissingHelper("UnityEngine.InputSystem", "InputActionReference"); + } else { + get_input_action = input_action_reference_helper.GetMethod("get_action", 0); + if (!get_input_action) + ErrorMsg::MissingMethod("InputActionReference", "get_action"); + } + if (!input_action_helper.isValidHelper()) { + ErrorMsg::MissingHelper("UnityEngine.InputSystem", "InputAction"); + } else { + get_input_action_name = input_action_helper.GetMethod("get_name", 0); + if (!get_input_action_name) + ErrorMsg::MissingMethod("InputAction", "get_name"); + } + + if (get_show_keybindings && set_show_keybindings && can_use_shortcuts && clear_text_override && + override_localized_text && shortcut_hint_fields_ready && shortcut_hint_update_text && + keybind_show_visibility_changed && get_input_action && get_input_action_name) { + original_shortcut_hint_update_text = + SPUD_STATIC_DETOUR(shortcut_hint_update_text, ShortcutKeybindHint_UpdateText_Hook); + SPUD_STATIC_DETOUR(keybind_show_visibility_changed, ShortcutKeybindHint_KeybindShowVisibilityChanged_Hook); + shortcut_hints_ready = true; } auto screen_manager_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "ScreenManager"); From 42d0742ab5cee03f825bcfc9def80981c7d5e05e Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 02:42:34 -0500 Subject: [PATCH 69/80] Hook shortcut visibility at safe method boundary --- mods/src/patches/parts/hotkeys.cc | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index 66a203139..e53aac8ad 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -62,7 +62,7 @@ using UpdateShortcutHintTextFn = void(void*); using GetInputActionFn = void*(void*); using GetInputActionNameFn = Il2CppString*(void*); using OverrideLocalizedTextFn = void(void*, Il2CppString*); -using KeybindVisibilityChangedFn = void(void*, bool); +using UpdateShortcutHintVisibilityFn = void(void*, bool); static GetShowKeybindingsFn* get_show_keybindings = nullptr; static SetShowKeybindingsFn* set_show_keybindings = nullptr; @@ -75,6 +75,7 @@ static UpdateShortcutHintTextFn* original_shortcut_hint_update_text = nullptr; static ptrdiff_t shortcut_hint_input_action_offset = 0; static ptrdiff_t shortcut_hint_text_localizer_offset = 0; static bool shortcut_hint_fields_ready = false; +static bool initialize_actions_hook_ready = false; static bool shortcut_hints_ready = false; bool SetNativeShortcutHintsVisible(bool visible) @@ -340,12 +341,12 @@ void ShortcutKeybindHint_UpdateText_Hook(auto original, void* _this) override_localized_text(text_localizer, il2cpp_string_new(shortcut.c_str())); } -void ShortcutKeybindHint_KeybindShowVisibilityChanged_Hook(auto original, void* _this, bool visible) +void ShortcutKeybindHint_UpdateVisibility_Hook(auto original, void* _this, bool visible) { + original(_this, visible); if (visible && shortcut_hints_ready && original_shortcut_hint_update_text) { ShortcutKeybindHint_UpdateText_Hook(original_shortcut_hint_update_text, _this); } - original(_this, visible); } bool force_space_action_next_frame = false; @@ -1351,6 +1352,7 @@ void InstallHotkeyHooks() ErrorMsg::MissingMethod("ShortcutsManager", "InitializeActions"); } else { SPUD_STATIC_DETOUR(ptr_can_user_shortcuts, InitializeActions_Hook); + initialize_actions_hook_ready = true; } } @@ -1368,7 +1370,7 @@ void InstallHotkeyHooks() } UpdateShortcutHintTextFn* shortcut_hint_update_text = nullptr; - KeybindVisibilityChangedFn* keybind_show_visibility_changed = nullptr; + UpdateShortcutHintVisibilityFn* shortcut_hint_update_visibility = nullptr; auto shortcut_hint_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameInput", "ShortcutKeybindHint"); if (!shortcut_hint_helper.isValidHelper()) { @@ -1389,13 +1391,13 @@ void InstallHotkeyHooks() } shortcut_hint_update_text = shortcut_hint_helper.GetMethod("UpdateText", 0); - keybind_show_visibility_changed = - shortcut_hint_helper.GetMethod("KeybindShowVisibilityChanged", 1); + shortcut_hint_update_visibility = + shortcut_hint_helper.GetMethod("UpdateVisibility", 1); if (!shortcut_hint_update_text) { ErrorMsg::MissingMethod("ShortcutKeybindHint", "UpdateText"); } - if (!keybind_show_visibility_changed) - ErrorMsg::MissingMethod("ShortcutKeybindHint", "KeybindShowVisibilityChanged"); + if (!shortcut_hint_update_visibility) + ErrorMsg::MissingMethod("ShortcutKeybindHint", "UpdateVisibility"); } auto input_action_reference_helper = @@ -1417,12 +1419,12 @@ void InstallHotkeyHooks() ErrorMsg::MissingMethod("InputAction", "get_name"); } - if (get_show_keybindings && set_show_keybindings && can_use_shortcuts && clear_text_override && - override_localized_text && shortcut_hint_fields_ready && shortcut_hint_update_text && - keybind_show_visibility_changed && get_input_action && get_input_action_name) { + if (get_show_keybindings && set_show_keybindings && can_use_shortcuts && initialize_actions_hook_ready && + clear_text_override && override_localized_text && shortcut_hint_fields_ready && shortcut_hint_update_text && + shortcut_hint_update_visibility && get_input_action && get_input_action_name) { original_shortcut_hint_update_text = SPUD_STATIC_DETOUR(shortcut_hint_update_text, ShortcutKeybindHint_UpdateText_Hook); - SPUD_STATIC_DETOUR(keybind_show_visibility_changed, ShortcutKeybindHint_KeybindShowVisibilityChanged_Hook); + SPUD_STATIC_DETOUR(shortcut_hint_update_visibility, ShortcutKeybindHint_UpdateVisibility_Hook); shortcut_hints_ready = true; } From d1f12b43f12a99eb37cb4516ba11024667c172ca Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 03:40:36 -0500 Subject: [PATCH 70/80] Keep officer preset identity order canonical --- .../patches/parts/officer_preset_reorder.cc | 339 +++++++++++++++--- 1 file changed, 296 insertions(+), 43 deletions(-) diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index 44edee30e..5fd479efd 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -4,6 +4,7 @@ #include "str_utils.h" #include +#include #include #include @@ -12,8 +13,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -50,8 +51,11 @@ using GetScrollPositionFn = float(void*); using RestoreScrollPositionFn = void(void*, float); std::vector session_order; +std::vector active_presentations; Il2CppObject* active_controller = nullptr; +Il2CppClass* item_context_class = nullptr; ptrdiff_t widget_context_offset = 0; +ptrdiff_t controller_context_offset = 0; ptrdiff_t controller_scroller_offset = 0; ptrdiff_t presets_items_offset = 0; ClearAndGenerateContentsFn* clear_and_generate = nullptr; @@ -221,7 +225,7 @@ bool replace_state_file(const std::filesystem::path& temporary_path, const std:: bool save_session_order() { - const auto path = state_path(); + const auto path = state_path(); StateFileLock lock(path); if (!lock.acquired()) { spdlog::warn("[OfficerPresetReorder] unable to lock state file '{}'", path.string()); @@ -249,7 +253,7 @@ bool save_session_order() state["version"] = 1; state["officer_preset_order"] = std::move(order); - const auto temporary_path = temporary_state_path(path); + const auto temporary_path = temporary_state_path(path); std::ofstream file(temporary_path, std::ios::out | std::ios::binary | std::ios::trunc); if (!file) { spdlog::warn("[OfficerPresetReorder] unable to open temporary state file '{}'", temporary_path.string()); @@ -274,6 +278,42 @@ static_assert(offsetof(OfficerPresetItemContext, order_id) == 0x20); static_assert(offsetof(OfficerPresetItemContext, slot_id) == 0x28); static_assert(offsetof(OfficerPresetItemContext, preset_name) == 0x30); +bool validate_context_field(Il2CppClass* context_class, const char* name, ptrdiff_t expected_offset, + Il2CppTypeEnum expected_type) +{ + auto* field = il2cpp_class_get_field_from_name(context_class, name); + if (field == nullptr || field->type == nullptr) { + spdlog::error("[OfficerPresetReorder] required context field '{}' is unavailable", name); + return false; + } + if (field->offset != expected_offset || field->type->type != expected_type) { + spdlog::error("[OfficerPresetReorder] context field '{}' layout mismatch: offset=0x{:X} type={} expected " + "offset=0x{:X} type={}; feature disabled", + name, field->offset, static_cast(field->type->type), expected_offset, + static_cast(expected_type)); + return false; + } + return true; +} + +bool validate_context_layout(Il2CppClass* context_class) +{ + return context_class != nullptr + && validate_context_field(context_class, "Presentation", offsetof(OfficerPresetItemContext, presentation), + IL2CPP_TYPE_VALUETYPE) + && validate_context_field(context_class, "IsOccupied", offsetof(OfficerPresetItemContext, is_occupied), + IL2CPP_TYPE_BOOLEAN) + && validate_context_field(context_class, "OrderId", offsetof(OfficerPresetItemContext, order_id), + IL2CPP_TYPE_I4) + && validate_context_field(context_class, "SlotId", offsetof(OfficerPresetItemContext, slot_id), IL2CPP_TYPE_I8) + && validate_context_field(context_class, "PresetName", offsetof(OfficerPresetItemContext, preset_name), + IL2CPP_TYPE_STRING) + && validate_context_field(context_class, "Officers", offsetof(OfficerPresetItemContext, officers), + IL2CPP_TYPE_SZARRAY) + && validate_context_field(context_class, "_officerPresetsViewContext", + offsetof(OfficerPresetItemContext, officer_presets_view_context), IL2CPP_TYPE_CLASS); +} + bool is_reorderable_preset(const OfficerPresetItemContext* context) { return context != nullptr && context->slot_id >= 0 && context->order_id >= 0 && context->preset_name != nullptr @@ -308,21 +348,65 @@ void remember_slots(OfficerPresetItemContext** items, il2cpp_array_size_t size) } } -void apply_session_order(OfficerPresetItemContext** items, il2cpp_array_size_t size) +bool has_native_identity(const OfficerPresetItemContext* context) +{ return context != nullptr && context->slot_id >= 0 && context->order_id >= 0; } + +bool validate_unique_preset_identity(OfficerPresetItemContext** items, int32_t size) { - remember_slots(items, size); - if (session_order.empty()) { - return; + std::vector slot_ids; + std::vector order_ids; + slot_ids.reserve(size); + order_ids.reserve(size); + for (int32_t index = 0; index < size; ++index) { + const auto* context = items[index]; + if (!has_native_identity(context)) { + continue; + } + if (std::find(slot_ids.begin(), slot_ids.end(), context->slot_id) != slot_ids.end() + || std::find(order_ids.begin(), order_ids.end(), context->order_id) != order_ids.end()) { + spdlog::error("[OfficerPresetReorder] duplicate preset identity detected at index {}: slot={} order={}", index, + context->slot_id, context->order_id); + return false; + } + slot_ids.push_back(context->slot_id); + order_ids.push_back(context->order_id); + } + return true; +} + +bool validate_canonical_order(OfficerPresetItemContext** items, int32_t size) +{ + if (!validate_unique_preset_identity(items, size)) { + return false; + } + for (int32_t index = 0; index < size; ++index) { + const auto* context = items[index]; + if (!has_native_identity(context)) { + continue; + } + if (context->order_id != index) { + spdlog::error("[OfficerPresetReorder] canonical preset invariant failed at index {}: slot={} order={}; " + "local ordering disabled for this view", + index, context->slot_id, context->order_id); + return false; + } } + return true; +} - std::vector occupied_positions; +std::vector make_view_order(OfficerPresetItemContext** canonical_items, int32_t size) +{ + std::vector view_items(canonical_items, canonical_items + size); + remember_slots(canonical_items, static_cast(size)); + + std::vector occupied_positions; std::vector occupied_contexts; occupied_positions.reserve(size); occupied_contexts.reserve(size); - for (il2cpp_array_size_t index = 0; index < size; ++index) { - if (is_reorderable_preset(items[index])) { + for (int32_t index = 0; index < size; ++index) { + if (is_reorderable_preset(canonical_items[index])) { occupied_positions.push_back(index); - occupied_contexts.push_back(items[index]); + occupied_contexts.push_back(canonical_items[index]); } } @@ -331,16 +415,10 @@ void apply_session_order(OfficerPresetItemContext** items, il2cpp_array_size_t s const auto right_order = std::find(session_order.begin(), session_order.end(), right->slot_id); return left_order < right_order; }); - - std::vector presentations; - presentations.reserve(occupied_positions.size()); - for (const auto position : occupied_positions) { - presentations.push_back(items[position]->presentation); - } for (size_t index = 0; index < occupied_positions.size(); ++index) { - items[occupied_positions[index]] = occupied_contexts[index]; - occupied_contexts[index]->presentation = presentations[index]; + view_items[occupied_positions[index]] = occupied_contexts[index]; } + return view_items; } bool try_get_preset_list(void* view_context, Il2CppObject** list, Il2CppArraySize** backing_items, int32_t* size) @@ -363,6 +441,76 @@ bool try_get_preset_list(void* view_context, Il2CppObject** list, Il2CppArraySiz && static_cast(*size) <= (*backing_items)->max_length; } +bool capture_native_presentations(OfficerPresetItemContext** canonical_items, int32_t size) +{ + if (!validate_canonical_order(canonical_items, size)) { + return false; + } + active_presentations.clear(); + active_presentations.reserve(size); + for (int32_t index = 0; index < size; ++index) { + active_presentations.push_back(canonical_items[index] != nullptr ? canonical_items[index]->presentation : 0); + } + return true; +} + +bool restore_native_presentations(OfficerPresetItemContext** canonical_items, int32_t size) +{ + if (!validate_canonical_order(canonical_items, size) || active_presentations.size() != static_cast(size)) { + spdlog::error("[OfficerPresetReorder] native presentation map is unavailable; local ordering disabled for this " + "view"); + return false; + } + for (int32_t index = 0; index < size; ++index) { + if (canonical_items[index] != nullptr) { + canonical_items[index]->presentation = active_presentations[index]; + } + } + return true; +} + +bool render_local_order(Il2CppObject* controller, bool preserve_scroll) +{ + if (controller == nullptr || item_context_class == nullptr || clear_and_generate == nullptr) { + return false; + } + + auto* view_context = read_object_field(controller, controller_context_offset); + auto* scroller = read_object_field(controller, controller_scroller_offset); + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (scroller == nullptr || !try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { + return false; + } + + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!validate_canonical_order(canonical_items, size) || active_presentations.size() != static_cast(size)) { + return false; + } + + auto view_items = make_view_order(canonical_items, size); + auto* view_array = il2cpp_array_new(item_context_class, static_cast(size)); + if (view_array == nullptr) { + spdlog::warn("[OfficerPresetReorder] unable to allocate the scroller view array"); + return false; + } + for (int32_t index = 0; index < size; ++index) { + auto* context = view_items[index]; + if (context != nullptr) { + context->presentation = active_presentations[index]; + } + il2cpp_array_setref(view_array, index, context); + } + + const auto scroll_position = preserve_scroll && get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; + clear_and_generate(scroller, controller, reinterpret_cast(view_array)); + if (preserve_scroll && restore_scroll_position != nullptr) { + restore_scroll_position(scroller, scroll_position); + } + return true; +} + bool move_preset(OfficerPresetItemContext* context, int direction) { if (!is_reorderable_preset(context) || context->officer_presets_view_context == nullptr @@ -378,10 +526,15 @@ bool move_preset(OfficerPresetItemContext* context, int direction) return false; } - auto** items = reinterpret_cast(backing_items->vector); + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!validate_canonical_order(canonical_items, size)) { + return false; + } + auto view_items = make_view_order(canonical_items, size); + int32_t current_index = -1; for (int32_t index = 0; index < size; ++index) { - if (items[index] == context) { + if (view_items[index] == context) { current_index = index; break; } @@ -391,7 +544,7 @@ bool move_preset(OfficerPresetItemContext* context, int direction) } int32_t target_index = current_index + direction; - while (target_index >= 0 && target_index < size && !is_reorderable_preset(items[target_index])) { + while (target_index >= 0 && target_index < size && !is_reorderable_preset(view_items[target_index])) { target_index += direction; } if (target_index < 0 || target_index >= size) { @@ -400,8 +553,13 @@ bool move_preset(OfficerPresetItemContext* context, int direction) return true; } - auto* target = items[target_index]; - remember_slots(items, static_cast(size)); + auto* scroller = read_object_field(active_controller, controller_scroller_offset); + if (scroller == nullptr) { + spdlog::warn("[OfficerPresetReorder] unable to access the active preset scroller"); + return false; + } + + auto* target = view_items[target_index]; const auto current_order = std::find(session_order.begin(), session_order.end(), context->slot_id); const auto target_order = std::find(session_order.begin(), session_order.end(), target->slot_id); bool persisted = false; @@ -410,23 +568,73 @@ bool move_preset(OfficerPresetItemContext* context, int direction) persisted = save_session_order(); } - std::swap(context->presentation, target->presentation); - std::swap(items[current_index], items[target_index]); - - auto* scroller = read_object_field(active_controller, controller_scroller_offset); - if (scroller == nullptr) { - return false; - } - - const auto scroll_position = get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; spdlog::info("[OfficerPresetReorder] moved slot={} {} across slot={} ({})", context->slot_id, direction < 0 ? "up" : "down", target->slot_id, persisted ? "persisted locally" : "local persistence failed"); - clear_and_generate(scroller, active_controller, list); + if (!render_local_order(active_controller, true)) { + spdlog::warn("[OfficerPresetReorder] local order changed but the scroller could not be refreshed"); + } + return true; +} + +void OfficerPresetsViewController_OnSaveSlotsSuccess_Hook(auto original, Il2CppObject* _this, + bool increase_occupied_slots_count) +{ + auto* view_context = read_object_field(_this, controller_context_offset); + auto* scroller = read_object_field(_this, controller_scroller_offset); + + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + const bool canonical_available = + scroller != nullptr && try_get_preset_list(view_context, &canonical_list, &backing_items, &size) + && validate_canonical_order(reinterpret_cast(backing_items->vector), size); + const bool can_rerender = + canonical_available && active_controller == _this + && restore_native_presentations(reinterpret_cast(backing_items->vector), size); + const auto scroll_position = can_rerender && get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; + if (canonical_available) { + // Scopely's callback indexes SmartScroller._data by OrderId. Give it the canonical list for the duration of the + // callback, then rebuild our separate presentation-only view. + clear_and_generate(scroller, _this, canonical_list); + } + + original(_this, increase_occupied_slots_count); + + if (!canonical_available) { + spdlog::warn("[OfficerPresetReorder] save succeeded without an available canonical scroller source"); + return; + } + if (!can_rerender) { + spdlog::debug("[OfficerPresetReorder] save succeeded in canonical order without an active local view"); + return; + } + + if (!try_get_preset_list(read_object_field(_this, controller_context_offset), &canonical_list, &backing_items, + &size)) { + spdlog::warn("[OfficerPresetReorder] save succeeded but the canonical preset list became unavailable"); + return; + } + + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!validate_canonical_order(canonical_items, size)) { + spdlog::error("[OfficerPresetReorder] canonical preset invariant failed after save; local ordering was not " + "reapplied"); + return; + } + if (active_presentations.size() != static_cast(size)) { + if (!capture_native_presentations(canonical_items, size)) { + return; + } + } + if (!render_local_order(_this, false)) { + spdlog::warn("[OfficerPresetReorder] save reconciled safely but the local scroller order could not be restored"); + return; + } if (restore_scroll_position != nullptr) { restore_scroll_position(scroller, scroll_position); } - return true; + spdlog::info("[OfficerPresetReorder] reconciled presentation-only ordering after preset save"); } bool OfficerManager_TryGetPresetItemContext_Hook(auto original, void* _this, Il2CppArraySize** preset_contexts, @@ -441,8 +649,12 @@ bool OfficerManager_TryGetPresetItemContext_Hook(auto original, void* _this, Il2 auto* contexts = *preset_contexts; auto** items = reinterpret_cast(contexts->vector); - apply_session_order(items, contexts->max_length); - spdlog::debug("[OfficerPresetReorder] applied local ordering to {} preset rows", contexts->max_length); + if (validate_canonical_order(items, static_cast(contexts->max_length))) { + remember_slots(items, contexts->max_length); + spdlog::debug("[OfficerPresetReorder] observed {} canonical preset rows", contexts->max_length); + } else { + spdlog::error("[OfficerPresetReorder] Scopely returned non-canonical preset rows; leaving them untouched"); + } return result; } @@ -464,12 +676,41 @@ void OfficerPresetsViewController_OnDidBindCanvasContext_Hook(auto original, Il2 { original(_this); active_controller = _this; + + auto* view_context = read_object_field(_this, controller_context_offset); + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (!try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { + active_presentations.clear(); + spdlog::warn("[OfficerPresetReorder] unable to capture the bound canonical preset list"); + return; + } + + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!capture_native_presentations(canonical_items, size) || !render_local_order(_this, false)) { + active_presentations.clear(); + spdlog::warn("[OfficerPresetReorder] local ordering was disabled for the bound preset view"); + } } void OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook(auto original, Il2CppObject* _this) { if (active_controller == _this) { + auto* view_context = read_object_field(_this, controller_context_offset); + auto* scroller = read_object_field(_this, controller_scroller_offset); + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (scroller != nullptr && try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (validate_canonical_order(canonical_items, size)) { + restore_native_presentations(canonical_items, size); + clear_and_generate(scroller, _this, canonical_list); + } + } active_controller = nullptr; + active_presentations.clear(); } original(_this); } @@ -495,21 +736,30 @@ void InstallOfficerPresetReorderHooks() il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewController"); auto view_context_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewContext"); + auto item_context_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetItemContext"); auto scroller_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "SmartScrollerBase"); if (!widget_helper.isValidHelper() || !controller_helper.isValidHelper() || !view_context_helper.isValidHelper() - || !scroller_helper.isValidHelper()) { + || !item_context_helper.isValidHelper() || !scroller_helper.isValidHelper()) { ErrorMsg::MissingHelper("Digit.Prime.OfficerPresets", "reorder UI surface"); return; } + if (!validate_context_layout(item_context_helper.get_cls())) { + return; + } + item_context_class = item_context_helper.get_cls(); - auto context_field = widget_helper.GetField("m_context"); - auto scroller_field = controller_helper.GetField("_smartScroller"); - auto presets_field = view_context_helper.GetField("PresetsItemsContext"); - if (!context_field.isValidHelper() || !scroller_field.isValidHelper() || !presets_field.isValidHelper()) { + auto context_field = widget_helper.GetField("m_context"); + auto controller_context_field = controller_helper.GetField("m_context"); + auto scroller_field = controller_helper.GetField("_smartScroller"); + auto presets_field = view_context_helper.GetField("PresetsItemsContext"); + if (!context_field.isValidHelper() || !controller_context_field.isValidHelper() || !scroller_field.isValidHelper() + || !presets_field.isValidHelper()) { ErrorMsg::MissingMethod("OfficerPresetReorder", "required field"); return; } widget_context_offset = context_field.offset(); + controller_context_offset = controller_context_field.offset(); controller_scroller_offset = scroller_field.offset(); presets_items_offset = presets_field.offset(); @@ -519,11 +769,13 @@ void InstallOfficerPresetReorderHooks() const auto edit_method = widget_helper.GetMethodInfo("OnEditNameButtonClicked", 0); const auto bind_method = controller_helper.GetMethodInfo("OnDidBindCanvasContext", 0); const auto release_method = controller_helper.GetMethodInfo("OnAboutToReleaseCanvasContext", 0); + const auto save_success_method = controller_helper.GetMethodInfo("OnSaveSlotsSuccess", 1); if (clear_method == nullptr || clear_method->methodPointer == nullptr || get_scroll_method == nullptr || get_scroll_method->methodPointer == nullptr || restore_scroll_method == nullptr || restore_scroll_method->methodPointer == nullptr || edit_method == nullptr || edit_method->methodPointer == nullptr || bind_method == nullptr || bind_method->methodPointer == nullptr - || release_method == nullptr || release_method->methodPointer == nullptr) { + || release_method == nullptr || release_method->methodPointer == nullptr || save_success_method == nullptr + || save_success_method->methodPointer == nullptr) { ErrorMsg::MissingMethod("OfficerPresetReorder", "required UI method"); return; } @@ -537,4 +789,5 @@ void InstallOfficerPresetReorderHooks() SPUD_STATIC_DETOUR(edit_method->methodPointer, OfficerPresetItemWidget_OnEditNameButtonClicked_Hook); SPUD_STATIC_DETOUR(bind_method->methodPointer, OfficerPresetsViewController_OnDidBindCanvasContext_Hook); SPUD_STATIC_DETOUR(release_method->methodPointer, OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook); + SPUD_STATIC_DETOUR(save_success_method->methodPointer, OfficerPresetsViewController_OnSaveSlotsSuccess_Hook); } From 10eacd11a707b106694e37d545f65cbe106eb105 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 03:52:06 -0500 Subject: [PATCH 71/80] Fix macOS officer preset array build --- mods/src/patches/parts/officer_preset_reorder.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index 5fd479efd..8653a6c7d 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -4,7 +4,6 @@ #include "str_utils.h" #include -#include #include #include @@ -495,12 +494,13 @@ bool render_local_order(Il2CppObject* controller, bool preserve_scroll) spdlog::warn("[OfficerPresetReorder] unable to allocate the scroller view array"); return false; } + auto** view_slots = reinterpret_cast(reinterpret_cast(view_array)->vector); for (int32_t index = 0; index < size; ++index) { auto* context = view_items[index]; if (context != nullptr) { context->presentation = active_presentations[index]; } - il2cpp_array_setref(view_array, index, context); + il2cpp_gc_wbarrier_set_field(reinterpret_cast(view_array), &view_slots[index], context); } const auto scroll_position = preserve_scroll && get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; From 76910a0a0fc445a41f1545c8aa52e12b529b5c91 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 03:40:36 -0500 Subject: [PATCH 72/80] Keep officer preset identity order canonical --- .../patches/parts/officer_preset_reorder.cc | 339 +++++++++++++++--- 1 file changed, 296 insertions(+), 43 deletions(-) diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index 44edee30e..5fd479efd 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -4,6 +4,7 @@ #include "str_utils.h" #include +#include #include #include @@ -12,8 +13,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -50,8 +51,11 @@ using GetScrollPositionFn = float(void*); using RestoreScrollPositionFn = void(void*, float); std::vector session_order; +std::vector active_presentations; Il2CppObject* active_controller = nullptr; +Il2CppClass* item_context_class = nullptr; ptrdiff_t widget_context_offset = 0; +ptrdiff_t controller_context_offset = 0; ptrdiff_t controller_scroller_offset = 0; ptrdiff_t presets_items_offset = 0; ClearAndGenerateContentsFn* clear_and_generate = nullptr; @@ -221,7 +225,7 @@ bool replace_state_file(const std::filesystem::path& temporary_path, const std:: bool save_session_order() { - const auto path = state_path(); + const auto path = state_path(); StateFileLock lock(path); if (!lock.acquired()) { spdlog::warn("[OfficerPresetReorder] unable to lock state file '{}'", path.string()); @@ -249,7 +253,7 @@ bool save_session_order() state["version"] = 1; state["officer_preset_order"] = std::move(order); - const auto temporary_path = temporary_state_path(path); + const auto temporary_path = temporary_state_path(path); std::ofstream file(temporary_path, std::ios::out | std::ios::binary | std::ios::trunc); if (!file) { spdlog::warn("[OfficerPresetReorder] unable to open temporary state file '{}'", temporary_path.string()); @@ -274,6 +278,42 @@ static_assert(offsetof(OfficerPresetItemContext, order_id) == 0x20); static_assert(offsetof(OfficerPresetItemContext, slot_id) == 0x28); static_assert(offsetof(OfficerPresetItemContext, preset_name) == 0x30); +bool validate_context_field(Il2CppClass* context_class, const char* name, ptrdiff_t expected_offset, + Il2CppTypeEnum expected_type) +{ + auto* field = il2cpp_class_get_field_from_name(context_class, name); + if (field == nullptr || field->type == nullptr) { + spdlog::error("[OfficerPresetReorder] required context field '{}' is unavailable", name); + return false; + } + if (field->offset != expected_offset || field->type->type != expected_type) { + spdlog::error("[OfficerPresetReorder] context field '{}' layout mismatch: offset=0x{:X} type={} expected " + "offset=0x{:X} type={}; feature disabled", + name, field->offset, static_cast(field->type->type), expected_offset, + static_cast(expected_type)); + return false; + } + return true; +} + +bool validate_context_layout(Il2CppClass* context_class) +{ + return context_class != nullptr + && validate_context_field(context_class, "Presentation", offsetof(OfficerPresetItemContext, presentation), + IL2CPP_TYPE_VALUETYPE) + && validate_context_field(context_class, "IsOccupied", offsetof(OfficerPresetItemContext, is_occupied), + IL2CPP_TYPE_BOOLEAN) + && validate_context_field(context_class, "OrderId", offsetof(OfficerPresetItemContext, order_id), + IL2CPP_TYPE_I4) + && validate_context_field(context_class, "SlotId", offsetof(OfficerPresetItemContext, slot_id), IL2CPP_TYPE_I8) + && validate_context_field(context_class, "PresetName", offsetof(OfficerPresetItemContext, preset_name), + IL2CPP_TYPE_STRING) + && validate_context_field(context_class, "Officers", offsetof(OfficerPresetItemContext, officers), + IL2CPP_TYPE_SZARRAY) + && validate_context_field(context_class, "_officerPresetsViewContext", + offsetof(OfficerPresetItemContext, officer_presets_view_context), IL2CPP_TYPE_CLASS); +} + bool is_reorderable_preset(const OfficerPresetItemContext* context) { return context != nullptr && context->slot_id >= 0 && context->order_id >= 0 && context->preset_name != nullptr @@ -308,21 +348,65 @@ void remember_slots(OfficerPresetItemContext** items, il2cpp_array_size_t size) } } -void apply_session_order(OfficerPresetItemContext** items, il2cpp_array_size_t size) +bool has_native_identity(const OfficerPresetItemContext* context) +{ return context != nullptr && context->slot_id >= 0 && context->order_id >= 0; } + +bool validate_unique_preset_identity(OfficerPresetItemContext** items, int32_t size) { - remember_slots(items, size); - if (session_order.empty()) { - return; + std::vector slot_ids; + std::vector order_ids; + slot_ids.reserve(size); + order_ids.reserve(size); + for (int32_t index = 0; index < size; ++index) { + const auto* context = items[index]; + if (!has_native_identity(context)) { + continue; + } + if (std::find(slot_ids.begin(), slot_ids.end(), context->slot_id) != slot_ids.end() + || std::find(order_ids.begin(), order_ids.end(), context->order_id) != order_ids.end()) { + spdlog::error("[OfficerPresetReorder] duplicate preset identity detected at index {}: slot={} order={}", index, + context->slot_id, context->order_id); + return false; + } + slot_ids.push_back(context->slot_id); + order_ids.push_back(context->order_id); + } + return true; +} + +bool validate_canonical_order(OfficerPresetItemContext** items, int32_t size) +{ + if (!validate_unique_preset_identity(items, size)) { + return false; + } + for (int32_t index = 0; index < size; ++index) { + const auto* context = items[index]; + if (!has_native_identity(context)) { + continue; + } + if (context->order_id != index) { + spdlog::error("[OfficerPresetReorder] canonical preset invariant failed at index {}: slot={} order={}; " + "local ordering disabled for this view", + index, context->slot_id, context->order_id); + return false; + } } + return true; +} - std::vector occupied_positions; +std::vector make_view_order(OfficerPresetItemContext** canonical_items, int32_t size) +{ + std::vector view_items(canonical_items, canonical_items + size); + remember_slots(canonical_items, static_cast(size)); + + std::vector occupied_positions; std::vector occupied_contexts; occupied_positions.reserve(size); occupied_contexts.reserve(size); - for (il2cpp_array_size_t index = 0; index < size; ++index) { - if (is_reorderable_preset(items[index])) { + for (int32_t index = 0; index < size; ++index) { + if (is_reorderable_preset(canonical_items[index])) { occupied_positions.push_back(index); - occupied_contexts.push_back(items[index]); + occupied_contexts.push_back(canonical_items[index]); } } @@ -331,16 +415,10 @@ void apply_session_order(OfficerPresetItemContext** items, il2cpp_array_size_t s const auto right_order = std::find(session_order.begin(), session_order.end(), right->slot_id); return left_order < right_order; }); - - std::vector presentations; - presentations.reserve(occupied_positions.size()); - for (const auto position : occupied_positions) { - presentations.push_back(items[position]->presentation); - } for (size_t index = 0; index < occupied_positions.size(); ++index) { - items[occupied_positions[index]] = occupied_contexts[index]; - occupied_contexts[index]->presentation = presentations[index]; + view_items[occupied_positions[index]] = occupied_contexts[index]; } + return view_items; } bool try_get_preset_list(void* view_context, Il2CppObject** list, Il2CppArraySize** backing_items, int32_t* size) @@ -363,6 +441,76 @@ bool try_get_preset_list(void* view_context, Il2CppObject** list, Il2CppArraySiz && static_cast(*size) <= (*backing_items)->max_length; } +bool capture_native_presentations(OfficerPresetItemContext** canonical_items, int32_t size) +{ + if (!validate_canonical_order(canonical_items, size)) { + return false; + } + active_presentations.clear(); + active_presentations.reserve(size); + for (int32_t index = 0; index < size; ++index) { + active_presentations.push_back(canonical_items[index] != nullptr ? canonical_items[index]->presentation : 0); + } + return true; +} + +bool restore_native_presentations(OfficerPresetItemContext** canonical_items, int32_t size) +{ + if (!validate_canonical_order(canonical_items, size) || active_presentations.size() != static_cast(size)) { + spdlog::error("[OfficerPresetReorder] native presentation map is unavailable; local ordering disabled for this " + "view"); + return false; + } + for (int32_t index = 0; index < size; ++index) { + if (canonical_items[index] != nullptr) { + canonical_items[index]->presentation = active_presentations[index]; + } + } + return true; +} + +bool render_local_order(Il2CppObject* controller, bool preserve_scroll) +{ + if (controller == nullptr || item_context_class == nullptr || clear_and_generate == nullptr) { + return false; + } + + auto* view_context = read_object_field(controller, controller_context_offset); + auto* scroller = read_object_field(controller, controller_scroller_offset); + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (scroller == nullptr || !try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { + return false; + } + + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!validate_canonical_order(canonical_items, size) || active_presentations.size() != static_cast(size)) { + return false; + } + + auto view_items = make_view_order(canonical_items, size); + auto* view_array = il2cpp_array_new(item_context_class, static_cast(size)); + if (view_array == nullptr) { + spdlog::warn("[OfficerPresetReorder] unable to allocate the scroller view array"); + return false; + } + for (int32_t index = 0; index < size; ++index) { + auto* context = view_items[index]; + if (context != nullptr) { + context->presentation = active_presentations[index]; + } + il2cpp_array_setref(view_array, index, context); + } + + const auto scroll_position = preserve_scroll && get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; + clear_and_generate(scroller, controller, reinterpret_cast(view_array)); + if (preserve_scroll && restore_scroll_position != nullptr) { + restore_scroll_position(scroller, scroll_position); + } + return true; +} + bool move_preset(OfficerPresetItemContext* context, int direction) { if (!is_reorderable_preset(context) || context->officer_presets_view_context == nullptr @@ -378,10 +526,15 @@ bool move_preset(OfficerPresetItemContext* context, int direction) return false; } - auto** items = reinterpret_cast(backing_items->vector); + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!validate_canonical_order(canonical_items, size)) { + return false; + } + auto view_items = make_view_order(canonical_items, size); + int32_t current_index = -1; for (int32_t index = 0; index < size; ++index) { - if (items[index] == context) { + if (view_items[index] == context) { current_index = index; break; } @@ -391,7 +544,7 @@ bool move_preset(OfficerPresetItemContext* context, int direction) } int32_t target_index = current_index + direction; - while (target_index >= 0 && target_index < size && !is_reorderable_preset(items[target_index])) { + while (target_index >= 0 && target_index < size && !is_reorderable_preset(view_items[target_index])) { target_index += direction; } if (target_index < 0 || target_index >= size) { @@ -400,8 +553,13 @@ bool move_preset(OfficerPresetItemContext* context, int direction) return true; } - auto* target = items[target_index]; - remember_slots(items, static_cast(size)); + auto* scroller = read_object_field(active_controller, controller_scroller_offset); + if (scroller == nullptr) { + spdlog::warn("[OfficerPresetReorder] unable to access the active preset scroller"); + return false; + } + + auto* target = view_items[target_index]; const auto current_order = std::find(session_order.begin(), session_order.end(), context->slot_id); const auto target_order = std::find(session_order.begin(), session_order.end(), target->slot_id); bool persisted = false; @@ -410,23 +568,73 @@ bool move_preset(OfficerPresetItemContext* context, int direction) persisted = save_session_order(); } - std::swap(context->presentation, target->presentation); - std::swap(items[current_index], items[target_index]); - - auto* scroller = read_object_field(active_controller, controller_scroller_offset); - if (scroller == nullptr) { - return false; - } - - const auto scroll_position = get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; spdlog::info("[OfficerPresetReorder] moved slot={} {} across slot={} ({})", context->slot_id, direction < 0 ? "up" : "down", target->slot_id, persisted ? "persisted locally" : "local persistence failed"); - clear_and_generate(scroller, active_controller, list); + if (!render_local_order(active_controller, true)) { + spdlog::warn("[OfficerPresetReorder] local order changed but the scroller could not be refreshed"); + } + return true; +} + +void OfficerPresetsViewController_OnSaveSlotsSuccess_Hook(auto original, Il2CppObject* _this, + bool increase_occupied_slots_count) +{ + auto* view_context = read_object_field(_this, controller_context_offset); + auto* scroller = read_object_field(_this, controller_scroller_offset); + + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + const bool canonical_available = + scroller != nullptr && try_get_preset_list(view_context, &canonical_list, &backing_items, &size) + && validate_canonical_order(reinterpret_cast(backing_items->vector), size); + const bool can_rerender = + canonical_available && active_controller == _this + && restore_native_presentations(reinterpret_cast(backing_items->vector), size); + const auto scroll_position = can_rerender && get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; + if (canonical_available) { + // Scopely's callback indexes SmartScroller._data by OrderId. Give it the canonical list for the duration of the + // callback, then rebuild our separate presentation-only view. + clear_and_generate(scroller, _this, canonical_list); + } + + original(_this, increase_occupied_slots_count); + + if (!canonical_available) { + spdlog::warn("[OfficerPresetReorder] save succeeded without an available canonical scroller source"); + return; + } + if (!can_rerender) { + spdlog::debug("[OfficerPresetReorder] save succeeded in canonical order without an active local view"); + return; + } + + if (!try_get_preset_list(read_object_field(_this, controller_context_offset), &canonical_list, &backing_items, + &size)) { + spdlog::warn("[OfficerPresetReorder] save succeeded but the canonical preset list became unavailable"); + return; + } + + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!validate_canonical_order(canonical_items, size)) { + spdlog::error("[OfficerPresetReorder] canonical preset invariant failed after save; local ordering was not " + "reapplied"); + return; + } + if (active_presentations.size() != static_cast(size)) { + if (!capture_native_presentations(canonical_items, size)) { + return; + } + } + if (!render_local_order(_this, false)) { + spdlog::warn("[OfficerPresetReorder] save reconciled safely but the local scroller order could not be restored"); + return; + } if (restore_scroll_position != nullptr) { restore_scroll_position(scroller, scroll_position); } - return true; + spdlog::info("[OfficerPresetReorder] reconciled presentation-only ordering after preset save"); } bool OfficerManager_TryGetPresetItemContext_Hook(auto original, void* _this, Il2CppArraySize** preset_contexts, @@ -441,8 +649,12 @@ bool OfficerManager_TryGetPresetItemContext_Hook(auto original, void* _this, Il2 auto* contexts = *preset_contexts; auto** items = reinterpret_cast(contexts->vector); - apply_session_order(items, contexts->max_length); - spdlog::debug("[OfficerPresetReorder] applied local ordering to {} preset rows", contexts->max_length); + if (validate_canonical_order(items, static_cast(contexts->max_length))) { + remember_slots(items, contexts->max_length); + spdlog::debug("[OfficerPresetReorder] observed {} canonical preset rows", contexts->max_length); + } else { + spdlog::error("[OfficerPresetReorder] Scopely returned non-canonical preset rows; leaving them untouched"); + } return result; } @@ -464,12 +676,41 @@ void OfficerPresetsViewController_OnDidBindCanvasContext_Hook(auto original, Il2 { original(_this); active_controller = _this; + + auto* view_context = read_object_field(_this, controller_context_offset); + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (!try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { + active_presentations.clear(); + spdlog::warn("[OfficerPresetReorder] unable to capture the bound canonical preset list"); + return; + } + + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!capture_native_presentations(canonical_items, size) || !render_local_order(_this, false)) { + active_presentations.clear(); + spdlog::warn("[OfficerPresetReorder] local ordering was disabled for the bound preset view"); + } } void OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook(auto original, Il2CppObject* _this) { if (active_controller == _this) { + auto* view_context = read_object_field(_this, controller_context_offset); + auto* scroller = read_object_field(_this, controller_scroller_offset); + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (scroller != nullptr && try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (validate_canonical_order(canonical_items, size)) { + restore_native_presentations(canonical_items, size); + clear_and_generate(scroller, _this, canonical_list); + } + } active_controller = nullptr; + active_presentations.clear(); } original(_this); } @@ -495,21 +736,30 @@ void InstallOfficerPresetReorderHooks() il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewController"); auto view_context_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewContext"); + auto item_context_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetItemContext"); auto scroller_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "SmartScrollerBase"); if (!widget_helper.isValidHelper() || !controller_helper.isValidHelper() || !view_context_helper.isValidHelper() - || !scroller_helper.isValidHelper()) { + || !item_context_helper.isValidHelper() || !scroller_helper.isValidHelper()) { ErrorMsg::MissingHelper("Digit.Prime.OfficerPresets", "reorder UI surface"); return; } + if (!validate_context_layout(item_context_helper.get_cls())) { + return; + } + item_context_class = item_context_helper.get_cls(); - auto context_field = widget_helper.GetField("m_context"); - auto scroller_field = controller_helper.GetField("_smartScroller"); - auto presets_field = view_context_helper.GetField("PresetsItemsContext"); - if (!context_field.isValidHelper() || !scroller_field.isValidHelper() || !presets_field.isValidHelper()) { + auto context_field = widget_helper.GetField("m_context"); + auto controller_context_field = controller_helper.GetField("m_context"); + auto scroller_field = controller_helper.GetField("_smartScroller"); + auto presets_field = view_context_helper.GetField("PresetsItemsContext"); + if (!context_field.isValidHelper() || !controller_context_field.isValidHelper() || !scroller_field.isValidHelper() + || !presets_field.isValidHelper()) { ErrorMsg::MissingMethod("OfficerPresetReorder", "required field"); return; } widget_context_offset = context_field.offset(); + controller_context_offset = controller_context_field.offset(); controller_scroller_offset = scroller_field.offset(); presets_items_offset = presets_field.offset(); @@ -519,11 +769,13 @@ void InstallOfficerPresetReorderHooks() const auto edit_method = widget_helper.GetMethodInfo("OnEditNameButtonClicked", 0); const auto bind_method = controller_helper.GetMethodInfo("OnDidBindCanvasContext", 0); const auto release_method = controller_helper.GetMethodInfo("OnAboutToReleaseCanvasContext", 0); + const auto save_success_method = controller_helper.GetMethodInfo("OnSaveSlotsSuccess", 1); if (clear_method == nullptr || clear_method->methodPointer == nullptr || get_scroll_method == nullptr || get_scroll_method->methodPointer == nullptr || restore_scroll_method == nullptr || restore_scroll_method->methodPointer == nullptr || edit_method == nullptr || edit_method->methodPointer == nullptr || bind_method == nullptr || bind_method->methodPointer == nullptr - || release_method == nullptr || release_method->methodPointer == nullptr) { + || release_method == nullptr || release_method->methodPointer == nullptr || save_success_method == nullptr + || save_success_method->methodPointer == nullptr) { ErrorMsg::MissingMethod("OfficerPresetReorder", "required UI method"); return; } @@ -537,4 +789,5 @@ void InstallOfficerPresetReorderHooks() SPUD_STATIC_DETOUR(edit_method->methodPointer, OfficerPresetItemWidget_OnEditNameButtonClicked_Hook); SPUD_STATIC_DETOUR(bind_method->methodPointer, OfficerPresetsViewController_OnDidBindCanvasContext_Hook); SPUD_STATIC_DETOUR(release_method->methodPointer, OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook); + SPUD_STATIC_DETOUR(save_success_method->methodPointer, OfficerPresetsViewController_OnSaveSlotsSuccess_Hook); } From 6d7fef70dbdb22c8a81811a28e828efbb69bc2e1 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 03:52:06 -0500 Subject: [PATCH 73/80] Fix macOS officer preset array build --- mods/src/patches/parts/officer_preset_reorder.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index 5fd479efd..8653a6c7d 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -4,7 +4,6 @@ #include "str_utils.h" #include -#include #include #include @@ -495,12 +494,13 @@ bool render_local_order(Il2CppObject* controller, bool preserve_scroll) spdlog::warn("[OfficerPresetReorder] unable to allocate the scroller view array"); return false; } + auto** view_slots = reinterpret_cast(reinterpret_cast(view_array)->vector); for (int32_t index = 0; index < size; ++index) { auto* context = view_items[index]; if (context != nullptr) { context->presentation = active_presentations[index]; } - il2cpp_array_setref(view_array, index, context); + il2cpp_gc_wbarrier_set_field(reinterpret_cast(view_array), &view_slots[index], context); } const auto scroll_position = preserve_scroll && get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; From 61b0f873d12c8939e135362733895236d59984a0 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 19:57:50 -0500 Subject: [PATCH 74/80] Fix fleet label hooks and add type profiles --- example_community_patch_settings_da.toml | 13 +- example_community_patch_settings_de.toml | 13 +- ...munity_patch_settings_en-GB-x-cockney.toml | 13 +- ...mmunity_patch_settings_en-x-minionese.toml | 13 +- example_community_patch_settings_en.toml | 13 +- example_community_patch_settings_es.toml | 13 +- example_community_patch_settings_fr.toml | 13 +- example_community_patch_settings_nl.toml | 13 +- example_community_patch_settings_ru.toml | 13 +- example_community_patch_settings_tlh.toml | 13 +- mods/src/config.cc | 39 +-- mods/src/config.h | 9 +- mods/src/defaultconfig.h | 6 +- mods/src/patches/parts/zoom.cc | 241 ++++++++++++++---- mods/src/prime/FleetDeployedData.h | 13 + mods/src/prime/NavigationFleetWidget.h | 27 ++ mods/src/prime/NavigationLOD.h | 14 +- 17 files changed, 369 insertions(+), 110 deletions(-) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 61634d6b5..37e53842c 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -121,15 +121,20 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 -# Detaljegrad for flådeetiketter i systemvisning: +# Detaljegrad for flådeetiketter i systemvisning. Konfigurer spiller- og ikke-spillerflåder separat: # "native" - brug Scopelys Near/Middle/Far-adfærd # "expanded" - vis altid lange flådeetiketter # "compact" - vis altid kompakte flådeetiketter -# "threshold" - skift fra lange til kompakte etiketter ved fleet_label_zoom_threshold -fleet_label_detail = "native" +# "threshold" - skift fra lange til kompakte etiketter ved den tilsvarende zoomgrænse +# Spillerflåder +fleet_label_player_detail = "native" # Normaliseret zoomgrænse for "threshold" (0.0 = altid kompakt, 1.0 = altid udvidet) -fleet_label_zoom_threshold = 0.5 +fleet_label_player_zoom_threshold = 0.5 + +# Ikke-spillerflåder og uklassificerede flåder +fleet_label_non_player_detail = "native" +fleet_label_non_player_zoom_threshold = 0.5 # Tiden mellem overgange; øg værdien for at gøre overgangene langsommere transition_time = 0.01 diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 560bd5c49..fee70f698 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -121,15 +121,20 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 -# Detailgrad der Flottenbeschriftungen in der Systemansicht: +# Detailgrad der Flottenbeschriftungen. Spieler- und Nicht-Spieler-Flotten werden separat konfiguriert: # "native" - Scopelys Near/Middle/Far-Verhalten verwenden # "expanded" - lange Flottenbeschriftungen immer anzeigen # "compact" - kompakte Flottenbeschriftungen immer anzeigen -# "threshold" - bei fleet_label_zoom_threshold von erweitert zu kompakt wechseln -fleet_label_detail = "native" +# "threshold" - an der zugehörigen Zoomgrenze von erweitert zu kompakt wechseln +# Spielerflotten +fleet_label_player_detail = "native" # Normalisierte Zoomgrenze für "threshold" (0.0 = immer kompakt, 1.0 = immer erweitert) -fleet_label_zoom_threshold = 0.5 +fleet_label_player_zoom_threshold = 0.5 + +# Nicht-Spieler-Flotten und nicht klassifizierte Flotten +fleet_label_non_player_detail = "native" +fleet_label_non_player_zoom_threshold = 0.5 # Dauer zwischen Übergängen; erhöhen, um Übergänge zu verlangsamen transition_time = 0.01 diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index ed856977f..86520edbd 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -121,15 +121,20 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 -# Fleet label detail in system view: +# Fleet label detail in system view. Configure player and non-player fleets separately: # "native" - use Scopely's Near/Middle/Far behaviour # "expanded" - always show long fleet labels # "compact" - always show compact fleet labels -# "threshold" - switch from expanded to compact at fleet_label_zoom_threshold -fleet_label_detail = "native" +# "threshold" - switch from expanded to compact at the matching zoom threshold +# Player fleets +fleet_label_player_detail = "native" # Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) -fleet_label_zoom_threshold = 0.5 +fleet_label_player_zoom_threshold = 0.5 + +# Non-player and unclassified fleets +fleet_label_non_player_detail = "native" +fleet_label_non_player_zoom_threshold = 0.5 # Amount of time to spend between transitions, increase to slow transitions down transition_time = 0.01 diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 2cebc8033..7c04690e3 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -121,15 +121,20 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 -# Fleet label detail in system view: +# Fleet label detail in system view. Configure player and non-player fleets separately: # "native" - use Scopely's Near/Middle/Far behaviour # "expanded" - always show long fleet labels # "compact" - always show compact fleet labels -# "threshold" - switch from expanded to compact at fleet_label_zoom_threshold -fleet_label_detail = "native" +# "threshold" - switch from expanded to compact at the matching zoom threshold +# Player fleets +fleet_label_player_detail = "native" # Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) -fleet_label_zoom_threshold = 0.5 +fleet_label_player_zoom_threshold = 0.5 + +# Non-player and unclassified fleets +fleet_label_non_player_detail = "native" +fleet_label_non_player_zoom_threshold = 0.5 # Amount of time to spend between transitions, increase to slow transitions down transition_time = 0.01 diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index a4c299452..558f003b5 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -121,15 +121,20 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 -# Fleet label detail in system view: +# Fleet label detail in system view. Configure player and non-player fleets separately: # "native" - use Scopely's Near/Middle/Far behaviour # "expanded" - always show long fleet labels # "compact" - always show compact fleet labels -# "threshold" - switch from expanded to compact at fleet_label_zoom_threshold -fleet_label_detail = "native" +# "threshold" - switch from expanded to compact at the matching zoom threshold +# Player fleets +fleet_label_player_detail = "native" # Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) -fleet_label_zoom_threshold = 0.5 +fleet_label_player_zoom_threshold = 0.5 + +# Non-player and unclassified fleets +fleet_label_non_player_detail = "native" +fleet_label_non_player_zoom_threshold = 0.5 # Amount of time to spend between transitions, increase to slow transitions down transition_time = 0.01 diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 9d02cfa1b..c4a69cc14 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -121,15 +121,20 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 -# Nivel de detalle de las etiquetas de flota en la vista del sistema: +# Detalle de las etiquetas. Configura por separado las flotas de jugadores y las que no son de jugadores: # "native" - usar el comportamiento Near/Middle/Far de Scopely # "expanded" - mostrar siempre las etiquetas largas # "compact" - mostrar siempre las etiquetas compactas -# "threshold" - cambiar de expandido a compacto en fleet_label_zoom_threshold -fleet_label_detail = "native" +# "threshold" - cambiar de expandido a compacto en el umbral de zoom correspondiente +# Flotas de jugadores +fleet_label_player_detail = "native" # Umbral de zoom normalizado para "threshold" (0.0 = siempre compacto, 1.0 = siempre expandido) -fleet_label_zoom_threshold = 0.5 +fleet_label_player_zoom_threshold = 0.5 + +# Flotas que no son de jugadores y flotas sin clasificar +fleet_label_non_player_detail = "native" +fleet_label_non_player_zoom_threshold = 0.5 # Tiempo entre transiciones; aumenta el valor para ralentizarlas transition_time = 0.01 diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index 14187e872..703ffdbd9 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -121,15 +121,20 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 -# Niveau de détail des étiquettes de flotte dans la vue système : +# Détail des étiquettes. Configurez séparément les flottes de joueurs et les flottes non-joueurs : # "native" - utiliser le comportement Near/Middle/Far de Scopely # "expanded" - toujours afficher les étiquettes longues # "compact" - toujours afficher les étiquettes compactes -# "threshold" - passer du format étendu au format compact à fleet_label_zoom_threshold -fleet_label_detail = "native" +# "threshold" - passer du format étendu au format compact au seuil de zoom correspondant +# Flottes de joueurs +fleet_label_player_detail = "native" # Seuil de zoom normalisé pour "threshold" (0.0 = toujours compact, 1.0 = toujours étendu) -fleet_label_zoom_threshold = 0.5 +fleet_label_player_zoom_threshold = 0.5 + +# Flottes non-joueurs et flottes non classées +fleet_label_non_player_detail = "native" +fleet_label_non_player_zoom_threshold = 0.5 # Temps entre les transitions, augmenter pour ralentir les transitions transition_time = 0.01 diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index c64fce52b..960f0b4bf 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -121,15 +121,20 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 -# Detailniveau van vlootlabels in de systeemweergave: +# Detailniveau van vlootlabels. Configureer spelers- en niet-spelersvloten afzonderlijk: # "native" - gebruik Scopely's Near/Middle/Far-gedrag # "expanded" - toon altijd lange vlootlabels # "compact" - toon altijd compacte vlootlabels -# "threshold" - schakel bij fleet_label_zoom_threshold van uitgebreid naar compact -fleet_label_detail = "native" +# "threshold" - schakel bij de bijbehorende zoomgrens van uitgebreid naar compact +# Spelersvloten +fleet_label_player_detail = "native" # Genormaliseerde zoomgrens voor "threshold" (0.0 = altijd compact, 1.0 = altijd uitgebreid) -fleet_label_zoom_threshold = 0.5 +fleet_label_player_zoom_threshold = 0.5 + +# Niet-spelersvloten en niet-geclassificeerde vloten +fleet_label_non_player_detail = "native" +fleet_label_non_player_zoom_threshold = 0.5 # Bepaald de tijd tussen transities, hoe hoger het getal des te trager de transitie transition_time = 0.01 diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 7583dd753..c920fe05f 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -121,15 +121,20 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 -# Детализация меток флотов в системном виде: +# Детализация меток. Настройте отдельно флоты игроков и неигровые флоты: # "native" - использовать поведение Scopely Near/Middle/Far # "expanded" - всегда показывать длинные метки флотов # "compact" - всегда показывать компактные метки флотов -# "threshold" - переключать расширенные метки на компактные при fleet_label_zoom_threshold -fleet_label_detail = "native" +# "threshold" - переключать расширенные метки на компактные при соответствующем пороге масштаба +# Флоты игроков +fleet_label_player_detail = "native" # Нормализованный порог масштаба для "threshold" (0.0 = всегда компактно, 1.0 = всегда расширенно) -fleet_label_zoom_threshold = 0.5 +fleet_label_player_zoom_threshold = 0.5 + +# Неигровые и неклассифицированные флоты +fleet_label_non_player_detail = "native" +fleet_label_non_player_zoom_threshold = 0.5 # Время между переходами; увеличьте значение, чтобы замедлить переходы transition_time = 0.01 diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 70658974e..b575c3991 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -121,15 +121,20 @@ enable_experimental = false # Frame-rate scaling factor fr_scale = 2.0 -# Fleet label detail in system view: +# Fleet label detail in system view. Configure player and non-player fleets separately: # "native" - use Scopely's Near/Middle/Far behaviour # "expanded" - always show long fleet labels # "compact" - always show compact fleet labels -# "threshold" - switch from expanded to compact at fleet_label_zoom_threshold -fleet_label_detail = "native" +# "threshold" - switch from expanded to compact at the matching zoom threshold +# Player fleets +fleet_label_player_detail = "native" # Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) -fleet_label_zoom_threshold = 0.5 +fleet_label_player_zoom_threshold = 0.5 + +# Non-player and unclassified fleets +fleet_label_non_player_detail = "native" +fleet_label_non_player_zoom_threshold = 0.5 # Amount of poH Daq spend between transitions, increase Daq slow transitions bIng transition_time = 0.01 diff --git a/mods/src/config.cc b/mods/src/config.cc index 87967e2de..e0c7ac1ce 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -439,7 +439,7 @@ std::string_view to_string(FleetLabelDetail detail) } } -FleetLabelDetail parse_fleet_label_detail(std::string_view value) +FleetLabelDetail parse_fleet_label_detail(std::string_view key, std::string_view value) { const auto normalized = AsciiStrToUpper(StripAsciiWhitespace(value)); @@ -457,39 +457,40 @@ FleetLabelDetail parse_fleet_label_detail(std::string_view value) return FleetLabelDetail::Native; } - spdlog::warn("invalid config value graphics.fleet_label_detail: '{}'; using native", value); + spdlog::warn("invalid config value graphics.{}: '{}'; using native", key, value); return FleetLabelDetail::Native; } -FleetLabelDetail get_fleet_label_detail(toml::table& config, toml::table& new_config, std::string_view default_value, - bool write_log) +FleetLabelDetail get_fleet_label_detail(toml::table& config, toml::table& new_config, std::string_view key, + std::string_view default_value, bool write_log) { - const auto value = config["graphics"]["fleet_label_detail"].value().value_or(std::string(default_value)); - const auto detail = parse_fleet_label_detail(value); + const auto value = config["graphics"][key].value().value_or(std::string(default_value)); + const auto detail = parse_fleet_label_detail(key, value); new_config.emplace("graphics", toml::table()); - new_config["graphics"].as_table()->insert_or_assign("fleet_label_detail", std::string(to_string(detail))); + new_config["graphics"].as_table()->insert_or_assign(key, std::string(to_string(detail))); if (write_log) { - spdlog::debug("config value graphics.fleet_label_detail value: {}", to_string(detail)); + spdlog::debug("config value graphics.{} value: {}", key, to_string(detail)); } return detail; } -float get_fleet_label_zoom_threshold(toml::table& config, toml::table& new_config, float default_value, bool write_log) +float get_fleet_label_zoom_threshold(toml::table& config, toml::table& new_config, std::string_view key, + float default_value, bool write_log) { - auto threshold = config["graphics"]["fleet_label_zoom_threshold"].value().value_or(default_value); + auto threshold = config["graphics"][key].value().value_or(default_value); if (!std::isfinite(threshold) || threshold < 0.0f || threshold > 1.0f) { - spdlog::warn("invalid config value graphics.fleet_label_zoom_threshold: {}; using {}", threshold, default_value); + spdlog::warn("invalid config value graphics.{}: {}; using {}", key, threshold, default_value); threshold = default_value; } new_config.emplace("graphics", toml::table()); - new_config["graphics"].as_table()->insert_or_assign("fleet_label_zoom_threshold", threshold); + new_config["graphics"].as_table()->insert_or_assign(key, threshold); if (write_log) { - spdlog::debug("config value graphics.fleet_label_zoom_threshold value: {}", threshold); + spdlog::debug("config value graphics.{} value: {}", key, threshold); } return threshold; @@ -955,9 +956,15 @@ void Config::Load() get_config_or_default(config, parsed, "graphics", "ui_scale_viewer", DCG::ui_scale_viewer, write_config); this->zoom = get_config_or_default(config, parsed, "graphics", "zoom", DCG::zoom, write_config); this->fr_scale = get_config_or_default(config, parsed, "graphics", "fr_scale", DCG::fr_scale, write_config); - this->fleet_label_detail = get_fleet_label_detail(config, parsed, DCG::fleet_label_detail, write_config); - this->fleet_label_zoom_threshold = - get_fleet_label_zoom_threshold(config, parsed, DCG::fleet_label_zoom_threshold, write_config); + this->fleet_label_player.detail = + get_fleet_label_detail(config, parsed, "fleet_label_player_detail", DCG::fleet_label_player_detail, write_config); + this->fleet_label_player.zoom_threshold = get_fleet_label_zoom_threshold( + config, parsed, "fleet_label_player_zoom_threshold", DCG::fleet_label_player_zoom_threshold, write_config); + this->fleet_label_non_player.detail = get_fleet_label_detail(config, parsed, "fleet_label_non_player_detail", + DCG::fleet_label_non_player_detail, write_config); + this->fleet_label_non_player.zoom_threshold = + get_fleet_label_zoom_threshold(config, parsed, "fleet_label_non_player_zoom_threshold", + DCG::fleet_label_non_player_zoom_threshold, write_config); this->free_resize = get_config_or_default(config, parsed, "graphics", "free_resize", DCG::free_resize, write_config); this->allow_cursor = get_config_or_default(config, parsed, "graphics", "allow_cursor", DCG::allow_cursor, write_config); diff --git a/mods/src/config.h b/mods/src/config.h index 4c2afd3ef..7a6d5c189 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -123,6 +123,11 @@ enum class FleetLabelDetail { Threshold, }; +struct FleetLabelProfile { + FleetLabelDetail detail; + float zoom_threshold; +}; + // Part of UI Scale void ApplyUiShipScaleToLoadedShips(float old_multiplier, float new_multiplier); @@ -160,8 +165,8 @@ class Config final float ui_scale_viewer; float zoom; float fr_scale; - FleetLabelDetail fleet_label_detail; - float fleet_label_zoom_threshold; + FleetLabelProfile fleet_label_player; + FleetLabelProfile fleet_label_non_player; bool allow_cursor; bool free_resize; bool adjust_scale_res; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 202764e2a..6a3c4be31 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -33,8 +33,10 @@ namespace Graphics { constexpr bool borderless_fullscreen = true; constexpr bool allow_cursor = true; - constexpr const char* fleet_label_detail = "native"; - constexpr auto fleet_label_zoom_threshold = 0.5; + constexpr const char* fleet_label_player_detail = "native"; + constexpr auto fleet_label_player_zoom_threshold = 0.5; + constexpr const char* fleet_label_non_player_detail = "native"; + constexpr auto fleet_label_non_player_zoom_threshold = 0.5; constexpr auto default_system_zoom = 1750; constexpr bool free_resize = true; constexpr auto keyboard_zoom_speed = 350; diff --git a/mods/src/patches/parts/zoom.cc b/mods/src/patches/parts/zoom.cc index 7627b6cfa..d4f151b8b 100644 --- a/mods/src/patches/parts/zoom.cc +++ b/mods/src/patches/parts/zoom.cc @@ -16,25 +16,77 @@ #include #include -#include +#include namespace { -std::unordered_set fleet_label_lods; -bool fleet_label_hooks_installed = false; -uintptr_t active_system_zoom_id = 0; -bool system_zoom_state_valid = false; -ZoomLevels system_zoom_level = ZoomLevels::None; -float system_normalized_zoom = 0.0f; -bool threshold_state_valid = false; -bool threshold_state_expanded = false; +std::unordered_map fleet_label_widgets; +bool fleet_label_hooks_installed = false; +uintptr_t active_system_zoom_id = 0; +bool system_zoom_state_valid = false; +ZoomLevels system_zoom_level = ZoomLevels::None; +float system_normalized_zoom = 0.0f; +bool threshold_state_valid = false; +bool player_threshold_state_expanded = false; +bool non_player_threshold_state_expanded = false; + +bool FleetLabelProfilesEnabled() +{ + const auto &config = Config::Get(); + return config.fleet_label_player.detail != FleetLabelDetail::Native + || config.fleet_label_non_player.detail != FleetLabelDetail::Native; +} + +bool FleetLabelThresholdEnabled() +{ + const auto &config = Config::Get(); + return config.fleet_label_player.detail == FleetLabelDetail::Threshold + || config.fleet_label_non_player.detail == FleetLabelDetail::Threshold; +} + +const FleetLabelProfile *FleetLabelProfileFor(NavigationFleetWidget *widget) +{ + if (widget == nullptr) { + return nullptr; + } + + const auto &config = Config::Get(); + auto *context = widget->Context; + if (context == nullptr) { + return &config.fleet_label_non_player; + } + + DeployedFleetType fleet_type; + if (!context->TryGetFleetType(fleet_type)) { + return &config.fleet_label_non_player; + } + + switch (fleet_type) { + case DeployedFleetType::Player: + return &config.fleet_label_player; + case DeployedFleetType::Marauder: + case DeployedFleetType::NpcInstantiated: + case DeployedFleetType::Sentinel: + case DeployedFleetType::Alliance: + case DeployedFleetType::Challenge: + return &config.fleet_label_non_player; + case DeployedFleetType::Nonexistent: + default: + return &config.fleet_label_non_player; + } +} + +const FleetLabelProfile *FleetLabelProfileFor(NavigationLOD *lod) +{ + const auto widget = fleet_label_widgets.find(lod); + return widget != fleet_label_widgets.end() ? FleetLabelProfileFor(widget->second) : nullptr; +} ZoomLevels ExpandedFleetLabelZoomLevel(ZoomLevels native_level) { return native_level == ZoomLevels::Near ? ZoomLevels::Near : ZoomLevels::Middle; } -bool FleetLabelsExpandedAt(float normalized_zoom) +bool FleetLabelsExpandedAt(float normalized_zoom, float threshold) { - const auto threshold = Config::Get().fleet_label_zoom_threshold; if (threshold <= 0.0f) { return false; } @@ -44,10 +96,9 @@ bool FleetLabelsExpandedAt(float normalized_zoom) return normalized_zoom <= threshold; } -ZoomLevels FleetLabelZoomLevel(ZoomLevels native_level) +ZoomLevels FleetLabelZoomLevel(ZoomLevels native_level, const FleetLabelProfile &profile) { - const auto detail = Config::Get().fleet_label_detail; - switch (detail) { + switch (profile.detail) { case FleetLabelDetail::Expanded: return ExpandedFleetLabelZoomLevel(native_level); case FleetLabelDetail::Compact: @@ -56,8 +107,9 @@ ZoomLevels FleetLabelZoomLevel(ZoomLevels native_level) if (!system_zoom_state_valid) { return native_level; } - return FleetLabelsExpandedAt(system_normalized_zoom) ? ExpandedFleetLabelZoomLevel(native_level) - : ZoomLevels::Far; + return FleetLabelsExpandedAt(system_normalized_zoom, profile.zoom_threshold) + ? ExpandedFleetLabelZoomLevel(native_level) + : ZoomLevels::Far; case FleetLabelDetail::Native: default: return native_level; @@ -66,16 +118,30 @@ ZoomLevels FleetLabelZoomLevel(ZoomLevels native_level) void ApplyFleetLabelDetail(NavigationLOD *lod, ZoomLevels native_level) { - if (!lod) { + if (lod == nullptr || !system_zoom_state_valid) { return; } - const auto detail = Config::Get().fleet_label_detail; - if (detail == FleetLabelDetail::Native || (detail == FleetLabelDetail::Threshold && !system_zoom_state_valid)) { + lod->UpdateLOD(native_level); +} + +void ApplyAllFleetLabelDetails() +{ + for (const auto &[lod, widget] : fleet_label_widgets) { + (void)widget; + ApplyFleetLabelDetail(lod, system_zoom_level); + } +} + +void RestoreNativeFleetLabelDetail(NavigationLOD *lod) +{ + if (lod == nullptr || !system_zoom_state_valid) { return; } - lod->OnZoomChanged(FleetLabelZoomLevel(native_level)); + lod->SetTargetLevel(system_zoom_level); + lod->UpdateLOD(system_zoom_level); + lod->SetTargetLevel(system_zoom_level); } void ResetFleetLabelSystemState() @@ -98,25 +164,31 @@ void BeginFleetLabelSystemState(NavigationZoom *zoom) void UpdateFleetLabelThreshold() { - if (!system_zoom_state_valid || Config::Get().fleet_label_detail != FleetLabelDetail::Threshold) { + if (!system_zoom_state_valid || !FleetLabelThresholdEnabled()) { threshold_state_valid = false; return; } - const auto expanded = FleetLabelsExpandedAt(system_normalized_zoom); - if (threshold_state_valid && expanded == threshold_state_expanded) { + const auto &config = Config::Get(); + const auto player_uses_threshold = config.fleet_label_player.detail == FleetLabelDetail::Threshold; + const auto non_player_uses_threshold = config.fleet_label_non_player.detail == FleetLabelDetail::Threshold; + const auto player_expanded = + player_uses_threshold && FleetLabelsExpandedAt(system_normalized_zoom, config.fleet_label_player.zoom_threshold); + const auto non_player_expanded = + non_player_uses_threshold + && FleetLabelsExpandedAt(system_normalized_zoom, config.fleet_label_non_player.zoom_threshold); + if (threshold_state_valid && (!player_uses_threshold || player_expanded == player_threshold_state_expanded) + && (!non_player_uses_threshold || non_player_expanded == non_player_threshold_state_expanded)) { return; } - threshold_state_valid = true; - threshold_state_expanded = expanded; - for (auto *lod : fleet_label_lods) { - ApplyFleetLabelDetail(lod, system_zoom_level); - } + threshold_state_valid = true; + player_threshold_state_expanded = player_expanded; + non_player_threshold_state_expanded = non_player_expanded; + ApplyAllFleetLabelDetails(); #ifdef _MODDBG - spdlog::info("Fleet label detail threshold: {} at normalized zoom {:.4f} (configured {:.4f})", - expanded ? "expanded" : "compact", system_normalized_zoom, Config::Get().fleet_label_zoom_threshold); + spdlog::info("Fleet label detail threshold state updated at normalized zoom {:.4f}", system_normalized_zoom); #endif } } // namespace @@ -353,19 +425,30 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) EnsureSystemZoomRange(_this); if (fleet_label_hooks_installed && _this->_depth == NodeDepth::SolarSystem) { BeginFleetLabelSystemState(_this); + const auto state_was_valid = system_zoom_state_valid; system_zoom_level = _this->_zoomLevel; system_normalized_zoom = _this->NormalizedZoom; system_zoom_state_valid = true; - if (config->fleet_label_detail == FleetLabelDetail::Threshold) { + if (FleetLabelThresholdEnabled()) { UpdateFleetLabelThreshold(); + } else if (!state_was_valid) { + ApplyAllFleetLabelDetails(); } } } -void NavigationLOD_OnZoomChanged_Hook(auto original, NavigationLOD *_this, ZoomLevels level) +void NavigationLOD_UpdateLOD_Hook(auto original, NavigationLOD *_this, ZoomLevels level) { - const auto effective_level = fleet_label_lods.contains(_this) ? FleetLabelZoomLevel(level) : level; + const auto tracked = fleet_label_widgets.contains(_this); + const auto *profile = FleetLabelProfileFor(_this); + const auto effective_level = profile != nullptr ? FleetLabelZoomLevel(level, *profile) : level; + if (tracked) { + _this->SetTargetLevel(effective_level); + } original(_this, effective_level); + if (tracked) { + _this->SetTargetLevel(effective_level); + } } void NavigationFleetWidget_OnEnable_Hook(auto original, NavigationFleetWidget *_this) @@ -377,8 +460,8 @@ void NavigationFleetWidget_OnEnable_Hook(auto original, NavigationFleetWidget *_ auto *lod = _this->_lod; if (lod) { - fleet_label_lods.insert(lod); - if (Config::Get().fleet_label_detail == FleetLabelDetail::Threshold && !system_zoom_state_valid) { + fleet_label_widgets.insert_or_assign(lod, _this); + if (FleetLabelThresholdEnabled() && !system_zoom_state_valid) { threshold_state_valid = false; } ApplyFleetLabelDetail(lod, system_zoom_level); @@ -388,7 +471,35 @@ void NavigationFleetWidget_OnEnable_Hook(auto original, NavigationFleetWidget *_ void NavigationFleetWidget_OnDisable_Hook(auto original, NavigationFleetWidget *_this) { if (_this) { - fleet_label_lods.erase(_this->_lod); + auto *lod = _this->_lod; + fleet_label_widgets.erase(lod); + RestoreNativeFleetLabelDetail(lod); + } + original(_this); +} + +void NavigationFleetWidget_OnDidBindContext_Hook(auto original, NavigationFleetWidget *_this) +{ + original(_this); + if (_this == nullptr) { + return; + } + + auto *lod = _this->_lod; + if (lod != nullptr) { + fleet_label_widgets.insert_or_assign(lod, _this); + ApplyFleetLabelDetail(lod, system_zoom_level); + } +} + +void NavigationFleetWidget_OnAboutToReleaseContext_Hook(auto original, NavigationFleetWidget *_this) +{ + if (_this != nullptr) { + auto *lod = _this->_lod; + if (lod != nullptr) { + fleet_label_widgets.erase(lod); + RestoreNativeFleetLabelDetail(lod); + } } original(_this); } @@ -472,21 +583,41 @@ void InstallZoomHooks() auto *normalized_zoom_property = navigation_zoom_class != nullptr ? il2cpp_class_get_property_from_name(navigation_zoom_class, "NormalizedZoom") : nullptr; - if (Config::Get().fleet_label_detail != FleetLabelDetail::Native) { + if (FleetLabelProfilesEnabled()) { auto lod_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationLOD"); auto fleet_widget_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationFleetWidget"); - auto ptr_on_zoom_changed = lod_helper.isValidHelper() ? lod_helper.GetMethod("OnZoomChanged") : nullptr; + auto fleet_data_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetDeployedData"); + auto ptr_update_lod = lod_helper.isValidHelper() ? lod_helper.GetMethod("UpdateLOD") : nullptr; auto ptr_on_enable = fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnEnable") : nullptr; auto ptr_on_disable = fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnDisable") : nullptr; + auto ptr_on_did_bind_context = + fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnDidBindContext") : nullptr; + auto ptr_on_about_to_release_context = + fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnAboutToReleaseContext") : nullptr; + auto *lod_class = lod_helper.get_cls(); + auto *target_level_field = + lod_class != nullptr ? il2cpp_class_get_field_from_name(lod_class, "k__BackingField") : nullptr; auto *fleet_widget_class = fleet_widget_helper.get_cls(); auto *lod_field = fleet_widget_class != nullptr ? il2cpp_class_get_field_from_name(fleet_widget_class, "_lod") : nullptr; + auto *context_field = NavigationFleetWidget::ContextField(); + auto *fleet_data_class = fleet_data_helper.get_cls(); + auto *fleet_type_property = + fleet_data_class != nullptr ? il2cpp_class_get_property_from_name(fleet_data_class, "FleetType") : nullptr; + auto *fleet_type_getter = + fleet_type_property != nullptr ? il2cpp_property_get_get_method((PropertyInfo *)fleet_type_property) : nullptr; if (!lod_helper.isValidHelper()) { ErrorMsg::MissingHelper("Navigation", "NavigationLOD"); - } else if (ptr_on_zoom_changed == nullptr) { - ErrorMsg::MissingMethod("NavigationLOD", "OnZoomChanged"); + } else { + if (ptr_update_lod == nullptr) { + ErrorMsg::MissingMethod("NavigationLOD", "UpdateLOD"); + } + if (target_level_field == nullptr) { + ErrorMsg::MissingMethod("NavigationLOD", "k__BackingField"); + } } if (!fleet_widget_helper.isValidHelper()) { ErrorMsg::MissingHelper("Navigation", "NavigationFleetWidget"); @@ -497,9 +628,23 @@ void InstallZoomHooks() if (ptr_on_disable == nullptr) { ErrorMsg::MissingMethod("NavigationFleetWidget", "OnDisable"); } + if (ptr_on_did_bind_context == nullptr) { + ErrorMsg::MissingMethod("NavigationFleetWidget", "OnDidBindContext"); + } + if (ptr_on_about_to_release_context == nullptr) { + ErrorMsg::MissingMethod("NavigationFleetWidget", "OnAboutToReleaseContext"); + } if (lod_field == nullptr) { ErrorMsg::MissingMethod("NavigationFleetWidget", "_lod"); } + if (context_field == nullptr) { + ErrorMsg::MissingMethod("NavigationFleetWidget", "m_context"); + } + } + if (!fleet_data_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Digit.PrimeServer.Models", "FleetDeployedData"); + } else if (fleet_type_property == nullptr || fleet_type_getter == nullptr) { + ErrorMsg::MissingMethod("FleetDeployedData", "FleetType"); } if (!navigation_zoom_helper.isValidHelper()) { ErrorMsg::MissingHelper("Navigation", "NavigationZoom"); @@ -515,16 +660,20 @@ void InstallZoomHooks() } } - const auto fleet_label_dependencies_valid = lod_helper.isValidHelper() && ptr_on_zoom_changed != nullptr - && fleet_widget_helper.isValidHelper() && ptr_on_enable != nullptr - && ptr_on_disable != nullptr && lod_field != nullptr - && navigation_zoom_helper.isValidHelper() && ptr_update != nullptr - && zoom_level_field != nullptr && normalized_zoom_property != nullptr; + const auto fleet_label_dependencies_valid = + lod_helper.isValidHelper() && ptr_update_lod != nullptr && target_level_field != nullptr + && fleet_widget_helper.isValidHelper() && ptr_on_enable != nullptr && ptr_on_disable != nullptr + && ptr_on_did_bind_context != nullptr && ptr_on_about_to_release_context != nullptr && lod_field != nullptr + && context_field != nullptr && fleet_data_helper.isValidHelper() && fleet_type_property != nullptr + && fleet_type_getter != nullptr && navigation_zoom_helper.isValidHelper() && ptr_update != nullptr + && zoom_level_field != nullptr && normalized_zoom_property != nullptr; if (fleet_label_dependencies_valid) { fleet_label_hooks_installed = true; - SPUD_STATIC_DETOUR(ptr_on_zoom_changed, NavigationLOD_OnZoomChanged_Hook); + SPUD_STATIC_DETOUR(ptr_update_lod, NavigationLOD_UpdateLOD_Hook); SPUD_STATIC_DETOUR(ptr_on_enable, NavigationFleetWidget_OnEnable_Hook); SPUD_STATIC_DETOUR(ptr_on_disable, NavigationFleetWidget_OnDisable_Hook); + SPUD_STATIC_DETOUR(ptr_on_did_bind_context, NavigationFleetWidget_OnDidBindContext_Hook); + SPUD_STATIC_DETOUR(ptr_on_about_to_release_context, NavigationFleetWidget_OnAboutToReleaseContext_Hook); } else { spdlog::error("Fleet label detail hooks were not installed; using native fleet labels"); } diff --git a/mods/src/prime/FleetDeployedData.h b/mods/src/prime/FleetDeployedData.h index 0f7bf2e13..fc3bc607c 100644 --- a/mods/src/prime/FleetDeployedData.h +++ b/mods/src/prime/FleetDeployedData.h @@ -10,6 +10,7 @@ enum class DeployedFleetType { NpcInstantiated, Sentinel, Alliance, + Challenge, }; struct FleetDeployedData { @@ -44,4 +45,16 @@ struct FleetDeployedData { static auto field = get_class_helper().GetProperty("FleetType"); return *field.Get(this); } + + bool TryGetFleetType(DeployedFleetType& fleet_type) + { + static auto property = get_class_helper().GetProperty("FleetType"); + const auto* value = property.Get(this); + if (value == nullptr) { + return false; + } + + fleet_type = *value; + return true; + } }; diff --git a/mods/src/prime/NavigationFleetWidget.h b/mods/src/prime/NavigationFleetWidget.h index c6a855fe7..03151b042 100644 --- a/mods/src/prime/NavigationFleetWidget.h +++ b/mods/src/prime/NavigationFleetWidget.h @@ -2,10 +2,31 @@ #include +#include + +#include "FleetDeployedData.h" #include "NavigationLOD.h" struct NavigationFleetWidget { __declspec(property(get = __get__lod)) NavigationLOD* _lod; + __declspec(property(get = __get_Context)) FleetDeployedData* Context; + + static FieldInfo* ContextField() + { + static auto* field = [] { + for (auto* cls = get_class_helper().get_cls(); cls != nullptr; cls = il2cpp_class_get_parent(cls)) { + void* iterator = nullptr; + while (auto* candidate = il2cpp_class_get_fields(cls, &iterator)) { + const auto* name = il2cpp_field_get_name(candidate); + if (name != nullptr && std::strcmp(name, "m_context") == 0) { + return candidate; + } + } + } + return static_cast(nullptr); + }(); + return field; + } private: static IL2CppClassHelper& get_class_helper() @@ -24,4 +45,10 @@ struct NavigationFleetWidget { }(); return field != nullptr ? *(NavigationLOD**)((ptrdiff_t)this + field->offset) : nullptr; } + + FleetDeployedData* __get_Context() + { + auto* field = ContextField(); + return field != nullptr ? *(FleetDeployedData**)((ptrdiff_t)this + field->offset) : nullptr; + } }; diff --git a/mods/src/prime/NavigationLOD.h b/mods/src/prime/NavigationLOD.h index 21a134449..581922b47 100644 --- a/mods/src/prime/NavigationLOD.h +++ b/mods/src/prime/NavigationLOD.h @@ -5,14 +5,20 @@ #include "ZoomLevels.h" struct NavigationLOD { - void OnZoomChanged(ZoomLevels level) + void UpdateLOD(ZoomLevels level) { - static auto on_zoom_changed = get_class_helper().GetMethod("OnZoomChanged"); - if (on_zoom_changed != nullptr) { - on_zoom_changed(this, level); + static auto update_lod = get_class_helper().GetMethod("UpdateLOD"); + if (update_lod != nullptr) { + update_lod(this, level); } } + void SetTargetLevel(ZoomLevels level) + { + static auto field = get_class_helper().GetField("k__BackingField"); + *(ZoomLevels*)((ptrdiff_t)this + field.offset()) = level; + } + private: static IL2CppClassHelper& get_class_helper() { From e47468c3dbc1510fe10d51597aa4511680edc2f9 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 20:38:01 -0500 Subject: [PATCH 75/80] Rename fleet label zoom settings --- example_community_patch_settings_da.toml | 8 +++--- example_community_patch_settings_de.toml | 8 +++--- ...munity_patch_settings_en-GB-x-cockney.toml | 8 +++--- ...mmunity_patch_settings_en-x-minionese.toml | 8 +++--- example_community_patch_settings_en.toml | 8 +++--- example_community_patch_settings_es.toml | 8 +++--- example_community_patch_settings_fr.toml | 8 +++--- example_community_patch_settings_nl.toml | 8 +++--- example_community_patch_settings_ru.toml | 8 +++--- example_community_patch_settings_tlh.toml | 8 +++--- mods/src/config.cc | 17 ++++++------ mods/src/config.h | 4 +-- mods/src/defaultconfig.h | 8 +++--- mods/src/patches/parts/zoom.cc | 26 +++++++++---------- 14 files changed, 67 insertions(+), 68 deletions(-) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 37e53842c..473d786fb 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -127,14 +127,14 @@ fr_scale = 2.0 # "compact" - vis altid kompakte flådeetiketter # "threshold" - skift fra lange til kompakte etiketter ved den tilsvarende zoomgrænse # Spillerflåder -fleet_label_player_detail = "native" +zoom_label_player_detail = "native" # Normaliseret zoomgrænse for "threshold" (0.0 = altid kompakt, 1.0 = altid udvidet) -fleet_label_player_zoom_threshold = 0.5 +zoom_label_player_threshold = 0.5 # Ikke-spillerflåder og uklassificerede flåder -fleet_label_non_player_detail = "native" -fleet_label_non_player_zoom_threshold = 0.5 +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 # Tiden mellem overgange; øg værdien for at gøre overgangene langsommere transition_time = 0.01 diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index fee70f698..d676838a0 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -127,14 +127,14 @@ fr_scale = 2.0 # "compact" - kompakte Flottenbeschriftungen immer anzeigen # "threshold" - an der zugehörigen Zoomgrenze von erweitert zu kompakt wechseln # Spielerflotten -fleet_label_player_detail = "native" +zoom_label_player_detail = "native" # Normalisierte Zoomgrenze für "threshold" (0.0 = immer kompakt, 1.0 = immer erweitert) -fleet_label_player_zoom_threshold = 0.5 +zoom_label_player_threshold = 0.5 # Nicht-Spieler-Flotten und nicht klassifizierte Flotten -fleet_label_non_player_detail = "native" -fleet_label_non_player_zoom_threshold = 0.5 +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 # Dauer zwischen Übergängen; erhöhen, um Übergänge zu verlangsamen transition_time = 0.01 diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 86520edbd..0d79ff793 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -127,14 +127,14 @@ fr_scale = 2.0 # "compact" - always show compact fleet labels # "threshold" - switch from expanded to compact at the matching zoom threshold # Player fleets -fleet_label_player_detail = "native" +zoom_label_player_detail = "native" # Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) -fleet_label_player_zoom_threshold = 0.5 +zoom_label_player_threshold = 0.5 # Non-player and unclassified fleets -fleet_label_non_player_detail = "native" -fleet_label_non_player_zoom_threshold = 0.5 +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 # Amount of time to spend between transitions, increase to slow transitions down transition_time = 0.01 diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 7c04690e3..523d7077d 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -127,14 +127,14 @@ fr_scale = 2.0 # "compact" - always show compact fleet labels # "threshold" - switch from expanded to compact at the matching zoom threshold # Player fleets -fleet_label_player_detail = "native" +zoom_label_player_detail = "native" # Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) -fleet_label_player_zoom_threshold = 0.5 +zoom_label_player_threshold = 0.5 # Non-player and unclassified fleets -fleet_label_non_player_detail = "native" -fleet_label_non_player_zoom_threshold = 0.5 +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 # Amount of time to spend between transitions, increase to slow transitions down transition_time = 0.01 diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 558f003b5..1b4a883c9 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -127,14 +127,14 @@ fr_scale = 2.0 # "compact" - always show compact fleet labels # "threshold" - switch from expanded to compact at the matching zoom threshold # Player fleets -fleet_label_player_detail = "native" +zoom_label_player_detail = "native" # Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) -fleet_label_player_zoom_threshold = 0.5 +zoom_label_player_threshold = 0.5 # Non-player and unclassified fleets -fleet_label_non_player_detail = "native" -fleet_label_non_player_zoom_threshold = 0.5 +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 # Amount of time to spend between transitions, increase to slow transitions down transition_time = 0.01 diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index c4a69cc14..7d4c05bc3 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -127,14 +127,14 @@ fr_scale = 2.0 # "compact" - mostrar siempre las etiquetas compactas # "threshold" - cambiar de expandido a compacto en el umbral de zoom correspondiente # Flotas de jugadores -fleet_label_player_detail = "native" +zoom_label_player_detail = "native" # Umbral de zoom normalizado para "threshold" (0.0 = siempre compacto, 1.0 = siempre expandido) -fleet_label_player_zoom_threshold = 0.5 +zoom_label_player_threshold = 0.5 # Flotas que no son de jugadores y flotas sin clasificar -fleet_label_non_player_detail = "native" -fleet_label_non_player_zoom_threshold = 0.5 +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 # Tiempo entre transiciones; aumenta el valor para ralentizarlas transition_time = 0.01 diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index 703ffdbd9..105527a2c 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -127,14 +127,14 @@ fr_scale = 2.0 # "compact" - toujours afficher les étiquettes compactes # "threshold" - passer du format étendu au format compact au seuil de zoom correspondant # Flottes de joueurs -fleet_label_player_detail = "native" +zoom_label_player_detail = "native" # Seuil de zoom normalisé pour "threshold" (0.0 = toujours compact, 1.0 = toujours étendu) -fleet_label_player_zoom_threshold = 0.5 +zoom_label_player_threshold = 0.5 # Flottes non-joueurs et flottes non classées -fleet_label_non_player_detail = "native" -fleet_label_non_player_zoom_threshold = 0.5 +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 # Temps entre les transitions, augmenter pour ralentir les transitions transition_time = 0.01 diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 960f0b4bf..0e81e648b 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -127,14 +127,14 @@ fr_scale = 2.0 # "compact" - toon altijd compacte vlootlabels # "threshold" - schakel bij de bijbehorende zoomgrens van uitgebreid naar compact # Spelersvloten -fleet_label_player_detail = "native" +zoom_label_player_detail = "native" # Genormaliseerde zoomgrens voor "threshold" (0.0 = altijd compact, 1.0 = altijd uitgebreid) -fleet_label_player_zoom_threshold = 0.5 +zoom_label_player_threshold = 0.5 # Niet-spelersvloten en niet-geclassificeerde vloten -fleet_label_non_player_detail = "native" -fleet_label_non_player_zoom_threshold = 0.5 +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 # Bepaald de tijd tussen transities, hoe hoger het getal des te trager de transitie transition_time = 0.01 diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index c920fe05f..02118f388 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -127,14 +127,14 @@ fr_scale = 2.0 # "compact" - всегда показывать компактные метки флотов # "threshold" - переключать расширенные метки на компактные при соответствующем пороге масштаба # Флоты игроков -fleet_label_player_detail = "native" +zoom_label_player_detail = "native" # Нормализованный порог масштаба для "threshold" (0.0 = всегда компактно, 1.0 = всегда расширенно) -fleet_label_player_zoom_threshold = 0.5 +zoom_label_player_threshold = 0.5 # Неигровые и неклассифицированные флоты -fleet_label_non_player_detail = "native" -fleet_label_non_player_zoom_threshold = 0.5 +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 # Время между переходами; увеличьте значение, чтобы замедлить переходы transition_time = 0.01 diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index b575c3991..fd45404e2 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -127,14 +127,14 @@ fr_scale = 2.0 # "compact" - always show compact fleet labels # "threshold" - switch from expanded to compact at the matching zoom threshold # Player fleets -fleet_label_player_detail = "native" +zoom_label_player_detail = "native" # Normalized zoom crossover used by "threshold" (0.0 = always compact, 1.0 = always expanded) -fleet_label_player_zoom_threshold = 0.5 +zoom_label_player_threshold = 0.5 # Non-player and unclassified fleets -fleet_label_non_player_detail = "native" -fleet_label_non_player_zoom_threshold = 0.5 +zoom_label_non_player_detail = "native" +zoom_label_non_player_threshold = 0.5 # Amount of poH Daq spend between transitions, increase Daq slow transitions bIng transition_time = 0.01 diff --git a/mods/src/config.cc b/mods/src/config.cc index e0c7ac1ce..fd0d23ee3 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -956,15 +956,14 @@ void Config::Load() get_config_or_default(config, parsed, "graphics", "ui_scale_viewer", DCG::ui_scale_viewer, write_config); this->zoom = get_config_or_default(config, parsed, "graphics", "zoom", DCG::zoom, write_config); this->fr_scale = get_config_or_default(config, parsed, "graphics", "fr_scale", DCG::fr_scale, write_config); - this->fleet_label_player.detail = - get_fleet_label_detail(config, parsed, "fleet_label_player_detail", DCG::fleet_label_player_detail, write_config); - this->fleet_label_player.zoom_threshold = get_fleet_label_zoom_threshold( - config, parsed, "fleet_label_player_zoom_threshold", DCG::fleet_label_player_zoom_threshold, write_config); - this->fleet_label_non_player.detail = get_fleet_label_detail(config, parsed, "fleet_label_non_player_detail", - DCG::fleet_label_non_player_detail, write_config); - this->fleet_label_non_player.zoom_threshold = - get_fleet_label_zoom_threshold(config, parsed, "fleet_label_non_player_zoom_threshold", - DCG::fleet_label_non_player_zoom_threshold, write_config); + this->zoom_label_player.detail = + get_fleet_label_detail(config, parsed, "zoom_label_player_detail", DCG::zoom_label_player_detail, write_config); + this->zoom_label_player.zoom_threshold = get_fleet_label_zoom_threshold( + config, parsed, "zoom_label_player_threshold", DCG::zoom_label_player_threshold, write_config); + this->zoom_label_non_player.detail = get_fleet_label_detail(config, parsed, "zoom_label_non_player_detail", + DCG::zoom_label_non_player_detail, write_config); + this->zoom_label_non_player.zoom_threshold = get_fleet_label_zoom_threshold( + config, parsed, "zoom_label_non_player_threshold", DCG::zoom_label_non_player_threshold, write_config); this->free_resize = get_config_or_default(config, parsed, "graphics", "free_resize", DCG::free_resize, write_config); this->allow_cursor = get_config_or_default(config, parsed, "graphics", "allow_cursor", DCG::allow_cursor, write_config); diff --git a/mods/src/config.h b/mods/src/config.h index 7a6d5c189..f080c0798 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -165,8 +165,8 @@ class Config final float ui_scale_viewer; float zoom; float fr_scale; - FleetLabelProfile fleet_label_player; - FleetLabelProfile fleet_label_non_player; + FleetLabelProfile zoom_label_player; + FleetLabelProfile zoom_label_non_player; bool allow_cursor; bool free_resize; bool adjust_scale_res; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 6a3c4be31..374b43b9a 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -33,10 +33,10 @@ namespace Graphics { constexpr bool borderless_fullscreen = true; constexpr bool allow_cursor = true; - constexpr const char* fleet_label_player_detail = "native"; - constexpr auto fleet_label_player_zoom_threshold = 0.5; - constexpr const char* fleet_label_non_player_detail = "native"; - constexpr auto fleet_label_non_player_zoom_threshold = 0.5; + constexpr const char* zoom_label_player_detail = "native"; + constexpr auto zoom_label_player_threshold = 0.5; + constexpr const char* zoom_label_non_player_detail = "native"; + constexpr auto zoom_label_non_player_threshold = 0.5; constexpr auto default_system_zoom = 1750; constexpr bool free_resize = true; constexpr auto keyboard_zoom_speed = 350; diff --git a/mods/src/patches/parts/zoom.cc b/mods/src/patches/parts/zoom.cc index d4f151b8b..b0ee211b2 100644 --- a/mods/src/patches/parts/zoom.cc +++ b/mods/src/patches/parts/zoom.cc @@ -33,15 +33,15 @@ bool non_player_threshol bool FleetLabelProfilesEnabled() { const auto &config = Config::Get(); - return config.fleet_label_player.detail != FleetLabelDetail::Native - || config.fleet_label_non_player.detail != FleetLabelDetail::Native; + return config.zoom_label_player.detail != FleetLabelDetail::Native + || config.zoom_label_non_player.detail != FleetLabelDetail::Native; } bool FleetLabelThresholdEnabled() { const auto &config = Config::Get(); - return config.fleet_label_player.detail == FleetLabelDetail::Threshold - || config.fleet_label_non_player.detail == FleetLabelDetail::Threshold; + return config.zoom_label_player.detail == FleetLabelDetail::Threshold + || config.zoom_label_non_player.detail == FleetLabelDetail::Threshold; } const FleetLabelProfile *FleetLabelProfileFor(NavigationFleetWidget *widget) @@ -53,26 +53,26 @@ const FleetLabelProfile *FleetLabelProfileFor(NavigationFleetWidget *widget) const auto &config = Config::Get(); auto *context = widget->Context; if (context == nullptr) { - return &config.fleet_label_non_player; + return &config.zoom_label_non_player; } DeployedFleetType fleet_type; if (!context->TryGetFleetType(fleet_type)) { - return &config.fleet_label_non_player; + return &config.zoom_label_non_player; } switch (fleet_type) { case DeployedFleetType::Player: - return &config.fleet_label_player; + return &config.zoom_label_player; case DeployedFleetType::Marauder: case DeployedFleetType::NpcInstantiated: case DeployedFleetType::Sentinel: case DeployedFleetType::Alliance: case DeployedFleetType::Challenge: - return &config.fleet_label_non_player; + return &config.zoom_label_non_player; case DeployedFleetType::Nonexistent: default: - return &config.fleet_label_non_player; + return &config.zoom_label_non_player; } } @@ -170,13 +170,13 @@ void UpdateFleetLabelThreshold() } const auto &config = Config::Get(); - const auto player_uses_threshold = config.fleet_label_player.detail == FleetLabelDetail::Threshold; - const auto non_player_uses_threshold = config.fleet_label_non_player.detail == FleetLabelDetail::Threshold; + const auto player_uses_threshold = config.zoom_label_player.detail == FleetLabelDetail::Threshold; + const auto non_player_uses_threshold = config.zoom_label_non_player.detail == FleetLabelDetail::Threshold; const auto player_expanded = - player_uses_threshold && FleetLabelsExpandedAt(system_normalized_zoom, config.fleet_label_player.zoom_threshold); + player_uses_threshold && FleetLabelsExpandedAt(system_normalized_zoom, config.zoom_label_player.zoom_threshold); const auto non_player_expanded = non_player_uses_threshold - && FleetLabelsExpandedAt(system_normalized_zoom, config.fleet_label_non_player.zoom_threshold); + && FleetLabelsExpandedAt(system_normalized_zoom, config.zoom_label_non_player.zoom_threshold); if (threshold_state_valid && (!player_uses_threshold || player_expanded == player_threshold_state_expanded) && (!non_player_uses_threshold || non_player_expanded == non_player_threshold_state_expanded)) { return; From 909e8fae80ec51fafb91ca747f52cdc915248ea0 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 20:54:41 -0500 Subject: [PATCH 76/80] Document detour size validation --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index a0124914f..507b3d8f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ void SomeFunction_Hook(auto original, SomeClass* _this, ...) { ``` macOS does not tolerate repeated hooks of the same function. If multiple features need to intercept the same game method, consolidate the behavior behind one detour or add platform guards instead of installing overlapping hooks. Do not over-focus on hidden IL2CPP `MethodInfo*` parameters during drift repair; match the game-visible signature from `dump.cs` unless there is concrete runtime evidence that the hidden parameter is the issue. +Before adding a detour to a small IL2CPP wrapper or thunk, verify its native method extent against SPUD's architecture-specific overwrite size on every supported architecture for the exact client build. A successful platform build does not validate hook fit; prefer a substantive downstream method when exact per-architecture extent is unavailable. **IL2CPP class resolution** — Game classes are resolved at runtime using helpers: ```cpp From 5dc6cb8909c5a772b09343c5f3eea5f4eeb4a6b8 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 23:28:00 -0500 Subject: [PATCH 77/80] Trim global audio wildcard handling --- example_community_patch_settings_da.toml | 2 +- example_community_patch_settings_de.toml | 2 +- ...munity_patch_settings_en-GB-x-cockney.toml | 2 +- ...mmunity_patch_settings_en-x-minionese.toml | 2 +- example_community_patch_settings_en.toml | 2 +- example_community_patch_settings_es.toml | 2 +- example_community_patch_settings_fr.toml | 2 +- example_community_patch_settings_nl.toml | 2 +- example_community_patch_settings_ru.toml | 2 +- example_community_patch_settings_tlh.toml | 2 +- mods/src/config.cc | 2 +- mods/src/patches/parts/audio_events.cc | 193 +++--------------- mods/src/str_utils.h | 4 +- 13 files changed, 43 insertions(+), 176 deletions(-) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 2b04751f9..4e9ebfa3d 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index dfb27a400..68bd2f3a2 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 7dd78d117..3a3c649a6 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index d33ce82dc..c1ff10fb6 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index a91fd28fc..915c07efc 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 14afc383c..5bfdde9c3 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index 8e5f304df..fb11ce6f3 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 6799cf3f1..82763b023 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 4c89babc4..e3a02ddf8 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 6f53304aa..933ce841d 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -31,7 +31,7 @@ # <[=======================================]> [audio] -# Comma-separated audio event names to suppress; use "All" to silence Scopely audio while keeping mod alert cues +# Comma-separated audio event names to suppress; use "All" as a wildcard for named events handled by this filter disabled_events = "" # Write audio event names to the trace log for troubleshooting diff --git a/mods/src/config.cc b/mods/src/config.cc index 825e753ba..5260ff970 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -966,7 +966,7 @@ void Config::Load() this->disabled_audio_events.clear(); for (const auto& event : StrSplit(disabled_audio_events, ',')) { auto stripped = StripAsciiWhitespace(event); - if (AsciiStrToUpper(stripped) == "ALL") { + if (AsciiStrToUpper(std::string{stripped}) == "ALL") { this->disable_all_audio_events = true; } else if (!stripped.empty()) { this->disabled_audio_events.emplace_back(stripped); diff --git a/mods/src/patches/parts/audio_events.cc b/mods/src/patches/parts/audio_events.cc index 5030bb355..7e276d1cf 100644 --- a/mods/src/patches/parts/audio_events.cc +++ b/mods/src/patches/parts/audio_events.cc @@ -8,25 +8,11 @@ #include #include -#include -#include -#include #include struct FabricEventManager { }; -struct FabricEvent { - Il2CppObject object; - Il2CppString* event_name; - int32_t event_id; - int event_action; -}; - -static_assert(offsetof(FabricEvent, event_name) == 0x10); -static_assert(offsetof(FabricEvent, event_id) == 0x18); -static_assert(offsetof(FabricEvent, event_action) == 0x1C); - namespace { enum class FabricEventAction : int { @@ -56,136 +42,39 @@ constexpr bool should_suppress_audio_event(bool disable_all, bool event_is_disab { return disable_all ? can_start_audio(event_action) : event_is_disabled; } static_assert(should_suppress_audio_event(true, false, static_cast(FabricEventAction::PlaySound))); -static_assert(should_suppress_audio_event(true, false, static_cast(FabricEventAction::UnpauseSound))); -static_assert(should_suppress_audio_event(false, true, static_cast(FabricEventAction::PlayScheduled))); static_assert(!should_suppress_audio_event(true, false, static_cast(FabricEventAction::StopSound))); -static_assert(!should_suppress_audio_event(true, false, static_cast(FabricEventAction::StopAll))); -static_assert(!should_suppress_audio_event(true, false, static_cast(FabricEventAction::UnloadAudio))); -static_assert(should_suppress_audio_event(false, true, static_cast(FabricEventAction::StopSound))); -static_assert(!should_suppress_audio_event(true, true, static_cast(FabricEventAction::StopSound))); -static_assert(!should_suppress_audio_event(true, true, static_cast(FabricEventAction::StopAll))); +static_assert(should_suppress_audio_event(false, true, static_cast(FabricEventAction::StopAll))); static_assert(!should_suppress_audio_event(true, true, static_cast(FabricEventAction::UnloadAudio))); +} // namespace -bool should_suppress_audio_event(const FabricEvent* event) +// Fabric.EventManager.PostEvent(string, EventAction, object, GameObject, +// InitialiseParameters, bool, OnEventNotify) +bool FabricEventManager_PostEvent_Hook(auto original, FabricEventManager* _this, Il2CppString* event_name, + int event_action, Il2CppObject* parameter, Il2CppObject* parent_game_object, + Il2CppObject* initialise_parameters, bool add_to_queue, + Il2CppObject* on_event_notify) { - if (event == nullptr) { - return false; - } - - auto& config = Config::Get(); - - // `All` is the common high-frequency path. Avoid converting every managed event name when tracing is off. - if (config.disable_all_audio_events && !config.trace_audio_events) { - if (can_start_audio(event->event_action)) { - spdlog::debug("Suppressed audio event ID {} (action {})", event->event_id, event->event_action); - return true; - } - return false; + if (event_name == nullptr) { + return original(_this, event_name, event_action, parameter, parent_game_object, initialise_parameters, + add_to_queue, on_event_notify); } - if (!config.trace_audio_events && config.disabled_audio_events.empty()) { - return false; - } - - std::string event_name; - if (event->event_name != nullptr) { - event_name = to_string(event->event_name); - } + const auto event = to_string(event_name); + auto& config = Config::Get(); if (config.trace_audio_events) { - if (event_name.empty()) { - spdlog::info("Audio event ID: {} (action {})", event->event_id, event->event_action); - } else { - spdlog::info("Audio event: {} (ID {}, action {})", event_name, event->event_id, event->event_action); - } + spdlog::info("Audio event: {}", event); } const bool event_is_disabled = - !event_name.empty() - && std::ranges::find(config.disabled_audio_events, event_name) != config.disabled_audio_events.end(); - if (!should_suppress_audio_event(config.disable_all_audio_events, event_is_disabled, event->event_action)) { - return false; - } - - if (event_name.empty()) { - spdlog::debug("Suppressed audio event ID {} (action {})", event->event_id, event->event_action); - } else { - spdlog::debug("Suppressed audio event: {} (ID {}, action {})", event_name, event->event_id, event->event_action); - } - return true; -} - -bool is_fabric_event_overload(int param_count, const Il2CppType** params, Il2CppClass* event_class) -{ - return param_count == 2 && params != nullptr && params[0] != nullptr && params[1] != nullptr - && il2cpp_class_from_type(params[0]) == event_class && params[1]->type == IL2CPP_TYPE_BOOLEAN; -} - -uintptr_t canonical_spud_target(uintptr_t address) -{ -#if defined(__aarch64__) || defined(_M_ARM64) - const auto instruction = *reinterpret_cast(address); - if ((instruction >> 26) != 0b000101) { - return address; - } - const auto offset = static_cast(static_cast(instruction << 6)) >> 4; - return address + offset; -#else - const auto* memory = reinterpret_cast(address); - if (memory[0] == 0x55 && memory[1] == 0x48 && memory[2] == 0x89 && memory[3] == 0xE5 && memory[4] == 0x5D) { - return canonical_spud_target(address + 5); - } - if (memory[0] != 0xE9) { - return address; - } - int32_t offset = 0; - std::memcpy(&offset, memory + 1, sizeof(offset)); - // Match Spud's x86_64 resolver exactly: it recursively skips the special prologue above, but an E9 is - // resolved only once because that first-hop destination is the address Spud will actually patch. - return address + offset + 5; -#endif -} - -bool validate_fabric_event_layout(Il2CppClass* event_class) -{ - struct ExpectedField { - const char* name; - size_t offset; - }; - constexpr ExpectedField expected_fields[] = { - {"_eventName", offsetof(FabricEvent, event_name)}, - {"_eventID", offsetof(FabricEvent, event_id)}, - {"EventAction", offsetof(FabricEvent, event_action)}, - }; - - for (const auto& expected : expected_fields) { - const auto* field = il2cpp_class_get_field_from_name(event_class, expected.name); - if (field == nullptr || static_cast(field->offset) != expected.offset) { - spdlog::error("Fabric.Event field '{}' is missing or has an unexpected layout", expected.name); - return false; - } - } - return true; -} -} // namespace - -// Fabric.EventManager.PostEvent(Event, bool). String and EventTrigger callers converge here. -bool FabricEventManager_PostEvent_Hook(auto original, FabricEventManager* _this, FabricEvent* event, bool add_to_queue) -{ - if (should_suppress_audio_event(event)) { + std::ranges::find(config.disabled_audio_events, event) != config.disabled_audio_events.end(); + if (should_suppress_audio_event(config.disable_all_audio_events, event_is_disabled, event_action)) { + spdlog::debug("Suppressed audio event: {}", event); return false; } - return original(_this, event, add_to_queue); -} -// Fabric.EventManager.PostEventID(Event, bool). Hashed integer-ID callers converge here. -bool FabricEventManager_PostEventID_Hook(auto original, FabricEventManager* _this, FabricEvent* event, - bool add_to_queue) -{ - if (should_suppress_audio_event(event)) { - return false; - } - return original(_this, event, add_to_queue); + return original(_this, event_name, event_action, parameter, parent_game_object, initialise_parameters, + add_to_queue, on_event_notify); } void InstallAudioEventHooks() @@ -196,43 +85,21 @@ void InstallAudioEventHooks() return; } - auto event_helper = il2cpp_get_class_helper("Fabric.Core", "Fabric", "Event"); - if (!event_helper.isValidHelper()) { - ErrorMsg::MissingHelper("Fabric", "Event"); - return; - } - - auto* event_class = event_helper.get_cls(); - if (event_class == nullptr || !validate_fabric_event_layout(event_class)) { - return; - } - auto event_filter = [event_class](int count, const Il2CppType** params) { - return is_fabric_event_overload(count, params, event_class); + // The shorter string PostEvent overloads funnel into this seven-argument overload. Select it by type because + // Fabric also has a seven-argument integer-ID overload with an incompatible native signature. + auto string_event_filter = [](int count, const Il2CppType** params) { + return count == 7 && params != nullptr && params[0] != nullptr && params[5] != nullptr + && params[0]->type == IL2CPP_TYPE_STRING && params[5]->type == IL2CPP_TYPE_BOOLEAN; }; - - const auto post_event = helper.GetMethodInfoSpecial("PostEvent", event_filter); - const auto post_event_id = helper.GetMethodInfoSpecial("PostEventID", event_filter); - if (post_event == nullptr || post_event->methodPointer == nullptr) { - ErrorMsg::MissingMethod("EventManager", "PostEvent(Event, bool)"); - return; - } - if (post_event_id == nullptr || post_event_id->methodPointer == nullptr) { - ErrorMsg::MissingMethod("EventManager", "PostEventID(Event, bool)"); + const auto method = helper.GetMethodInfoSpecial("PostEvent", string_event_filter); + if (method == nullptr || method->methodPointer == nullptr) { + ErrorMsg::MissingMethod("EventManager", "PostEvent(string, ..., bool, ...)"); return; } - if (post_event->return_type == nullptr || post_event->return_type->type != IL2CPP_TYPE_BOOLEAN - || post_event_id->return_type == nullptr || post_event_id->return_type->type != IL2CPP_TYPE_BOOLEAN) { - spdlog::error("Fabric EventManager event routes have an unexpected return type"); - return; - } - - const auto post_event_target = canonical_spud_target(reinterpret_cast(post_event->methodPointer)); - const auto post_event_id_target = canonical_spud_target(reinterpret_cast(post_event_id->methodPointer)); - if (post_event_target == post_event_id_target) { - spdlog::error("Fabric EventManager event and event-ID routes unexpectedly share a hook target"); + if (method->return_type == nullptr || method->return_type->type != IL2CPP_TYPE_BOOLEAN) { + spdlog::error("Fabric EventManager string PostEvent route has an unexpected return type"); return; } - SPUD_STATIC_DETOUR(post_event->methodPointer, FabricEventManager_PostEvent_Hook); - SPUD_STATIC_DETOUR(post_event_id->methodPointer, FabricEventManager_PostEventID_Hook); + SPUD_STATIC_DETOUR(method->methodPointer, FabricEventManager_PostEvent_Hook); } diff --git a/mods/src/str_utils.h b/mods/src/str_utils.h index 89ec079c2..a9fbfb7df 100644 --- a/mods/src/str_utils.h +++ b/mods/src/str_utils.h @@ -57,14 +57,14 @@ constexpr std::string_view StripPrefix(std::string_view str, const std::string_v constexpr std::string AsciiStrToUpper(const std::string_view s) { - std::string str{s}; + std::string str = s.data(); std::ranges::transform(str, str.begin(), ::toupper); return str; } constexpr std::string AsciiStrToLower(const std::string_view s) { - std::string str{s}; + std::string str = s.data(); std::ranges::transform(str, str.begin(), ::tolower); return str; } From 0d1ce20bb09eae72aa1f323aa2384468ded27e8d Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 23:53:40 -0500 Subject: [PATCH 78/80] Preserve bounded string view conversions --- mods/src/str_utils.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mods/src/str_utils.h b/mods/src/str_utils.h index a9fbfb7df..89ec079c2 100644 --- a/mods/src/str_utils.h +++ b/mods/src/str_utils.h @@ -57,14 +57,14 @@ constexpr std::string_view StripPrefix(std::string_view str, const std::string_v constexpr std::string AsciiStrToUpper(const std::string_view s) { - std::string str = s.data(); + std::string str{s}; std::ranges::transform(str, str.begin(), ::toupper); return str; } constexpr std::string AsciiStrToLower(const std::string_view s) { - std::string str = s.data(); + std::string str{s}; std::ranges::transform(str, str.begin(), ::tolower); return str; } From 34a09a22c39ea9bc537f7740744349a4ca09a6a5 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Tue, 1 Sep 2026 01:02:17 -0500 Subject: [PATCH 79/80] Fix fleet label bind lifecycle --- mods/src/config.cc | 3 ++- mods/src/patches/parts/zoom.cc | 26 +++++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/mods/src/config.cc b/mods/src/config.cc index fd0d23ee3..80953dede 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -441,7 +441,8 @@ std::string_view to_string(FleetLabelDetail detail) FleetLabelDetail parse_fleet_label_detail(std::string_view key, std::string_view value) { - const auto normalized = AsciiStrToUpper(StripAsciiWhitespace(value)); + const auto trimmed = std::string(StripAsciiWhitespace(value)); + const auto normalized = AsciiStrToUpper(trimmed); if (normalized == "EXPANDED" || normalized == "ALWAYS") { return FleetLabelDetail::Expanded; diff --git a/mods/src/patches/parts/zoom.cc b/mods/src/patches/parts/zoom.cc index b0ee211b2..5304f06fd 100644 --- a/mods/src/patches/parts/zoom.cc +++ b/mods/src/patches/parts/zoom.cc @@ -79,7 +79,10 @@ const FleetLabelProfile *FleetLabelProfileFor(NavigationFleetWidget *widget) const FleetLabelProfile *FleetLabelProfileFor(NavigationLOD *lod) { const auto widget = fleet_label_widgets.find(lod); - return widget != fleet_label_widgets.end() ? FleetLabelProfileFor(widget->second) : nullptr; + if (widget == fleet_label_widgets.end() || widget->second == nullptr || widget->second->Context == nullptr) { + return nullptr; + } + return FleetLabelProfileFor(widget->second); } ZoomLevels ExpandedFleetLabelZoomLevel(ZoomLevels native_level) @@ -118,7 +121,7 @@ ZoomLevels FleetLabelZoomLevel(ZoomLevels native_level, const FleetLabelProfile void ApplyFleetLabelDetail(NavigationLOD *lod, ZoomLevels native_level) { - if (lod == nullptr || !system_zoom_state_valid) { + if (lod == nullptr || !system_zoom_state_valid || FleetLabelProfileFor(lod) == nullptr) { return; } @@ -439,14 +442,13 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) void NavigationLOD_UpdateLOD_Hook(auto original, NavigationLOD *_this, ZoomLevels level) { - const auto tracked = fleet_label_widgets.contains(_this); const auto *profile = FleetLabelProfileFor(_this); const auto effective_level = profile != nullptr ? FleetLabelZoomLevel(level, *profile) : level; - if (tracked) { + if (profile != nullptr) { _this->SetTargetLevel(effective_level); } original(_this, effective_level); - if (tracked) { + if (profile != nullptr) { _this->SetTargetLevel(effective_level); } } @@ -458,6 +460,12 @@ void NavigationFleetWidget_OnEnable_Hook(auto original, NavigationFleetWidget *_ return; } + // Fresh pooled widgets are enabled before BindDataContext assigns m_context. Forcing an LOD in that window can fire + // OnLODChanged callbacks that dereference the not-yet-bound fleet data. + if (_this->Context == nullptr) { + return; + } + auto *lod = _this->_lod; if (lod) { fleet_label_widgets.insert_or_assign(lod, _this); @@ -473,7 +481,9 @@ void NavigationFleetWidget_OnDisable_Hook(auto original, NavigationFleetWidget * if (_this) { auto *lod = _this->_lod; fleet_label_widgets.erase(lod); - RestoreNativeFleetLabelDetail(lod); + if (_this->Context != nullptr) { + RestoreNativeFleetLabelDetail(lod); + } } original(_this); } @@ -498,7 +508,9 @@ void NavigationFleetWidget_OnAboutToReleaseContext_Hook(auto original, Navigatio auto *lod = _this->_lod; if (lod != nullptr) { fleet_label_widgets.erase(lod); - RestoreNativeFleetLabelDetail(lod); + if (_this->Context != nullptr) { + RestoreNativeFleetLabelDetail(lod); + } } } original(_this); From a947ae0699cd996eb1e90ee22b29bff5484ea177 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Tue, 1 Sep 2026 02:00:48 -0500 Subject: [PATCH 80/80] Guard Fleet Watch service readiness --- mods/src/patches/fleet_watch.cc | 13 +++++++----- mods/src/prime/FleetsManager.h | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index b252714cd..89f1256a9 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -344,7 +344,9 @@ std::string cargo_text(FleetPlayerData* fleet) FleetPlayerData* fleet_for_slot(int slot_index) { auto* manager = FleetsManager::Instance(); - return manager && slot_index >= 0 && slot_index < kFleetSlotCount ? manager->GetFleetPlayerData(slot_index) : nullptr; + return manager && manager->HasFleetService() && slot_index >= 0 && slot_index < kFleetSlotCount + ? manager->GetFleetPlayerData(slot_index) + : nullptr; } FleetPlayerData* fleet_for_snapshot(int slot_index, uint64_t fleet_id) @@ -586,7 +588,7 @@ int occupied_snapshot_count() bool manager_baseline_matches(int& changed_slot, uint64_t& changed_fleet) { auto* manager = FleetsManager::Instance(); - if (!manager) { + if (!manager || !manager->HasFleetService()) { changed_slot = -1; changed_fleet = 0; return false; @@ -604,8 +606,9 @@ bool manager_baseline_matches(int& changed_slot, uint64_t& changed_fleet) bool manager_slot_matches(int slot_index, uint64_t fleet_id) { auto* manager = FleetsManager::Instance(); - auto* fleet = - manager && slot_index >= 0 && slot_index < kFleetSlotCount ? manager->GetFleetPlayerData(slot_index) : nullptr; + auto* fleet = manager && manager->HasFleetService() && slot_index >= 0 && slot_index < kFleetSlotCount + ? manager->GetFleetPlayerData(slot_index) + : nullptr; return fleet && fleet->Id == fleet_id; } @@ -634,7 +637,7 @@ ScanResult scan_fleets(bool allow_notifications, bool sample_opc, std::string_vi { ScanResult result; auto* manager = FleetsManager::Instance(); - if (!manager) { + if (!manager || !manager->HasFleetService()) { if (allow_notifications && !s_seed_pending && s_seed_manager_backed) { restart_seed_stabilization(-1, 0); } diff --git a/mods/src/prime/FleetsManager.h b/mods/src/prime/FleetsManager.h index df4649868..6555f510b 100644 --- a/mods/src/prime/FleetsManager.h +++ b/mods/src/prime/FleetsManager.h @@ -104,6 +104,42 @@ struct FleetsManager : MonoSingleton { return nullptr; } + bool HasFleetService() + { + static auto* fleet_service_cache = + il2cpp_class_get_field_from_name(get_class_helper().get_cls(), "_fleetServiceCache"); + static auto FleetServiceCacheWarn = true; + if (!fleet_service_cache) { + if (FleetServiceCacheWarn) { + FleetServiceCacheWarn = false; + spdlog::error("Unable to find field 'FleetsManager->_fleetServiceCache'"); + } + return false; + } + + auto* cache = reinterpret_cast(reinterpret_cast(this) + fleet_service_cache->offset); + if (*cache) { + return true; + } + + static auto* HasServiceMethod = [] { + auto* cache_class = il2cpp_class_from_type(il2cpp_field_get_type(fleet_service_cache)); + return cache_class ? il2cpp_class_get_method_from_name(cache_class, "get_HasService", 0) : nullptr; + }(); + static auto HasServiceWarn = true; + if (!HasServiceMethod) { + if (HasServiceWarn) { + HasServiceWarn = false; + ErrorMsg::MissingMethod("CachedService", "get_HasService"); + } + return false; + } + + Il2CppException* exception = nullptr; + auto* result = il2cpp_runtime_invoke(HasServiceMethod, cache, nullptr, &exception); + return !exception && result && *static_cast(il2cpp_object_unbox(result)); + } + FleetDeployedData* __get_TargetFleetData() { static auto field = get_class_helper().GetField("_targetFleetData").offset();