From 2d9dd4ae43096e5a4a722675d624ab825c73d6cd Mon Sep 17 00:00:00 2001 From: NukeAtty Date: Mon, 10 Aug 2026 09:43:27 +0800 Subject: [PATCH 1/5] Add veteran and elite reload time customization with RELOAD and EMPTY_RELOAD abilities --- CREDITS.md | 1 + docs/New-or-Enhanced-Logics.md | 26 +++++++++ docs/Whats-New.md | 1 + docs/locale/zh_CN/LC_MESSAGES/CREDITS.po | 2 + .../LC_MESSAGES/New-or-Enhanced-Logics.po | 47 ++++++++++++++++ docs/locale/zh_CN/LC_MESSAGES/Whats-New.po | 8 +++ src/Ext/Rules/Body.cpp | 20 +++++++ src/Ext/Rules/Body.h | 4 ++ src/Ext/Techno/Body.Update.cpp | 43 +++++++++++++++ src/Ext/Techno/Body.cpp | 18 +++++++ src/Ext/Techno/Body.h | 4 ++ src/Ext/Techno/Hooks.Firing.cpp | 14 ++++- src/Ext/TechnoType/Body.cpp | 53 +++++++++++++++++++ src/Ext/TechnoType/Body.h | 10 ++++ src/Utilities/Enum.h | 20 +++++++ 15 files changed, 270 insertions(+), 1 deletion(-) diff --git a/CREDITS.md b/CREDITS.md index 5d9ea011ce..75f06ae8b0 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -930,3 +930,4 @@ This page lists all the individual contributions to the project by their author. - Interop export interface for accessing scenario local/global variables - Add `ClampToScreen` tag for `BannerType` to control whether banner position is clamped to the visible area - **obsidianus** - Automatic conversion based on health +- **Nuke** - Veteran reload time customization diff --git a/docs/New-or-Enhanced-Logics.md b/docs/New-or-Enhanced-Logics.md index 2842e1fa43..68ab53daf1 100644 --- a/docs/New-or-Enhanced-Logics.md +++ b/docs/New-or-Enhanced-Logics.md @@ -2454,6 +2454,32 @@ TiberiumEater.AnimMove=true ; boolean TiberiumEater.UnderEMP=false ; boolean ``` +### Veteran reload time customization + +- In vanilla, veteran and elite technos only support the predefined ability list. Now you can add the following abilities to `VeteranAbilities` and `EliteAbilities` to speed up or slow down their ammo reload. + - `RELOAD` scales the duration of normal reload cycles. + - `EMPTY_RELOAD` scales the duration of the reload cycle that starts when the clip is completely empty, which is only used when the TechnoType has a positive `EmptyReload`. +- Abilities from `VeteranAbilities` keep working at elite level, and `EliteAbilities` are added on top, matching how vanilla abilities accumulate. +- `VeteranReload` and `VeteranEmptyReload` are multipliers applied to the final reload duration computed by the game, so other reload modifiers such as `ReloadIncrement` are still taken into account. + - Values greater than `1.0` lengthen the reload, values smaller than `1.0` shorten it, and `1.0` (the default) leaves it unchanged. + +```{note} +For `EMPTY_RELOAD` to have any effect, the TechnoType must have a positive `EmptyReload` value, otherwise the empty clip uses the regular `Reload` duration instead. +``` + +In `rulesmd.ini`: +```ini +[General] +VeteranReload=1.0 ; floating point value, multiplier +VeteranEmptyReload=1.0 ; floating point value, multiplier + +[SOMETECHNO] ; TechnoType +VeteranReload= ; floating point value, multiplier, defaults to [General] -> VeteranReload +VeteranEmptyReload= ; floating point value, multiplier, defaults to [General] -> VeteranEmptyReload +VeteranAbilities=RELOAD ; Ability, `RELOAD` and `EMPTY_RELOAD` in addition to the vanilla abilities +EliteAbilities=EMPTY_RELOAD ; Ability +``` + ### Weapons fired on warping in / out - It is now possible to add weapons that are fired on a teleporting TechnoType when it warps in or out. They are at the same time as the appropriate animations (`WarpIn` / `WarpOut`) are displayed. diff --git a/docs/Whats-New.md b/docs/Whats-New.md index 3f918553c5..34fa2e5747 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -664,6 +664,7 @@ HideShakeEffects=false ; boolean - [Allow the unit to keep pursuing the target during ApproachTarget](Fixed-or-Improved-Logics.md#keep-pursuing-the-target-during-approachtarget) (by TaranDahl) - [Customize whether warhead can prevent crew escape from techno](New-or-Enhanced-Logics.md#customize-whether-warhead-can-prevent-crew-escape-from-techno) (by NetsuNegi) - [Disable AlphaImage during Buildup](Fixed-or-Improved-Logics.md#disable-alphaimage-during-buildup) (by Noble_Fish) +- [Veteran reload time customization](New-or-Enhanced-Logics.md#veteran-reload-time-customization) (by Nuke) #### Vanilla fixes: - Fixed sidebar not updating queued unit numbers when adding or removing units when the production is on hold (by CrimRecya) diff --git a/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po b/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po index 23f45b1289..9b9c882cbb 100644 --- a/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po +++ b/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po @@ -3178,3 +3178,5 @@ msgstr "为 `BannerType` 添加了 `ClampToScreen` 标签以控制横幅位置 msgid "**obsidianus** - Automatic conversion based on health" msgstr "**obsidianus** - 自动根据血量变形" +msgid "**Nuke** - Veteran reload time customization" +msgstr "**Nuke**:自定义老兵与精英装填时间" diff --git a/docs/locale/zh_CN/LC_MESSAGES/New-or-Enhanced-Logics.po b/docs/locale/zh_CN/LC_MESSAGES/New-or-Enhanced-Logics.po index d073303cea..7f81cc307c 100644 --- a/docs/locale/zh_CN/LC_MESSAGES/New-or-Enhanced-Logics.po +++ b/docs/locale/zh_CN/LC_MESSAGES/New-or-Enhanced-Logics.po @@ -5230,6 +5230,53 @@ msgid "" msgstr "" "如果 `TiberiumEater.UnderEMP` 设为 true 则在单位受 EMP 影响或瘫痪时继续执行吃矿操作。" +msgid "Veteran reload time customization" +msgstr "自定义老兵与精英装填时间" + +msgid "" +"In vanilla, veteran and elite technos only support the predefined ability " +"list. Now you can add the following abilities to `VeteranAbilities` and " +"`EliteAbilities` to speed up or slow down their ammo reload." +msgstr "" +"原版中,老兵和精英单位只能使用预定义的能力列表。现在你可以在 `VeteranAbilities` 和 `EliteAbilities` " +"中添加以下能力,以加快或减慢其弹药装填速度。" + +msgid "`RELOAD` scales the duration of normal reload cycles." +msgstr "`RELOAD` 缩放普通装填周期的时长。" + +msgid "" +"`EMPTY_RELOAD` scales the duration of the reload cycle that starts when the " +"clip is completely empty, which is only used when the TechnoType has a " +"positive `EmptyReload`." +msgstr "`EMPTY_RELOAD` 缩放弹夹完全打空后开始的空仓装填周期时长,仅当单位具有正的 `EmptyReload` 时才会使用。" + +msgid "" +"Abilities from `VeteranAbilities` keep working at elite level, and " +"`EliteAbilities` are added on top, matching how vanilla abilities " +"accumulate." +msgstr "" +"`VeteranAbilities` 中的能力在精英等级仍然生效,`EliteAbilities` 则在此基础上额外生效,这与原版能力的叠加方式一致。" + +msgid "" +"`VeteranReload` and `VeteranEmptyReload` are multipliers applied to the " +"final reload duration computed by the game, so other reload modifiers such " +"as `ReloadIncrement` are still taken into account." +msgstr "" +"`VeteranReload` 和 `VeteranEmptyReload` 是对游戏最终计算出的装填时长的倍率,因此 " +"`ReloadIncrement` 等其他装填修正仍然会生效。" + +msgid "" +"Values greater than `1.0` lengthen the reload, values smaller than `1.0` " +"shorten it, and `1.0` (the default) leaves it unchanged." +msgstr "大于 `1.0` 的数值会延长装填时间,小于 `1.0` 的数值会缩短装填时间,`1.0`(默认值)则保持不变。" + +msgid "" +"For `EMPTY_RELOAD` to have any effect, the TechnoType must have a positive " +"`EmptyReload` value, otherwise the empty clip uses the regular `Reload` " +"duration instead." +msgstr "" +"要使 `EMPTY_RELOAD` 生效,单位必须设置正的 `EmptyReload` 值,否则打空弹夹时仍会使用普通的 `Reload` 时长。" + msgid "Weapons fired on warping in / out" msgstr "武器在传送时开火" diff --git a/docs/locale/zh_CN/LC_MESSAGES/Whats-New.po b/docs/locale/zh_CN/LC_MESSAGES/Whats-New.po index 9c8d5f810c..400cbed4b3 100644 --- a/docs/locale/zh_CN/LC_MESSAGES/Whats-New.po +++ b/docs/locale/zh_CN/LC_MESSAGES/Whats-New.po @@ -2459,6 +2459,14 @@ msgstr "" "[自定义弹头是否阻止生还者逃出](New-or-Enhanced-Logics.md#customize-whether-warhead-can-" "prevent-crew-escape-from-techno)(by NetsuNegi)" +msgid "" +"[Veteran reload time customization](New-or-Enhanced-Logics.md#veteran-" +"reload-time-customization) (by Nuke)" +msgstr "" +"[自定义老兵与精英装填时间](New-or-Enhanced-Logics.md#veteran-reload-time-" +"customization)(by Nuke)" + + msgid "Vanilla fixes:" msgstr "原版问题修复:" diff --git a/src/Ext/Rules/Body.cpp b/src/Ext/Rules/Body.cpp index cbc1f4f8bc..96fdc329e8 100644 --- a/src/Ext/Rules/Body.cpp +++ b/src/Ext/Rules/Body.cpp @@ -1,5 +1,7 @@ #include "Body.h" +#include + #include #include #include @@ -352,6 +354,22 @@ void RulesExt::ExtData::LoadBeforeTypeData(RulesClass* pThis, CCINIClass* pINI) this->NoRearm_Temporal.Read(exINI, GameStrings::General, "NoRearm.Temporal"); this->NoReload_UnderEMP.Read(exINI, GameStrings::General, "NoReload.UnderEMP"); this->NoReload_Temporal.Read(exINI, GameStrings::General, "NoReload.Temporal"); + + this->VeteranReload.Read(exINI, GameStrings::General, "VeteranReload"); + this->VeteranEmptyReload.Read(exINI, GameStrings::General, "VeteranEmptyReload"); + + const auto validateReloadMultiplier = [](const char* pKey, Valueable& value) + { + if (!std::isfinite(value.Get()) || value.Get() <= 0.0) + { + Debug::INIParseFailed(GameStrings::General, pKey, "", "Expected a finite value greater than 0.0"); + value = 1.0; + } + }; + + validateReloadMultiplier("VeteranReload", this->VeteranReload); + validateReloadMultiplier("VeteranEmptyReload", this->VeteranEmptyReload); + this->NoTurret_TrackTarget.Read(exINI, GameStrings::General, "NoTurret.TrackTarget"); this->GatherWhenMCVDeploy.Read(exINI, GameStrings::General, "GatherWhenMCVDeploy"); @@ -901,6 +919,8 @@ void RulesExt::ExtData::Serialize(T& Stm) .Process(this->NoRearm_Temporal) .Process(this->NoReload_UnderEMP) .Process(this->NoReload_Temporal) + .Process(this->VeteranReload) + .Process(this->VeteranEmptyReload) .Process(this->NoTurret_TrackTarget) .Process(this->GatherWhenMCVDeploy) .Process(this->AIFireSale) diff --git a/src/Ext/Rules/Body.h b/src/Ext/Rules/Body.h index a9321791f4..0308dd182a 100644 --- a/src/Ext/Rules/Body.h +++ b/src/Ext/Rules/Body.h @@ -288,6 +288,8 @@ class RulesExt Valueable NoRearm_Temporal; Valueable NoReload_UnderEMP; Valueable NoReload_Temporal; + Valueable VeteranReload; + Valueable VeteranEmptyReload; Valueable NoTurret_TrackTarget; Valueable GatherWhenMCVDeploy; @@ -771,6 +773,8 @@ class RulesExt , NoRearm_Temporal { false } , NoReload_UnderEMP { false } , NoReload_Temporal { false } + , VeteranReload { 1.0 } + , VeteranEmptyReload { 1.0 } , NoTurret_TrackTarget { false } , GatherWhenMCVDeploy { true } , AIFireSale { true } diff --git a/src/Ext/Techno/Body.Update.cpp b/src/Ext/Techno/Body.Update.cpp index c0f3f68e66..ce9f3886bc 100644 --- a/src/Ext/Techno/Body.Update.cpp +++ b/src/Ext/Techno/Body.Update.cpp @@ -7,13 +7,17 @@ #include #include #include +#include #include +#include + // TechnoClass_AI_0x6F9E50 // It's not recommended to do anything more here it could have a better place for performance consideration void TechnoExt::OnEarlyUpdate() { + this->ApplyPendingReloadVeterancy(); this->UpdateShield(); this->UpdateAttachEffects(); this->EatPassengers(); @@ -29,6 +33,45 @@ void TechnoExt::OnEarlyUpdate() this->ApplyInterceptor(); } +void TechnoExt::ApplyPendingReloadVeterancy() +{ + const auto pending = this->PendingReloadVeterancyAdjustment; + + if (pending == PendingReloadVeterancy::None) + return; + + // Clear first so that any early return cannot re-apply the scaling next frame. + this->PendingReloadVeterancyAdjustment = PendingReloadVeterancy::None; + + const auto pThis = this->OwnerObject(); + auto& timer = pThis->ReloadTimer; + + if (!timer.HasStarted() || timer.TimeLeft <= 0) + return; + + const bool isEmptyReload = pending == PendingReloadVeterancy::EmptyReload; + + const auto ability = isEmptyReload + ? AdditionalAbility::EmptyReload + : AdditionalAbility::Reload; + + if (!TechnoExt::HasAdditionalAbility(pThis, ability)) + return; + + const auto pTypeExt = this->TypeExtData; + const auto pRulesExt = RulesExt::Global(); + + const double multiplier = isEmptyReload + ? pTypeExt->VeteranEmptyReload.Get(pRulesExt->VeteranEmptyReload) + : pTypeExt->VeteranReload.Get(pRulesExt->VeteranReload); + + // A non-positive or non-finite multiplier must not create an invalid timer. + if (!std::isfinite(multiplier) || multiplier <= 0.0) + return; + + timer.TimeLeft = Math::max(1, GeneralUtils::SafeMultiply(timer.TimeLeft, multiplier)); +} + void TechnoExt::ApplyInterceptor() { const auto pTypeExt = this->TypeExtData; diff --git a/src/Ext/Techno/Body.cpp b/src/Ext/Techno/Body.cpp index 7a425db851..4dbca5cb67 100644 --- a/src/Ext/Techno/Body.cpp +++ b/src/Ext/Techno/Body.cpp @@ -173,6 +173,23 @@ void TechnoExt::SyncInvulnerability(TechnoClass* pFrom, TechnoClass* pTo) } } +bool TechnoExt::HasAdditionalAbility(TechnoClass* pThis, AdditionalAbility ability) +{ + if (!pThis || (!pThis->Veterancy.IsVeteran() && !pThis->Veterancy.IsElite())) + return false; + + const auto index = static_cast(ability); + const auto pTypeExt = TechnoExt::Fetch(pThis)->TypeExtData; + + if (pThis->Veterancy.IsElite()) + { + return pTypeExt->AdditionalVeteranAbilities.test(index) + || pTypeExt->AdditionalEliteAbilities.test(index); + } + + return pTypeExt->AdditionalVeteranAbilities.test(index); +} + double TechnoExt::GetCurrentSpeedMultiplier(FootClass* pThis) { double houseMultiplier = 1.0; @@ -1169,6 +1186,7 @@ void TechnoExt::Serialize(T& Stm) .Process(this->HasBeenPlacedOnMap) .Process(this->ForceFullRearmDelay) .Process(this->LastRearmWasFullDelay) + .Process(this->PendingReloadVeterancyAdjustment) .Process(this->CanCloakDuringRearm) .Process(this->WHAnimRemainingCreationInterval) .Process(this->LastWeaponType) diff --git a/src/Ext/Techno/Body.h b/src/Ext/Techno/Body.h index 01b6a07674..53777177a5 100644 --- a/src/Ext/Techno/Body.h +++ b/src/Ext/Techno/Body.h @@ -45,6 +45,7 @@ class TechnoExt : public RadioExt, public Detach::Listener bool HasBeenPlacedOnMap; // Set to true on first Unlimbo() call. bool ForceFullRearmDelay; bool LastRearmWasFullDelay; + PendingReloadVeterancy PendingReloadVeterancyAdjustment; bool CanCloakDuringRearm; // Current rearm timer was started by DecloakToFire=no weapon. int WHAnimRemainingCreationInterval; WeaponTypeClass* LastWeaponType; @@ -105,6 +106,7 @@ class TechnoExt : public RadioExt, public Detach::Listener , HasBeenPlacedOnMap { false } , ForceFullRearmDelay { false } , LastRearmWasFullDelay { false } + , PendingReloadVeterancyAdjustment { PendingReloadVeterancy::None } , CanCloakDuringRearm { false } , WHAnimRemainingCreationInterval { 0 } , LastWeaponType {} @@ -140,6 +142,7 @@ class TechnoExt : public RadioExt, public Detach::Listener { } void OnEarlyUpdate(); + void ApplyPendingReloadVeterancy(); // the extension state that goes with TechnoClass::Init void InitializeState(TechnoTypeClass* pType = nullptr); @@ -233,6 +236,7 @@ class TechnoExt : public RadioExt, public Detach::Listener static void KillSelf(TechnoClass* pThis, AutoDeathBehavior deathOption, const std::vector& pVanishAnimation, bool isInLimbo = false); static void ObjectKilledBy(TechnoClass* pThis, TechnoClass* pKiller); static void UpdateSharedAmmo(TechnoClass* pThis); + static bool HasAdditionalAbility(TechnoClass* pThis, AdditionalAbility ability); static double GetCurrentSpeedMultiplier(FootClass* pThis); static double GetCurrentFirepowerMultiplier(TechnoClass* pThis); static double GetCurrentArmorMultiplier(TechnoClass* pThis, TechnoTypeClass* pType, HouseClass* pSourceHouse = nullptr, WarheadTypeClass* pWarhead = nullptr); diff --git a/src/Ext/Techno/Hooks.Firing.cpp b/src/Ext/Techno/Hooks.Firing.cpp index b26dff9a85..f0d08d535d 100644 --- a/src/Ext/Techno/Hooks.Firing.cpp +++ b/src/Ext/Techno/Hooks.Firing.cpp @@ -1147,13 +1147,25 @@ DEFINE_HOOK(0x6FD05E, TechnoClass_RearmDelay_BurstDelays, 0x7) return idxCurrentBurst <= 0 || idxCurrentBurst > 4 ? 0x6FD084 : 0x6FD067; } -// Update ammo rounds +// Update ammo rounds and mark the next reload timer for veterancy scaling. DEFINE_HOOK(0x6FB086, TechnoClass_Reload_ReloadAmount, 0x8) { GET(TechnoClass* const, pThis, ECX); TechnoExt::UpdateSharedAmmo(pThis); + const auto pType = pThis->GetTechnoType(); + + if (pType->Ammo <= 0) + return 0; + + const auto pExt = TechnoExt::Fetch(pThis); + + pExt->PendingReloadVeterancyAdjustment = + (pThis->Ammo == 0 && pType->EmptyReload > 0) + ? PendingReloadVeterancy::EmptyReload + : PendingReloadVeterancy::Reload; + return 0; } diff --git a/src/Ext/TechnoType/Body.cpp b/src/Ext/TechnoType/Body.cpp index 5bc8edfabb..56153db476 100644 --- a/src/Ext/TechnoType/Body.cpp +++ b/src/Ext/TechnoType/Body.cpp @@ -12,6 +12,46 @@ #include +namespace +{ + void ReadAdditionalAbilities( + INI_EX& parser, + const char* section, + const char* key, + std::bitset& result) + { + std::vector values; + + if (!parser.ParseStringList(values, section, key)) + return; + + // When the key is present, fully replace the previous value with this + // list so that map INIs can override rules values. + result.reset(); + + for (const auto& value : values) + { + if (!_stricmp(value.c_str(), "RELOAD")) + { + result.set(static_cast(AdditionalAbility::Reload)); + } + else if (!_stricmp(value.c_str(), "EMPTY_RELOAD")) + { + result.set(static_cast(AdditionalAbility::EmptyReload)); + } + } + } + + void ValidateReloadMultiplier(const char* pSection, const char* pKey, Nullable& value) + { + if (value.isset() && (!std::isfinite(value.Get()) || value.Get() <= 0.0)) + { + Debug::INIParseFailed(pSection, pKey, "", "Expected a finite value greater than 0.0"); + value.Reset(); + } + } +} + bool TechnoTypeExt::SelectWeaponMutex = false; void TechnoTypeExt::ApplyTurretOffset(Matrix3D* mtx, double factor) @@ -1010,6 +1050,15 @@ void TechnoTypeExt::LoadFromINIFile(CCINIClass* const pINI) this->NoReload_UnderEMP.Read(exINI, pSection, "NoReload.UnderEMP"); this->NoReload_Temporal.Read(exINI, pSection, "NoReload.Temporal"); + ReadAdditionalAbilities(exINI, pSection, "VeteranAbilities", this->AdditionalVeteranAbilities); + ReadAdditionalAbilities(exINI, pSection, "EliteAbilities", this->AdditionalEliteAbilities); + + this->VeteranReload.Read(exINI, pSection, "VeteranReload"); + this->VeteranEmptyReload.Read(exINI, pSection, "VeteranEmptyReload"); + + ValidateReloadMultiplier(pSection, "VeteranReload", this->VeteranReload); + ValidateReloadMultiplier(pSection, "VeteranEmptyReload", this->VeteranEmptyReload); + this->Wake.Read(exINI, pSection, "Wake"); this->Wake_Grapple.Read(exINI, pSection, "Wake.Grapple"); this->Wake_Sinking.Read(exINI, pSection, "Wake.Sinking"); @@ -1641,6 +1690,10 @@ void TechnoTypeExt::Serialize(T& Stm) .Process(this->NoRearm_Temporal) .Process(this->NoReload_UnderEMP) .Process(this->NoReload_Temporal) + .Process(this->AdditionalVeteranAbilities) + .Process(this->AdditionalEliteAbilities) + .Process(this->VeteranReload) + .Process(this->VeteranEmptyReload) .Process(this->Wake) .Process(this->Wake_Grapple) diff --git a/src/Ext/TechnoType/Body.h b/src/Ext/TechnoType/Body.h index 5430cf9711..e4ee3ab3e7 100644 --- a/src/Ext/TechnoType/Body.h +++ b/src/Ext/TechnoType/Body.h @@ -294,6 +294,11 @@ class TechnoTypeExt : public ObjectTypeExt Nullable NoReload_UnderEMP; Nullable NoReload_Temporal; + std::bitset AdditionalVeteranAbilities; + std::bitset AdditionalEliteAbilities; + Nullable VeteranReload; + Nullable VeteranEmptyReload; + Nullable Wake; Nullable Wake_Grapple; Nullable Wake_Sinking; @@ -690,6 +695,11 @@ class TechnoTypeExt : public ObjectTypeExt , NoReload_UnderEMP {} , NoReload_Temporal {} + , AdditionalVeteranAbilities {} + , AdditionalEliteAbilities {} + , VeteranReload {} + , VeteranEmptyReload {} + , Wake { } , Wake_Grapple { } , Wake_Sinking { } diff --git a/src/Utilities/Enum.h b/src/Utilities/Enum.h index 9dd1504515..d295ab84e5 100644 --- a/src/Utilities/Enum.h +++ b/src/Utilities/Enum.h @@ -32,6 +32,7 @@ #pragma once +#include #include enum class AttachedAnimFlag @@ -440,3 +441,22 @@ enum class EdgeType : BYTE Closest = 1, Random = 2 }; + +// Phobos extension abilities that augment the vanilla veteran/elite ability +// lists (VeteranAbilities / EliteAbilities). Do not extend the vanilla Ability +// enum, whose storage is a fixed-size AbilitiesStruct. +enum class AdditionalAbility : unsigned char +{ + Reload = 0, + EmptyReload = 1, + Count +}; + +constexpr size_t AdditionalAbilityCount = static_cast(AdditionalAbility::Count); + +enum class PendingReloadVeterancy : unsigned char +{ + None, + Reload, + EmptyReload +}; From a9fa9afc6fd877d6ed378ee81c6057be376ef9d8 Mon Sep 17 00:00:00 2001 From: NukeAtty Date: Tue, 11 Aug 2026 22:46:50 +0800 Subject: [PATCH 2/5] Apply veteran reload scaling synchronously at the reload timer start Rework per review feedback: instead of recording a pending adjustment in the StartReloading hook and scaling ReloadTimer on the next frame's early update, override the reload duration in EAX right before it is written to TimeLeft, the same way the ROF multiplier is applied. Two hooks cover the empty-clip (EmptyReload) and normal (Reload) reload cycles in TechnoClass::StartReloading. --- src/Ext/Techno/Body.Update.cpp | 42 ------------------------- src/Ext/Techno/Body.cpp | 1 - src/Ext/Techno/Body.h | 3 -- src/Ext/Techno/Hooks.Firing.cpp | 56 +++++++++++++++++++++++++++------ src/Utilities/Enum.h | 7 ----- 5 files changed, 47 insertions(+), 62 deletions(-) diff --git a/src/Ext/Techno/Body.Update.cpp b/src/Ext/Techno/Body.Update.cpp index ce9f3886bc..21dbb2e57c 100644 --- a/src/Ext/Techno/Body.Update.cpp +++ b/src/Ext/Techno/Body.Update.cpp @@ -10,14 +10,11 @@ #include #include -#include - // TechnoClass_AI_0x6F9E50 // It's not recommended to do anything more here it could have a better place for performance consideration void TechnoExt::OnEarlyUpdate() { - this->ApplyPendingReloadVeterancy(); this->UpdateShield(); this->UpdateAttachEffects(); this->EatPassengers(); @@ -33,45 +30,6 @@ void TechnoExt::OnEarlyUpdate() this->ApplyInterceptor(); } -void TechnoExt::ApplyPendingReloadVeterancy() -{ - const auto pending = this->PendingReloadVeterancyAdjustment; - - if (pending == PendingReloadVeterancy::None) - return; - - // Clear first so that any early return cannot re-apply the scaling next frame. - this->PendingReloadVeterancyAdjustment = PendingReloadVeterancy::None; - - const auto pThis = this->OwnerObject(); - auto& timer = pThis->ReloadTimer; - - if (!timer.HasStarted() || timer.TimeLeft <= 0) - return; - - const bool isEmptyReload = pending == PendingReloadVeterancy::EmptyReload; - - const auto ability = isEmptyReload - ? AdditionalAbility::EmptyReload - : AdditionalAbility::Reload; - - if (!TechnoExt::HasAdditionalAbility(pThis, ability)) - return; - - const auto pTypeExt = this->TypeExtData; - const auto pRulesExt = RulesExt::Global(); - - const double multiplier = isEmptyReload - ? pTypeExt->VeteranEmptyReload.Get(pRulesExt->VeteranEmptyReload) - : pTypeExt->VeteranReload.Get(pRulesExt->VeteranReload); - - // A non-positive or non-finite multiplier must not create an invalid timer. - if (!std::isfinite(multiplier) || multiplier <= 0.0) - return; - - timer.TimeLeft = Math::max(1, GeneralUtils::SafeMultiply(timer.TimeLeft, multiplier)); -} - void TechnoExt::ApplyInterceptor() { const auto pTypeExt = this->TypeExtData; diff --git a/src/Ext/Techno/Body.cpp b/src/Ext/Techno/Body.cpp index 4dbca5cb67..1615942f11 100644 --- a/src/Ext/Techno/Body.cpp +++ b/src/Ext/Techno/Body.cpp @@ -1186,7 +1186,6 @@ void TechnoExt::Serialize(T& Stm) .Process(this->HasBeenPlacedOnMap) .Process(this->ForceFullRearmDelay) .Process(this->LastRearmWasFullDelay) - .Process(this->PendingReloadVeterancyAdjustment) .Process(this->CanCloakDuringRearm) .Process(this->WHAnimRemainingCreationInterval) .Process(this->LastWeaponType) diff --git a/src/Ext/Techno/Body.h b/src/Ext/Techno/Body.h index 53777177a5..72c255a142 100644 --- a/src/Ext/Techno/Body.h +++ b/src/Ext/Techno/Body.h @@ -45,7 +45,6 @@ class TechnoExt : public RadioExt, public Detach::Listener bool HasBeenPlacedOnMap; // Set to true on first Unlimbo() call. bool ForceFullRearmDelay; bool LastRearmWasFullDelay; - PendingReloadVeterancy PendingReloadVeterancyAdjustment; bool CanCloakDuringRearm; // Current rearm timer was started by DecloakToFire=no weapon. int WHAnimRemainingCreationInterval; WeaponTypeClass* LastWeaponType; @@ -106,7 +105,6 @@ class TechnoExt : public RadioExt, public Detach::Listener , HasBeenPlacedOnMap { false } , ForceFullRearmDelay { false } , LastRearmWasFullDelay { false } - , PendingReloadVeterancyAdjustment { PendingReloadVeterancy::None } , CanCloakDuringRearm { false } , WHAnimRemainingCreationInterval { 0 } , LastWeaponType {} @@ -142,7 +140,6 @@ class TechnoExt : public RadioExt, public Detach::Listener { } void OnEarlyUpdate(); - void ApplyPendingReloadVeterancy(); // the extension state that goes with TechnoClass::Init void InitializeState(TechnoTypeClass* pType = nullptr); diff --git a/src/Ext/Techno/Hooks.Firing.cpp b/src/Ext/Techno/Hooks.Firing.cpp index f0d08d535d..9766e002a1 100644 --- a/src/Ext/Techno/Hooks.Firing.cpp +++ b/src/Ext/Techno/Hooks.Firing.cpp @@ -4,9 +4,13 @@ #include #include #include +#include #include #include #include +#include + +#include #pragma region TechnoClass_SelectWeapon @@ -1147,24 +1151,58 @@ DEFINE_HOOK(0x6FD05E, TechnoClass_RearmDelay_BurstDelays, 0x7) return idxCurrentBurst <= 0 || idxCurrentBurst > 4 ? 0x6FD084 : 0x6FD067; } -// Update ammo rounds and mark the next reload timer for veterancy scaling. +// Update ammo rounds DEFINE_HOOK(0x6FB086, TechnoClass_Reload_ReloadAmount, 0x8) { GET(TechnoClass* const, pThis, ECX); TechnoExt::UpdateSharedAmmo(pThis); - const auto pType = pThis->GetTechnoType(); + return 0; +} - if (pType->Ammo <= 0) - return 0; +namespace +{ + int ScaleReloadDurationForVeterancy(TechnoClass* pThis, int duration, AdditionalAbility ability) + { + if (duration <= 0 || !TechnoExt::HasAdditionalAbility(pThis, ability)) + return duration; + + const auto pTypeExt = TechnoExt::Fetch(pThis)->TypeExtData; + const auto pRulesExt = RulesExt::Global(); - const auto pExt = TechnoExt::Fetch(pThis); + const double multiplier = ability == AdditionalAbility::EmptyReload + ? pTypeExt->VeteranEmptyReload.Get(pRulesExt->VeteranEmptyReload) + : pTypeExt->VeteranReload.Get(pRulesExt->VeteranReload); + + // A non-positive or non-finite multiplier must not create an invalid timer. + if (!std::isfinite(multiplier) || multiplier <= 0.0) + return duration; + + return Math::max(1, GeneralUtils::SafeMultiply(duration, multiplier)); + } +} + +// Scale the reload cycle that uses the EmptyReload duration (clip empty and `EmptyReload` is set). +// Hooked at `add esi, 1FCh` in StartReloading: EAX holds the duration, ESI holds `this`. +DEFINE_HOOK(0x6FB0CF, TechnoClass_StartReloading_EmptyReload_Veterancy, 0x6) +{ + GET(TechnoClass* const, pThis, ESI); + GET(const int, duration, EAX); + + R->EAX(ScaleReloadDurationForVeterancy(pThis, duration, AdditionalAbility::EmptyReload)); + + return 0; +} + +// Scale the normal reload cycle duration for veterancy. The duration in EAX is the final +// value computed by the game, including the ReloadIncrement adjustment. +DEFINE_HOOK(0x6FB14C, TechnoClass_StartReloading_Reload_Veterancy, 0x6) +{ + GET(TechnoClass* const, pThis, ESI); + GET(const int, duration, EAX); - pExt->PendingReloadVeterancyAdjustment = - (pThis->Ammo == 0 && pType->EmptyReload > 0) - ? PendingReloadVeterancy::EmptyReload - : PendingReloadVeterancy::Reload; + R->EAX(ScaleReloadDurationForVeterancy(pThis, duration, AdditionalAbility::Reload)); return 0; } diff --git a/src/Utilities/Enum.h b/src/Utilities/Enum.h index d295ab84e5..98430c02e5 100644 --- a/src/Utilities/Enum.h +++ b/src/Utilities/Enum.h @@ -453,10 +453,3 @@ enum class AdditionalAbility : unsigned char }; constexpr size_t AdditionalAbilityCount = static_cast(AdditionalAbility::Count); - -enum class PendingReloadVeterancy : unsigned char -{ - None, - Reload, - EmptyReload -}; From 32799f1cb9cd61d53d8634775fd47fadca0ba39d Mon Sep 17 00:00:00 2001 From: NukeAtty Date: Tue, 11 Aug 2026 22:48:15 +0800 Subject: [PATCH 3/5] Update CREDITS.po Restore the maintainer's translation fix to the Nuke credits entry. --- docs/locale/zh_CN/LC_MESSAGES/CREDITS.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po b/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po index 9b9c882cbb..094879ceda 100644 --- a/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po +++ b/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po @@ -3179,4 +3179,4 @@ msgid "**obsidianus** - Automatic conversion based on health" msgstr "**obsidianus** - 自动根据血量变形" msgid "**Nuke** - Veteran reload time customization" -msgstr "**Nuke**:自定义老兵与精英装填时间" +msgstr "**Nuke** - 自定义老兵与精英装填时间" From 636862e4ccf27aa13af091628b936c4a571f8f82 Mon Sep 17 00:00:00 2001 From: Coronia <2217891145@qq.com> Date: Thu, 13 Aug 2026 12:07:56 +0800 Subject: [PATCH 4/5] tweaks - make [General] -> VeteranEmptyReload default to [General] -> VeteranReload - remove unnecessary check in ScaleReloadDutationForVeterancy since it's been done in parsing already - misc codestyle things --- docs/New-or-Enhanced-Logics.md | 10 +++---- src/Ext/Rules/Body.cpp | 4 ++- src/Ext/Rules/Body.h | 2 +- src/Ext/Techno/Body.Update.cpp | 1 - src/Ext/Techno/Hooks.Firing.cpp | 24 +++++------------ src/Ext/TechnoType/Body.cpp | 47 +++++++++++++++------------------ 6 files changed, 38 insertions(+), 50 deletions(-) diff --git a/docs/New-or-Enhanced-Logics.md b/docs/New-or-Enhanced-Logics.md index 68ab53daf1..ba677979fc 100644 --- a/docs/New-or-Enhanced-Logics.md +++ b/docs/New-or-Enhanced-Logics.md @@ -2471,13 +2471,13 @@ In `rulesmd.ini`: ```ini [General] VeteranReload=1.0 ; floating point value, multiplier -VeteranEmptyReload=1.0 ; floating point value, multiplier +VeteranEmptyReload= ; floating point value, multiplier, default to [General] -> VeteranReload [SOMETECHNO] ; TechnoType -VeteranReload= ; floating point value, multiplier, defaults to [General] -> VeteranReload -VeteranEmptyReload= ; floating point value, multiplier, defaults to [General] -> VeteranEmptyReload -VeteranAbilities=RELOAD ; Ability, `RELOAD` and `EMPTY_RELOAD` in addition to the vanilla abilities -EliteAbilities=EMPTY_RELOAD ; Ability +VeteranReload= ; floating point value, multiplier, default to [General] -> VeteranReload +VeteranEmptyReload= ; floating point value, multiplier, default to [General] -> VeteranEmptyReload +VeteranAbilities= ; Ability, `RELOAD` and `EMPTY_RELOAD` in addition to the vanilla abilities +EliteAbilities= ; Ability, `RELOAD` and `EMPTY_RELOAD` in addition to the vanilla abilities ``` ### Weapons fired on warping in / out diff --git a/src/Ext/Rules/Body.cpp b/src/Ext/Rules/Body.cpp index 96fdc329e8..37bc4860aa 100644 --- a/src/Ext/Rules/Body.cpp +++ b/src/Ext/Rules/Body.cpp @@ -368,7 +368,9 @@ void RulesExt::ExtData::LoadBeforeTypeData(RulesClass* pThis, CCINIClass* pINI) }; validateReloadMultiplier("VeteranReload", this->VeteranReload); - validateReloadMultiplier("VeteranEmptyReload", this->VeteranEmptyReload); + + if (this->VeteranEmptyReload.isset()) + validateReloadMultiplier("VeteranEmptyReload", this->VeteranEmptyReload); this->NoTurret_TrackTarget.Read(exINI, GameStrings::General, "NoTurret.TrackTarget"); diff --git a/src/Ext/Rules/Body.h b/src/Ext/Rules/Body.h index 0308dd182a..2d6153da26 100644 --- a/src/Ext/Rules/Body.h +++ b/src/Ext/Rules/Body.h @@ -289,7 +289,7 @@ class RulesExt Valueable NoReload_UnderEMP; Valueable NoReload_Temporal; Valueable VeteranReload; - Valueable VeteranEmptyReload; + Nullable VeteranEmptyReload; Valueable NoTurret_TrackTarget; Valueable GatherWhenMCVDeploy; diff --git a/src/Ext/Techno/Body.Update.cpp b/src/Ext/Techno/Body.Update.cpp index 21dbb2e57c..c0f3f68e66 100644 --- a/src/Ext/Techno/Body.Update.cpp +++ b/src/Ext/Techno/Body.Update.cpp @@ -7,7 +7,6 @@ #include #include #include -#include #include diff --git a/src/Ext/Techno/Hooks.Firing.cpp b/src/Ext/Techno/Hooks.Firing.cpp index 9766e002a1..bf27eaaa3d 100644 --- a/src/Ext/Techno/Hooks.Firing.cpp +++ b/src/Ext/Techno/Hooks.Firing.cpp @@ -1161,26 +1161,16 @@ DEFINE_HOOK(0x6FB086, TechnoClass_Reload_ReloadAmount, 0x8) return 0; } -namespace +static inline int ScaleReloadDurationForVeterancy(TechnoClass* pThis, int duration, AdditionalAbility ability) { - int ScaleReloadDurationForVeterancy(TechnoClass* pThis, int duration, AdditionalAbility ability) - { - if (duration <= 0 || !TechnoExt::HasAdditionalAbility(pThis, ability)) - return duration; - - const auto pTypeExt = TechnoExt::Fetch(pThis)->TypeExtData; - const auto pRulesExt = RulesExt::Global(); + if (duration <= 0 || !TechnoExt::HasAdditionalAbility(pThis, ability)) + return duration; - const double multiplier = ability == AdditionalAbility::EmptyReload - ? pTypeExt->VeteranEmptyReload.Get(pRulesExt->VeteranEmptyReload) - : pTypeExt->VeteranReload.Get(pRulesExt->VeteranReload); + const double multiplier = ability == AdditionalAbility::EmptyReload + ? TechnoExt::Fetch(pThis)->TypeExtData->VeteranEmptyReload.Get(RulesExt::Global()->VeteranEmptyReload.Get(RulesExt::Global()->VeteranReload)) + : TechnoExt::Fetch(pThis)->TypeExtData->VeteranReload.Get(RulesExt::Global()->VeteranReload); - // A non-positive or non-finite multiplier must not create an invalid timer. - if (!std::isfinite(multiplier) || multiplier <= 0.0) - return duration; - - return Math::max(1, GeneralUtils::SafeMultiply(duration, multiplier)); - } + return Math::max(1, GeneralUtils::SafeMultiply(duration, multiplier)); } // Scale the reload cycle that uses the EmptyReload duration (clip empty and `EmptyReload` is set). diff --git a/src/Ext/TechnoType/Body.cpp b/src/Ext/TechnoType/Body.cpp index 56153db476..f6ac3f6562 100644 --- a/src/Ext/TechnoType/Body.cpp +++ b/src/Ext/TechnoType/Body.cpp @@ -12,43 +12,40 @@ #include -namespace -{ - void ReadAdditionalAbilities( +void ReadAdditionalAbilities( INI_EX& parser, const char* section, const char* key, std::bitset& result) - { - std::vector values; +{ + std::vector values; - if (!parser.ParseStringList(values, section, key)) - return; + if (!parser.ParseStringList(values, section, key)) + return; - // When the key is present, fully replace the previous value with this - // list so that map INIs can override rules values. - result.reset(); + // When the key is present, fully replace the previous value with this + // list so that map INIs can override rules values. + result.reset(); - for (const auto& value : values) + for (const auto& value : values) + { + if (!_stricmp(value.c_str(), "RELOAD")) { - if (!_stricmp(value.c_str(), "RELOAD")) - { - result.set(static_cast(AdditionalAbility::Reload)); - } - else if (!_stricmp(value.c_str(), "EMPTY_RELOAD")) - { - result.set(static_cast(AdditionalAbility::EmptyReload)); - } + result.set(static_cast(AdditionalAbility::Reload)); + } + else if (!_stricmp(value.c_str(), "EMPTY_RELOAD")) + { + result.set(static_cast(AdditionalAbility::EmptyReload)); } } +} - void ValidateReloadMultiplier(const char* pSection, const char* pKey, Nullable& value) +void ValidateReloadMultiplier(const char* pSection, const char* pKey, Nullable& value) +{ + if (value.isset() && (!std::isfinite(value.Get()) || value.Get() <= 0.0)) { - if (value.isset() && (!std::isfinite(value.Get()) || value.Get() <= 0.0)) - { - Debug::INIParseFailed(pSection, pKey, "", "Expected a finite value greater than 0.0"); - value.Reset(); - } + Debug::INIParseFailed(pSection, pKey, "", "Expected a finite value greater than 0.0"); + value.Reset(); } } From 64227606ad5aeab08b6f0fc73c13bc82bc249e0a Mon Sep 17 00:00:00 2001 From: Coronia <2217891145@qq.com> Date: Thu, 13 Aug 2026 17:54:24 +0800 Subject: [PATCH 5/5] fix default value --- src/Ext/Rules/Body.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ext/Rules/Body.h b/src/Ext/Rules/Body.h index 2d6153da26..58db82c07f 100644 --- a/src/Ext/Rules/Body.h +++ b/src/Ext/Rules/Body.h @@ -774,7 +774,7 @@ class RulesExt , NoReload_UnderEMP { false } , NoReload_Temporal { false } , VeteranReload { 1.0 } - , VeteranEmptyReload { 1.0 } + , VeteranEmptyReload {} , NoTurret_TrackTarget { false } , GatherWhenMCVDeploy { true } , AIFireSale { true }