diff --git a/AI_HANDOFF.md b/AI_HANDOFF.md
index 961830a..f7c80b2 100644
--- a/AI_HANDOFF.md
+++ b/AI_HANDOFF.md
@@ -1,5 +1,9 @@
# AI Handoff - Live Project Memory
+## RangedRange3x projectile reach fix (2026-07-13)
+
+- User report: with 3× range enabled, bows "根本射不到" even stationary enemies that vanilla hits directly. Root cause is ballistic, not the earlier sight/targeting gap (already fixed): horizontal arrival time is the distance-independent constant `4/ISF` (≈2.67 s) while vertical flight-to-ground is `2·emit/ysub` (bows ≈2.47 s), so every arrow lands at ~92.5 % of the target distance — a **fixed ~7.5 %-of-distance short fall**. Vanilla range keeps that within `w_drad` (arrows 45); tripling the range triples the absolute shortfall to far beyond `w_drad`, so arrows land in front of the target. Per user direction the damage radius (`w_drad`) was **not** touched. Fix: `EparaPatcher` now raises `cl_epara.ini [ProjectileInitSpeedFactor]` by `InitSpeedReachMultiplier` = 1.08 (1.5 → 1.62) whenever `RangedRange3x` is enabled, so `4/ISF` drops to ≈ the bow flight time and the land-short fraction → ≈0 at every distance (also reduces mover dodge via shorter flight time). Wired through `PatchEngine` (`EparaPatcher.GetPatchedBytes` now takes `rangedRange3x`); detection is unaffected (RangedRange3x is detected from objdef range columns, epara is not read back, same as the existing variance patch). The 1.08 factor is static-derived from the reverse-engineered estimates (like `ArcEmitMultiplier`) and needs in-game calibration — the 3× hit window is tight (ISF within ~±0.05 of 1.62 for bows). Added `Epara_range3x_raises_init_speed_...` round-trip test and updated the existing epara test's signature. See `docs/reverse-engineering/projectile-ballistics.md`. NOT YET locally built (no .NET SDK in this session) — CI/dev build to confirm.
+
## Custom Troop Balance Table (2026-07-13)
- Corrected the custom-troop balance-table task after a scope clarification: `TroopConfig.BalancedUnitStats` is the fixed in-modifier source of the selectable `修改器內建平衡` / `Balanced Base Stats` template and the Balance feature. All 42 entries now contain the original-game values extracted once from the repository's read-only original `SYSTEM/DATA_MP/DEFAULTS/objdef.dau` through the production `BackupManager.GetOriginalStats` field mapping. The template remains a static table at runtime; it does not read the selected game directory. The preset editor and localization source paths were restored to `GetDefaultBalancedStats` / their original labels. Because applying the stock table alone no longer changes `objdef.dau`, detection correctly leaves `Balance` false unless custom values produce a difference; affected characterization/integration assertions were updated. Verification: Release build 0 warnings/errors; xUnit 122/122 passed.
diff --git a/docs/reverse-engineering/projectile-ballistics.md b/docs/reverse-engineering/projectile-ballistics.md
index 839a1c1..97c2b13 100644
--- a/docs/reverse-engineering/projectile-ballistics.md
+++ b/docs/reverse-engineering/projectile-ballistics.md
@@ -127,3 +127,23 @@ Let `k` = arc-raise factor, `s` = speed-up factor:
Do not scale `w_emit` when scaling ranges — it is not a range field; scaling
it alone shifts every landing point long (overshoot).
+
+## Range 3× interaction: arrows land short (fixed 2026-07-13)
+
+The land-short margin is a **fixed fraction of shot distance**, not an absolute
+value: on flat ground the arrow returns to launch height at `T_fall = 2·emit/ysub`
+(bows ≈ 2.47 s) while the horizontal arrival time is the distance-independent
+constant `T_arrival = 4/ISF` (≈ 2.67 s at ISF 1.5), so every shot lands at
+`T_fall/T_arrival ≈ 92.5 %` of the target distance — a ~7.5 %-of-distance short
+fall. At vanilla range that ~7.5 % is roughly within `w_drad` (arrows 45) so
+stationary targets are hit; with **`RangedRange3x`** the shortfall triples to far
+beyond `w_drad`, so arrows physically land in front of the enemy ("根本射不到").
+
+Fix (`EparaPatcher.InitSpeedReachMultiplier`, tied to `RangedRange3x`): raise ISF
+so `T_arrival = 4/ISF` drops to the bow flight time `2·emit/ysub ≈ 2.47 s`, which
+needs ISF ≈ 1.618; shipped multiplier is 1.08 (1.5 → 1.62). This drives the
+land-short fraction to ≈ 0 at **every** distance, so projectiles reach the tripled
+range. It also shortens flight time, reducing dodge on movers. The exact factor is
+static-derived from the estimates above (like `ArcEmitMultiplier`) and still needs
+in-game calibration — the hit window at 3× range is tight (`|1 − T_fall·ISF/4| ·
+range_3x < w_drad`, i.e. ISF within roughly ±0.05 of 1.62 for bows).
diff --git a/src/Core/Patches/EparaPatcher.cs b/src/Core/Patches/EparaPatcher.cs
index 18d92f1..615cf0a 100644
--- a/src/Core/Patches/EparaPatcher.cs
+++ b/src/Core/Patches/EparaPatcher.cs
@@ -5,33 +5,51 @@ namespace AgainstRomeModifier.Core.Patches;
///
/// cl_epara.ini 補丁:拋射物瞄準參數。
/// [ProjectileVarianceOnMove] 是對移動目標預判提前量的隨機散布倍率(0.5 = 最多 0.5×3×移動速度的偏差);
-/// 遠程命中強化將其設為 0.0,預判完全精準。詳見 docs/reverse-engineering/projectile-ballistics.md。
+/// 遠程命中強化將其設為 0.0,預判完全精準。
+/// [ProjectileInitSpeedFactor](ISF)是拋射物水平初速倍率,水平抵達時間為常數 4/ISF 秒(原版 1.5 → 2.67 秒)。
+/// 拋射物的垂直飛行時間為 2×emit/ysub(弓箭約 2.47 秒),與水平抵達時間無關,因此每箭都固定落在目標距離的
+/// T_fall/T_arrival ≈ 92.5%,形成「射短 ≈ 7.5%×距離」的系統性偏差。原版射程下 7.5% 尚在傷害半徑內,
+/// 但開啟三倍射程後射短距離同步三倍、遠超傷害半徑,弓箭因此「根本射不到」站著不動的敵人。
+/// RangedRange3x 啟用時將 ISF 乘以 (1.5 → 1.62),使水平抵達時間
+/// 4/ISF ≈ 2.47 秒對齊垂直飛行時間,射短比例趨近 0,拋射物即可覆蓋放大後的射程。
+/// 詳見 docs/reverse-engineering/projectile-ballistics.md。
///
public static class EparaPatcher {
internal const string VarianceSection = "[ProjectileVarianceOnMove]";
internal const string AccuracyVarianceValue = "0.0";
+ internal const string InitSpeedSection = "[ProjectileInitSpeedFactor]";
+ /// 三倍射程時的 ISF 放大倍率。1.5×1.08=1.62;由 4/ISF = 2×emit/ysub(弓箭 2×110/89≈2.47 秒)解得
+ /// ISF≈1.618,取 1.08 使射短比例趨近 0。與 ArcEmitMultiplier 相同屬靜態逆向推導,正式倍率仍待實機校準。
+ internal const double InitSpeedReachMultiplier = 1.08;
- public static byte[] GetPatchedBytes(byte[] original, bool rangedAccuracy) {
+ public static byte[] GetPatchedBytes(byte[] original, bool rangedAccuracy, bool rangedRange3x) {
ArgumentNullException.ThrowIfNull(original);
- if (!rangedAccuracy) return original;
+ if (!rangedAccuracy && !rangedRange3x) return original;
var (text, lineEnding, lines) = PatchText.Read(original);
- bool patched = false;
- for (int i = 0; i < lines.Length && !patched; i++) {
- if (!lines[i].Trim().Equals(VarianceSection, StringComparison.OrdinalIgnoreCase)) continue;
+ if (rangedAccuracy)
+ PatchSectionValue(lines, VarianceSection, _ => AccuracyVarianceValue);
+ if (rangedRange3x)
+ PatchSectionValue(lines, InitSpeedSection,
+ value => (value * InitSpeedReachMultiplier).ToString("0.###", CultureInfo.InvariantCulture));
+ return PatchText.Write(text, lineEnding, lines, original);
+ }
+
+ /// 找到區段後的第一個值行,以 的結果取代原值(沿用原行的前後文字)。
+ /// 找不到區段或值行、或值行無法解析為數字時擲出,中止整次套用。
+ private static void PatchSectionValue(string[] lines, string section, Func transform) {
+ for (int i = 0; i < lines.Length; i++) {
+ if (!lines[i].Trim().Equals(section, StringComparison.OrdinalIgnoreCase)) continue;
for (int j = i + 1; j < lines.Length; j++) {
string trimmed = lines[j].Trim();
if (trimmed.Length == 0 || trimmed.StartsWith(";", StringComparison.Ordinal)) continue;
if (trimmed.StartsWith("[", StringComparison.Ordinal)) break; // 區段內沒有值行
- if (!double.TryParse(trimmed, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
- throw new InvalidDataException($"cl_epara.ini {VarianceSection} 的值行無法解析: '{trimmed}',已取消整次套用。");
- lines[j] = lines[j].Replace(trimmed, AccuracyVarianceValue);
- patched = true;
- break;
+ if (!double.TryParse(trimmed, NumberStyles.Any, CultureInfo.InvariantCulture, out double value))
+ throw new InvalidDataException($"cl_epara.ini {section} 的值行無法解析: '{trimmed}',已取消整次套用。");
+ lines[j] = lines[j].Replace(trimmed, transform(value));
+ return;
}
}
- if (!patched)
- throw new InvalidDataException($"cl_epara.ini 找不到 {VarianceSection} 區段的值行,已取消整次套用。");
- return PatchText.Write(text, lineEnding, lines, original);
+ throw new InvalidDataException($"cl_epara.ini 找不到 {section} 區段的值行,已取消整次套用。");
}
}
diff --git a/src/Core/Services/PatchEngine.cs b/src/Core/Services/PatchEngine.cs
index 183678d..6d66b24 100644
--- a/src/Core/Services/PatchEngine.cs
+++ b/src/Core/Services/PatchEngine.cs
@@ -104,9 +104,10 @@ public void ApplyPatches(string gamePath, PatchProfile profile, BackupManager ba
byte[] clBytes = IniFeaturePatcher.BuildClScript(backupManager, options);
patchedFiles[Path.Combine(gamePath, @"SYSTEM\cl_script.ini")] = clBytes;
- // C. cl_epara.ini — 遠程命中強化(拋射預判散布歸零);未啟用時還原為原版備份
+ // C. cl_epara.ini — 遠程命中強化(拋射預判散布歸零)+三倍射程時提高拋射初速讓箭矢能命中放大後的射程;
+ // 兩者皆未啟用時還原為原版備份
patchedFiles[Path.Combine(gamePath, @"SYSTEM\cl_epara.ini")] =
- EparaPatcher.GetPatchedBytes(backupManager.GetBackupBytes("SYSTEM/cl_epara.ini"), options.RangedAccuracy);
+ EparaPatcher.GetPatchedBytes(backupManager.GetBackupBytes("SYSTEM/cl_epara.ini"), options.RangedAccuracy, options.RangedRange3x);
// C2. partgeo.dau — 拋射彈道增高(重力 ysub 與 objdef w*_emit 同倍率);未啟用時還原為原版備份。
// 舊備份可能沒有 partgeo.dau(不在內嵌 Backup.zip、且自動補齊失敗時):
diff --git a/tests/AgainstRomeModifier.Tests/PatcherRoundTripTests.cs b/tests/AgainstRomeModifier.Tests/PatcherRoundTripTests.cs
index 6720288..f6e6ce6 100644
--- a/tests/AgainstRomeModifier.Tests/PatcherRoundTripTests.cs
+++ b/tests/AgainstRomeModifier.Tests/PatcherRoundTripTests.cs
@@ -221,11 +221,28 @@ public void Epara_accuracy_patch_zeros_variance_on_move_and_restores() {
const string text = ";comment\r\n[ProjectileInitSpeedFactor]\r\n1.5\r\n\r\n;lead scatter\r\n[ProjectileVarianceOnMove]\r\n0.5\r\n\r\n[ProjectileVarianceMaximumAngle]\r\n45\r\n";
byte[] original = SyntheticFixture.Pfil(text);
- string patched = SyntheticFixture.Text(EparaPatcher.GetPatchedBytes(original, rangedAccuracy: true));
+ string patched = SyntheticFixture.Text(EparaPatcher.GetPatchedBytes(original, rangedAccuracy: true, rangedRange3x: false));
Assert.Contains("[ProjectileVarianceOnMove]\r\n0.0\r\n", patched);
- Assert.Contains("[ProjectileInitSpeedFactor]\r\n1.5\r\n", patched); // 其他區段不可動
+ Assert.Contains("[ProjectileInitSpeedFactor]\r\n1.5\r\n", patched); // 未開三倍射程時初速不動
Assert.Contains("[ProjectileVarianceMaximumAngle]\r\n45\r\n", patched);
- Assert.Equal(original, EparaPatcher.GetPatchedBytes(original, rangedAccuracy: false));
+ Assert.Equal(original, EparaPatcher.GetPatchedBytes(original, rangedAccuracy: false, rangedRange3x: false));
+ }
+
+ [Fact]
+ public void Epara_range3x_raises_init_speed_so_projectiles_reach_extended_range() {
+ const string text = ";comment\r\n[ProjectileInitSpeedFactor]\r\n1.5\r\n\r\n;lead scatter\r\n[ProjectileVarianceOnMove]\r\n0.5\r\n\r\n[ProjectileVarianceMaximumAngle]\r\n45\r\n";
+ byte[] original = SyntheticFixture.Pfil(text);
+
+ // 只開三倍射程:拉高初速讓箭矢能命中放大後的射程,散布不動
+ string range3xOnly = SyntheticFixture.Text(EparaPatcher.GetPatchedBytes(original, rangedAccuracy: false, rangedRange3x: true));
+ Assert.Contains("[ProjectileInitSpeedFactor]\r\n1.62\r\n", range3xOnly);
+ Assert.Contains("[ProjectileVarianceOnMove]\r\n0.5\r\n", range3xOnly); // 未開命中強化時散布不動
+ Assert.Contains("[ProjectileVarianceMaximumAngle]\r\n45\r\n", range3xOnly);
+
+ // 三倍射程+命中強化:兩個區段都改
+ string both = SyntheticFixture.Text(EparaPatcher.GetPatchedBytes(original, rangedAccuracy: true, rangedRange3x: true));
+ Assert.Contains("[ProjectileInitSpeedFactor]\r\n1.62\r\n", both);
+ Assert.Contains("[ProjectileVarianceOnMove]\r\n0.0\r\n", both);
}
[Fact]