Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AI_HANDOFF.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
20 changes: 20 additions & 0 deletions docs/reverse-engineering/projectile-ballistics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
46 changes: 32 additions & 14 deletions src/Core/Patches/EparaPatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,51 @@ namespace AgainstRomeModifier.Core.Patches;
/// <summary>
/// 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 乘以 <see cref="InitSpeedReachMultiplier"/>(1.5 → 1.62),使水平抵達時間
/// 4/ISF ≈ 2.47 秒對齊垂直飛行時間,射短比例趨近 0,拋射物即可覆蓋放大後的射程。
/// 詳見 docs/reverse-engineering/projectile-ballistics.md。
/// </summary>
public static class EparaPatcher {
internal const string VarianceSection = "[ProjectileVarianceOnMove]";
internal const string AccuracyVarianceValue = "0.0";
internal const string InitSpeedSection = "[ProjectileInitSpeedFactor]";
/// <summary>三倍射程時的 ISF 放大倍率。1.5×1.08=1.62;由 4/ISF = 2×emit/ysub(弓箭 2×110/89≈2.47 秒)解得
/// ISF≈1.618,取 1.08 使射短比例趨近 0。與 ArcEmitMultiplier 相同屬靜態逆向推導,正式倍率仍待實機校準。</summary>
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);
}

/// <summary>找到區段後的第一個值行,以 <paramref name="transform"/> 的結果取代原值(沿用原行的前後文字)。
/// 找不到區段或值行、或值行無法解析為數字時擲出,中止整次套用。</summary>
private static void PatchSectionValue(string[] lines, string section, Func<double, string> 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} 區段的值行,已取消整次套用。");
}
}
5 changes: 3 additions & 2 deletions src/Core/Services/PatchEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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、且自動補齊失敗時):
Expand Down
23 changes: 20 additions & 3 deletions tests/AgainstRomeModifier.Tests/PatcherRoundTripTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading