diff --git a/AI_HANDOFF.md b/AI_HANDOFF.md index ee18bac..3db8c68 100644 --- a/AI_HANDOFF.md +++ b/AI_HANDOFF.md @@ -1,5 +1,32 @@ # AI Handoff - Live Project Memory +## Decoupling code-review fixes (2026-07-15) + +- Reviewed the full `4acd542` decoupling commit line-by-line on `claude/decoupled-code-review-3ly42q` (fast-forwarded from `codex/decouple-core-and-file-workflows`) and applied the surviving findings: `TroopPresetCodec.Write` now uses invariant shortest round-trip formatting instead of `0.##`, so exported presets keep more than two decimals (regression added to `TroopPresetCodecTests`); the `TroopPresetForm` export loop now checks every `TryReadValidatedCell` result like the apply path instead of discarding them; `SaveBackupService.RestoreBackup` no longer embeds a Chinese prefix in the temp-cleanup warning (the UI's `LogRestoreBackupCleanupFailed` prefix was being doubled) and restored the pre-decoupling double-failure semantics — a rollback failure is reported through a new optional `onRollbackFailure` callback (UI logs the legacy `還原存檔失敗後回復原存檔也失敗:` message) while the original restore exception is rethrown instead of an `AggregateException`; `ModifierForm.Data.cs` builds `BuildCurrentPatchProfile` once per default-stats reload rather than once per unit row. +- Intentionally NOT changed from the review: `CleanEparaBaseline` trailing-whitespace trimming (comment-only, epara is never read back for detection, and restoring it would reintroduce `git diff --check` noise) and `TeamDatFeatureDetector` keeping partial evidence plus always running the RomanEndless EXE reconciliation on exceptions (both are improvements over the monolith). +- Verification status: this session has no local .NET SDK; `git diff --check` is clean and the changes are syntax-reviewed only. Build and full xUnit must be confirmed by CI on the pushed branch before claiming green. + +## Typed feature catalog decoupling (2026-07-15) + +- Completed the recommended UI/file-boundary follow-up. `SaveBackupService`/manifest models now own save scanning, preview reads, ZIP creation, transactional restore, cache invalidation, and deletes; `ModifierForm.SaveManager.cs` dropped from 617 to 247 lines and retains only DataGridView/dialog/log coordination. `TroopPresetCodec` owns 4/6/9-column compatibility parsing and six editable-field serialization, while `UnitStatsEditorService` removes `TroopPresetForm`'s direct dependency on `ModifierForm` and centralizes independent-field normalization. Temporary-directory tests cover create/scan/preview/restore/delete, legacy manifest-less ZIP names, unsafe names, and rejection of a ZIP without `save.ini` while preserving the existing save; codec tests cover all supported row shapes and round trip. A pre-commit focused run exposed a hidden test-order dependency: several core readers called `Encoding.GetEncoding(1251)` before `Program` or another test registered the code-page provider. Core readers now consistently use `PatchText.GameEncoding`, whose static initializer registers the provider; the formerly isolated failure now passes 5/5. Full isolated xUnit passed 186/186; isolated Release build completed with 0 warnings/errors; `git diff --check` is clean; handoff remains UTF-8 BOM. The first normal Release-path command and a `UseAppHost=false` retry were blocked by manifest-elevated smoke PID 35644 locking the EXE/DLL, so build/test validation used SDK `--artifacts-path` under `%TEMP%`; do not mistake this lock for a code regression. No new UI smoke was launched because the preceding elevated smoke remains visible and cannot be terminated from this lower-integrity terminal. +- Completed the final identified decoupling pass. `BackupManager.BackupFiles` is now a `ReadOnlyDictionary` view; mutation remains behind the internal cache-invalidating `SetBackupFile` boundary used by focused tests. Added `UnitStatsProjectionService`, so the default-stats UI no longer duplicates balance/custom/independent modifier composition, and centralized UI-to-`PatchProfile` construction for Apply and preview. Replaced `PatchFileComposer` target knowledge with eight `IPatchFileContributor` implementations (EXE, cl_script, epara, partgeo, scint, ress, objdef, team.dat) plus an injectable contributor regression. The first full test run compiled and passed 178/179; the new projection test used the nonexistent key `FigKelPri00_Druide`, so the expected priest range was 30000 while projection correctly returned unknown-unit baseline 0. Correcting it to `FigKelPri00_Priester` produced full xUnit 179/179. Final serial Release build completed with 0 warnings/errors and `git diff --check` is clean. UI smoke rendered the full main window and remained responsive; cleanup via `Stop-Process`/`taskkill` was denied because the manifest-elevated process outranks the current terminal, and injected Alt+F4/click input could not cross that elevation boundary. PID 35644 remains open and must be closed manually from its visible window. Final core sizes: `FeatureDetector` 44 lines, `PatchEngine` 138, `BackupManager` 184, and `PatchFileComposer` 28; remaining `ModifierForm.Data.cs` code is WinForms grid/file display orchestration rather than duplicated patch-domain policy. +- Began the next behavior-preserving decoupling pass. Added typed `FeatureKey` declarations as the single source of feature IDs/default values; `PatchProfile` named properties and the WinForms toggle map now consume those keys instead of repeating string literals. `FeatureRegistry` distinguishes ordinary toggles from specialized controls and validates the complete UI toggle map at startup, so a newly registered toggle cannot be silently omitted. Registry tests cover declared-key registration, typed defaults/round trips, and missing/unexpected UI mappings. Verification: focused `FeatureRegistryTests` 5/5 passed; Release build 0 warnings/errors; full xUnit 163/163 passed. +- Extracted EXE-backed detection from the monolithic `FeatureDetector` into `Features/Exe/ExeFeatureDetector`. It owns EXE reading and detection for focus-loss, village range, spell altar, game speed, civilian batch production, and unit recruitment, while returning the RomanEndless EXE state to the coordinator for the existing EXE/team.dat mismatch policy. A synthetic-EXE regression covers every extracted field. Focused detector/integration/characterization verification: 10/10 passed. Final verification for the combined decoupling pass: serial Release build 0 warnings/errors, full xUnit 164/164 passed, hidden startup smoke remained alive after 3 seconds, and `git diff --check` clean. A preceding parallel build/test run produced one transient `MSB3026` DLL-lock retry; the serial rerun was clean. +- Extracted all `cl_script.ini` detection (civilian delay, morale, spell damage/healing/resurrection/radius, and general skills) plus its private regex/scaling helpers into `Features/Ini/ClScriptFeatureDetector`; the coordinator now delegates to it, and the focused test adds `SpellRange3x` apply/detect coverage. The new detector uses only `BackupManager.HasFile/GetBackupBytes` for its baseline dependency. The machine SDK briefly disappeared during this work, then was restored as .NET SDK 10.0.302. Final verification after restoration: focused cl-script/integration/characterization tests 10/10, full xUnit 164/164, and serial Release build 0 warnings/errors. +- Extracted `ress.ini` detection into `Features/Ini/RessFeatureDetector`, covering FreeProduction, FreeUpgrade, and NoSpellCost through the same CSV index ranges used previously. Its first focused run failed only in the new synthetic fixture: the `Ger_Kampf` row had 71 columns while `VolkresIndex.UnitUpgradeStart` begins at 264, so no upgrade fields existed to patch/detect. After correcting the fixture to cover the real index range, focused detector/integration/characterization tests passed 10/10. Final verification: full xUnit 165/165 and serial Release build 0 warnings/errors. +- Extracted the complete `objdef.dau` detection body and its dedicated parsing/rule helpers into `Features/Objdef/ObjdefFeatureDetector`. This preserves the existing ordering for capacity/build/HP/projectile checks, Balance false-positive exclusions, LeaderGlory, speed/range, and priest casting distance. The detector now accesses its baseline through `BackupManager.HasFile/GetBackupBytes`. A direct composer-output regression covers every objdef-backed feature and Balance exclusion. Focused detector/integration/characterization verification: 10/10 passed. +- Extracted team.dat detection into `Features/Map/TeamDatFeatureDetector`, which owns per-file enumeration, four-state reconstruction, unknown-state conservative fallback, and aggregate MaxPopulation/RomanPlayer evidence; the coordinator retains the cross-source RomanEndless EXE/team.dat reconciliation policy. The first focused command stopped at compile because removing the old helper also removed the still-needed `AgainstRomeModifier.Core.Patches` using for `ExeRomanEndlessPatchState` (`CS0103`); after restoring the using, focused team/RomanEndless/objdef/integration tests passed 19/19. Final combined verification: `FeatureDetector` is 79 lines, full xUnit 166/166 passed, and serial Release build completed with 0 warnings/errors. +- Completed detector responsibility extraction: `Features/Bci/BciFeatureDetector` now owns Endless AI module and FoodHealing detection, while `Features/Install/InstallFeatureDetector` owns LanguagePack and DgVoodoo state detection. `FeatureDetector` is now a 44-line target-detector orchestrator plus the intentional RomanEndless EXE/team.dat reconciliation policy; formatting was normalized after the extraction. Focused BCI/install/integration/characterization verification: 14/14 passed. Final verification: full xUnit 166/166 and serial Release build 0 warnings/errors. +- Completed the first Apply-side extraction: `Services/PatchFileComposer` now composes the ordinary EXE/INI/DAU/team.dat target bytes as a dry-run `PatchFilePlan`, while `PatchEngine` retains transactional writes, shared BCI flush, language-pack, dgVoodoo, and restore coordination. The first focused run passed 9/10; the new direct composer test alone failed because it enabled `ProjectileArcHeight` against `BackupZipGameFixture`, whose baseline intentionally lacks `partgeo.dau`, correctly triggering the existing safety exception. After restricting the test to files present in that fixture, composer/Apply/characterization tests passed 10/10 and confirmed composition does not mutate the fixture before transaction commit. Final verification: full xUnit 167/167 and serial Release build 0 warnings/errors. +- Extracted category restore rules into `Services/PatchRestoreService`. It now owns Stats/Compat/Language restoration, EXE preservation rules, original-memory file restoration, team.dat population reset, BCI restore/flush coordination, and install-feature removal. `PatchEngine` keeps its public restore facade and Apply ordering and is reduced to 138 lines. Focused composer/Apply/restore/BCI-cache verification passed 11/11; final verification passed full xUnit 167/167 and serial Release build with 0 warnings/errors. +- Completed the first `BackupManager` decomposition slice with `Services/UnitBaselineCatalog` and `UnitStatParser`. The catalog owns the cached `objdef.dau` unit-row projection, original/balanced/custom baseline selection, and cache invalidation; the parser owns pure weapon damage/reload/range parsing and custom-layer merging. `BackupManager` keeps compatibility facade methods and dropped from 864 to 651 lines. A direct regression confirms `Clear()` invalidates previously parsed unit rows/stats; focused unit/objdef/Apply tests passed 12/12. Final verification: full xUnit 168/168 and serial Release build 0 warnings/errors. +- Extracted ZIP source selection/parsing into `Services/BackupSourceLoader` and required-resource rules into `BackupValidator`. Embedded ZIP still takes precedence over local `Backup.zip`, unsafe rooted/parent-segment entries remain rejected, and `partgeo.dau` intentionally remains outside the general required-resource list. `BackupManager` is reduced to 581 lines. Focused ZIP safety/validator/physical-backup/baseline/Apply tests passed 8/8. Final verification: full xUnit 170/170 and serial Release build 0 warnings/errors. +- Extracted physical `.bak` creation/loading into `Services/GameDirectoryBackupLoader` and EXE/objdef/partgeo baseline-signature checks into `OriginalFileValidator`. The loader retains existing-file `.bak` precedence, first-run `.bak` creation, custom-map exclusion, clean `cl_epara.ini` injection, fail-closed dirty-file handling, and `showError` behavior. `BackupManager` is reduced from 581 to 391 lines. +- A new invalid-payload validator regression initially passed 8/9: `IsObjdefOriginal([1,2,3,4])` returned true because the legacy algorithm treated a successfully decompressed payload with no verified leader row as original. This real fail-open gap is fixed: objdef now requires an explicitly parsed `FigRomAnf00_Anfuehrer` HP value of 400. EXE and partgeo already failed closed. Invalid-payload and known-good baseline regressions now cover both directions; focused physical-backup/validator/Apply tests passed 10/10. Final verification: full xUnit 172/172 and serial Release build 0 warnings/errors. +- Extracted missing-entry recovery into `Services/BackupAutoHealer` and the canonical `cl_epara.ini` text/PFIL generation into `CleanEparaBaseline`. The healer preserves `.bak` preference, partgeo validation/backup, custom-map exclusion, and localized logging, and now reports mutations so `BackupManager` can invalidate its unit-row cache after public auto-heal retries. After removing obsolete imports, `BackupManager` is reduced from 391 to 175 lines. Cache-reset, epara PFIL round-trip, physical-backup, Apply, and ClScript focused tests passed 13/13. Final verification: full xUnit 174/174 and serial Release build 0 warnings/errors. +- Added `Services/PatchOperationRunner` as the UI-neutral transaction executor for Apply and all four Restore flows. It creates/disposes `FileRollbackScope`, runs the mutation on a worker thread, commits on success, and restores/rethrows on failure; `ModifierForm` retains validation, button state, dialogs, toggle reset, and post-success reload. Direct commit/rollback regressions plus Apply/Restore characterization tests passed 11/11; `ModifierForm.Patches.cs` dropped from 391 to 328 lines. Full xUnit passed 176/176 and serial Release build completed with 0 warnings/errors. A hidden startup smoke remained alive after 3 seconds. Its first forced-cleanup attempts returned Access denied, but a final process check confirmed PID 45128 had exited and no smoke process remained. +- Removed the duplicate unit-layer, weapon damage/reload, and maximum-range parsers from `ModifierForm.Data.cs`; both default/current UI tables now consume the same `Services/UnitStatParser` used by backup baselines and objdef detection. A direct mixed melee/ranged slot regression covers damage, reload, ordinary range, and priest sight/range. Focused parser/baseline/objdef/characterization tests passed 12/12; `ModifierForm.Data.cs` dropped to 943 lines. Final verification: full xUnit 177/177 and serial Release build 0 warnings/errors. + ## Runtime-verified player batch controls (2026-07-15) - The user confirmed in-game that `CiviProduce20` makes one residential-tent click produce/queue 20 civilians and that `UnitRecruit20` makes one villager-to-unit conversion click select 20. Both are now formal Resource & Combat Upgrades toggles (`pnlSwitchesCard`) and participate in `BtnEnableAll`; they were removed from `pnlExperimentalCard`. Patch, detection, and restore behavior remain unchanged. Canonical runtime evidence is recorded in `docs/reverse-engineering/known-patches.md` and both READMEs. Verification: Release build 0 warnings/errors, full xUnit 161/161 passed, and `git diff --check` clean. diff --git a/src/Core/Features/Bci/BciFeatureDetector.cs b/src/Core/Features/Bci/BciFeatureDetector.cs new file mode 100644 index 0000000..da983f9 --- /dev/null +++ b/src/Core/Features/Bci/BciFeatureDetector.cs @@ -0,0 +1,37 @@ +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Core.Features.Bci; + +internal sealed class BciFeatureDetector +{ + private readonly ILogger _logger; + + internal BciFeatureDetector(ILogger logger) => _logger = logger; + + internal void Detect(string gamePath, PatchProfile profile) + { + try + { + var orchestrator = new EndlessAiOrchestrator(); + foreach (var module in orchestrator.UserModules) + { + PatchState state = orchestrator.DetectModule(gamePath, module); + profile.EndlessAiModules[module.Id] = state == PatchState.Ultimate; + } + } + catch (Exception ex) + { + _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "Endless AI", ex.Message)); + } + + try + { + FoodHealingFeature.TryDetect(gamePath, out bool foodHealing); + profile.FoodHealing10x = foodHealing; + } + catch (Exception ex) + { + _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "food healing", ex.Message)); + } + } +} diff --git a/src/Core/Features/Exe/ExeFeatureDetector.cs b/src/Core/Features/Exe/ExeFeatureDetector.cs new file mode 100644 index 0000000..3aa5a94 --- /dev/null +++ b/src/Core/Features/Exe/ExeFeatureDetector.cs @@ -0,0 +1,56 @@ +using AgainstRomeModifier.Core.Patches; +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Core.Features.Exe; + +internal readonly record struct ExeFeatureDetection(ExeRomanEndlessPatchState RomanEndlessState); + +internal sealed class ExeFeatureDetector +{ + private readonly ILogger _logger; + + internal ExeFeatureDetector(ILogger logger) => _logger = logger; + + internal ExeFeatureDetection Detect(string gamePath, PatchProfile profile) + { + ExeRomanEndlessPatchState romanEndlessState = ExeRomanEndlessPatchState.Unknown; + string exePath = Path.Combine(gamePath, "Against_Rome.exe"); + if (!File.Exists(exePath)) return new(romanEndlessState); + + try + { + byte[] exeBytes = File.ReadAllBytes(exePath); + ExePatchState exeState = ExePatchModel.GetExePatchState(exeBytes); + if (exeState != ExePatchState.Unknown) + profile.FocusLoss = exeState == ExePatchState.FocusPatched; + + ExeVillageSetterPatchState setterState = ExePatchModel.GetVillageSetterPatchState(exeBytes); + if (setterState != ExeVillageSetterPatchState.Unknown) + { + profile.VillageBuildRange = setterState is + ExeVillageSetterPatchState.Legacy2x or + ExeVillageSetterPatchState.Legacy2Point5x or + ExeVillageSetterPatchState.Legacy3x or + ExeVillageSetterPatchState.Legacy5x or + ExeVillageSetterPatchState.EntireMap; + } + + ExeSpellAltarPatchState altarState = ExePatchModel.GetSpellAltarPatchState(exeBytes); + if (altarState != ExeSpellAltarPatchState.Unknown) + profile.NoSpellAltar = altarState == ExeSpellAltarPatchState.Patched; + + romanEndlessState = ExePatchModel.GetRomanEndlessPatchState(exeBytes); + profile.GameSpeed = ExePatchModel.GetGameSpeedMultiplier(exeBytes); + profile.CiviProduce20 = + ExePatchModel.GetCiviProduce20PatchState(exeBytes) == ExeCiviProduce20PatchState.Patched; + profile.UnitRecruit20 = + ExePatchModel.GetUnitRecruit20PatchState(exeBytes) == ExeUnitRecruit20PatchState.Patched; + } + catch (Exception ex) + { + _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "Against_Rome.exe", ex.Message)); + } + + return new(romanEndlessState); + } +} diff --git a/src/Core/Features/FeatureDetector.cs b/src/Core/Features/FeatureDetector.cs index c3f55e9..12c603c 100644 --- a/src/Core/Features/FeatureDetector.cs +++ b/src/Core/Features/FeatureDetector.cs @@ -1,8 +1,9 @@ -using System.Globalization; -using System.Text; -using System.Text.RegularExpressions; using AgainstRomeModifier.Core.Features.Bci; +using AgainstRomeModifier.Core.Features.Exe; +using AgainstRomeModifier.Core.Features.Ini; using AgainstRomeModifier.Core.Features.Install; +using AgainstRomeModifier.Core.Features.Map; +using AgainstRomeModifier.Core.Features.Objdef; using AgainstRomeModifier.Core.Patches; using AgainstRomeModifier.Core.Services; @@ -10,696 +11,34 @@ namespace AgainstRomeModifier.Core.Features; internal sealed class FeatureDetector { - private static readonly Regex RegexCiviLoad = new(@"CiviDelay\s*=\s*([A-Z]{3})\s*,\s*(\d+)", RegexOptions.Compiled); - private static readonly Regex RegexMoraleLostMemLoad = new(@"MoralsDecLostMem\s*=\s*GER\s*,\s*(\d+)", RegexOptions.Compiled); - private static readonly Regex RegexMoraleFleeLoad = new(@"MoralsDecFlee\s*=\s*GER\s*,\s*(\d+)", RegexOptions.Compiled); - private static readonly Regex RegexMoraleOverPopLoad = new(@"MoralsDecOverPop\s*=\s*GER\s*,\s*(\d+)", RegexOptions.Compiled); - private static readonly Regex RegexMoraleIdleLoad = new(@"MoralsIncIdle\s*=\s*GER\s*,\s*(\d+)", RegexOptions.Compiled); - private static readonly Regex RegexSpellValueLoad = new(@"^(Value\d*)\s*=\s*([A-Z]{3})\s*,\s*(Spell\d+)\s*,\s*([^;\r\n]+)", RegexOptions.Compiled | RegexOptions.Multiline); - private static readonly Regex RegexRadiusLoad = new(@"^Radius\s*=\s*([A-Z]{3})\s*,\s*(Spell\d+)\s*,\s*([^;\r\n]+)", RegexOptions.Compiled | RegexOptions.Multiline); - private static readonly Regex RegexAbilityValueLoad = new(@"^(Value\d*)\s*=\s*([A-Z]{3})\s*,\s*(SAbility\d+)\s*,\s*([^;\r\n]+)", RegexOptions.Compiled | RegexOptions.Multiline); - private const int HousingCapacityMultiplier = 20; - private const int StorageCapacityMultiplier = 10; private readonly ILogger _logger; internal FeatureDetector(ILogger logger) => _logger = logger; - internal PatchProfile Detect(string gamePath, BackupManager backupManager) - { - var options = new PatchProfile(); - ExeRomanEndlessPatchState romanExeState = ExeRomanEndlessPatchState.Unknown; - - // A. Against_Rome.exe - string exePath = Path.Combine(gamePath, "Against_Rome.exe"); - if (File.Exists(exePath)) - { - try - { - byte[] exeBytes = File.ReadAllBytes(exePath); - ExePatchState exeState = ExePatchModel.GetExePatchState(exeBytes); - if (exeState != ExePatchState.Unknown) - { - options.FocusLoss = (exeState == ExePatchState.FocusPatched); - } - - var setterState = ExePatchModel.GetVillageSetterPatchState(exeBytes); - if (setterState != ExeVillageSetterPatchState.Unknown) - { - options.VillageBuildRange = (setterState == ExeVillageSetterPatchState.Legacy2x || - setterState == ExeVillageSetterPatchState.Legacy2Point5x || - setterState == ExeVillageSetterPatchState.Legacy3x || - setterState == ExeVillageSetterPatchState.Legacy5x || - setterState == ExeVillageSetterPatchState.EntireMap); - } - - var altarState = ExePatchModel.GetSpellAltarPatchState(exeBytes); - if (altarState != ExeSpellAltarPatchState.Unknown) - { - options.NoSpellAltar = (altarState == ExeSpellAltarPatchState.Patched); - } - - romanExeState = ExePatchModel.GetRomanEndlessPatchState(exeBytes); - - int speed = ExePatchModel.GetGameSpeedMultiplier(exeBytes); - options.GameSpeed = speed; - - options.CiviProduce20 = - ExePatchModel.GetCiviProduce20PatchState(exeBytes) == ExeCiviProduce20PatchState.Patched; - options.UnitRecruit20 = - ExePatchModel.GetUnitRecruit20PatchState(exeBytes) == ExeUnitRecruit20PatchState.Patched; - } - catch (Exception ex) { _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "Against_Rome.exe", ex.Message)); } - } - - // B. cl_script.ini (CiviDelay, InfiniteMorale, Balance) - string clPath = Path.Combine(gamePath, @"SYSTEM\cl_script.ini"); - if (File.Exists(clPath)) - { - try - { - byte[] clBytes = File.ReadAllBytes(clPath); - byte[] decomp = GameLZSS.DecompressPfil(clBytes); - string text = Encoding.GetEncoding(1251).GetString(decomp); - - MatchCollection civiMatches = RegexCiviLoad.Matches(text); - options.FastCiviProduction = civiMatches.Count > 0 && civiMatches.Cast().All(match => - double.TryParse(match.Groups[2].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out double delay) && - delay > 0 && delay <= 500.0); - - var matchLost = RegexMoraleLostMemLoad.Match(text); - var matchFlee = RegexMoraleFleeLoad.Match(text); - var matchOverPop = RegexMoraleOverPopLoad.Match(text); - var matchIdle = RegexMoraleIdleLoad.Match(text); - options.InfiniteMorale = matchLost.Success && matchFlee.Success && matchOverPop.Success && matchIdle.Success && - int.TryParse(matchLost.Groups[1].Value, out int lost) && lost == 0 && - int.TryParse(matchFlee.Groups[1].Value, out int flee) && flee == 0 && - int.TryParse(matchOverPop.Groups[1].Value, out int overPop) && overPop >= 99999999 && - int.TryParse(matchIdle.Groups[1].Value, out int idle) && idle == 500; - - // 法術/技能強化偵測:與備份原檔逐鍵比對倍率(與 ClScriptPatcher 寫入邏輯對稱) - if (backupManager.BackupFiles.TryGetValue("SYSTEM/cl_script.ini", out byte[]? clOriginalBytes)) - { - string originalText = Encoding.GetEncoding(1251).GetString(GameLZSS.DecompressPfil(clOriginalBytes)); - var currentSpells = ReadKeyedValues(text, RegexSpellValueLoad); - var originalSpells = ReadKeyedValues(originalText, RegexSpellValueLoad); - var currentRadius = ReadRadiusValues(text); - var originalRadius = ReadRadiusValues(originalText); - options.SpellDamage5x = AllScaled(currentSpells, originalSpells, ClScriptPatcher.DamageSpellKeys.Select(k => $"Value_{k}"), 5); - options.SpellHealing10x = AllScaled(currentSpells, originalSpells, new[] { "Value_KEL_Spell1" }, 10); - options.SpellResurrection = DetectResurrection(currentSpells, originalSpells); - options.SpellRange3x = RadiusScaledByThree(currentRadius, originalRadius); - - var currentAbilities = ReadKeyedValues(text, RegexAbilityValueLoad); - var originalAbilities = ReadKeyedValues(originalText, RegexAbilityValueLoad); - options.GeneralSkills = AllScaled(currentAbilities, originalAbilities, originalAbilities.Keys, 5); - } - } - catch (Exception ex) { _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "cl_script.ini", ex.Message)); } - } - - // C. ress.ini (FreeProduction, FreeUpgrade, NoSpellCost) - string ressPath = Path.Combine(gamePath, @"SYSTEM\ress.ini"); - if (File.Exists(ressPath)) - { - try - { - byte[] ressBytes = File.ReadAllBytes(ressPath); - byte[] decomp = GameLZSS.DecompressPfil(ressBytes); - string text = Encoding.GetEncoding(1251).GetString(decomp); - - var mProd = Regex.Match(text, @"^FigRomInf00_Lanze_Schild\s*,.*$", RegexOptions.Multiline); - if (mProd.Success) - { - string[] cols = PatchText.ParseCsvLine(mProd.Value); - options.FreeProduction = Enumerable.Range((int)RessIndex.FigProdCostStart, (int)RessIndex.FigProdCostEnd - (int)RessIndex.FigProdCostStart + 1) - .All(idx => idx < cols.Length && cols[idx].Trim() == "0"); - } - - var mUp = Regex.Match(text, @"^.*Ger_Kampf.*$", RegexOptions.Multiline); - if (mUp.Success) - { - string[] cols = PatchText.ParseCsvLine(mUp.Value); - options.FreeUpgrade = Enumerable.Range((int)VolkresIndex.UnitUpgradeStart, (int)VolkresIndex.UnitUpgradeEnd - (int)VolkresIndex.UnitUpgradeStart + 1) - .All(idx => idx < cols.Length && cols[idx].Trim() == "0"); - } - - var mPri = Regex.Match(text, @"^FigGerPri00_Priester\s*,.*", RegexOptions.Multiline); - if (mPri.Success) - { - string[] cols = PatchText.ParseCsvLine(mPri.Value); - options.NoSpellCost = Enumerable.Range((int)RessIndex.FigPriestSpellCostStart, (int)RessIndex.FigPriestSpellCostEnd - (int)RessIndex.FigPriestSpellCostStart + 1) - .All(idx => idx < cols.Length && cols[idx].Trim() == "0"); - } - } - catch (Exception ex) { _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "ress.ini", ex.Message)); } - } - - // D. objdef.dau (housingCapacity20x, storageCapacity10x, fastBuildUpgradeRepair, balance) - string objdefPath = Path.Combine(gamePath, @"SYSTEM\DATA_MP\DEFAULTS\objdef.dau"); - if (File.Exists(objdefPath)) - { - try - { - byte[] raw = File.ReadAllBytes(objdefPath); - byte[] decomp = GameLZSS.DecompressPfil(raw); - string currentObjdef = Encoding.GetEncoding(1251).GetString(decomp); - // 當前與原版文本各解析一次,四項偵測(Housing/Storage/FastBuild/Balance)共用 - List currentRows = ParseObjdefRows(currentObjdef); - - if (backupManager.BackupFiles.TryGetValue("SYSTEM/DATA_MP/DEFAULTS/objdef.dau", out byte[]? originalObjdefBytes)) - { - string originalObjdef = Encoding.GetEncoding(1251).GetString(GameLZSS.DecompressPfil(originalObjdefBytes)); - List originalRows = ParseObjdefRows(originalObjdef); - options.HousingCapacity20x = HasHousingCapacityMultiplier(currentRows, originalRows, HousingCapacityMultiplier); - options.StorageCapacity10x = HasStorageCapacityMultiplier(currentRows, originalRows, StorageCapacityMultiplier); - options.HqHp10x = HasHqHpMultiplier(currentRows, originalRows, 10); - options.FastBuildUpgradeRepair = HasFastBuildUpgradeRepair(currentRows, originalRows); - options.ProjectileArcHeight = HasProjectileWeaponScale(currentRows, originalRows, useDrad: false); - } - - // 偵測是否已套用 Balance (若有任何一個兵種屬性被修改) - var unitRows = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (string[] cols in currentRows) - { - if (cols.Length < 192) continue; - string name = cols[52].Trim(); - if (TroopConfig.UnitMeta.ContainsKey(name) || name == "FigZivMan00_Zivilist") - { - unitRows[name] = cols; - } - } - - var origUnitRows = backupManager.GetBackupUnitRows(); - - // 射程 3 倍會同步擴大遠程單位的 Sirad,讓 AI 能鎖定新射程外 - // 的目標;在平衡偵測前先識別此狀態,避免把該視野變更誤判為平衡。 - bool range3x = false; - if (unitRows.TryGetValue("FigRomSch01_Bogen", out var rangeProbeCols)) - { - double currentRange = BackupManager.GetUnitMaxRange(rangeProbeCols, "ranged_inf"); - double originalRange = backupManager.GetOriginalStats("FigRomSch01_Bogen")[7]; - range3x = currentRange > 0 && Math.Abs(currentRange - originalRange * 3.0) < 5.0; - } - - bool isFileBalanced = false; - foreach (string key in TroopConfig.UnitMeta.Keys) - { - if (!unitRows.ContainsKey(key) || !origUnitRows.ContainsKey(key)) continue; - string utype = TroopConfig.UnitMeta[key].UnitType; - - string[] cols = unitRows[key]; - string[] origCols = origUnitRows[key]; - - double curHp = 0, origHp = 0; - double.TryParse(cols[(int)ObjdefIndex.Hp].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curHp); - double.TryParse(origCols[(int)ObjdefIndex.Hp].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origHp); - - double curVw = 0, origVw = 0; - double.TryParse(cols[(int)ObjdefIndex.Vw].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curVw); - double.TryParse(origCols[(int)ObjdefIndex.Vw].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origVw); - - double curAw = 0, origAw = 0; - double.TryParse(cols[(int)ObjdefIndex.Aw].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curAw); - double.TryParse(origCols[(int)ObjdefIndex.Aw].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origAw); - double curMoves = 0, origMoves = 0; - double.TryParse(cols[(int)ObjdefIndex.Moves].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curMoves); - double.TryParse(origCols[(int)ObjdefIndex.Moves].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origMoves); + internal PatchProfile Detect(string gamePath, BackupManager backupManager) + { + var options = new PatchProfile(); + ExeFeatureDetection exeDetection = new ExeFeatureDetector(_logger).Detect(gamePath, options); + new ClScriptFeatureDetector(_logger).Detect(gamePath, backupManager, options); + new RessFeatureDetector(_logger).Detect(gamePath, options); + new ObjdefFeatureDetector(_logger).Detect(gamePath, backupManager, options); - double curSight = 0, origSight = 0; - double.TryParse(cols[(int)ObjdefIndex.Sirad].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curSight); - double.TryParse(origCols[(int)ObjdefIndex.Sirad].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origSight); - - double curMeleeDmg = 0, curRangedDmg = 0; - BackupManager.GetMeleeAndRangedDmg(cols, utype, out curMeleeDmg, out curRangedDmg); - double origMeleeDmg = 0, origRangedDmg = 0; - BackupManager.GetMeleeAndRangedDmg(origCols, utype, out origMeleeDmg, out origRangedDmg); - - double curMeleeRelt = 0, curRangedRelt = 0; - BackupManager.GetMeleeAndRangedRelt(cols, utype, out curMeleeRelt, out curRangedRelt); - double origMeleeRelt = 0, origRangedRelt = 0; - BackupManager.GetMeleeAndRangedRelt(origCols, utype, out origMeleeRelt, out origRangedRelt); - - double curRange = BackupManager.GetUnitMaxRange(cols, utype); - double origRange = BackupManager.GetUnitMaxRange(origCols, utype); - - // 排除領袖以防 LeaderGlory 的干擾,且只比對不受 Range3x/Speed2x 影響的屬性 (Hp, Vw, Aw, Sight) - if (TroopConfig.UnitMeta[key].Tier != "leader") - { - bool isPriest = utype == "priest"; - bool hasExpandedRangeSight = range3x && TroopConfig.SupportsRangedRange3x(utype); - bool hasDiff = Math.Abs(curHp - origHp) > 0.01 || - Math.Abs(curVw - origVw) > 0.01 || - Math.Abs(curAw - origAw) > 0.01 || - (!isPriest && !hasExpandedRangeSight && Math.Abs(curSight - origSight) > 0.01); - - if (hasDiff) - { - isFileBalanced = true; - } - } - } - options.Balance = isFileBalanced; - options.LeaderGlory = DetectLeaderGlory(currentRows, origUnitRows); - - // 偵測遠程單位射程 3 倍與單位移動速度提升 2 倍 - bool speed2x = false; - - // 使用羅馬輕裝步兵 FigRomInf00_Lanze_Schild 偵測速度 2 倍 - if (unitRows.TryGetValue("FigRomInf00_Lanze_Schild", out var testMovesCols)) - { - double curMoves = 0; - double.TryParse(testMovesCols[(int)ObjdefIndex.Moves].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curMoves); - double expectedBaseMoves = backupManager.GetBaseStatsForUnit("FigRomInf00_Lanze_Schild", options)[4] / 2.0; - if (curMoves > 0 && Math.Abs(curMoves - expectedBaseMoves * 2.0) < 0.05) - { - speed2x = true; - } - } - - // 使用羅馬弓箭手 FigRomSch01_Bogen 偵測射程 3 倍 - if (unitRows.TryGetValue("FigRomSch01_Bogen", out var testRangeCols)) - { - double curRange = BackupManager.GetUnitMaxRange(testRangeCols, "ranged_inf"); - double expectedBaseRange = backupManager.GetBaseStatsForUnit("FigRomSch01_Bogen", options)[7]; - if (curRange > 0 && Math.Abs(curRange - expectedBaseRange * 3.0) < 5.0) - { - range3x = true; - } - } - - options.UnitMovementSpeed2x = speed2x; - options.RangedRange3x = range3x; - - // 使用塞爾特祭司 FigKelPri00_Priester 偵測法師施法距離 - bool spellEntireMap = false; - if (unitRows.TryGetValue("FigKelPri00_Priester", out var testSpellCols) && - origUnitRows.ContainsKey("FigKelPri00_Priester")) - { - double curRange = BackupManager.GetUnitMaxRange(testSpellCols, "priest"); - if (curRange > 0) - { - if (Math.Abs(curRange - 30000.0) < 100.0) - { - spellEntireMap = true; - } - } - } - options.SpellEntireMap = spellEntireMap; - } - catch (Exception ex) { _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "objdef.dau", ex.Message)); } - } - - // E. team.dat (maxPopulation + romanEndless) - // 由原版備份重合成四種合法組合,避免只變更玩家陣營時誤報人口上限。 - try - { - bool maxPop = false; - bool romanEndless = false; - foreach (var kvp in backupManager.BackupFiles) - { - if (kvp.Key.StartsWith("MAPS/", StringComparison.OrdinalIgnoreCase) && kvp.Key.EndsWith("team.dat", StringComparison.OrdinalIgnoreCase)) - { - string path = Path.Combine(gamePath, kvp.Key.Replace('/', '\\')); - if (File.Exists(path)) - { - byte[] currentBytes = File.ReadAllBytes(path); - bool isEndless = kvp.Key.StartsWith("MAPS/ENDL_", StringComparison.OrdinalIgnoreCase); - if (TryDetectTeamDatOptions(kvp.Value, currentBytes, isEndless, out bool fileMaxPop, out bool fileRoman)) - { - maxPop |= fileMaxPop; - romanEndless |= fileRoman; - } - else - { - // 保留舊版對未知 team.dat 修改的保守相容行為。 - maxPop = true; - _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), kvp.Key, "unknown team.dat state")); - } - } - } - } - options.MaxPopulation = maxPop; - if (romanExeState == ExeRomanEndlessPatchState.Unknown) - { - options.RomanEndless = romanEndless; - _logger.Log(Loc.Get("SvcLogRomanEndlessExeUnknown")); - } - else - { - bool exeEnabled = romanExeState == ExeRomanEndlessPatchState.Patched; - options.RomanEndless = exeEnabled; - if (exeEnabled != romanEndless) - _logger.Log(Loc.Get("SvcLogRomanEndlessMismatch")); - } - } - catch (Exception ex) { _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "team.dat", ex.Message)); } - - // F. Endless AI (M1..M5) - try - { - var orchestrator = new EndlessAiOrchestrator(); - foreach (var module in orchestrator.UserModules) - { - var aiState = orchestrator.DetectModule(gamePath, module); - options.EndlessAiModules[module.Id] = (aiState == PatchState.Ultimate); - } - } - catch (Exception ex) { _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "Endless AI", ex.Message)); } - - // G. toEnglish - try - { - new LanguagePackFeature(_logger).TryGetLanguageOverlayState(gamePath, out bool langEng); - options.ToEnglish = langEng; - } - catch (Exception ex) { _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "ToEng overlay", ex.Message)); } - - // H. DgVoodoo - try - { - options.DgVoodoo = new DgVoodooFeature(_logger).IsInstalled(gamePath); - } - catch (Exception ex) { _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "dgVoodoo2", ex.Message)); } - - // I. FoodHealing10x - try - { - FoodHealingFeature.TryDetect(gamePath, out bool foodHealing); - options.FoodHealing10x = foodHealing; - } - catch (Exception ex) { _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "food healing", ex.Message)); } - - return options; - } - - internal static bool TryDetectTeamDatOptions(byte[] original, byte[] current, bool isEndless, - out bool maxPopulation, out bool romanPlayer) + TeamDatFeatureDetection teamDetection = new TeamDatFeatureDetector(_logger).Detect(gamePath, backupManager); + options.MaxPopulation = teamDetection.MaxPopulation; + if (exeDetection.RomanEndlessState == ExeRomanEndlessPatchState.Unknown) { - (bool MaxPopulation, bool RomanPlayer)[] combinations = - { - (false, false), - (true, false), - (false, true), - (true, true), - }; - - foreach (var combination in combinations) - { - bool effectiveRoman = combination.RomanPlayer && isEndless; - byte[] candidate = TeamDatPatcher.GetPatchedBytes(original, - new TeamDatOptions(combination.MaxPopulation, RomanPlayer: effectiveRoman)); - if (!current.SequenceEqual(candidate)) continue; - - maxPopulation = combination.MaxPopulation; - romanPlayer = effectiveRoman; - return true; - } - - maxPopulation = false; - romanPlayer = false; - return false; + options.RomanEndless = teamDetection.RomanPlayer; + _logger.Log(Loc.Get("SvcLogRomanEndlessExeUnknown")); } - - // --- cl_script.ini 法術/技能強化偵測輔助函數 --- - - /// 以行錨定 regex 解析 ValueN 行為 {key}_{faction}_{spell|ability} → 數值 的字典。 - private static Dictionary ReadKeyedValues(string text, Regex regex) + else { - var values = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (Match m in regex.Matches(text)) - { - if (double.TryParse(m.Groups[4].Value.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double value)) - values[$"{m.Groups[1].Value.Trim()}_{m.Groups[2].Value.Trim()}_{m.Groups[3].Value.Trim()}"] = value; - } - return values; - } - - private static Dictionary ReadRadiusValues(string text) - { - var values = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (Match m in RegexRadiusLoad.Matches(text)) - { - if (double.TryParse(m.Groups[3].Value.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double value)) - values[$"Radius_{m.Groups[1].Value.Trim()}_{m.Groups[2].Value.Trim()}"] = value; - } - return values; - } - - /// 指定鍵的當前值是否全部等於原值 × multiplier(與 patcher 相同的 (int) 截斷);至少須有一鍵可比對。 - private static bool AllScaled(Dictionary current, Dictionary original, IEnumerable keys, int multiplier) - { - bool any = false; - foreach (string key in keys) - { - if (!original.TryGetValue(key, out double orig) || orig <= 0) continue; - int expected = (int)(orig * multiplier); - if (expected == (int)orig) continue; // 倍率後不變(無法區分),跳過 - if (!current.TryGetValue(key, out double cur) || (int)cur != expected) return false; - any = true; - } - return any; - } - - private static bool RadiusScaledByThree(Dictionary current, Dictionary original) - { - bool any = false; - foreach (string key in original.Keys) - { - double baseline = original[key]; - if (baseline <= 0) continue; - if (!current.TryGetValue(key, out double value)) return false; - double ratio = value / baseline; - // Balance/custom radius may contribute its own baseline (the shipped balance is 2.5x). - if (Math.Abs(ratio - 3.0) > 0.05 && Math.Abs(ratio - 7.5) > 0.1) return false; - any = true; - } - return any; - } - - /// 復活術強化偵測:KEL Spell3 的 Value/Value2 是否已被寫為 100(且原值非 100,否則無法區分)。 - private static bool DetectResurrection(Dictionary current, Dictionary original) - { - bool any = false; - foreach (string key in new[] { "Value_KEL_Spell3", "Value2_KEL_Spell3" }) - { - if (!original.TryGetValue(key, out double orig) || (int)orig == 100) continue; - if (!current.TryGetValue(key, out double cur) || (int)cur != 100) return false; - any = true; - } - return any; - } - - /// 首領榮譽偵測:四族首領列的成長/光環欄位是否全部等於備份原值 × 5。 - private static bool DetectLeaderGlory(List currentRows, Dictionary originalRows) - { - bool any = false; - foreach (string leader in ObjdefPatcher.GloryLeaders) - { - if (!originalRows.TryGetValue(leader, out string[]? orig)) continue; - string[]? cur = currentRows.FirstOrDefault(r => r.Length > 52 && r[52].Trim().Equals(leader, StringComparison.OrdinalIgnoreCase)); - if (cur == null) continue; - foreach (int index in ObjdefPatcher.GloryColumns) - { - if (index >= orig.Length || index >= cur.Length) continue; - if (!double.TryParse(orig[index].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double origValue) || origValue <= 0) continue; - if (!double.TryParse(cur[index].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double curValue)) return false; - if (Math.Abs(curValue - origValue * ObjdefPatcher.GloryMultiplier) > 0.01) return false; - any = true; - } - } - return any; - } - - // --- Objdef dau 偵測細部輔助函數 --- - - /// 把 objdef 文本解析為資料列(長度 >= 100 的行),供各偵測共用、避免重複 split 與解析。 - private static List ParseObjdefRows(string content) - { - var rows = new List(); - foreach (string line in content.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)) - { - if (line.Length < 100) continue; - rows.Add(PatchText.ParseCsvLine(line)); - } - return rows; - } - - private static bool HasHousingCapacityMultiplier(List currentRows, List originalRows, int multiplier) - { - var currentValues = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (string[] cols in currentRows) - { - if (cols.Length <= (int)ObjdefIndex.HousingCapacity || cols.Length <= (int)ObjdefIndex.Name) continue; - if (int.TryParse(cols[(int)ObjdefIndex.HousingCapacity].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)) - { - currentValues[cols[(int)ObjdefIndex.Name].Trim()] = value; - } - } - - bool foundHousing = false; - foreach (string[] cols in originalRows) - { - if (cols.Length <= (int)ObjdefIndex.HousingCapacity || cols.Length <= (int)ObjdefIndex.Name) continue; - if (!int.TryParse(cols[(int)ObjdefIndex.HousingCapacity].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int originalValue) || originalValue <= 0) continue; - - foundHousing = true; - string name = cols[(int)ObjdefIndex.Name].Trim(); - if (!currentValues.TryGetValue(name, out int currentValue) || currentValue != checked(originalValue * multiplier)) - { - return false; - } - } - return foundHousing; - } - - private static bool HasStorageCapacityMultiplier(List currentRows, List originalRows, int multiplier) - { - var currentValues = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (string[] cols in currentRows) - { - if (cols.Length <= (int)ObjdefIndex.StorageCapacity || cols.Length <= (int)ObjdefIndex.Name) continue; - if (int.TryParse(cols[(int)ObjdefIndex.StorageCapacity].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)) - { - currentValues[cols[(int)ObjdefIndex.Name].Trim()] = value; - } - } - - bool foundStorage = false; - foreach (string[] cols in originalRows) - { - if (cols.Length <= (int)ObjdefIndex.StorageCapacity || cols.Length <= (int)ObjdefIndex.Name) continue; - string name = cols[(int)ObjdefIndex.Name].Trim(); - if (!name.StartsWith("Bau") || !(name.Contains("Hau") || name.Contains("Lag"))) continue; - if (!int.TryParse(cols[(int)ObjdefIndex.StorageCapacity].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int originalValue) || originalValue <= 0) continue; - - foundStorage = true; - if (!currentValues.TryGetValue(name, out int currentValue) || currentValue != checked(originalValue * multiplier)) - { - return false; - } - } - return foundStorage; - } - - private static bool HasHqHpMultiplier(List currentRows, List originalRows, int multiplier) - { - var currentValues = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (string[] cols in currentRows) - { - if (cols.Length <= (int)ObjdefIndex.Hp || cols.Length <= (int)ObjdefIndex.Name) continue; - if (int.TryParse(cols[(int)ObjdefIndex.Hp].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)) - { - currentValues[cols[(int)ObjdefIndex.Name].Trim()] = value; - } - } - - bool foundHq = false; - foreach (string[] cols in originalRows) - { - if (cols.Length <= (int)ObjdefIndex.Hp || cols.Length <= (int)ObjdefIndex.Name) continue; - string name = cols[(int)ObjdefIndex.Name].Trim(); - if (!name.StartsWith("Bau") || !name.Contains("Hau")) continue; - if (!int.TryParse(cols[(int)ObjdefIndex.Hp].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int originalValue) || originalValue <= 0) continue; - - foundHq = true; - if (!currentValues.TryGetValue(name, out int currentValue) || currentValue != checked(originalValue * multiplier)) - { - return false; - } - } - return foundHq; - } - - /// 拋射武器縮放偵測:所有「akti=1 且 emit>0」的武器欄位,是否全部等於原值 × 對應倍率。 - /// useDrad=false 比對 w*_emit ×ArcEmitMultiplier(拋射彈道增高);useDrad=true 比對 w*_drad ×AccuracyDradMultiplier(遠程命中強化)。 - /// 與 ObjdefPatcher.PatchProjectileWeapons 的寫入邏輯對稱。 - private static bool HasProjectileWeaponScale(List currentRows, List originalRows, bool useDrad) - { - var currentByName = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (string[] cols in currentRows) - { - if (cols.Length > (int)ObjdefIndex.Name) currentByName[cols[(int)ObjdefIndex.Name].Trim()] = cols; - } - - bool any = false; - foreach (string[] orig in originalRows) - { - if (orig.Length <= (int)ObjdefIndex.Name) continue; - if (!currentByName.TryGetValue(orig[(int)ObjdefIndex.Name].Trim(), out string[]? cur)) continue; - for (int w = 1; w <= 8; w++) - { - int active = (int)ObjdefIndex.Weapon1Akti + (w - 1) * 8; - int emit = (int)ObjdefIndex.Weapon1Emit + (w - 1) * 8; - int drad = (int)ObjdefIndex.Weapon1Drad + (w - 1) * 2; - int target = useDrad ? drad : emit; - if (emit >= orig.Length || target >= orig.Length || target >= cur.Length) continue; - if (orig[active].Trim() != "1") continue; - if (!long.TryParse(orig[emit].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long emitValue) || emitValue <= 0) continue; - if (!long.TryParse(orig[target].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long origValue) || origValue <= 0) continue; - long expected = useDrad - ? origValue * ObjdefPatcher.AccuracyDradMultiplier - : (long)Math.Round(origValue * ObjdefPatcher.ArcEmitMultiplier); - if (expected == origValue) continue; // 倍率後不變,無法區分 - if (!long.TryParse(cur[target].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long curValue) || curValue != expected) return false; - any = true; - } - } - return any; - } - - private static bool HasFastBuildUpgradeRepair(List currentRows, List originalRows) - { - var currentBuildValues = new Dictionary(StringComparer.OrdinalIgnoreCase); - var currentUpgValues = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (string[] cols in currentRows) - { - if (cols.Length < 192) continue; - string name = cols[52].Trim(); - if (!name.StartsWith("Bau")) continue; - - if (int.TryParse(cols[73].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int buildVal)) - { - currentBuildValues[name] = buildVal; - } - if (int.TryParse(cols[74].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int upgVal)) - { - currentUpgValues[name] = upgVal; - } - } - - bool foundBuilding = false; - foreach (string[] cols in originalRows) - { - if (cols.Length < 192) continue; - string name = cols[52].Trim(); - if (!name.StartsWith("Bau")) continue; - - bool hasBuild = int.TryParse(cols[73].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int origBuildVal) && origBuildVal > 0; - bool hasUpg = int.TryParse(cols[74].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int origUpgVal) && origUpgVal > 0; - - if (!hasBuild && !hasUpg) continue; - - foundBuilding = true; - - if (hasBuild) - { - int expectedBuild = Math.Max(1, origBuildVal / 10); - if (!currentBuildValues.TryGetValue(name, out int curBuild) || curBuild != expectedBuild) - { - return false; - } - } - - if (hasUpg) - { - int expectedUpg = Math.Max(1, origUpgVal / 10); - if (!currentUpgValues.TryGetValue(name, out int curUpg) || curUpg != expectedUpg) - { - return false; - } - } - } - return foundBuilding; + bool exeEnabled = exeDetection.RomanEndlessState == ExeRomanEndlessPatchState.Patched; + options.RomanEndless = exeEnabled; + if (exeEnabled != teamDetection.RomanPlayer) + _logger.Log(Loc.Get("SvcLogRomanEndlessMismatch")); } + new BciFeatureDetector(_logger).Detect(gamePath, options); + new InstallFeatureDetector(_logger).Detect(gamePath, options); + return options; + } } diff --git a/src/Core/Features/FeatureKey.cs b/src/Core/Features/FeatureKey.cs new file mode 100644 index 0000000..33bcb6d --- /dev/null +++ b/src/Core/Features/FeatureKey.cs @@ -0,0 +1,90 @@ +namespace AgainstRomeModifier.Core.Features; + +public interface IFeatureKey +{ + string Id { get; } + FeatureValue DisabledValue { get; } +} + +public sealed class FeatureKey : IFeatureKey +{ + private readonly Func _encode; + private readonly Func _decode; + + private FeatureKey(string id, T disabledValue, Func encode, Func decode) + { + Id = id; + Disabled = disabledValue; + _encode = encode; + _decode = decode; + } + + public string Id { get; } + public T Disabled { get; } + public FeatureValue DisabledValue => Encode(Disabled); + + internal FeatureValue Encode(T value) => _encode(value); + internal T Decode(FeatureValue value) => _decode(value); + + public static FeatureKey Bool(string id) => + new(id, false, FeatureValue.Of, value => value.AsBool); + + public static FeatureKey Int(string id, int disabledValue) => + new(id, disabledValue, FeatureValue.Of, value => value.AsInt); + + public static FeatureKey Reference(string id) where TReference : class => + new(id, null, value => FeatureValue.Of(value), value => value.AsObject as TReference); +} + +public static class FeatureKeys +{ + public static readonly FeatureKey FastCiviProduction = FeatureKey.Bool("FastCiviProduction"); + public static readonly FeatureKey InfiniteMorale = FeatureKey.Bool("InfiniteMorale"); + public static readonly FeatureKey Balance = FeatureKey.Bool("Balance"); + public static readonly FeatureKey FreeProduction = FeatureKey.Bool("FreeProduction"); + public static readonly FeatureKey FreeUpgrade = FeatureKey.Bool("FreeUpgrade"); + public static readonly FeatureKey NoSpellCost = FeatureKey.Bool("NoSpellCost"); + public static readonly FeatureKey HousingCapacity20x = FeatureKey.Bool("HousingCapacity20x"); + public static readonly FeatureKey StorageCapacity10x = FeatureKey.Bool("StorageCapacity10x"); + public static readonly FeatureKey HqHp10x = FeatureKey.Bool("HqHp10x"); + public static readonly FeatureKey FastBuildUpgradeRepair = FeatureKey.Bool("FastBuildUpgradeRepair"); + public static readonly FeatureKey FoodHealing10x = FeatureKey.Bool("FoodHealing10x"); + public static readonly FeatureKey MaxPopulation = FeatureKey.Bool("MaxPopulation"); + public static readonly FeatureKey CiviProduce20 = FeatureKey.Bool("CiviProduce20"); + public static readonly FeatureKey UnitRecruit20 = FeatureKey.Bool("UnitRecruit20"); + public static readonly FeatureKey RomanEndless = FeatureKey.Bool("RomanEndless"); + public static readonly FeatureKey SpellDamage5x = FeatureKey.Bool("SpellDamage5x"); + public static readonly FeatureKey SpellHealing10x = FeatureKey.Bool("SpellHealing10x"); + public static readonly FeatureKey SpellResurrection = FeatureKey.Bool("SpellResurrection"); + public static readonly FeatureKey GeneralSkills = FeatureKey.Bool("GeneralSkills"); + public static readonly FeatureKey LeaderGlory = FeatureKey.Bool("LeaderGlory"); + public static readonly FeatureKey RangedRange3x = FeatureKey.Bool("RangedRange3x"); + public static readonly FeatureKey UnitMovementSpeed2x = FeatureKey.Bool("UnitMovementSpeed2x"); + public static readonly FeatureKey SpellEntireMap = FeatureKey.Bool("SpellEntireMap"); + public static readonly FeatureKey SpellRange3x = FeatureKey.Bool("SpellRange3x"); + public static readonly FeatureKey ProjectileArcHeight = FeatureKey.Bool("ProjectileArcHeight"); + public static readonly FeatureKey?> CustomUnitStats = FeatureKey?>.Reference>("CustomUnitStats"); + public static readonly FeatureKey FocusLoss = FeatureKey.Bool("FocusLoss"); + public static readonly FeatureKey VillageBuildRange = FeatureKey.Bool("VillageBuildRange"); + public static readonly FeatureKey NoSpellAltar = FeatureKey.Bool("NoSpellAltar"); + public static readonly FeatureKey GameSpeed = FeatureKey.Int("GameSpeed", 1); + public static readonly FeatureKey DgVoodoo = FeatureKey.Bool("DgVoodoo"); + public static readonly FeatureKey EndlessAiM1 = FeatureKey.Bool("EndlessAi.M1"); + public static readonly FeatureKey EndlessAiM2 = FeatureKey.Bool("EndlessAi.M2"); + public static readonly FeatureKey EndlessAiM3 = FeatureKey.Bool("EndlessAi.M3"); + public static readonly FeatureKey EndlessAiM4 = FeatureKey.Bool("EndlessAi.M4"); + public static readonly FeatureKey EndlessAiM5 = FeatureKey.Bool("EndlessAi.M5"); + public static readonly FeatureKey EndlessAiM6 = FeatureKey.Bool("EndlessAi.M6"); + public static readonly FeatureKey ToEnglish = FeatureKey.Bool("ToEnglish"); + + public static FeatureKey EndlessAi(string moduleId) => moduleId.ToUpperInvariant() switch + { + "M1" => EndlessAiM1, + "M2" => EndlessAiM2, + "M3" => EndlessAiM3, + "M4" => EndlessAiM4, + "M5" => EndlessAiM5, + "M6" => EndlessAiM6, + _ => throw new ArgumentOutOfRangeException(nameof(moduleId), moduleId, "Unknown Endless AI module id."), + }; +} diff --git a/src/Core/Features/FeatureRegistry.cs b/src/Core/Features/FeatureRegistry.cs index badac8f..9cdd7d2 100644 --- a/src/Core/Features/FeatureRegistry.cs +++ b/src/Core/Features/FeatureRegistry.cs @@ -1,7 +1,15 @@ namespace AgainstRomeModifier.Core.Features; -public sealed record FeatureDefinition(string Id, FeatureCategory Category, FeatureValue DisabledValue) : IFeatureModule +public enum FeatureControlKind { + Toggle, + Specialized, +} + +public sealed record FeatureDefinition(IFeatureKey Key, FeatureCategory Category, FeatureControlKind ControlKind = FeatureControlKind.Toggle) : IFeatureModule +{ + public string Id => Key.Id; + public FeatureValue DisabledValue => Key.DisabledValue; public void Plan(PatchContext context, FeatureValue value) => context.Set(Id, value); public FeatureValue Detect(DetectContext context) => context.DetectedProfile.Get(Id); } @@ -10,34 +18,46 @@ public static class FeatureRegistry { public static IReadOnlyList All { get; } = new FeatureDefinition[] { - Bool("FastCiviProduction", FeatureCategory.Stats), Bool("InfiniteMorale", FeatureCategory.Stats), - Bool("Balance", FeatureCategory.Stats), Bool("FreeProduction", FeatureCategory.Stats), - Bool("FreeUpgrade", FeatureCategory.Stats), Bool("NoSpellCost", FeatureCategory.Stats), - Bool("HousingCapacity20x", FeatureCategory.Stats), Bool("StorageCapacity10x", FeatureCategory.Stats), - Bool("HqHp10x", FeatureCategory.Stats), Bool("FastBuildUpgradeRepair", FeatureCategory.Stats), - Bool("FoodHealing10x", FeatureCategory.Stats), Bool("MaxPopulation", FeatureCategory.Stats), - Bool("CiviProduce20", FeatureCategory.Stats), Bool("UnitRecruit20", FeatureCategory.Stats), - Bool("RomanEndless", FeatureCategory.Stats), - Bool("SpellDamage5x", FeatureCategory.Stats), Bool("SpellHealing10x", FeatureCategory.Stats), - Bool("SpellResurrection", FeatureCategory.Stats), Bool("GeneralSkills", FeatureCategory.Stats), - Bool("LeaderGlory", FeatureCategory.Stats), - Bool("RangedRange3x", FeatureCategory.Stats), Bool("UnitMovementSpeed2x", FeatureCategory.Stats), - Bool("SpellEntireMap", FeatureCategory.Stats), Bool("SpellRange3x", FeatureCategory.Stats), - Bool("ProjectileArcHeight", FeatureCategory.Stats), - new("CustomUnitStats", FeatureCategory.Stats, FeatureValue.Of((object?)null)), - Bool("FocusLoss", FeatureCategory.Compat), Bool("VillageBuildRange", FeatureCategory.Compat), - Bool("NoSpellAltar", FeatureCategory.Compat), new("GameSpeed", FeatureCategory.Compat, FeatureValue.Of(1)), - Bool("DgVoodoo", FeatureCategory.Compat), - Bool("EndlessAi.M1", FeatureCategory.Compat), Bool("EndlessAi.M2", FeatureCategory.Compat), - Bool("EndlessAi.M3", FeatureCategory.Compat), Bool("EndlessAi.M4", FeatureCategory.Compat), - Bool("EndlessAi.M5", FeatureCategory.Compat), Bool("EndlessAi.M6", FeatureCategory.Compat), - Bool("ToEnglish", FeatureCategory.Language), + Bool(FeatureKeys.FastCiviProduction, FeatureCategory.Stats), Bool(FeatureKeys.InfiniteMorale, FeatureCategory.Stats), + Bool(FeatureKeys.Balance, FeatureCategory.Stats), Bool(FeatureKeys.FreeProduction, FeatureCategory.Stats), + Bool(FeatureKeys.FreeUpgrade, FeatureCategory.Stats), Bool(FeatureKeys.NoSpellCost, FeatureCategory.Stats), + Bool(FeatureKeys.HousingCapacity20x, FeatureCategory.Stats), Bool(FeatureKeys.StorageCapacity10x, FeatureCategory.Stats), + Bool(FeatureKeys.HqHp10x, FeatureCategory.Stats), Bool(FeatureKeys.FastBuildUpgradeRepair, FeatureCategory.Stats), + Bool(FeatureKeys.FoodHealing10x, FeatureCategory.Stats), Bool(FeatureKeys.MaxPopulation, FeatureCategory.Stats), + Bool(FeatureKeys.CiviProduce20, FeatureCategory.Stats), Bool(FeatureKeys.UnitRecruit20, FeatureCategory.Stats), + Bool(FeatureKeys.RomanEndless, FeatureCategory.Stats), + Bool(FeatureKeys.SpellDamage5x, FeatureCategory.Stats), Bool(FeatureKeys.SpellHealing10x, FeatureCategory.Stats), + Bool(FeatureKeys.SpellResurrection, FeatureCategory.Stats), Bool(FeatureKeys.GeneralSkills, FeatureCategory.Stats), + Bool(FeatureKeys.LeaderGlory, FeatureCategory.Stats), + Bool(FeatureKeys.RangedRange3x, FeatureCategory.Stats), Bool(FeatureKeys.UnitMovementSpeed2x, FeatureCategory.Stats), + Bool(FeatureKeys.SpellEntireMap, FeatureCategory.Stats), Bool(FeatureKeys.SpellRange3x, FeatureCategory.Stats), + Bool(FeatureKeys.ProjectileArcHeight, FeatureCategory.Stats), + new(FeatureKeys.CustomUnitStats, FeatureCategory.Stats, FeatureControlKind.Specialized), + Bool(FeatureKeys.FocusLoss, FeatureCategory.Compat), Bool(FeatureKeys.VillageBuildRange, FeatureCategory.Compat), + Bool(FeatureKeys.NoSpellAltar, FeatureCategory.Compat), new(FeatureKeys.GameSpeed, FeatureCategory.Compat, FeatureControlKind.Specialized), + Bool(FeatureKeys.DgVoodoo, FeatureCategory.Compat), + Bool(FeatureKeys.EndlessAiM1, FeatureCategory.Compat), Bool(FeatureKeys.EndlessAiM2, FeatureCategory.Compat), + Bool(FeatureKeys.EndlessAiM3, FeatureCategory.Compat), Bool(FeatureKeys.EndlessAiM4, FeatureCategory.Compat), + Bool(FeatureKeys.EndlessAiM5, FeatureCategory.Compat), Bool(FeatureKeys.EndlessAiM6, FeatureCategory.Compat), + Bool(FeatureKeys.ToEnglish, FeatureCategory.Language), }; public static IEnumerable ByCategory(FeatureCategory category) => All.Where(x => x.Category == category); + public static IEnumerable ToggleFeatures => All.Where(x => x.ControlKind == FeatureControlKind.Toggle); public static FeatureValue GetDisabledValue(string id) => All.FirstOrDefault(x => x.Id.Equals(id, StringComparison.OrdinalIgnoreCase))?.DisabledValue ?? FeatureValue.Of(false); - private static FeatureDefinition Bool(string id, FeatureCategory category) => new(id, category, FeatureValue.Of(false)); + public static void ValidateToggleIds(IEnumerable ids) + { + var actual = ids.ToHashSet(StringComparer.OrdinalIgnoreCase); + var expected = ToggleFeatures.Select(x => x.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + if (actual.SetEquals(expected)) return; + + string missing = string.Join(", ", expected.Except(actual, StringComparer.OrdinalIgnoreCase).OrderBy(x => x)); + string unexpected = string.Join(", ", actual.Except(expected, StringComparer.OrdinalIgnoreCase).OrderBy(x => x)); + throw new InvalidOperationException($"Feature toggle map mismatch. Missing: [{missing}]. Unexpected: [{unexpected}]."); + } + + private static FeatureDefinition Bool(FeatureKey key, FeatureCategory category) => new(key, category); } diff --git a/src/Core/Features/Ini/ClScriptFeatureDetector.cs b/src/Core/Features/Ini/ClScriptFeatureDetector.cs new file mode 100644 index 0000000..e15d4b0 --- /dev/null +++ b/src/Core/Features/Ini/ClScriptFeatureDetector.cs @@ -0,0 +1,134 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AgainstRomeModifier.Core.Patches; +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Core.Features.Ini; + +internal sealed class ClScriptFeatureDetector +{ + private const string BackupKey = "SYSTEM/cl_script.ini"; + private static readonly Regex RegexCiviLoad = new(@"CiviDelay\s*=\s*([A-Z]{3})\s*,\s*(\d+)", RegexOptions.Compiled); + private static readonly Regex RegexMoraleLostMemLoad = new(@"MoralsDecLostMem\s*=\s*GER\s*,\s*(\d+)", RegexOptions.Compiled); + private static readonly Regex RegexMoraleFleeLoad = new(@"MoralsDecFlee\s*=\s*GER\s*,\s*(\d+)", RegexOptions.Compiled); + private static readonly Regex RegexMoraleOverPopLoad = new(@"MoralsDecOverPop\s*=\s*GER\s*,\s*(\d+)", RegexOptions.Compiled); + private static readonly Regex RegexMoraleIdleLoad = new(@"MoralsIncIdle\s*=\s*GER\s*,\s*(\d+)", RegexOptions.Compiled); + private static readonly Regex RegexSpellValueLoad = new(@"^(Value\d*)\s*=\s*([A-Z]{3})\s*,\s*(Spell\d+)\s*,\s*([^;\r\n]+)", RegexOptions.Compiled | RegexOptions.Multiline); + private static readonly Regex RegexRadiusLoad = new(@"^Radius\s*=\s*([A-Z]{3})\s*,\s*(Spell\d+)\s*,\s*([^;\r\n]+)", RegexOptions.Compiled | RegexOptions.Multiline); + private static readonly Regex RegexAbilityValueLoad = new(@"^(Value\d*)\s*=\s*([A-Z]{3})\s*,\s*(SAbility\d+)\s*,\s*([^;\r\n]+)", RegexOptions.Compiled | RegexOptions.Multiline); + private readonly ILogger _logger; + + internal ClScriptFeatureDetector(ILogger logger) => _logger = logger; + + internal void Detect(string gamePath, BackupManager backupManager, PatchProfile profile) + { + string path = Path.Combine(gamePath, @"SYSTEM\cl_script.ini"); + if (!File.Exists(path)) return; + + try + { + string text = Decode(File.ReadAllBytes(path)); + + MatchCollection civiMatches = RegexCiviLoad.Matches(text); + profile.FastCiviProduction = civiMatches.Count > 0 && civiMatches.Cast().All(match => + double.TryParse(match.Groups[2].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out double delay) && + delay > 0 && delay <= 500.0); + + Match matchLost = RegexMoraleLostMemLoad.Match(text); + Match matchFlee = RegexMoraleFleeLoad.Match(text); + Match matchOverPop = RegexMoraleOverPopLoad.Match(text); + Match matchIdle = RegexMoraleIdleLoad.Match(text); + profile.InfiniteMorale = matchLost.Success && matchFlee.Success && matchOverPop.Success && matchIdle.Success && + int.TryParse(matchLost.Groups[1].Value, out int lost) && lost == 0 && + int.TryParse(matchFlee.Groups[1].Value, out int flee) && flee == 0 && + int.TryParse(matchOverPop.Groups[1].Value, out int overPop) && overPop >= 99999999 && + int.TryParse(matchIdle.Groups[1].Value, out int idle) && idle == 500; + + if (!backupManager.HasFile(BackupKey)) return; + + string originalText = Decode(backupManager.GetBackupBytes(BackupKey)); + Dictionary currentSpells = ReadKeyedValues(text, RegexSpellValueLoad); + Dictionary originalSpells = ReadKeyedValues(originalText, RegexSpellValueLoad); + Dictionary currentRadius = ReadRadiusValues(text); + Dictionary originalRadius = ReadRadiusValues(originalText); + profile.SpellDamage5x = AllScaled(currentSpells, originalSpells, ClScriptPatcher.DamageSpellKeys.Select(key => $"Value_{key}"), 5); + profile.SpellHealing10x = AllScaled(currentSpells, originalSpells, new[] { "Value_KEL_Spell1" }, 10); + profile.SpellResurrection = DetectResurrection(currentSpells, originalSpells); + profile.SpellRange3x = RadiusScaledByThree(currentRadius, originalRadius); + + Dictionary currentAbilities = ReadKeyedValues(text, RegexAbilityValueLoad); + Dictionary originalAbilities = ReadKeyedValues(originalText, RegexAbilityValueLoad); + profile.GeneralSkills = AllScaled(currentAbilities, originalAbilities, originalAbilities.Keys, 5); + } + catch (Exception ex) + { + _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "cl_script.ini", ex.Message)); + } + } + + private static string Decode(byte[] bytes) => + PatchText.GameEncoding.GetString(GameLZSS.DecompressPfil(bytes)); + + private static Dictionary ReadKeyedValues(string text, Regex regex) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (Match match in regex.Matches(text)) + { + if (double.TryParse(match.Groups[4].Value.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double value)) + values[$"{match.Groups[1].Value.Trim()}_{match.Groups[2].Value.Trim()}_{match.Groups[3].Value.Trim()}"] = value; + } + return values; + } + + private static Dictionary ReadRadiusValues(string text) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (Match match in RegexRadiusLoad.Matches(text)) + { + if (double.TryParse(match.Groups[3].Value.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double value)) + values[$"Radius_{match.Groups[1].Value.Trim()}_{match.Groups[2].Value.Trim()}"] = value; + } + return values; + } + + private static bool AllScaled(Dictionary current, Dictionary original, IEnumerable keys, int multiplier) + { + bool any = false; + foreach (string key in keys) + { + if (!original.TryGetValue(key, out double originalValue) || originalValue <= 0) continue; + int expected = (int)(originalValue * multiplier); + if (expected == (int)originalValue) continue; + if (!current.TryGetValue(key, out double currentValue) || (int)currentValue != expected) return false; + any = true; + } + return any; + } + + private static bool RadiusScaledByThree(Dictionary current, Dictionary original) + { + bool any = false; + foreach (string key in original.Keys) + { + double baseline = original[key]; + if (baseline <= 0) continue; + if (!current.TryGetValue(key, out double value)) return false; + double ratio = value / baseline; + if (Math.Abs(ratio - 3.0) > 0.05 && Math.Abs(ratio - 7.5) > 0.1) return false; + any = true; + } + return any; + } + + private static bool DetectResurrection(Dictionary current, Dictionary original) + { + bool any = false; + foreach (string key in new[] { "Value_KEL_Spell3", "Value2_KEL_Spell3" }) + { + if (!original.TryGetValue(key, out double originalValue) || (int)originalValue == 100) continue; + if (!current.TryGetValue(key, out double currentValue) || (int)currentValue != 100) return false; + any = true; + } + return any; + } +} diff --git a/src/Core/Features/Ini/RessFeatureDetector.cs b/src/Core/Features/Ini/RessFeatureDetector.cs new file mode 100644 index 0000000..998c45f --- /dev/null +++ b/src/Core/Features/Ini/RessFeatureDetector.cs @@ -0,0 +1,52 @@ +using System.Text.RegularExpressions; +using AgainstRomeModifier.Core.Patches; +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Core.Features.Ini; + +internal sealed class RessFeatureDetector +{ + private readonly ILogger _logger; + + internal RessFeatureDetector(ILogger logger) => _logger = logger; + + internal void Detect(string gamePath, PatchProfile profile) + { + string path = Path.Combine(gamePath, @"SYSTEM\ress.ini"); + if (!File.Exists(path)) return; + + try + { + (string text, _, _) = PatchText.Read(File.ReadAllBytes(path)); + + Match production = Regex.Match(text, @"^FigRomInf00_Lanze_Schild\s*,.*$", RegexOptions.Multiline); + if (production.Success) + { + string[] columns = PatchText.ParseCsvLine(production.Value); + profile.FreeProduction = AllZero(columns, (int)RessIndex.FigProdCostStart, (int)RessIndex.FigProdCostEnd); + } + + Match upgrade = Regex.Match(text, @"^.*Ger_Kampf.*$", RegexOptions.Multiline); + if (upgrade.Success) + { + string[] columns = PatchText.ParseCsvLine(upgrade.Value); + profile.FreeUpgrade = AllZero(columns, (int)VolkresIndex.UnitUpgradeStart, (int)VolkresIndex.UnitUpgradeEnd); + } + + Match priest = Regex.Match(text, @"^FigGerPri00_Priester\s*,.*", RegexOptions.Multiline); + if (priest.Success) + { + string[] columns = PatchText.ParseCsvLine(priest.Value); + profile.NoSpellCost = AllZero(columns, (int)RessIndex.FigPriestSpellCostStart, (int)RessIndex.FigPriestSpellCostEnd); + } + } + catch (Exception ex) + { + _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "ress.ini", ex.Message)); + } + } + + private static bool AllZero(string[] columns, int first, int last) => + Enumerable.Range(first, last - first + 1) + .All(index => index < columns.Length && columns[index].Trim() == "0"); +} diff --git a/src/Core/Features/Install/InstallFeatureDetector.cs b/src/Core/Features/Install/InstallFeatureDetector.cs new file mode 100644 index 0000000..d0e5398 --- /dev/null +++ b/src/Core/Features/Install/InstallFeatureDetector.cs @@ -0,0 +1,32 @@ +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Core.Features.Install; + +internal sealed class InstallFeatureDetector +{ + private readonly ILogger _logger; + + internal InstallFeatureDetector(ILogger logger) => _logger = logger; + + internal void Detect(string gamePath, PatchProfile profile) + { + try + { + new LanguagePackFeature(_logger).TryGetLanguageOverlayState(gamePath, out bool languageOverlayEnabled); + profile.ToEnglish = languageOverlayEnabled; + } + catch (Exception ex) + { + _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "ToEng overlay", ex.Message)); + } + + try + { + profile.DgVoodoo = new DgVoodooFeature(_logger).IsInstalled(gamePath); + } + catch (Exception ex) + { + _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "dgVoodoo2", ex.Message)); + } + } +} diff --git a/src/Core/Features/Map/TeamDatFeatureDetector.cs b/src/Core/Features/Map/TeamDatFeatureDetector.cs new file mode 100644 index 0000000..0c54bed --- /dev/null +++ b/src/Core/Features/Map/TeamDatFeatureDetector.cs @@ -0,0 +1,78 @@ +using AgainstRomeModifier.Core.Patches; +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Core.Features.Map; + +internal readonly record struct TeamDatFeatureDetection(bool MaxPopulation, bool RomanPlayer); + +internal sealed class TeamDatFeatureDetector +{ + private readonly ILogger _logger; + + internal TeamDatFeatureDetector(ILogger logger) => _logger = logger; + + internal TeamDatFeatureDetection Detect(string gamePath, BackupManager backupManager) + { + bool maxPopulation = false; + bool romanPlayer = false; + try + { + foreach ((string key, byte[] original) in backupManager.BackupFiles) + { + if (!key.StartsWith("MAPS/", StringComparison.OrdinalIgnoreCase) || + !key.EndsWith("team.dat", StringComparison.OrdinalIgnoreCase)) continue; + + string path = Path.Combine(gamePath, key.Replace('/', '\\')); + if (!File.Exists(path)) continue; + + byte[] current = File.ReadAllBytes(path); + bool isEndless = key.StartsWith("MAPS/ENDL_", StringComparison.OrdinalIgnoreCase); + if (TryDetectOptions(original, current, isEndless, out bool fileMaxPopulation, out bool fileRomanPlayer)) + { + maxPopulation |= fileMaxPopulation; + romanPlayer |= fileRomanPlayer; + } + else + { + // Preserve the legacy conservative behavior for unknown team.dat modifications. + maxPopulation = true; + _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), key, "unknown team.dat state")); + } + } + } + catch (Exception ex) + { + _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "team.dat", ex.Message)); + } + + return new(maxPopulation, romanPlayer); + } + + internal static bool TryDetectOptions(byte[] original, byte[] current, bool isEndless, + out bool maxPopulation, out bool romanPlayer) + { + (bool MaxPopulation, bool RomanPlayer)[] combinations = + { + (false, false), + (true, false), + (false, true), + (true, true), + }; + + foreach ((bool candidateMaxPopulation, bool candidateRomanPlayer) in combinations) + { + bool effectiveRoman = candidateRomanPlayer && isEndless; + byte[] candidate = TeamDatPatcher.GetPatchedBytes(original, + new TeamDatOptions(candidateMaxPopulation, RomanPlayer: effectiveRoman)); + if (!current.SequenceEqual(candidate)) continue; + + maxPopulation = candidateMaxPopulation; + romanPlayer = effectiveRoman; + return true; + } + + maxPopulation = false; + romanPlayer = false; + return false; + } +} diff --git a/src/Core/Features/Objdef/ObjdefFeatureDetector.cs b/src/Core/Features/Objdef/ObjdefFeatureDetector.cs new file mode 100644 index 0000000..39b1ef2 --- /dev/null +++ b/src/Core/Features/Objdef/ObjdefFeatureDetector.cs @@ -0,0 +1,389 @@ +using System.Globalization; +using AgainstRomeModifier.Core.Patches; +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Core.Features.Objdef; + +internal sealed class ObjdefFeatureDetector +{ + private const int HousingCapacityMultiplier = 20; + private const int StorageCapacityMultiplier = 10; + private readonly ILogger _logger; + + internal ObjdefFeatureDetector(ILogger logger) => _logger = logger; + + internal void Detect(string gamePath, BackupManager backupManager, PatchProfile options) + { + // D. objdef.dau (housingCapacity20x, storageCapacity10x, fastBuildUpgradeRepair, balance) + string objdefPath = Path.Combine(gamePath, @"SYSTEM\DATA_MP\DEFAULTS\objdef.dau"); + if (File.Exists(objdefPath)) + { + try + { + byte[] raw = File.ReadAllBytes(objdefPath); + byte[] decomp = GameLZSS.DecompressPfil(raw); + string currentObjdef = PatchText.GameEncoding.GetString(decomp); + // 當前與原版文本各解析一次,四項偵測(Housing/Storage/FastBuild/Balance)共用 + List currentRows = ParseObjdefRows(currentObjdef); + + const string backupKey = "SYSTEM/DATA_MP/DEFAULTS/objdef.dau"; + if (backupManager.HasFile(backupKey)) + { + byte[] originalObjdefBytes = backupManager.GetBackupBytes(backupKey); + string originalObjdef = PatchText.GameEncoding.GetString(GameLZSS.DecompressPfil(originalObjdefBytes)); + List originalRows = ParseObjdefRows(originalObjdef); + options.HousingCapacity20x = HasHousingCapacityMultiplier(currentRows, originalRows, HousingCapacityMultiplier); + options.StorageCapacity10x = HasStorageCapacityMultiplier(currentRows, originalRows, StorageCapacityMultiplier); + options.HqHp10x = HasHqHpMultiplier(currentRows, originalRows, 10); + options.FastBuildUpgradeRepair = HasFastBuildUpgradeRepair(currentRows, originalRows); + options.ProjectileArcHeight = HasProjectileWeaponScale(currentRows, originalRows, useDrad: false); + } + + // 偵測是否已套用 Balance (若有任何一個兵種屬性被修改) + var unitRows = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string[] cols in currentRows) + { + if (cols.Length < 192) continue; + string name = cols[52].Trim(); + if (TroopConfig.UnitMeta.ContainsKey(name) || name == "FigZivMan00_Zivilist") + { + unitRows[name] = cols; + } + } + + var origUnitRows = backupManager.GetBackupUnitRows(); + + // 射程 3 倍會同步擴大遠程單位的 Sirad,讓 AI 能鎖定新射程外 + // 的目標;在平衡偵測前先識別此狀態,避免把該視野變更誤判為平衡。 + bool range3x = false; + if (unitRows.TryGetValue("FigRomSch01_Bogen", out var rangeProbeCols)) + { + double currentRange = BackupManager.GetUnitMaxRange(rangeProbeCols, "ranged_inf"); + double originalRange = backupManager.GetOriginalStats("FigRomSch01_Bogen")[7]; + range3x = currentRange > 0 && Math.Abs(currentRange - originalRange * 3.0) < 5.0; + } + + bool isFileBalanced = false; + foreach (string key in TroopConfig.UnitMeta.Keys) + { + if (!unitRows.ContainsKey(key) || !origUnitRows.ContainsKey(key)) continue; + string utype = TroopConfig.UnitMeta[key].UnitType; + + string[] cols = unitRows[key]; + string[] origCols = origUnitRows[key]; + + double curHp = 0, origHp = 0; + double.TryParse(cols[(int)ObjdefIndex.Hp].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curHp); + double.TryParse(origCols[(int)ObjdefIndex.Hp].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origHp); + + double curVw = 0, origVw = 0; + double.TryParse(cols[(int)ObjdefIndex.Vw].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curVw); + double.TryParse(origCols[(int)ObjdefIndex.Vw].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origVw); + + double curAw = 0, origAw = 0; + double.TryParse(cols[(int)ObjdefIndex.Aw].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curAw); + double.TryParse(origCols[(int)ObjdefIndex.Aw].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origAw); + + double curMoves = 0, origMoves = 0; + double.TryParse(cols[(int)ObjdefIndex.Moves].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curMoves); + double.TryParse(origCols[(int)ObjdefIndex.Moves].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origMoves); + + double curSight = 0, origSight = 0; + double.TryParse(cols[(int)ObjdefIndex.Sirad].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curSight); + double.TryParse(origCols[(int)ObjdefIndex.Sirad].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origSight); + + double curMeleeDmg = 0, curRangedDmg = 0; + BackupManager.GetMeleeAndRangedDmg(cols, utype, out curMeleeDmg, out curRangedDmg); + double origMeleeDmg = 0, origRangedDmg = 0; + BackupManager.GetMeleeAndRangedDmg(origCols, utype, out origMeleeDmg, out origRangedDmg); + + double curMeleeRelt = 0, curRangedRelt = 0; + BackupManager.GetMeleeAndRangedRelt(cols, utype, out curMeleeRelt, out curRangedRelt); + double origMeleeRelt = 0, origRangedRelt = 0; + BackupManager.GetMeleeAndRangedRelt(origCols, utype, out origMeleeRelt, out origRangedRelt); + + double curRange = BackupManager.GetUnitMaxRange(cols, utype); + double origRange = BackupManager.GetUnitMaxRange(origCols, utype); + + // 排除領袖以防 LeaderGlory 的干擾,且只比對不受 Range3x/Speed2x 影響的屬性 (Hp, Vw, Aw, Sight) + if (TroopConfig.UnitMeta[key].Tier != "leader") + { + bool isPriest = utype == "priest"; + bool hasExpandedRangeSight = range3x && TroopConfig.SupportsRangedRange3x(utype); + bool hasDiff = Math.Abs(curHp - origHp) > 0.01 || + Math.Abs(curVw - origVw) > 0.01 || + Math.Abs(curAw - origAw) > 0.01 || + (!isPriest && !hasExpandedRangeSight && Math.Abs(curSight - origSight) > 0.01); + + if (hasDiff) + { + isFileBalanced = true; + } + } + } + options.Balance = isFileBalanced; + options.LeaderGlory = DetectLeaderGlory(currentRows, origUnitRows); + + // 偵測遠程單位射程 3 倍與單位移動速度提升 2 倍 + bool speed2x = false; + + // 使用羅馬輕裝步兵 FigRomInf00_Lanze_Schild 偵測速度 2 倍 + if (unitRows.TryGetValue("FigRomInf00_Lanze_Schild", out var testMovesCols)) + { + double curMoves = 0; + double.TryParse(testMovesCols[(int)ObjdefIndex.Moves].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curMoves); + double expectedBaseMoves = backupManager.GetBaseStatsForUnit("FigRomInf00_Lanze_Schild", options)[4] / 2.0; + if (curMoves > 0 && Math.Abs(curMoves - expectedBaseMoves * 2.0) < 0.05) + { + speed2x = true; + } + } + + // 使用羅馬弓箭手 FigRomSch01_Bogen 偵測射程 3 倍 + if (unitRows.TryGetValue("FigRomSch01_Bogen", out var testRangeCols)) + { + double curRange = BackupManager.GetUnitMaxRange(testRangeCols, "ranged_inf"); + double expectedBaseRange = backupManager.GetBaseStatsForUnit("FigRomSch01_Bogen", options)[7]; + if (curRange > 0 && Math.Abs(curRange - expectedBaseRange * 3.0) < 5.0) + { + range3x = true; + } + } + + options.UnitMovementSpeed2x = speed2x; + options.RangedRange3x = range3x; + + // 使用塞爾特祭司 FigKelPri00_Priester 偵測法師施法距離 + bool spellEntireMap = false; + if (unitRows.TryGetValue("FigKelPri00_Priester", out var testSpellCols) && + origUnitRows.ContainsKey("FigKelPri00_Priester")) + { + double curRange = BackupManager.GetUnitMaxRange(testSpellCols, "priest"); + if (curRange > 0) + { + if (Math.Abs(curRange - 30000.0) < 100.0) + { + spellEntireMap = true; + } + } + } + options.SpellEntireMap = spellEntireMap; + } + catch (Exception ex) { _logger.Log(string.Format(Loc.Get("SvcLogDetectFailed"), "objdef.dau", ex.Message)); } + } + } + + /// 首領榮譽偵測:四族首領列的成長/光環欄位是否全部等於備份原值 × 5。 + private static bool DetectLeaderGlory(List currentRows, Dictionary originalRows) + { + bool any = false; + foreach (string leader in ObjdefPatcher.GloryLeaders) + { + if (!originalRows.TryGetValue(leader, out string[]? orig)) continue; + string[]? cur = currentRows.FirstOrDefault(r => r.Length > 52 && r[52].Trim().Equals(leader, StringComparison.OrdinalIgnoreCase)); + if (cur == null) continue; + foreach (int index in ObjdefPatcher.GloryColumns) + { + if (index >= orig.Length || index >= cur.Length) continue; + if (!double.TryParse(orig[index].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double origValue) || origValue <= 0) continue; + if (!double.TryParse(cur[index].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double curValue)) return false; + if (Math.Abs(curValue - origValue * ObjdefPatcher.GloryMultiplier) > 0.01) return false; + any = true; + } + } + return any; + } + + // --- Objdef dau 偵測細部輔助函數 --- + + /// 把 objdef 文本解析為資料列(長度 >= 100 的行),供各偵測共用、避免重複 split 與解析。 + private static List ParseObjdefRows(string content) + { + var rows = new List(); + foreach (string line in content.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)) + { + if (line.Length < 100) continue; + rows.Add(PatchText.ParseCsvLine(line)); + } + return rows; + } + + private static bool HasHousingCapacityMultiplier(List currentRows, List originalRows, int multiplier) + { + var currentValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string[] cols in currentRows) + { + if (cols.Length <= (int)ObjdefIndex.HousingCapacity || cols.Length <= (int)ObjdefIndex.Name) continue; + if (int.TryParse(cols[(int)ObjdefIndex.HousingCapacity].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)) + { + currentValues[cols[(int)ObjdefIndex.Name].Trim()] = value; + } + } + + bool foundHousing = false; + foreach (string[] cols in originalRows) + { + if (cols.Length <= (int)ObjdefIndex.HousingCapacity || cols.Length <= (int)ObjdefIndex.Name) continue; + if (!int.TryParse(cols[(int)ObjdefIndex.HousingCapacity].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int originalValue) || originalValue <= 0) continue; + + foundHousing = true; + string name = cols[(int)ObjdefIndex.Name].Trim(); + if (!currentValues.TryGetValue(name, out int currentValue) || currentValue != checked(originalValue * multiplier)) + { + return false; + } + } + return foundHousing; + } + + private static bool HasStorageCapacityMultiplier(List currentRows, List originalRows, int multiplier) + { + var currentValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string[] cols in currentRows) + { + if (cols.Length <= (int)ObjdefIndex.StorageCapacity || cols.Length <= (int)ObjdefIndex.Name) continue; + if (int.TryParse(cols[(int)ObjdefIndex.StorageCapacity].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)) + { + currentValues[cols[(int)ObjdefIndex.Name].Trim()] = value; + } + } + + bool foundStorage = false; + foreach (string[] cols in originalRows) + { + if (cols.Length <= (int)ObjdefIndex.StorageCapacity || cols.Length <= (int)ObjdefIndex.Name) continue; + string name = cols[(int)ObjdefIndex.Name].Trim(); + if (!name.StartsWith("Bau") || !(name.Contains("Hau") || name.Contains("Lag"))) continue; + if (!int.TryParse(cols[(int)ObjdefIndex.StorageCapacity].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int originalValue) || originalValue <= 0) continue; + + foundStorage = true; + if (!currentValues.TryGetValue(name, out int currentValue) || currentValue != checked(originalValue * multiplier)) + { + return false; + } + } + return foundStorage; + } + + private static bool HasHqHpMultiplier(List currentRows, List originalRows, int multiplier) + { + var currentValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string[] cols in currentRows) + { + if (cols.Length <= (int)ObjdefIndex.Hp || cols.Length <= (int)ObjdefIndex.Name) continue; + if (int.TryParse(cols[(int)ObjdefIndex.Hp].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)) + { + currentValues[cols[(int)ObjdefIndex.Name].Trim()] = value; + } + } + + bool foundHq = false; + foreach (string[] cols in originalRows) + { + if (cols.Length <= (int)ObjdefIndex.Hp || cols.Length <= (int)ObjdefIndex.Name) continue; + string name = cols[(int)ObjdefIndex.Name].Trim(); + if (!name.StartsWith("Bau") || !name.Contains("Hau")) continue; + if (!int.TryParse(cols[(int)ObjdefIndex.Hp].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int originalValue) || originalValue <= 0) continue; + + foundHq = true; + if (!currentValues.TryGetValue(name, out int currentValue) || currentValue != checked(originalValue * multiplier)) + { + return false; + } + } + return foundHq; + } + + /// 拋射武器縮放偵測:所有「akti=1 且 emit>0」的武器欄位,是否全部等於原值 × 對應倍率。 + /// useDrad=false 比對 w*_emit ×ArcEmitMultiplier(拋射彈道增高);useDrad=true 比對 w*_drad ×AccuracyDradMultiplier(遠程命中強化)。 + /// 與 ObjdefPatcher.PatchProjectileWeapons 的寫入邏輯對稱。 + private static bool HasProjectileWeaponScale(List currentRows, List originalRows, bool useDrad) + { + var currentByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string[] cols in currentRows) + { + if (cols.Length > (int)ObjdefIndex.Name) currentByName[cols[(int)ObjdefIndex.Name].Trim()] = cols; + } + + bool any = false; + foreach (string[] orig in originalRows) + { + if (orig.Length <= (int)ObjdefIndex.Name) continue; + if (!currentByName.TryGetValue(orig[(int)ObjdefIndex.Name].Trim(), out string[]? cur)) continue; + for (int w = 1; w <= 8; w++) + { + int active = (int)ObjdefIndex.Weapon1Akti + (w - 1) * 8; + int emit = (int)ObjdefIndex.Weapon1Emit + (w - 1) * 8; + int drad = (int)ObjdefIndex.Weapon1Drad + (w - 1) * 2; + int target = useDrad ? drad : emit; + if (emit >= orig.Length || target >= orig.Length || target >= cur.Length) continue; + if (orig[active].Trim() != "1") continue; + if (!long.TryParse(orig[emit].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long emitValue) || emitValue <= 0) continue; + if (!long.TryParse(orig[target].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long origValue) || origValue <= 0) continue; + long expected = useDrad + ? origValue * ObjdefPatcher.AccuracyDradMultiplier + : (long)Math.Round(origValue * ObjdefPatcher.ArcEmitMultiplier); + if (expected == origValue) continue; // 倍率後不變,無法區分 + if (!long.TryParse(cur[target].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long curValue) || curValue != expected) return false; + any = true; + } + } + return any; + } + + private static bool HasFastBuildUpgradeRepair(List currentRows, List originalRows) + { + var currentBuildValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + var currentUpgValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (string[] cols in currentRows) + { + if (cols.Length < 192) continue; + string name = cols[52].Trim(); + if (!name.StartsWith("Bau")) continue; + + if (int.TryParse(cols[73].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int buildVal)) + { + currentBuildValues[name] = buildVal; + } + if (int.TryParse(cols[74].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int upgVal)) + { + currentUpgValues[name] = upgVal; + } + } + + bool foundBuilding = false; + foreach (string[] cols in originalRows) + { + if (cols.Length < 192) continue; + string name = cols[52].Trim(); + if (!name.StartsWith("Bau")) continue; + + bool hasBuild = int.TryParse(cols[73].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int origBuildVal) && origBuildVal > 0; + bool hasUpg = int.TryParse(cols[74].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int origUpgVal) && origUpgVal > 0; + + if (!hasBuild && !hasUpg) continue; + + foundBuilding = true; + + if (hasBuild) + { + int expectedBuild = Math.Max(1, origBuildVal / 10); + if (!currentBuildValues.TryGetValue(name, out int curBuild) || curBuild != expectedBuild) + { + return false; + } + } + + if (hasUpg) + { + int expectedUpg = Math.Max(1, origUpgVal / 10); + if (!currentUpgValues.TryGetValue(name, out int curUpg) || curUpg != expectedUpg) + { + return false; + } + } + } + return foundBuilding; + } +} diff --git a/src/Core/Features/PatchProfile.cs b/src/Core/Features/PatchProfile.cs index 02268e4..3a4b422 100644 --- a/src/Core/Features/PatchProfile.cs +++ b/src/Core/Features/PatchProfile.cs @@ -6,58 +6,59 @@ public sealed class PatchProfile public void Set(string id, FeatureValue value) => _values[id] = value; + public void Set(FeatureKey key, T value) => Set(key.Id, key.Encode(value)); + public FeatureValue Get(string id) => _values.TryGetValue(id, out FeatureValue value) ? value : FeatureRegistry.GetDisabledValue(id); + public T Get(FeatureKey key) => key.Decode(Get(key.Id)); + public bool GetBool(string id) => Get(id).AsBool; public int GetInt(string id) => Get(id).AsInt; public T? GetObject(string id) where T : class => Get(id).AsObject as T; - private bool Bool(string id) => GetBool(id); - private void Bool(string id, bool value) => Set(id, FeatureValue.Of(value)); - - public bool FocusLoss { get => Bool("FocusLoss"); set => Bool("FocusLoss", value); } - public bool FastCiviProduction { get => Bool("FastCiviProduction"); set => Bool("FastCiviProduction", value); } - public bool InfiniteMorale { get => Bool("InfiniteMorale"); set => Bool("InfiniteMorale", value); } - public bool FreeProduction { get => Bool("FreeProduction"); set => Bool("FreeProduction", value); } - public bool FreeUpgrade { get => Bool("FreeUpgrade"); set => Bool("FreeUpgrade", value); } - public bool NoSpellCost { get => Bool("NoSpellCost"); set => Bool("NoSpellCost", value); } - public bool MaxPopulation { get => Bool("MaxPopulation"); set => Bool("MaxPopulation", value); } - public bool RomanEndless { get => Bool("RomanEndless"); set => Bool("RomanEndless", value); } - public bool Balance { get => Bool("Balance"); set => Bool("Balance", value); } - public bool HousingCapacity20x { get => Bool("HousingCapacity20x"); set => Bool("HousingCapacity20x", value); } - public bool StorageCapacity10x { get => Bool("StorageCapacity10x"); set => Bool("StorageCapacity10x", value); } - public bool HqHp10x { get => Bool("HqHp10x"); set => Bool("HqHp10x", value); } - public bool FastBuildUpgradeRepair { get => Bool("FastBuildUpgradeRepair"); set => Bool("FastBuildUpgradeRepair", value); } - public bool FoodHealing10x { get => Bool("FoodHealing10x"); set => Bool("FoodHealing10x", value); } - public bool CiviProduce20 { get => Bool("CiviProduce20"); set => Bool("CiviProduce20", value); } - public bool UnitRecruit20 { get => Bool("UnitRecruit20"); set => Bool("UnitRecruit20", value); } - public bool VillageBuildRange { get => Bool("VillageBuildRange"); set => Bool("VillageBuildRange", value); } - public bool DgVoodoo { get => Bool("DgVoodoo"); set => Bool("DgVoodoo", value); } - public bool ToEnglish { get => Bool("ToEnglish"); set => Bool("ToEnglish", value); } - public bool NoSpellAltar { get => Bool("NoSpellAltar"); set => Bool("NoSpellAltar", value); } - public bool SpellDamage5x { get => Bool("SpellDamage5x"); set => Bool("SpellDamage5x", value); } - public bool SpellHealing10x { get => Bool("SpellHealing10x"); set => Bool("SpellHealing10x", value); } - public bool SpellResurrection { get => Bool("SpellResurrection"); set => Bool("SpellResurrection", value); } - public bool GeneralSkills { get => Bool("GeneralSkills"); set => Bool("GeneralSkills", value); } - public bool LeaderGlory { get => Bool("LeaderGlory"); set => Bool("LeaderGlory", value); } - public bool RangedRange3x { get => Bool("RangedRange3x"); set => Bool("RangedRange3x", value); } - public bool UnitMovementSpeed2x { get => Bool("UnitMovementSpeed2x"); set => Bool("UnitMovementSpeed2x", value); } - public bool SpellEntireMap { get => Bool("SpellEntireMap"); set => Bool("SpellEntireMap", value); } - public bool SpellRange3x { get => Bool("SpellRange3x"); set => Bool("SpellRange3x", value); } - public bool ProjectileArcHeight { get => Bool("ProjectileArcHeight"); set => Bool("ProjectileArcHeight", value); } - public int GameSpeed { get => GetInt("GameSpeed"); set => Set("GameSpeed", FeatureValue.Of(value)); } + public bool FocusLoss { get => Get(FeatureKeys.FocusLoss); set => Set(FeatureKeys.FocusLoss, value); } + public bool FastCiviProduction { get => Get(FeatureKeys.FastCiviProduction); set => Set(FeatureKeys.FastCiviProduction, value); } + public bool InfiniteMorale { get => Get(FeatureKeys.InfiniteMorale); set => Set(FeatureKeys.InfiniteMorale, value); } + public bool FreeProduction { get => Get(FeatureKeys.FreeProduction); set => Set(FeatureKeys.FreeProduction, value); } + public bool FreeUpgrade { get => Get(FeatureKeys.FreeUpgrade); set => Set(FeatureKeys.FreeUpgrade, value); } + public bool NoSpellCost { get => Get(FeatureKeys.NoSpellCost); set => Set(FeatureKeys.NoSpellCost, value); } + public bool MaxPopulation { get => Get(FeatureKeys.MaxPopulation); set => Set(FeatureKeys.MaxPopulation, value); } + public bool RomanEndless { get => Get(FeatureKeys.RomanEndless); set => Set(FeatureKeys.RomanEndless, value); } + public bool Balance { get => Get(FeatureKeys.Balance); set => Set(FeatureKeys.Balance, value); } + public bool HousingCapacity20x { get => Get(FeatureKeys.HousingCapacity20x); set => Set(FeatureKeys.HousingCapacity20x, value); } + public bool StorageCapacity10x { get => Get(FeatureKeys.StorageCapacity10x); set => Set(FeatureKeys.StorageCapacity10x, value); } + public bool HqHp10x { get => Get(FeatureKeys.HqHp10x); set => Set(FeatureKeys.HqHp10x, value); } + public bool FastBuildUpgradeRepair { get => Get(FeatureKeys.FastBuildUpgradeRepair); set => Set(FeatureKeys.FastBuildUpgradeRepair, value); } + public bool FoodHealing10x { get => Get(FeatureKeys.FoodHealing10x); set => Set(FeatureKeys.FoodHealing10x, value); } + public bool CiviProduce20 { get => Get(FeatureKeys.CiviProduce20); set => Set(FeatureKeys.CiviProduce20, value); } + public bool UnitRecruit20 { get => Get(FeatureKeys.UnitRecruit20); set => Set(FeatureKeys.UnitRecruit20, value); } + public bool VillageBuildRange { get => Get(FeatureKeys.VillageBuildRange); set => Set(FeatureKeys.VillageBuildRange, value); } + public bool DgVoodoo { get => Get(FeatureKeys.DgVoodoo); set => Set(FeatureKeys.DgVoodoo, value); } + public bool ToEnglish { get => Get(FeatureKeys.ToEnglish); set => Set(FeatureKeys.ToEnglish, value); } + public bool NoSpellAltar { get => Get(FeatureKeys.NoSpellAltar); set => Set(FeatureKeys.NoSpellAltar, value); } + public bool SpellDamage5x { get => Get(FeatureKeys.SpellDamage5x); set => Set(FeatureKeys.SpellDamage5x, value); } + public bool SpellHealing10x { get => Get(FeatureKeys.SpellHealing10x); set => Set(FeatureKeys.SpellHealing10x, value); } + public bool SpellResurrection { get => Get(FeatureKeys.SpellResurrection); set => Set(FeatureKeys.SpellResurrection, value); } + public bool GeneralSkills { get => Get(FeatureKeys.GeneralSkills); set => Set(FeatureKeys.GeneralSkills, value); } + public bool LeaderGlory { get => Get(FeatureKeys.LeaderGlory); set => Set(FeatureKeys.LeaderGlory, value); } + public bool RangedRange3x { get => Get(FeatureKeys.RangedRange3x); set => Set(FeatureKeys.RangedRange3x, value); } + public bool UnitMovementSpeed2x { get => Get(FeatureKeys.UnitMovementSpeed2x); set => Set(FeatureKeys.UnitMovementSpeed2x, value); } + public bool SpellEntireMap { get => Get(FeatureKeys.SpellEntireMap); set => Set(FeatureKeys.SpellEntireMap, value); } + public bool SpellRange3x { get => Get(FeatureKeys.SpellRange3x); set => Set(FeatureKeys.SpellRange3x, value); } + public bool ProjectileArcHeight { get => Get(FeatureKeys.ProjectileArcHeight); set => Set(FeatureKeys.ProjectileArcHeight, value); } + public int GameSpeed { get => Get(FeatureKeys.GameSpeed); set => Set(FeatureKeys.GameSpeed, value); } public Dictionary EndlessAiModules { get; set; } = new(StringComparer.OrdinalIgnoreCase); public bool GetEndlessAiModule(string moduleId) => - EndlessAiModules.TryGetValue(moduleId, out bool enabled) ? enabled : GetBool("EndlessAi." + moduleId); + EndlessAiModules.TryGetValue(moduleId, out bool enabled) ? enabled : Get(FeatureKeys.EndlessAi(moduleId)); public Dictionary? CustomUnitStats { - get => GetObject>("CustomUnitStats"); - set => Set("CustomUnitStats", FeatureValue.Of(value)); + get => Get(FeatureKeys.CustomUnitStats); + set => Set(FeatureKeys.CustomUnitStats, value); } public void NormalizeCompositeValues() { - foreach (var (id, enabled) in EndlessAiModules) Set("EndlessAi." + id, FeatureValue.Of(enabled)); + foreach (var (id, enabled) in EndlessAiModules) Set(FeatureKeys.EndlessAi(id), enabled); } } diff --git a/src/Core/Services/BackupAutoHealer.cs b/src/Core/Services/BackupAutoHealer.cs new file mode 100644 index 0000000..59fab63 --- /dev/null +++ b/src/Core/Services/BackupAutoHealer.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using AgainstRomeModifier.Maps; + +namespace AgainstRomeModifier.Core.Services; + +internal sealed class BackupAutoHealer +{ + private static readonly string[] RecoverableFiles = + { + "Against_Rome.exe", + "SYSTEM/cl_script.ini", + "SYSTEM/cl_epara.ini", + "SYSTEM/ress.ini", + "SYSTEM/DATA_MP/DEFAULTS/objdef.dau", + "SYSTEM/DATA_MP/DEFAULTS/partgeo.dau", + "SYSTEM/CLMK/icon.ini", + "SYSTEM/CLAK/cl_scint.ini", + "SYSTEM/CLAK/SCRIPT/ak_anfuehrer.bci" + }; + + private readonly IDictionary _files; + private readonly ILogger _logger; + private readonly Func _cleanEparaFactory; + + internal BackupAutoHealer( + IDictionary files, + ILogger logger, + Func cleanEparaFactory) + { + _files = files; + _logger = logger; + _cleanEparaFactory = cleanEparaFactory; + } + + internal bool Heal(string gamePath) + { + if (string.IsNullOrWhiteSpace(gamePath) || !Directory.Exists(gamePath)) return false; + + bool changed = false; + foreach (string relativePath in RecoverableFiles) + { + if (_files.ContainsKey(relativePath)) continue; + changed |= relativePath == "SYSTEM/cl_epara.ini" + ? TryHealEpara(relativePath) + : TryHealFile(gamePath, relativePath); + } + + if (!HasTeamDat()) changed |= TryHealTeamFiles(gamePath); + return changed; + } + + private bool TryHealEpara(string relativePath) + { + try + { + _files[relativePath] = _cleanEparaFactory(); + _logger.Log(Loc.Get("SvcLogEparaHealed")); + return true; + } + catch (Exception exception) + { + _logger.Log(string.Format(Loc.Get("SvcLogEparaHealFailed"), exception.Message)); + return false; + } + } + + private bool TryHealFile(string gamePath, string relativePath) + { + string fullPath = Path.Combine(gamePath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + string backupPath = fullPath + ".bak"; + string loadPath = File.Exists(backupPath) ? backupPath : fullPath; + if (!File.Exists(loadPath)) return false; + + try + { + byte[] bytes = File.ReadAllBytes(loadPath); + if (relativePath == "SYSTEM/DATA_MP/DEFAULTS/partgeo.dau") + { + if (!OriginalFileValidator.IsPartgeoOriginal(bytes)) + { + _logger.Log(string.Format(Loc.Get("SvcLogAutoHealFailed"), relativePath, + "partgeo.dau 已被修改,不能作為備份基準 / partgeo.dau is already modified")); + return false; + } + if (!File.Exists(backupPath)) + { + File.Copy(loadPath, backupPath, overwrite: false); + _logger.Log(string.Format("已建立原版檔案實體備份: {0}", backupPath)); + } + } + + _files[relativePath] = bytes; + _logger.Log(string.Format(Loc.Get("SvcLogAutoHealed"), relativePath)); + return true; + } + catch (Exception exception) + { + _logger.Log(string.Format(Loc.Get("SvcLogAutoHealFailed"), relativePath, exception.Message)); + return false; + } + } + + private bool HasTeamDat() => _files.Keys.Any(key => + key.StartsWith("MAPS/", StringComparison.OrdinalIgnoreCase) && + key.EndsWith("team.dat", StringComparison.OrdinalIgnoreCase)); + + private bool TryHealTeamFiles(string gamePath) + { + string mapsPath = Path.Combine(gamePath, "MAPS"); + if (!Directory.Exists(mapsPath)) return false; + + try + { + bool changed = false; + string normalizedGamePath = Path.GetFullPath(gamePath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + foreach (string file in Directory.GetFiles(mapsPath, "team.dat", SearchOption.AllDirectories)) + { + if (CustomMapManifest.IsCustomMapDirectory(Path.GetDirectoryName(file)!)) continue; + string relativePath = Path.GetRelativePath(normalizedGamePath, file).Replace('\\', '/'); + string backupPath = file + ".bak"; + string loadPath = File.Exists(backupPath) ? backupPath : file; + _files[relativePath] = File.ReadAllBytes(loadPath); + changed = true; + } + _logger.Log(Loc.Get("SvcLogTeamDatHealed")); + return changed; + } + catch (Exception exception) + { + _logger.Log(string.Format(Loc.Get("SvcLogTeamDatHealFailed"), exception.Message)); + return false; + } + } +} diff --git a/src/Core/Services/BackupManager.cs b/src/Core/Services/BackupManager.cs index 43bae8c..d3d7698 100644 --- a/src/Core/Services/BackupManager.cs +++ b/src/Core/Services/BackupManager.cs @@ -1,29 +1,38 @@ using System; using System.IO; -using System.IO.Compression; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Globalization; -using AgainstRomeModifier.Core.Patches; +using System.Collections.ObjectModel; using AgainstRomeModifier.Core.Features; -using AgainstRomeModifier.Maps; namespace AgainstRomeModifier.Core.Services { public class BackupManager { private readonly Dictionary _backupFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); - private Dictionary? _backupUnitRows; - private readonly object _unitRowsLock = new object(); + private readonly IReadOnlyDictionary _backupFilesView; + private readonly BackupAutoHealer _autoHealer; + private readonly GameDirectoryBackupLoader _gameDirectoryLoader; + private readonly BackupSourceLoader _sourceLoader; + private readonly UnitBaselineCatalog _unitBaselines; private readonly ILogger _logger; public BackupManager(ILogger logger) { _logger = logger; + _backupFilesView = new ReadOnlyDictionary(_backupFiles); + _autoHealer = new BackupAutoHealer(_backupFiles, logger, CleanEparaBaseline.CreateBytes); + _gameDirectoryLoader = new GameDirectoryBackupLoader(logger, CleanEparaBaseline.CreateBytes); + _sourceLoader = new BackupSourceLoader(typeof(BackupManager).Assembly, AppContext.BaseDirectory); + _unitBaselines = new UnitBaselineCatalog(_backupFiles); } - public Dictionary BackupFiles => _backupFiles; + public IReadOnlyDictionary BackupFiles => _backupFilesView; + + internal void SetBackupFile(string key, byte[] bytes) + { + _backupFiles[key] = bytes; + _unitBaselines.Reset(); + } public bool HasFile(string key) => _backupFiles.ContainsKey(key); @@ -39,36 +48,20 @@ public byte[] GetBackupBytes(string key) public void Clear() { _backupFiles.Clear(); - lock (_unitRowsLock) - { - _backupUnitRows = null; - } + _unitBaselines.Reset(); } public void LoadBackupZipToMemory(string gamePath) { - string? resourceName = typeof(BackupManager).Assembly - .GetManifestResourceNames() - .FirstOrDefault(n => n.EndsWith("Backup.zip")); - - if (resourceName != null) - { - using Stream stream = typeof(BackupManager).Assembly.GetManifestResourceStream(resourceName)!; - LoadZipToDictionary(stream); - TryAutoHealBackupFiles(gamePath); - ValidateBackupResources(); - _logger.Log(Loc.Get("SvcLogBackupLoadedEmbedded")); - return; - } - - string localZip = Path.Combine(AppContext.BaseDirectory, "Backup.zip"); - if (File.Exists(localZip)) + BackupZipLoadResult zip = _sourceLoader.TryLoadPreferredZip(); + if (zip.Source != BackupZipSource.None) { - using FileStream stream = File.OpenRead(localZip); - LoadZipToDictionary(stream); + ReplaceBackupFiles(zip.Files); TryAutoHealBackupFiles(gamePath); ValidateBackupResources(); - _logger.Log(Loc.Get("SvcLogBackupLoadedLocal")); + _logger.Log(Loc.Get(zip.Source == BackupZipSource.Embedded + ? "SvcLogBackupLoadedEmbedded" + : "SvcLogBackupLoadedLocal")); return; } @@ -80,139 +73,18 @@ public void LoadBackupZipToMemory(string gamePath) public void TryAutoHealBackupFiles(string gamePath) { - if (string.IsNullOrWhiteSpace(gamePath) || !Directory.Exists(gamePath)) - { - return; - } - - string[] requiredFiles = { - "Against_Rome.exe", - "SYSTEM/cl_script.ini", - "SYSTEM/cl_epara.ini", - "SYSTEM/ress.ini", - "SYSTEM/DATA_MP/DEFAULTS/objdef.dau", - "SYSTEM/DATA_MP/DEFAULTS/partgeo.dau", - "SYSTEM/CLMK/icon.ini", - "SYSTEM/CLAK/cl_scint.ini", - "SYSTEM/CLAK/SCRIPT/ak_anfuehrer.bci" - }; - - foreach (string relPath in requiredFiles) - { - if (!_backupFiles.ContainsKey(relPath)) - { - if (relPath == "SYSTEM/cl_epara.ini") - { - try - { - byte[] cleanEparaBytes = Encoding.GetEncoding(1251).GetBytes(GetCleanEparaText()); - _backupFiles[relPath] = GameLZSS.CompressPfil(cleanEparaBytes, CreateEmptyPfilHeader()); - _logger.Log(Loc.Get("SvcLogEparaHealed")); - } - catch (Exception ex) - { - _logger.Log(string.Format(Loc.Get("SvcLogEparaHealFailed"), ex.Message)); - } - } - else - { - string fullPath = Path.Combine(gamePath, relPath.Replace('/', Path.DirectorySeparatorChar)); - string bakPath = fullPath + ".bak"; - string loadPath = File.Exists(bakPath) ? bakPath : fullPath; - if (File.Exists(loadPath)) - { - try - { - byte[] healBytes = File.ReadAllBytes(loadPath); - if (relPath == "SYSTEM/DATA_MP/DEFAULTS/partgeo.dau") - { - // partgeo.dau 不在內嵌 Backup.zip 內,只能以現場檔案為基準: - // 必須先驗證未被修改,並立即建立 .bak,之後每次啟動都優先讀 .bak, - // 避免套用「拋射彈道增高」後的檔案被誤收為備份。 - if (!IsPartgeoOriginal(healBytes)) - { - _logger.Log(string.Format(Loc.Get("SvcLogAutoHealFailed"), relPath, "partgeo.dau 已被修改,不能作為備份基準 / partgeo.dau is already modified")); - continue; - } - if (!File.Exists(bakPath)) - { - File.Copy(loadPath, bakPath, overwrite: false); - _logger.Log(string.Format("已建立原版檔案實體備份: {0}", bakPath)); - } - } - _backupFiles[relPath] = healBytes; - _logger.Log(string.Format(Loc.Get("SvcLogAutoHealed"), relPath)); - } - catch (Exception ex) { _logger.Log(string.Format(Loc.Get("SvcLogAutoHealFailed"), relPath, ex.Message)); } - } - } - } - } - - bool hasTeamDat = _backupFiles.Keys.Any(k => k.StartsWith("MAPS/", StringComparison.OrdinalIgnoreCase) && k.EndsWith("team.dat", StringComparison.OrdinalIgnoreCase)); - if (!hasTeamDat) - { - string mapsPath = Path.Combine(gamePath, "MAPS"); - if (Directory.Exists(mapsPath)) - { - try - { - string normalizedGamePath = Path.GetFullPath(gamePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - foreach (string file in Directory.GetFiles(mapsPath, "team.dat", SearchOption.AllDirectories)) - { - if (CustomMapManifest.IsCustomMapDirectory(Path.GetDirectoryName(file)!)) continue; - string relPath = Path.GetRelativePath(normalizedGamePath, file).Replace('\\', '/'); - string bakPath = file + ".bak"; - string loadPath = File.Exists(bakPath) ? bakPath : file; - _backupFiles[relPath] = File.ReadAllBytes(loadPath); - } - _logger.Log(Loc.Get("SvcLogTeamDatHealed")); - } - catch (Exception ex) { _logger.Log(string.Format(Loc.Get("SvcLogTeamDatHealFailed"), ex.Message)); } - } - } + if (_autoHealer.Heal(gamePath)) + _unitBaselines.Reset(); } public List FindMissingBackupResources() { - var missing = new List(); - string[] requiredFiles = { - "Against_Rome.exe", - "SYSTEM/cl_script.ini", - "SYSTEM/cl_epara.ini", - "SYSTEM/ress.ini", - "SYSTEM/DATA_MP/DEFAULTS/objdef.dau", - "SYSTEM/CLMK/icon.ini", - "SYSTEM/CLAK/cl_scint.ini", - "SYSTEM/CLAK/SCRIPT/ak_anfuehrer.bci" - }; - - foreach (string key in requiredFiles) - { - if (!_backupFiles.ContainsKey(key)) - { - missing.Add(key); - } - } - - bool hasTeamDat = _backupFiles.Keys.Any(k => k.StartsWith("MAPS/", StringComparison.OrdinalIgnoreCase) && k.EndsWith("team.dat", StringComparison.OrdinalIgnoreCase)); - if (!hasTeamDat) - { - missing.Add("MAPS/.../team.dat"); - } - - return missing; + return BackupValidator.FindMissing(_backupFiles); } public void ValidateBackupResources() { - var missing = FindMissingBackupResources(); - if (missing.Count > 0) - { - string msg = Loc.Get("SvcLogBackupIncomplete") + "\r\n" + string.Join("\r\n", missing); - _logger.Log(msg); - throw new InvalidDataException(msg); - } + BackupValidator.Validate(_backupFiles, _logger); } public bool EnsureBackupLoadedForGamePath(string gamePath) @@ -226,136 +98,10 @@ public bool EnsureBackupLoadedForGamePath(string gamePath) public bool TryLoadBackupFromGameDirectory(string gamePath, bool showError) { - if (string.IsNullOrWhiteSpace(gamePath) || !Directory.Exists(gamePath)) - { - if (showError) - { - throw new InvalidDataException("找不到 Backup.zip,且遊戲路徑無效,無法建立本機備份。 / Backup.zip not found and game path is invalid."); - } - return false; - } - - var loaded = new Dictionary(StringComparer.OrdinalIgnoreCase); - string[] requiredFiles = { - "Against_Rome.exe", - "SYSTEM/cl_script.ini", - "SYSTEM/cl_epara.ini", - "SYSTEM/ress.ini", - "SYSTEM/DATA_MP/DEFAULTS/objdef.dau", - "SYSTEM/DATA_MP/DEFAULTS/partgeo.dau", - "SYSTEM/CLMK/icon.ini", - "SYSTEM/CLAK/cl_scint.ini", - "SYSTEM/CLAK/SCRIPT/ak_anfuehrer.bci" - }; - - try - { - foreach (string relPath in requiredFiles) - { - if (relPath == "SYSTEM/cl_epara.ini") - { - try - { - byte[] cleanEparaBytes = Encoding.GetEncoding(1251).GetBytes(GetCleanEparaText()); - loaded[relPath] = GameLZSS.CompressPfil(cleanEparaBytes, CreateEmptyPfilHeader()); - } - catch (Exception ex) - { - _logger.Log(string.Format(Loc.Get("SvcLogEparaBuildFailed"), ex.Message)); - } - continue; - } - - string fullPath = Path.Combine(gamePath, relPath.Replace('/', Path.DirectorySeparatorChar)); - string bakPath = fullPath + ".bak"; - - if (File.Exists(bakPath)) - { - loaded[relPath] = File.ReadAllBytes(bakPath); - } - else - { - if (!File.Exists(fullPath)) - { - continue; - } - - byte[] fileBytes = File.ReadAllBytes(fullPath); - - if (relPath == "Against_Rome.exe") - { - if (!IsExeOriginal(fileBytes)) - { - throw new InvalidDataException("Against_Rome.exe 已經被修改過,或不是支援的原版檔案,無法建立備份。請先還原原版檔案。 / Against_Rome.exe is already modified or not supported. Please restore original file first."); - } - } - else if (relPath == "SYSTEM/DATA_MP/DEFAULTS/objdef.dau") - { - if (!IsObjdefOriginal(fileBytes)) - { - throw new InvalidDataException("SYSTEM/DATA_MP/DEFAULTS/objdef.dau 已經被修改過,無法作為備份基準。請先驗證遊戲完整性。 / objdef.dau is already modified. Please verify game integrity first."); - } - } - else if (relPath == "SYSTEM/DATA_MP/DEFAULTS/partgeo.dau") - { - if (!IsPartgeoOriginal(fileBytes)) - { - throw new InvalidDataException("SYSTEM/DATA_MP/DEFAULTS/partgeo.dau 已經被修改過,無法作為備份基準。請先驗證遊戲完整性。 / partgeo.dau is already modified. Please verify game integrity first."); - } - } - - File.Copy(fullPath, bakPath, overwrite: false); - _logger.Log(string.Format("已建立原版檔案實體備份: {0}", bakPath)); - loaded[relPath] = fileBytes; - } - } - - string mapsPath = Path.Combine(gamePath, "MAPS"); - if (Directory.Exists(mapsPath)) - { - string normalizedGamePath = Path.GetFullPath(gamePath) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - foreach (string file in Directory.GetFiles(mapsPath, "team.dat", SearchOption.AllDirectories)) - { - // 自製圖由 .arm_custom_map 明確標記;它們不是原廠備份基準,也不可建立 .bak。 - if (CustomMapManifest.IsCustomMapDirectory(Path.GetDirectoryName(file)!)) continue; - string relPath = Path.GetRelativePath(normalizedGamePath, file).Replace('\\', '/'); - string bakPath = file + ".bak"; - - if (File.Exists(bakPath)) - { - loaded[relPath] = File.ReadAllBytes(bakPath); - } - else - { - byte[] fileBytes = File.ReadAllBytes(file); - File.Copy(file, bakPath, overwrite: false); - _logger.Log(string.Format("已建立原版地圖檔案實體備份: {0}", bakPath)); - loaded[relPath] = fileBytes; - } - } - } - } - catch (Exception ex) - { - if (showError) - { - throw; - } - _logger.Log("建立備份時發生錯誤: " + ex.Message); + if (!_gameDirectoryLoader.TryLoad(gamePath, showError, out Dictionary loaded)) return false; - } - - _backupFiles.Clear(); - foreach (var kvp in loaded) - { - _backupFiles[kvp.Key] = kvp.Value; - } - lock (_unitRowsLock) - { - _backupUnitRows = null; - } + ReplaceBackupFiles(loaded); TryAutoHealBackupFiles(gamePath); var missing = FindMissingBackupResources(); @@ -372,493 +118,67 @@ public bool TryLoadBackupFromGameDirectory(string gamePath, bool showError) return true; } - private bool IsExeOriginal(byte[] exeBytes) - { - try - { - var focusState = ExePatchModel.GetExePatchState(exeBytes); - var spellState = ExePatchModel.GetSpellAltarPatchState(exeBytes); - var rangeState = ExePatchModel.GetVillageBuildRangePatchState(exeBytes); - var setterState = ExePatchModel.GetVillageSetterPatchState(exeBytes); - var romanEndlessState = ExePatchModel.GetRomanEndlessPatchState(exeBytes); - - return focusState == ExePatchState.Original && - spellState == ExeSpellAltarPatchState.Original && - rangeState == ExeVillageRangePatchState.Original && - setterState == ExeVillageSetterPatchState.Original && - romanEndlessState == ExeRomanEndlessPatchState.Original; - } - catch - { - return false; - } - } - - private bool IsObjdefOriginal(byte[] dauBytes) - { - try - { - byte[] decomp = GameLZSS.DecompressPfil(dauBytes); - string text = Encoding.GetEncoding(1251).GetString(decomp); - string lineEnding = text.Contains("\r\n") ? "\r\n" : "\n"; - string[] lines = text.Split(new string[] { lineEnding }, StringSplitOptions.None); - - foreach (string line in lines) - { - if (line.Length < 100) continue; - string[] cols = PatchText.ParseCsvLine(line); - if (cols.Length < 192) continue; - - string name = cols[52].Trim(); - if (name == "FigRomAnf00_Anfuehrer") - { - string hpStr = cols[19].Trim(); - if (double.TryParse(hpStr, NumberStyles.Any, CultureInfo.InvariantCulture, out double hp)) - { - if (Math.Abs(hp - 400) > 0.1) - { - return false; - } - } - } - } - return true; - } - catch - { - return false; - } - } - - /// 以 Pfeil00(弓箭拋射物)的原版 ysub 值 5832704 (= 89.0 的 16.16 定點) 驗證 partgeo.dau 未被「拋射彈道增高」修改過。 - private static bool IsPartgeoOriginal(byte[] dauBytes) - { - try - { - byte[] decomp = GameLZSS.DecompressPfil(dauBytes); - string text = Encoding.GetEncoding(1251).GetString(decomp); - foreach (string line in text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)) - { - string[] cols = PatchText.ParseCsvLine(line); - if (cols.Length <= PartgeoPatcher.YsubColumn) continue; - if (cols[2].Trim() != "Pfeil00") continue; - return long.TryParse(cols[PartgeoPatcher.YsubColumn].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long ysub) && ysub == 5832704; - } - return false; - } - catch - { - return false; - } - } - - private void LoadZipToDictionary(Stream stream) + private void ReplaceBackupFiles(IReadOnlyDictionary files) { _backupFiles.Clear(); - using (ZipArchive archive = new ZipArchive(stream)) - { - foreach (ZipArchiveEntry entry in archive.Entries) - { - if (entry.Name == "") continue; - string key = entry.FullName.Replace('\\', '/'); - if (Path.IsPathRooted(key) || - key.StartsWith("/", StringComparison.Ordinal) || - key.Split('/').Any(part => part == "..")) - { - throw new InvalidDataException("Backup.zip contains an unsafe entry path: " + entry.FullName); - } - using (Stream entryStream = entry.Open()) - { - using (MemoryStream ms = new MemoryStream()) - { - entryStream.CopyTo(ms); - _backupFiles[key] = ms.ToArray(); - } - } - } - } - lock (_unitRowsLock) - { - _backupUnitRows = null; - } - } - - /// 建立內容全零的 64-byte PFIL 標頭,供無原始檔可沿用標頭時壓縮使用。 - private static byte[] CreateEmptyPfilHeader() - { - byte[] header = new byte[64]; - header[0] = (byte)'P'; - header[1] = (byte)'F'; - header[2] = (byte)'I'; - header[3] = (byte)'L'; - return header; + foreach ((string key, byte[] bytes) in files) + _backupFiles[key] = bytes; + _unitBaselines.Reset(); } public Dictionary GetBackupUnitRows() { - lock (_unitRowsLock) - { - if (_backupUnitRows != null) return _backupUnitRows; - - _backupUnitRows = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (_backupFiles.TryGetValue("SYSTEM/DATA_MP/DEFAULTS/objdef.dau", out byte[]? dauBytes)) - { - byte[] decomp = GameLZSS.DecompressPfil(dauBytes); - string text = Encoding.GetEncoding(1251).GetString(decomp); - string lineEnding = text.Contains("\r\n") ? "\r\n" : "\n"; - string[] lines = text.Split(new string[] { lineEnding }, StringSplitOptions.None); - for (int idx = 2; idx < lines.Length; idx++) - { - string line = lines[idx]; - if (line.Length < 100) continue; - string[] cols = PatchText.ParseCsvLine(line); - if (cols.Length < 192) continue; - string name = cols[52].Trim(); // Name - if (TroopConfig.UnitMeta.ContainsKey(name) || name == "FigZivMan00_Zivilist") - { - _backupUnitRows[name] = cols; - } - } - } - return _backupUnitRows; - } + return _unitBaselines.GetUnitRows(); } // --- 屬性解析核心方法(原本寫在 UI 裡的) --- public static bool SupportsConfigurableSpellRadius(string key) { - return key.Equals("FigKelPri00_Priester", StringComparison.OrdinalIgnoreCase) || - key.Equals("FigHunPri00_Priester", StringComparison.OrdinalIgnoreCase); + return UnitStatParser.SupportsConfigurableSpellRadius(key); } public double[] GetOriginalStats(string key) { - var backupUnitRows = GetBackupUnitRows(); - if (!backupUnitRows.ContainsKey(key)) - { - return new double[] { 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - } - string[] cols = backupUnitRows[key]; - - double hp = 0; - double.TryParse(cols[(int)ObjdefIndex.Hp].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out hp); - - double vw = 0; - double.TryParse(cols[(int)ObjdefIndex.Vw].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out vw); - - double aw = 0; - double.TryParse(cols[(int)ObjdefIndex.Aw].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out aw); - - string utype = "melee_inf"; - if (TroopConfig.UnitMeta.ContainsKey(key)) - { - utype = TroopConfig.UnitMeta[key].UnitType; - } - - double meleeDam = 0, rangedDam = 0; - GetMeleeAndRangedDmg(cols, utype, out meleeDam, out rangedDam); - - double meleeRelt = 0, rangedRelt = 0; - GetMeleeAndRangedRelt(cols, utype, out meleeRelt, out rangedRelt); - - double dmg = meleeDam; - double relt = meleeRelt; - if (utype == "ranged_inf" || utype == "ranged_cav") - { - dmg = rangedDam; - relt = rangedRelt; - } - else if (utype == "siege") - { - dmg = Math.Max(meleeDam, rangedDam); - relt = Math.Max(meleeRelt, rangedRelt); - } - else if (utype == "hybrid_inf") - { - dmg = meleeDam; - relt = meleeRelt; - } - - double origMoves = 0; - double.TryParse(cols[(int)ObjdefIndex.Moves].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origMoves); - double speed = origMoves > 0 ? Math.Round(origMoves * 2.0, 1) : 0; - - double origSight = 0; - double.TryParse(cols[(int)ObjdefIndex.Sirad].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origSight); - double sight = origSight; - - double range = GetUnitMaxRange(cols, utype); - - bool supportsSpellRadius = SupportsConfigurableSpellRadius(key); - double spellRadius = supportsSpellRadius ? 500 : 0; - - return new double[] { hp, dmg, vw, aw, speed, sight, relt, range, spellRadius }; + return _unitBaselines.GetOriginalStats(key); } public double[] GetDefaultBalancedStats(string key) { - if (TroopConfig.BalancedUnitStats.TryGetValue(key, out double[]? stats)) - { - return (double[])stats.Clone(); - } - return GetOriginalStats(key); + return _unitBaselines.GetDefaultBalancedStats(key); } public double[] GetBaseStatsForUnit(string key, PatchProfile options) { - double[] original = GetOriginalStats(key); - double[] balanced = options.Balance ? GetDefaultBalancedStats(key) : original; - - // 自訂兵種屬性與內建平衡同受「啟用自訂兵種屬性平衡與陣營特色」開關把關, - // 未勾選時一律回到原版屬性。 - if (options.Balance && options.CustomUnitStats != null && options.CustomUnitStats.TryGetValue(key, out double[]? custom) && custom != null) - { - bool ignoreRange = TroopConfig.UnitMeta.TryGetValue(key, out var meta) && - ((options.RangedRange3x && TroopConfig.SupportsRangedRange3x(meta.UnitType)) || - (options.SpellEntireMap && meta.UnitType == "priest")); - bool ignoreSpeed = options.UnitMovementSpeed2x; - bool ignoreSpellRadius = options.SpellRange3x && SupportsConfigurableSpellRadius(key); - return MergeUnitStatsLayers(balanced, custom, SupportsConfigurableSpellRadius(key), ignoreSpeed, ignoreRange, ignoreSpellRadius, - meta?.UnitType == "priest"); - } - - return balanced; + return _unitBaselines.GetBaseStatsForUnit(key, options); } public static double[] MergeUnitStatsLayers(double[] fallback, double[] custom, bool supportsSpellRadius, bool ignoreMovementSpeed = false, bool ignoreRange = false, bool ignoreSpellRadius = false, bool removePriestSight = false) { - ArgumentNullException.ThrowIfNull(fallback); - ArgumentNullException.ThrowIfNull(custom); - - double[] layered = new double[9]; - for (int i = 0; i < layered.Length; i++) - { - if (i == 8 && !supportsSpellRadius) - { - layered[i] = 0; - continue; - } - - // 這三個欄位已從自訂兵種功能移除,永遠回到基準值;由四個獨立功能負責。 - if (i is 4 or 7 or 8 || (i == 5 && removePriestSight)) - { - layered[i] = fallback.Length > i ? fallback[i] : 0; - continue; - } - - layered[i] = custom.Length > i - ? custom[i] - : (fallback.Length > i ? fallback[i] : 0); - } - return layered; + return UnitStatParser.MergeLayers(fallback, custom, supportsSpellRadius, + ignoreMovementSpeed, ignoreRange, ignoreSpellRadius, removePriestSight); } public static void GetMeleeAndRangedDmg(string[] cols, string utype, out double meleeDmg, out double rangedDmg) { - meleeDmg = 0; - rangedDmg = 0; - for (int w = 1; w <= 8; w++) - { - int wAktiIdx = (int)ObjdefIndex.Weapon1Akti + (w - 1) * 8; - int wDamIdx = wAktiIdx + 1; - int wDtypIdx = (int)ObjdefIndex.Weapon1Dtyp + (w - 1); - if (wAktiIdx >= cols.Length || wDamIdx >= cols.Length || wDtypIdx >= cols.Length) - { - continue; - } - if (cols[wAktiIdx].Trim() == "1") - { - double damVal; - double.TryParse(cols[wDamIdx].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out damVal); - string wDtyp = cols[wDtypIdx].Trim(); - bool isRangedWeapon = (wDtyp == "1" || wDtyp == "2" || wDtyp == "3" || wDtyp == "4" || utype == "siege"); - if (isRangedWeapon) - { - if (damVal > rangedDmg) rangedDmg = damVal; - } - else - { - if (damVal > meleeDmg) meleeDmg = damVal; - } - } - } + UnitStatParser.GetMeleeAndRangedDamage(cols, utype, out meleeDmg, out rangedDmg); } public static void GetMeleeAndRangedRelt(string[] cols, string utype, out double meleeRelt, out double rangedRelt) { - meleeRelt = 0; - rangedRelt = 0; - for (int w = 1; w <= 8; w++) - { - int wAktiIdx = (int)ObjdefIndex.Weapon1Akti + (w - 1) * 8; - int wReltIdx = wAktiIdx + 6; - int wDtypIdx = (int)ObjdefIndex.Weapon1Dtyp + (w - 1); - if (wAktiIdx >= cols.Length || wReltIdx >= cols.Length || wDtypIdx >= cols.Length) - { - continue; - } - if (cols[wAktiIdx].Trim() == "1") - { - double reltVal; - double.TryParse(cols[wReltIdx].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out reltVal); - string wDtyp = cols[wDtypIdx].Trim(); - bool isRangedWeapon = (wDtyp == "1" || wDtyp == "2" || wDtyp == "3" || wDtyp == "4" || utype == "siege"); - if (isRangedWeapon) - { - if (reltVal > 0 && (rangedRelt == 0 || reltVal < rangedRelt)) - { - rangedRelt = reltVal; - } - } - else - { - if (reltVal > 0 && (meleeRelt == 0 || reltVal < meleeRelt)) - { - meleeRelt = reltVal; - } - } - } - } + UnitStatParser.GetMeleeAndRangedReload(cols, utype, out meleeRelt, out rangedRelt); } public static double GetUnitMaxRange(string[] cols, string utype) { - double maxR = 0; - if (utype == "priest") - return double.TryParse(cols[(int)ObjdefIndex.Sirad].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double sight) ? sight : 0; - for (int w = 1; w <= 8; w++) - { - int activeIndex = (int)ObjdefIndex.Weapon1Akti + (w - 1) * 8; - int rangeMinIndex = (int)ObjdefIndex.Weapon1RangeMin + (w - 1) * 8; - int rangeMaxIndex = (int)ObjdefIndex.Weapon1RangeMax + (w - 1) * 8; - if (rangeMaxIndex >= cols.Length || cols[activeIndex].Trim() != "1") continue; - - if (double.TryParse(cols[rangeMinIndex].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double minRange)) - { - maxR = Math.Max(maxR, minRange); - } - if (double.TryParse(cols[rangeMaxIndex].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double maxRange)) - { - maxR = Math.Max(maxR, maxRange); - } - } - return maxR; + return UnitStatParser.GetMaximumRange(cols, utype); } public static string GetCleanEparaText() { - return @";Multiplikator fuer FormationsRotationTempo -;1.0 entspricht maximalem RotationsTempo wenn alle Figuren die FormationsPosition halten -; -;1.0 bis 500.0 -[FormationRotationFaktor] -500.0 - -;Multiplikator fuer FormationsBewegungsTempo derjenigen Muckel -;die die Formation gerade einhalten, so koennen nicht einhaltende Muckel wieder aufholen -; -;0.01 bis 1.00 -[FormationSpeedFaktor] -0.7 - -;maximale Anzahl an Pfadfindungsversuchen pro Muckel wenn Ziel bei Stillstand der -;Formation durch Kollision belegt ist -; -;2..16 -[FormationPathDepth] -2 - -;Zeit in ms die eine Figur in einer Formationsbewegung wartet, wenn sie auf eine Kollision trifft -;Defautl=1000 -;0..X -[FormationCollisionWaitTime] -150 - -;Gibt in % an, wieviel eine Heohendifferenz von einem Pattern zum naechsten die -;Globale Hoehenrichtungsbeleuchtung beeinflusst -; -;0..100 -[GlobalFloorLightIntensity] -10 - -;Gibt die Intensitдt an von 0 bis 100% an, mit welcher der 3-dimensionale Bewegungsvektor -;genutzt wird, es ergibt sich fьr die Bewegungsgeschwindigkeit eine Konvexkombination (baryzentrisch) -;speed= 3Dspeed*Intensity + 2Dspeed*(100%-Intensity) (default: intensity=100) -; -[MoveVector3DIntensity] -100 - -;Gibt die Geschwindigkeit der Bewegung der Wolkenspiegelungstextur an -;0=keine 1=langsam 16=normal 256=schnell 4095=maximal (Default=16) -; -[CloudReflectMoveSpeed] -16 - -;Gibt den Angriffswertfaktor an, mit dem der normale Angriffswert im aktiven Zustand 'Berserker' -;multipliziert wird, z.B. bewirkt 2.0 eine Verdopplung des AW, 0.5 bewirkt eine Halbierung -[BerserkerAWfaktor] -2.0 - -;Gibt den Damagewertfaktor an (Nahkampf), mit dem der normale Schaden im aktiven Zustand 'Berserker' -;multipliziert wird, z.B. bewirkt 2.0 eine Verdopplung des Schadens, 0.5 bewirkt eine Halbierung -[BerserkerDAMfaktor] -2.0 - -;Gibt den Verteigungswertfaktor an (Nahkampf), mit dem der normale Verteigungswert im aktiven Zustand 'Berserker' -;multipliziert wird, z.B. bewirkt 2.0 eine Verdopplung des VW, 0.5 bewirkt eine Halbierung, 0.0 bewirkt eine Setzung zu VW=0 -[BerserkerVWfaktor] -0.0 - -;Gibt den Schussradiusfaktor an (Fernkampfwaffe 1+2), mit dem der normale Schussradius im aktiven Zustand 'Schuetzengeschick' -;multipliziert wird, z.B. bewirkt 2.0 eine Verdopplung des Radius, 0.5 bewirkt eine Halbierung -[SchuetzengeschickRADfaktor] -1.2 - -;Gibt den Schadensfaktor an (saemtlicher Schaeden), mit dem der normale Schaden im aktiven Zustand 'Schutzschild' -;multipliziert wird, z.B. bewirkt 2.0 eine Verdopplung des Schadens, 0.5 bewirkt eine Halbierung -[SchutzschildDAMfaktor] -0.8 - -;Gibt den Schadensfaktor an (Waffe 0), mit dem der normale Schaden im aktiven Zustand 'Donnerschlag' -;multipliziert wird, z.B. bewirkt 2.0 eine Verdopplung des Schadens, 0.5 bewirkt eine Halbierung -[DonnerschlagDAMfaktor] -1.5 - -;Gibt den Geschwindigkeitabschussfaktor fьr Geschosse an (Waffe 1-7) ausgehend vom ursprьnglich eingestellten Faktor 1.0 -;annдhernde Korrektur der Flugbahnlдnge durch Multiplikation mit 1.52 des zugehцrigen Parameter Ysub in den ParticleDefaults -[ProjectileInitSpeedFactor] -1.5 - -;gibt die Unsicherheit der Vorhalte bei Projektilattacken an (nur fuer sich bewegende Ziele) -;0.0 bedeutet: keine Unsicherheit, das Projektil trifft mit Vorhalte absolut prдzise -;0.5 bedeutet: eine Abweichung von bis zu 0.5*3*MoveSpeed_des_Ziels (in Pattern) ist moeglich -;1.0 bedeutet: eine Abweichung von bis zu 1.0*3*MoveSpeed_des_Ziels (in Pattern) ist moeglich -;1.5 bedeutet: eine Abweichung von bis zu 1.5*3*MoveSpeed_des_Ziels (in Pattern) ist moeglich -;Default =0.5 -[ProjectileVarianceOnMove] -0.5 - -;gibt den Winkel zwischen Zielposition und prognostizierter Zielposition in Grad an, ab dem die Vorhalte abgeschaltet wird -;Vermeidung zu starker Abweichung zwischen Projektilflugrichtung und Blickrichtung des feuernden Objektes -;Default=45 -[ProjectileVarianceMaximumAngle] -45 - -;Gibt den Bereich an, in dem die Distanz zwischen Zielposition und prognostizierter Zielposition variieren darf, bevor die -;Vorhalte abgeschaltet wird -;0.4 bedeutet: Distanz zur Vorhalteposition muss zwischen der (1-0.4)=0.6 und (1+0.4)=1.4'fachen Distanz zur Zielposition liegen -;Default=0.4 -[ProjectileVarianceDistanceRange] -0.4 - -;Gibt die Zeit in ms, die als maximale Zeitdifferenz zwischen zwei logischen Frames an -;Default=3000 -;(Wer hier rumfummelt und nicht genau weiss was er tut, bekommt die Figer abgehackt :-) -[MaxLogicFrameTime] -3000"; + return CleanEparaBaseline.Text; } } } diff --git a/src/Core/Services/BackupSourceLoader.cs b/src/Core/Services/BackupSourceLoader.cs new file mode 100644 index 0000000..b233062 --- /dev/null +++ b/src/Core/Services/BackupSourceLoader.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; + +namespace AgainstRomeModifier.Core.Services; + +internal enum BackupZipSource +{ + None, + Embedded, + Local +} + +internal sealed record BackupZipLoadResult( + BackupZipSource Source, + IReadOnlyDictionary Files); + +internal sealed class BackupSourceLoader +{ + private readonly Assembly _assembly; + private readonly string _baseDirectory; + + internal BackupSourceLoader(Assembly assembly, string baseDirectory) + { + _assembly = assembly; + _baseDirectory = baseDirectory; + } + + internal BackupZipLoadResult TryLoadPreferredZip() + { + string? resourceName = _assembly.GetManifestResourceNames() + .FirstOrDefault(name => name.EndsWith("Backup.zip", StringComparison.Ordinal)); + if (resourceName != null) + { + using Stream stream = _assembly.GetManifestResourceStream(resourceName)!; + return new(BackupZipSource.Embedded, LoadZip(stream)); + } + + string localPath = Path.Combine(_baseDirectory, "Backup.zip"); + if (File.Exists(localPath)) + { + using FileStream stream = File.OpenRead(localPath); + return new(BackupZipSource.Local, LoadZip(stream)); + } + + return new(BackupZipSource.None, + new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + + internal static IReadOnlyDictionary LoadZip(Stream stream) + { + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (entry.Name.Length == 0) continue; + string key = entry.FullName.Replace('\\', '/'); + if (Path.IsPathRooted(key) || key.StartsWith("/", StringComparison.Ordinal) || + key.Split('/').Any(part => part == "..")) + throw new InvalidDataException("Backup.zip contains an unsafe entry path: " + entry.FullName); + + using Stream entryStream = entry.Open(); + using var buffer = new MemoryStream(); + entryStream.CopyTo(buffer); + files[key] = buffer.ToArray(); + } + + return files; + } +} diff --git a/src/Core/Services/BackupValidator.cs b/src/Core/Services/BackupValidator.cs new file mode 100644 index 0000000..1380e0b --- /dev/null +++ b/src/Core/Services/BackupValidator.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace AgainstRomeModifier.Core.Services; + +internal static class BackupValidator +{ + private static readonly string[] RequiredFiles = + { + "Against_Rome.exe", + "SYSTEM/cl_script.ini", + "SYSTEM/cl_epara.ini", + "SYSTEM/ress.ini", + "SYSTEM/DATA_MP/DEFAULTS/objdef.dau", + "SYSTEM/CLMK/icon.ini", + "SYSTEM/CLAK/cl_scint.ini", + "SYSTEM/CLAK/SCRIPT/ak_anfuehrer.bci" + }; + + internal static List FindMissing(IReadOnlyDictionary files) + { + var missing = RequiredFiles.Where(key => !files.ContainsKey(key)).ToList(); + bool hasTeamDat = files.Keys.Any(key => + key.StartsWith("MAPS/", StringComparison.OrdinalIgnoreCase) && + key.EndsWith("team.dat", StringComparison.OrdinalIgnoreCase)); + if (!hasTeamDat) missing.Add("MAPS/.../team.dat"); + return missing; + } + + internal static void Validate(IReadOnlyDictionary files, ILogger logger) + { + List missing = FindMissing(files); + if (missing.Count == 0) return; + + string message = Loc.Get("SvcLogBackupIncomplete") + "\r\n" + string.Join("\r\n", missing); + logger.Log(message); + throw new InvalidDataException(message); + } +} diff --git a/src/Core/Services/CleanEparaBaseline.cs b/src/Core/Services/CleanEparaBaseline.cs new file mode 100644 index 0000000..f95be0d --- /dev/null +++ b/src/Core/Services/CleanEparaBaseline.cs @@ -0,0 +1,126 @@ +using AgainstRomeModifier.Core.Patches; + +namespace AgainstRomeModifier.Core.Services; + +internal static class CleanEparaBaseline +{ + internal const string Text = @";Multiplikator fuer FormationsRotationTempo +;1.0 entspricht maximalem RotationsTempo wenn alle Figuren die FormationsPosition halten +; +;1.0 bis 500.0 +[FormationRotationFaktor] +500.0 + +;Multiplikator fuer FormationsBewegungsTempo derjenigen Muckel +;die die Formation gerade einhalten, so koennen nicht einhaltende Muckel wieder aufholen +; +;0.01 bis 1.00 +[FormationSpeedFaktor] +0.7 + +;maximale Anzahl an Pfadfindungsversuchen pro Muckel wenn Ziel bei Stillstand der +;Formation durch Kollision belegt ist +; +;2..16 +[FormationPathDepth] +2 + +;Zeit in ms die eine Figur in einer Formationsbewegung wartet, wenn sie auf eine Kollision trifft +;Defautl=1000 +;0..X +[FormationCollisionWaitTime] +150 + +;Gibt in % an, wieviel eine Heohendifferenz von einem Pattern zum naechsten die +;Globale Hoehenrichtungsbeleuchtung beeinflusst +; +;0..100 +[GlobalFloorLightIntensity] +10 + +;Gibt die Intensitдt an von 0 bis 100% an, mit welcher der 3-dimensionale Bewegungsvektor +;genutzt wird, es ergibt sich fьr die Bewegungsgeschwindigkeit eine Konvexkombination (baryzentrisch) +;speed= 3Dspeed*Intensity + 2Dspeed*(100%-Intensity) (default: intensity=100) +; +[MoveVector3DIntensity] +100 + +;Gibt die Geschwindigkeit der Bewegung der Wolkenspiegelungstextur an +;0=keine 1=langsam 16=normal 256=schnell 4095=maximal (Default=16) +; +[CloudReflectMoveSpeed] +16 + +;Gibt den Angriffswertfaktor an, mit dem der normale Angriffswert im aktiven Zustand 'Berserker' +;multipliziert wird, z.B. bewirkt 2.0 eine Verdopplung des AW, 0.5 bewirkt eine Halbierung +[BerserkerAWfaktor] +2.0 + +;Gibt den Damagewertfaktor an (Nahkampf), mit dem der normale Schaden im aktiven Zustand 'Berserker' +;multipliziert wird, z.B. bewirkt 2.0 eine Verdopplung des Schadens, 0.5 bewirkt eine Halbierung +[BerserkerDAMfaktor] +2.0 + +;Gibt den Verteigungswertfaktor an (Nahkampf), mit dem der normale Verteigungswert im aktiven Zustand 'Berserker' +;multipliziert wird, z.B. bewirkt 2.0 eine Verdopplung des VW, 0.5 bewirkt eine Halbierung, 0.0 bewirkt eine Setzung zu VW=0 +[BerserkerVWfaktor] +0.0 + +;Gibt den Schussradiusfaktor an (Fernkampfwaffe 1+2), mit dem der normale Schussradius im aktiven Zustand 'Schuetzengeschick' +;multipliziert wird, z.B. bewirkt 2.0 eine Verdopplung des Radius, 0.5 bewirkt eine Halbierung +[SchuetzengeschickRADfaktor] +1.2 + +;Gibt den Schadensfaktor an (saemtlicher Schaeden), mit dem der normale Schaden im aktiven Zustand 'Schutzschild' +;multipliziert wird, z.B. bewirkt 2.0 eine Verdopplung des Schadens, 0.5 bewirkt eine Halbierung +[SchutzschildDAMfaktor] +0.8 + +;Gibt den Schadensfaktor an (Waffe 0), mit dem der normale Schaden im aktiven Zustand 'Donnerschlag' +;multipliziert wird, z.B. bewirkt 2.0 eine Verdopplung des Schadens, 0.5 bewirkt eine Halbierung +[DonnerschlagDAMfaktor] +1.5 + +;Gibt den Geschwindigkeitabschussfaktor fьr Geschosse an (Waffe 1-7) ausgehend vom ursprьnglich eingestellten Faktor 1.0 +;annдhernde Korrektur der Flugbahnlдnge durch Multiplikation mit 1.52 des zugehцrigen Parameter Ysub in den ParticleDefaults +[ProjectileInitSpeedFactor] +1.5 + +;gibt die Unsicherheit der Vorhalte bei Projektilattacken an (nur fuer sich bewegende Ziele) +;0.0 bedeutet: keine Unsicherheit, das Projektil trifft mit Vorhalte absolut prдzise +;0.5 bedeutet: eine Abweichung von bis zu 0.5*3*MoveSpeed_des_Ziels (in Pattern) ist moeglich +;1.0 bedeutet: eine Abweichung von bis zu 1.0*3*MoveSpeed_des_Ziels (in Pattern) ist moeglich +;1.5 bedeutet: eine Abweichung von bis zu 1.5*3*MoveSpeed_des_Ziels (in Pattern) ist moeglich +;Default =0.5 +[ProjectileVarianceOnMove] +0.5 + +;gibt den Winkel zwischen Zielposition und prognostizierter Zielposition in Grad an, ab dem die Vorhalte abgeschaltet wird +;Vermeidung zu starker Abweichung zwischen Projektilflugrichtung und Blickrichtung des feuernden Objektes +;Default=45 +[ProjectileVarianceMaximumAngle] +45 + +;Gibt den Bereich an, in dem die Distanz zwischen Zielposition und prognostizierter Zielposition variieren darf, bevor die +;Vorhalte abgeschaltet wird +;0.4 bedeutet: Distanz zur Vorhalteposition muss zwischen der (1-0.4)=0.6 und (1+0.4)=1.4'fachen Distanz zur Zielposition liegen +;Default=0.4 +[ProjectileVarianceDistanceRange] +0.4 + +;Gibt die Zeit in ms, die als maximale Zeitdifferenz zwischen zwei logischen Frames an +;Default=3000 +;(Wer hier rumfummelt und nicht genau weiss was er tut, bekommt die Figer abgehackt :-) +[MaxLogicFrameTime] +3000"; + + internal static byte[] CreateBytes() + { + byte[] header = new byte[64]; + header[0] = (byte)'P'; + header[1] = (byte)'F'; + header[2] = (byte)'I'; + header[3] = (byte)'L'; + return GameLZSS.CompressPfil(PatchText.GameEncoding.GetBytes(Text), header); + } +} diff --git a/src/Core/Services/GameDirectoryBackupLoader.cs b/src/Core/Services/GameDirectoryBackupLoader.cs new file mode 100644 index 0000000..78ed49d --- /dev/null +++ b/src/Core/Services/GameDirectoryBackupLoader.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AgainstRomeModifier.Maps; + +namespace AgainstRomeModifier.Core.Services; + +internal sealed class GameDirectoryBackupLoader +{ + private static readonly string[] BaselineFiles = + { + "Against_Rome.exe", + "SYSTEM/cl_script.ini", + "SYSTEM/cl_epara.ini", + "SYSTEM/ress.ini", + "SYSTEM/DATA_MP/DEFAULTS/objdef.dau", + "SYSTEM/DATA_MP/DEFAULTS/partgeo.dau", + "SYSTEM/CLMK/icon.ini", + "SYSTEM/CLAK/cl_scint.ini", + "SYSTEM/CLAK/SCRIPT/ak_anfuehrer.bci" + }; + + private readonly ILogger _logger; + private readonly Func _cleanEparaFactory; + + internal GameDirectoryBackupLoader(ILogger logger, Func cleanEparaFactory) + { + _logger = logger; + _cleanEparaFactory = cleanEparaFactory; + } + + internal bool TryLoad(string gamePath, bool showError, out Dictionary files) + { + files = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(gamePath) || !Directory.Exists(gamePath)) + { + if (showError) + throw new InvalidDataException("找不到 Backup.zip,且遊戲路徑無效,無法建立本機備份。 / Backup.zip not found and game path is invalid."); + return false; + } + + try + { + LoadBaselineFiles(gamePath, files); + LoadTeamFiles(gamePath, files); + return true; + } + catch (Exception exception) + { + if (showError) throw; + _logger.Log("建立備份時發生錯誤: " + exception.Message); + files.Clear(); + return false; + } + } + + private void LoadBaselineFiles(string gamePath, Dictionary files) + { + foreach (string relativePath in BaselineFiles) + { + if (relativePath == "SYSTEM/cl_epara.ini") + { + try + { + files[relativePath] = _cleanEparaFactory(); + } + catch (Exception exception) + { + _logger.Log(string.Format(Loc.Get("SvcLogEparaBuildFailed"), exception.Message)); + } + continue; + } + + string fullPath = Path.Combine(gamePath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + string backupPath = fullPath + ".bak"; + if (File.Exists(backupPath)) + { + files[relativePath] = File.ReadAllBytes(backupPath); + continue; + } + if (!File.Exists(fullPath)) continue; + + byte[] bytes = File.ReadAllBytes(fullPath); + ValidateOriginal(relativePath, bytes); + File.Copy(fullPath, backupPath, overwrite: false); + _logger.Log(string.Format("已建立原版檔案實體備份: {0}", backupPath)); + files[relativePath] = bytes; + } + } + + private void LoadTeamFiles(string gamePath, Dictionary files) + { + string mapsPath = Path.Combine(gamePath, "MAPS"); + if (!Directory.Exists(mapsPath)) return; + + string normalizedGamePath = Path.GetFullPath(gamePath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + foreach (string file in Directory.GetFiles(mapsPath, "team.dat", SearchOption.AllDirectories)) + { + if (CustomMapManifest.IsCustomMapDirectory(Path.GetDirectoryName(file)!)) continue; + string relativePath = Path.GetRelativePath(normalizedGamePath, file).Replace('\\', '/'); + string backupPath = file + ".bak"; + if (File.Exists(backupPath)) + { + files[relativePath] = File.ReadAllBytes(backupPath); + continue; + } + + byte[] bytes = File.ReadAllBytes(file); + File.Copy(file, backupPath, overwrite: false); + _logger.Log(string.Format("已建立原版地圖檔案實體備份: {0}", backupPath)); + files[relativePath] = bytes; + } + } + + private static void ValidateOriginal(string relativePath, byte[] bytes) + { + if (relativePath == "Against_Rome.exe" && !OriginalFileValidator.IsExeOriginal(bytes)) + throw new InvalidDataException("Against_Rome.exe 已經被修改過,或不是支援的原版檔案,無法建立備份。請先還原原版檔案。 / Against_Rome.exe is already modified or not supported. Please restore original file first."); + if (relativePath == "SYSTEM/DATA_MP/DEFAULTS/objdef.dau" && !OriginalFileValidator.IsObjdefOriginal(bytes)) + throw new InvalidDataException("SYSTEM/DATA_MP/DEFAULTS/objdef.dau 已經被修改過,無法作為備份基準。請先驗證遊戲完整性。 / objdef.dau is already modified. Please verify game integrity first."); + if (relativePath == "SYSTEM/DATA_MP/DEFAULTS/partgeo.dau" && !OriginalFileValidator.IsPartgeoOriginal(bytes)) + throw new InvalidDataException("SYSTEM/DATA_MP/DEFAULTS/partgeo.dau 已經被修改過,無法作為備份基準。請先驗證遊戲完整性。 / partgeo.dau is already modified. Please verify game integrity first."); + } +} diff --git a/src/Core/Services/OriginalFileValidator.cs b/src/Core/Services/OriginalFileValidator.cs new file mode 100644 index 0000000..b6c3ff9 --- /dev/null +++ b/src/Core/Services/OriginalFileValidator.cs @@ -0,0 +1,71 @@ +using System; +using System.Globalization; +using AgainstRomeModifier.Core.Patches; + +namespace AgainstRomeModifier.Core.Services; + +internal static class OriginalFileValidator +{ + internal static bool IsExeOriginal(byte[] bytes) + { + try + { + return ExePatchModel.GetExePatchState(bytes) == ExePatchState.Original && + ExePatchModel.GetSpellAltarPatchState(bytes) == ExeSpellAltarPatchState.Original && + ExePatchModel.GetVillageBuildRangePatchState(bytes) == ExeVillageRangePatchState.Original && + ExePatchModel.GetVillageSetterPatchState(bytes) == ExeVillageSetterPatchState.Original && + ExePatchModel.GetRomanEndlessPatchState(bytes) == ExeRomanEndlessPatchState.Original; + } + catch + { + return false; + } + } + + internal static bool IsObjdefOriginal(byte[] bytes) + { + try + { + byte[] decompressed = GameLZSS.DecompressPfil(bytes); + string text = PatchText.GameEncoding.GetString(decompressed); + foreach (string line in text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)) + { + if (line.Length < 100) continue; + string[] columns = PatchText.ParseCsvLine(line); + if (columns.Length < 192 || columns[(int)ObjdefIndex.Name].Trim() != "FigRomAnf00_Anfuehrer") + continue; + + return double.TryParse(columns[(int)ObjdefIndex.Hp].Trim(), NumberStyles.Any, + CultureInfo.InvariantCulture, out double hp) && + Math.Abs(hp - 400) <= 0.1; + } + return false; + } + catch + { + return false; + } + } + + internal static bool IsPartgeoOriginal(byte[] bytes) + { + try + { + byte[] decompressed = GameLZSS.DecompressPfil(bytes); + string text = PatchText.GameEncoding.GetString(decompressed); + foreach (string line in text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)) + { + string[] columns = PatchText.ParseCsvLine(line); + if (columns.Length <= PartgeoPatcher.YsubColumn || columns[2].Trim() != "Pfeil00") + continue; + return long.TryParse(columns[PartgeoPatcher.YsubColumn].Trim(), NumberStyles.Integer, + CultureInfo.InvariantCulture, out long ysub) && ysub == 5832704; + } + return false; + } + catch + { + return false; + } + } +} diff --git a/src/Core/Services/PatchEngine.cs b/src/Core/Services/PatchEngine.cs index 5f519ba..d49e856 100644 --- a/src/Core/Services/PatchEngine.cs +++ b/src/Core/Services/PatchEngine.cs @@ -1,348 +1,138 @@ -using System; using System.IO; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Collections.Generic; -using System.Globalization; -using System.Security.Cryptography; -using AgainstRomeModifier.Core.Patches; -using AgainstRomeModifier.Core.Features.Ini; -using AgainstRomeModifier.Core.Features.Map; -using AgainstRomeModifier.Core.Features.Objdef; -using AgainstRomeModifier.Core.Features.Exe; +using AgainstRomeModifier.Core.Features; using AgainstRomeModifier.Core.Features.Bci; using AgainstRomeModifier.Core.Features.Install; -using AgainstRomeModifier.Core.Features; - -namespace AgainstRomeModifier.Core.Services -{ - public class PatchEngine - { - private readonly ILogger _logger; - - public PatchEngine(ILogger logger) - { - _logger = logger; - } - - public bool IsDgVoodooInstalled(string gamePath) => new DgVoodooFeature(_logger).IsInstalled(gamePath); - - public PatchProfile DetectCurrentPatchState(string gamePath, BackupManager backupManager) - { - PatchProfile detected = new FeatureDetector(_logger).Detect(gamePath, backupManager); - detected.NormalizeCompositeValues(); - var context = new DetectContext(detected); - var result = new PatchProfile(); - foreach (IFeatureModule module in FeatureRegistry.All) - result.Set(module.Id, module.Detect(context)); - return result; - } - - public PatchProfile DetectCurrentPatchProfile(string gamePath, BackupManager backupManager) => - DetectCurrentPatchState(gamePath, backupManager); - - // --- 核心安全寫入工具(實作集中於 SafeFileWriter,此處保留原有呼叫介面)--- - - public void SafeWriteAllBytes(string dest, byte[] bytes, FileRollbackScope? rollback = null) - { - SafeFileWriter.WriteAllBytes(dest, bytes, rollback); - } - - public void SafeCopyFile(string src, string dest, bool overwrite, FileRollbackScope? rollback = null) - { - if (!overwrite && File.Exists(dest)) - { - throw new IOException("目標檔案已存在: " + dest); - } - SafeWriteAllBytes(dest, File.ReadAllBytes(src), rollback); - } - - public void SafeDeleteFile(string path, FileRollbackScope? rollback = null) - { - if (!File.Exists(path)) return; - rollback?.TrackFile(path); - File.SetAttributes(path, FileAttributes.Normal); - File.Delete(path); - } - - // --- 補丁套用與還原引擎端點 --- - - public void ApplyPatches(string gamePath, PatchProfile profile, BackupManager backupManager, FileRollbackScope rollback) - { - profile.NormalizeCompositeValues(); - var context = new PatchContext(); - foreach (IFeatureModule module in FeatureRegistry.All) - module.Plan(context, profile.Get(module.Id)); - PatchProfile options = context.Profile; - // 還原與重套共用同一個 orchestrator:同一批 BCI 腳本只解壓一次, - // 且中間的還原狀態留在快取、由最後的 SaveAll 一次寫入最終狀態。 - var orchestrator = new EndlessAiOrchestrator(); - - // 總是先在同個交易中執行原版恢復 - _logger.Log(Loc.Get("SvcLogPreApplyRestore")); - RestoreCategories(gamePath, backupManager, rollback, - new[] { FeatureCategory.Stats, FeatureCategory.Compat, FeatureCategory.Language }, - orchestrator, saveOrchestrator: false); - - // 1. Dry Run 階段:在記憶體中生成所有補丁 byte[] 並驗證 - var patchedFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); - - // A. Against_Rome.exe - string exePath = Path.Combine(gamePath, @"Against_Rome.exe"); - byte[] exeBytes = File.ReadAllBytes(exePath); - bool exeModified = false; - exeModified = ExeFeaturePatcher.Apply(exeBytes, options.FocusLoss, options.VillageBuildRange, - options.NoSpellAltar, options.RomanEndless, options.GameSpeed, _logger); - if (ExeFeaturePatcher.ApplyCiviProduce20(exeBytes, options.CiviProduce20, _logger)) - exeModified = true; - if (ExeFeaturePatcher.ApplyUnitRecruit20(exeBytes, options.UnitRecruit20, _logger)) - exeModified = true; - if (exeModified) - { - patchedFiles[exePath] = exeBytes; - } - - // B. cl_script.ini - byte[] clBytes = IniFeaturePatcher.BuildClScript(backupManager, options); - patchedFiles[Path.Combine(gamePath, @"SYSTEM\cl_script.ini")] = clBytes; - - // C. cl_epara.ini — 遠程命中修正(拋射預判散布歸零)+三倍射程時提高拋射初速讓箭矢能命中放大後的射程; - // 兩者皆已整合進射程 3 倍,未啟用時還原為原版備份 - patchedFiles[Path.Combine(gamePath, @"SYSTEM\cl_epara.ini")] = - EparaPatcher.GetPatchedBytes(backupManager.GetBackupBytes("SYSTEM/cl_epara.ini"), options.RangedRange3x); - - // C2. partgeo.dau — 拋射彈道增高(重力 ysub 與 objdef w*_emit 同倍率);未啟用時還原為原版備份。 - // 舊備份可能沒有 partgeo.dau(不在內嵌 Backup.zip、且自動補齊失敗時): - // 功能未啟用就跳過不動檔案,要啟用則以明確錯誤中止。 - const string partgeoKey = "SYSTEM/DATA_MP/DEFAULTS/partgeo.dau"; - if (backupManager.HasFile(partgeoKey)) - { - patchedFiles[Path.Combine(gamePath, @"SYSTEM\DATA_MP\DEFAULTS\partgeo.dau")] = - PartgeoPatcher.GetPatchedBytes(backupManager.GetBackupBytes(partgeoKey), new PartgeoOptions(options.ProjectileArcHeight)); - } - else if (options.ProjectileArcHeight) - { - throw new InvalidDataException("記憶體備份中缺少 partgeo.dau,無法套用拋射彈道增高。請確認遊戲檔案完整後重新啟動。 / partgeo.dau backup missing; cannot apply projectile arc height."); - } - - // D. cl_scint.ini — 一律還原為原版備份 - patchedFiles[Path.Combine(gamePath, @"SYSTEM\CLAK\cl_scint.ini")] = backupManager.GetBackupBytes("SYSTEM/CLAK/cl_scint.ini"); - - // E. ress.ini - byte[] ressBytes = GetPatchedRessBytes(backupManager, options.FreeProduction, options.FreeUpgrade, options.NoSpellCost); - patchedFiles[Path.Combine(gamePath, @"SYSTEM\ress.ini")] = ressBytes; - - // F. objdef.dau - byte[] objdefBytes = GetPatchedObjdefBytes(backupManager, options); - patchedFiles[Path.Combine(gamePath, @"SYSTEM\DATA_MP\DEFAULTS\objdef.dau")] = objdefBytes; - - // G. team.dat - var teamDatPatches = GetPatchedTeamDatBytes(gamePath, backupManager, options.MaxPopulation, options.RomanEndless); - foreach (var kvp in teamDatPatches) - { - patchedFiles[Path.Combine(gamePath, kvp.Key.Replace('/', '\\'))] = kvp.Value; - } - - // H. Endless AI 模組(沿用開頭建立、已含還原後快取狀態的 orchestrator) - // 以模組 Id 查表,不依賴 UserModules 的排列順序。 - foreach (var module in orchestrator.UserModules) - { - orchestrator.ApplyModule(gamePath, module, options.GetEndlessAiModule(module.Id)); - } - orchestrator.ApplyMandatoryRepair(gamePath); - FoodHealingFeature.Apply(gamePath, options.FoodHealing10x, backupManager, orchestrator, _logger); - // 清理:早期版本曾把「一次生產 20」誤打到 ak_npc.bci(AI 路徑),一律還原為原版。 - CiviProduce20Feature.RestoreAkNpcOriginal(gamePath, orchestrator); - // Dry Run 順利結束,進行實體檔案寫入與交易範圍 - foreach (var kvp in patchedFiles) - { - SafeWriteAllBytes(kvp.Key, kvp.Value, rollback); - } - orchestrator.SaveAll(gamePath, rollback); +namespace AgainstRomeModifier.Core.Services; - // 其它補丁 - new LanguagePackFeature(_logger).Apply(gamePath, options.ToEnglish, rollback); - new DgVoodooFeature(_logger).Apply(gamePath, options.DgVoodoo, rollback); - } - - /// - /// 啟動時的安全遷移:只修復已知會造成問題的舊版寫入—— - /// (1) R0 常駐修復(ak_npc / ak_produktion 的被否決舊補丁、DELETE_TEAM 終結狀態), - /// (2) 會導致戰鬥閃退的舊版首領榮耀腳本(在 ApplyFoodHealingAmountPatch 內以原版重建, - /// 並保留使用者目前的待機回血設定值)。 - /// 刻意不呼叫完整的 ApplyPatches:完整套用會先整體還原再依「偵測到的選項」重套, - /// 但自訂兵種屬性無法從檔案偵測回來,會在啟動瞬間被無聲覆蓋。 - /// - public void RunStartupSafeMigrations(string gamePath, BackupManager backupManager, FileRollbackScope rollback) - { - var orchestrator = new EndlessAiOrchestrator(); - orchestrator.ApplyMandatoryRepair(gamePath); - - // 保留現況:可判定時沿用目前的待機回血狀態;無法判定時視為原版。 - if (!FoodHealingFeature.TryDetect(gamePath, out bool foodHealingEnabled)) - { - foodHealingEnabled = false; - } - FoodHealingFeature.Apply(gamePath, foodHealingEnabled, backupManager, orchestrator, _logger); - // 清理早期誤寫入 ak_npc.bci 的 AI 生產數量(真正的功能改為 EXE 補丁,玩家專屬)。 - CiviProduce20Feature.RestoreAkNpcOriginal(gamePath, orchestrator); - orchestrator.SaveAll(gamePath, rollback); - } - - public void RestoreOriginalFiles(string gamePath, BackupManager backupManager, FileRollbackScope rollback) - { - RestoreCategories(gamePath, backupManager, rollback, - new[] { FeatureCategory.Stats, FeatureCategory.Compat, FeatureCategory.Language }); - } - - public void RestoreStatsOnly(string gamePath, BackupManager backupManager, FileRollbackScope rollback) - { - RestoreCategories(gamePath, backupManager, rollback, new[] { FeatureCategory.Stats }); - } - - public void RestoreCompatOnly(string gamePath, BackupManager backupManager, FileRollbackScope rollback) - { - RestoreCategories(gamePath, backupManager, rollback, new[] { FeatureCategory.Compat }); - } - - public void RestoreLanguageOnly(string gamePath, FileRollbackScope rollback) - { - RestoreCategories(gamePath, null, rollback, new[] { FeatureCategory.Language }); - } - - /// - /// 供 ApplyPatches 傳入共用的 orchestrator:還原與後續重套共用同一份 BCI 檔案快取, - /// 同一批腳本不必解壓兩次。共用時由呼叫端負責最終 SaveAll(saveOrchestrator = false)。 - /// - private void RestoreCategories(string gamePath, BackupManager? backupManager, FileRollbackScope rollback, - IEnumerable categories, EndlessAiOrchestrator? sharedOrchestrator = null, - bool saveOrchestrator = true) - { - var requested = categories.ToHashSet(); - bool restoreStats = requested.Contains(FeatureCategory.Stats); - bool restoreCompat = requested.Contains(FeatureCategory.Compat); - bool restoreLanguage = requested.Contains(FeatureCategory.Language); - var patchedFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); - EndlessAiOrchestrator? orchestrator = null; - - if (restoreCompat) - { - string exePath = Path.Combine(gamePath, @"Against_Rome.exe"); - byte[] exeBytes = File.ReadAllBytes(exePath); - bool keepRomanEndless = !restoreStats && - ExePatchModel.GetRomanEndlessPatchState(exeBytes) == ExeRomanEndlessPatchState.Patched; - if (ExeFeaturePatcher.Apply(exeBytes, false, false, false, keepRomanEndless, 1, _logger)) - patchedFiles[exePath] = exeBytes; - - orchestrator = sharedOrchestrator ?? new EndlessAiOrchestrator(); - foreach (var module in orchestrator.UserModules) - orchestrator.ApplyModule(gamePath, module, false); - orchestrator.ApplyMandatoryRepair(gamePath); - } - - if (restoreStats) - { - string exePath = Path.Combine(gamePath, @"Against_Rome.exe"); - byte[] exeBytes = patchedFiles.TryGetValue(exePath, out byte[]? pendingExe) - ? pendingExe - : File.ReadAllBytes(exePath); - bool exeChanged = false; - if (!restoreCompat) - exeChanged |= ExeFeaturePatcher.ApplyRomanEndless(exeBytes, false, _logger); - // CiviProduce20 / UnitRecruit20 屬 Stats:無論是否同時還原 Compat,都要在此還原這些玩家按鈕補丁。 - exeChanged |= ExeFeaturePatcher.ApplyCiviProduce20(exeBytes, false, _logger); - exeChanged |= ExeFeaturePatcher.ApplyUnitRecruit20(exeBytes, false, _logger); - if (exeChanged) - patchedFiles[exePath] = exeBytes; - - RestoreStatsFiles(gamePath, backupManager ?? throw new ArgumentNullException(nameof(backupManager)), rollback); - } - - if (restoreStats) - { - orchestrator ??= sharedOrchestrator ?? new EndlessAiOrchestrator(); - FoodHealingFeature.Apply(gamePath, false, backupManager!, orchestrator, _logger); - CiviProduce20Feature.RestoreAkNpcOriginal(gamePath, orchestrator); - } - - foreach (var kvp in patchedFiles) - SafeWriteAllBytes(kvp.Key, kvp.Value, rollback); - - if (orchestrator != null && saveOrchestrator) - orchestrator.SaveAll(gamePath, rollback); - if (restoreLanguage) - new LanguagePackFeature(_logger).Apply(gamePath, false, rollback); - if (restoreCompat) - new DgVoodooFeature(_logger).Apply(gamePath, false, rollback); - } - - private void RestoreStatsFiles(string gamePath, BackupManager backupManager, FileRollbackScope? rollback = null) - { - RestoreMemoryFile(backupManager, "SYSTEM/cl_script.ini", Path.Combine(gamePath, @"SYSTEM\cl_script.ini"), rollback); - RestoreMemoryFile(backupManager, "SYSTEM/cl_epara.ini", Path.Combine(gamePath, @"SYSTEM\cl_epara.ini"), rollback); - RestoreMemoryFile(backupManager, "SYSTEM/CLAK/cl_scint.ini", Path.Combine(gamePath, @"SYSTEM\CLAK\cl_scint.ini"), rollback); - RestoreMemoryFile(backupManager, "SYSTEM/ress.ini", Path.Combine(gamePath, @"SYSTEM\ress.ini"), rollback); - RestoreMemoryFile(backupManager, "SYSTEM/DATA_MP/DEFAULTS/objdef.dau", Path.Combine(gamePath, @"SYSTEM\DATA_MP\DEFAULTS\objdef.dau"), rollback); - RestoreMemoryFile(backupManager, "SYSTEM/DATA_MP/DEFAULTS/partgeo.dau", Path.Combine(gamePath, @"SYSTEM\DATA_MP\DEFAULTS\partgeo.dau"), rollback); - foreach (var kvp in backupManager.BackupFiles) - { - if (kvp.Key.StartsWith("MAPS/", StringComparison.OrdinalIgnoreCase) && kvp.Key.EndsWith("team.dat", StringComparison.OrdinalIgnoreCase)) - { - string destPath = Path.Combine(gamePath, kvp.Key.Replace('/', '\\')); - // 與套用路徑一致:地圖已被玩家移除時不重建其 team.dat。 - if (!File.Exists(destPath)) continue; - byte[] patchedBytes = TeamDatPatcher.GetPatchedBytes(kvp.Value, new TeamDatOptions(false)); - SafeWriteAllBytes(destPath, patchedBytes, rollback); - _logger.Log(string.Format(Loc.Get("SvcLogRestoredPopulation"), destPath)); - } - } - } +public class PatchEngine +{ + private readonly ILogger _logger; - private void RestoreMemoryFile(BackupManager backupManager, string key, string dest, FileRollbackScope? rollback = null) - { - if (backupManager.BackupFiles.TryGetValue(key, out byte[]? fileBytes)) - { - SafeWriteAllBytes(dest, fileBytes!, rollback); - _logger.Log(string.Format(Loc.Get("SvcLogRestoredFile"), dest)); - } - } + public PatchEngine(ILogger logger) => _logger = logger; - // --- 補丁生成與檔案偵測邏輯 --- + public bool IsDgVoodooInstalled(string gamePath) => + new DgVoodooFeature(_logger).IsInstalled(gamePath); - private byte[] GetPatchedRessBytes(BackupManager backupManager, bool freeProdChecked, bool freeUpgradeChecked, bool noSpellCostChecked) - { - return IniFeaturePatcher.BuildRess(backupManager, freeProdChecked, freeUpgradeChecked, noSpellCostChecked); - } + public PatchProfile DetectCurrentPatchState(string gamePath, BackupManager backupManager) + { + PatchProfile detected = new FeatureDetector(_logger).Detect(gamePath, backupManager); + detected.NormalizeCompositeValues(); + var context = new DetectContext(detected); + var result = new PatchProfile(); + foreach (IFeatureModule module in FeatureRegistry.All) + result.Set(module.Id, module.Detect(context)); + return result; + } - private byte[] GetPatchedObjdefBytes(BackupManager backupManager, PatchProfile options) - { - return ObjdefFeaturePatcher.Build(backupManager, options); - } + public PatchProfile DetectCurrentPatchProfile(string gamePath, BackupManager backupManager) => + DetectCurrentPatchState(gamePath, backupManager); - private Dictionary GetPatchedTeamDatBytes(string gamePath, BackupManager backupManager, bool maxPopulation, bool romanEndless) - { - var results = MaxPopulationFeature.Build(backupManager, maxPopulation, romanEndless); - // 備份中可能保有玩家已自行移除的地圖;只改寫磁碟上仍存在的 team.dat,不重建已刪除的地圖檔。 - foreach (string key in results.Keys.Where(k => !File.Exists(Path.Combine(gamePath, k.Replace('/', '\\')))).ToList()) - { - results.Remove(key); - } - _logger.Log(maxPopulation ? string.Format(Loc.Get("SvcLogTeamDatApplied"), TeamDatPatcher.DefaultPopulationLimit, results.Count) : Loc.Get("SvcLogTeamDatRestored")); - int endlessCount = results.Keys.Count(key => key.StartsWith("MAPS/ENDL_", StringComparison.OrdinalIgnoreCase)); - _logger.Log(romanEndless - ? string.Format(Loc.Get("SvcLogTeamDatRoman"), endlessCount) - : Loc.Get("SvcLogTeamDatRomanRestored")); - return results; - } + // These public helpers are retained as compatibility endpoints; SafeFileWriter owns the implementation. + public void SafeWriteAllBytes(string destination, byte[] bytes, FileRollbackScope? rollback = null) => + SafeFileWriter.WriteAllBytes(destination, bytes, rollback); - // --- 語言與 dgVoodoo2 補丁邏輯 --- + public void SafeCopyFile( + string source, + string destination, + bool overwrite, + FileRollbackScope? rollback = null) + { + if (!overwrite && File.Exists(destination)) + throw new IOException("目標檔案已存在: " + destination); + SafeWriteAllBytes(destination, File.ReadAllBytes(source), rollback); + } - // --- dgVoodoo2 專用核心邏輯 --- + public void SafeDeleteFile(string path, FileRollbackScope? rollback = null) + { + if (!File.Exists(path)) return; + rollback?.TrackFile(path); + File.SetAttributes(path, FileAttributes.Normal); + File.Delete(path); + } - // --- 全方位修改狀態偵測方法 --- + public void ApplyPatches( + string gamePath, + PatchProfile profile, + BackupManager backupManager, + FileRollbackScope rollback) + { + profile.NormalizeCompositeValues(); + var context = new PatchContext(); + foreach (IFeatureModule module in FeatureRegistry.All) + module.Plan(context, profile.Get(module.Id)); + PatchProfile options = context.Profile; + + // Restore and reapply share one cache so every BCI file is decompressed and saved only once. + var orchestrator = new EndlessAiOrchestrator(); + _logger.Log(Loc.Get("SvcLogPreApplyRestore")); + new PatchRestoreService(_logger).RestoreCategories( + gamePath, + backupManager, + rollback, + new[] { FeatureCategory.Stats, FeatureCategory.Compat, FeatureCategory.Language }, + orchestrator, + saveOrchestrator: false); + + PatchFilePlan filePlan = new PatchFileComposer(_logger).Compose( + gamePath, backupManager, options); + + foreach (var module in orchestrator.UserModules) + orchestrator.ApplyModule(gamePath, module, options.GetEndlessAiModule(module.Id)); + orchestrator.ApplyMandatoryRepair(gamePath); + FoodHealingFeature.Apply( + gamePath, options.FoodHealing10x, backupManager, orchestrator, _logger); + CiviProduce20Feature.RestoreAkNpcOriginal(gamePath, orchestrator); + + foreach ((string path, byte[] bytes) in filePlan.Files) + SafeWriteAllBytes(path, bytes, rollback); + orchestrator.SaveAll(gamePath, rollback); + + new LanguagePackFeature(_logger).Apply(gamePath, options.ToEnglish, rollback); + new DgVoodooFeature(_logger).Apply(gamePath, options.DgVoodoo, rollback); + } + public void RunStartupSafeMigrations( + string gamePath, + BackupManager backupManager, + FileRollbackScope rollback) + { + var orchestrator = new EndlessAiOrchestrator(); + orchestrator.ApplyMandatoryRepair(gamePath); + + if (!FoodHealingFeature.TryDetect(gamePath, out bool foodHealingEnabled)) + foodHealingEnabled = false; + FoodHealingFeature.Apply( + gamePath, foodHealingEnabled, backupManager, orchestrator, _logger); + CiviProduce20Feature.RestoreAkNpcOriginal(gamePath, orchestrator); + orchestrator.SaveAll(gamePath, rollback); } + + public void RestoreOriginalFiles( + string gamePath, + BackupManager backupManager, + FileRollbackScope rollback) => + new PatchRestoreService(_logger).RestoreCategories( + gamePath, + backupManager, + rollback, + new[] { FeatureCategory.Stats, FeatureCategory.Compat, FeatureCategory.Language }); + + public void RestoreStatsOnly( + string gamePath, + BackupManager backupManager, + FileRollbackScope rollback) => + new PatchRestoreService(_logger).RestoreCategories( + gamePath, backupManager, rollback, new[] { FeatureCategory.Stats }); + + public void RestoreCompatOnly( + string gamePath, + BackupManager backupManager, + FileRollbackScope rollback) => + new PatchRestoreService(_logger).RestoreCategories( + gamePath, backupManager, rollback, new[] { FeatureCategory.Compat }); + + public void RestoreLanguageOnly(string gamePath, FileRollbackScope rollback) => + new PatchRestoreService(_logger).RestoreCategories( + gamePath, null, rollback, new[] { FeatureCategory.Language }); } diff --git a/src/Core/Services/PatchFileComposer.cs b/src/Core/Services/PatchFileComposer.cs new file mode 100644 index 0000000..f26ee3e --- /dev/null +++ b/src/Core/Services/PatchFileComposer.cs @@ -0,0 +1,28 @@ +using AgainstRomeModifier.Core.Features; + +namespace AgainstRomeModifier.Core.Services; + +internal sealed record PatchFilePlan(IReadOnlyDictionary Files); + +internal sealed class PatchFileComposer +{ + private readonly IReadOnlyList _contributors; + private readonly ILogger _logger; + + internal PatchFileComposer(ILogger logger) + : this(logger, PatchFileContributors.CreateDefault()) { } + + internal PatchFileComposer(ILogger logger, IReadOnlyList contributors) + { + _logger = logger; + _contributors = contributors; + } + + internal PatchFilePlan Compose(string gamePath, BackupManager backupManager, PatchProfile profile) + { + var context = new PatchFileCompositionContext(gamePath, backupManager, profile, _logger); + foreach (IPatchFileContributor contributor in _contributors) + contributor.Contribute(context); + return new PatchFilePlan(context.Files); + } +} diff --git a/src/Core/Services/PatchFileContributors.cs b/src/Core/Services/PatchFileContributors.cs new file mode 100644 index 0000000..68cd281 --- /dev/null +++ b/src/Core/Services/PatchFileContributors.cs @@ -0,0 +1,141 @@ +using AgainstRomeModifier.Core.Features; +using AgainstRomeModifier.Core.Features.Exe; +using AgainstRomeModifier.Core.Features.Ini; +using AgainstRomeModifier.Core.Features.Map; +using AgainstRomeModifier.Core.Features.Objdef; +using AgainstRomeModifier.Core.Patches; + +namespace AgainstRomeModifier.Core.Services; + +internal interface IPatchFileContributor +{ + void Contribute(PatchFileCompositionContext context); +} + +internal sealed class PatchFileCompositionContext +{ + private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); + + internal PatchFileCompositionContext(string gamePath, BackupManager backup, PatchProfile profile, ILogger logger) + { + GamePath = gamePath; + Backup = backup; + Profile = profile; + Logger = logger; + } + + internal string GamePath { get; } + internal BackupManager Backup { get; } + internal PatchProfile Profile { get; } + internal ILogger Logger { get; } + internal IReadOnlyDictionary Files => _files; + + internal string Resolve(string relativePath) => + Path.Combine(GamePath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + + internal void Add(string relativePath, byte[] bytes) => _files[Resolve(relativePath)] = bytes; +} + +internal static class PatchFileContributors +{ + internal static IReadOnlyList CreateDefault() => new IPatchFileContributor[] + { + new ExePatchFileContributor(), + new ClScriptPatchFileContributor(), + new EparaPatchFileContributor(), + new PartgeoPatchFileContributor(), + new ScintPatchFileContributor(), + new RessPatchFileContributor(), + new ObjdefPatchFileContributor(), + new TeamDatPatchFileContributor(), + }; +} + +internal sealed class ExePatchFileContributor : IPatchFileContributor +{ + public void Contribute(PatchFileCompositionContext context) + { + string path = context.Resolve("Against_Rome.exe"); + byte[] bytes = File.ReadAllBytes(path); + PatchProfile profile = context.Profile; + bool modified = ExeFeaturePatcher.Apply(bytes, profile.FocusLoss, profile.VillageBuildRange, + profile.NoSpellAltar, profile.RomanEndless, profile.GameSpeed, context.Logger); + modified |= ExeFeaturePatcher.ApplyCiviProduce20(bytes, profile.CiviProduce20, context.Logger); + modified |= ExeFeaturePatcher.ApplyUnitRecruit20(bytes, profile.UnitRecruit20, context.Logger); + if (modified) context.Add("Against_Rome.exe", bytes); + } +} + +internal sealed class ClScriptPatchFileContributor : IPatchFileContributor +{ + public void Contribute(PatchFileCompositionContext context) => + context.Add("SYSTEM/cl_script.ini", IniFeaturePatcher.BuildClScript(context.Backup, context.Profile)); +} + +internal sealed class EparaPatchFileContributor : IPatchFileContributor +{ + public void Contribute(PatchFileCompositionContext context) => + context.Add("SYSTEM/cl_epara.ini", EparaPatcher.GetPatchedBytes( + context.Backup.GetBackupBytes("SYSTEM/cl_epara.ini"), context.Profile.RangedRange3x)); +} + +internal sealed class PartgeoPatchFileContributor : IPatchFileContributor +{ + private const string Key = "SYSTEM/DATA_MP/DEFAULTS/partgeo.dau"; + + public void Contribute(PatchFileCompositionContext context) + { + if (context.Backup.HasFile(Key)) + { + context.Add(Key, PartgeoPatcher.GetPatchedBytes(context.Backup.GetBackupBytes(Key), + new PartgeoOptions(context.Profile.ProjectileArcHeight))); + } + else if (context.Profile.ProjectileArcHeight) + { + throw new InvalidDataException("缺少 partgeo.dau 原始備份,無法套用拋射彈道增高。 / partgeo.dau backup missing; cannot apply projectile arc height."); + } + } +} + +internal sealed class ScintPatchFileContributor : IPatchFileContributor +{ + public void Contribute(PatchFileCompositionContext context) => + context.Add("SYSTEM/CLAK/cl_scint.ini", context.Backup.GetBackupBytes("SYSTEM/CLAK/cl_scint.ini")); +} + +internal sealed class RessPatchFileContributor : IPatchFileContributor +{ + public void Contribute(PatchFileCompositionContext context) => + context.Add("SYSTEM/ress.ini", IniFeaturePatcher.BuildRess(context.Backup, + context.Profile.FreeProduction, context.Profile.FreeUpgrade, context.Profile.NoSpellCost)); +} + +internal sealed class ObjdefPatchFileContributor : IPatchFileContributor +{ + public void Contribute(PatchFileCompositionContext context) => + context.Add("SYSTEM/DATA_MP/DEFAULTS/objdef.dau", + ObjdefFeaturePatcher.Build(context.Backup, context.Profile)); +} + +internal sealed class TeamDatPatchFileContributor : IPatchFileContributor +{ + public void Contribute(PatchFileCompositionContext context) + { + PatchProfile profile = context.Profile; + Dictionary files = MaxPopulationFeature.Build( + context.Backup, profile.MaxPopulation, profile.RomanEndless); + foreach (string key in files.Keys.Where(key => !File.Exists(context.Resolve(key))).ToList()) + files.Remove(key); + + context.Logger.Log(profile.MaxPopulation + ? string.Format(Loc.Get("SvcLogTeamDatApplied"), TeamDatPatcher.DefaultPopulationLimit, files.Count) + : Loc.Get("SvcLogTeamDatRestored")); + int endlessCount = files.Keys.Count(key => key.StartsWith("MAPS/ENDL_", StringComparison.OrdinalIgnoreCase)); + context.Logger.Log(profile.RomanEndless + ? string.Format(Loc.Get("SvcLogTeamDatRoman"), endlessCount) + : Loc.Get("SvcLogTeamDatRomanRestored")); + + foreach ((string relativePath, byte[] bytes) in files) + context.Add(relativePath, bytes); + } +} diff --git a/src/Core/Services/PatchOperationRunner.cs b/src/Core/Services/PatchOperationRunner.cs new file mode 100644 index 0000000..4cbeb3a --- /dev/null +++ b/src/Core/Services/PatchOperationRunner.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; + +namespace AgainstRomeModifier.Core.Services; + +internal sealed class PatchOperationRunner +{ + private readonly Action _log; + + internal PatchOperationRunner(Action log) => _log = log; + + internal async Task ExecuteAsync( + Action operation, + string checkpointCreatedMessage, + string rollbackStartedMessage, + string rollbackCompletedMessage) + { + using var rollback = new FileRollbackScope(); + _log(checkpointCreatedMessage); + try + { + await Task.Run(() => operation(rollback)); + rollback.Commit(); + } + catch + { + if (!rollback.IsCommitted) + { + _log(rollbackStartedMessage); + rollback.RestoreAll(_log); + _log(rollbackCompletedMessage); + } + throw; + } + } +} diff --git a/src/Core/Services/PatchRestoreService.cs b/src/Core/Services/PatchRestoreService.cs new file mode 100644 index 0000000..30f66a1 --- /dev/null +++ b/src/Core/Services/PatchRestoreService.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using AgainstRomeModifier.Core.Features; +using AgainstRomeModifier.Core.Features.Bci; +using AgainstRomeModifier.Core.Features.Exe; +using AgainstRomeModifier.Core.Features.Install; +using AgainstRomeModifier.Core.Features.Map; +using AgainstRomeModifier.Core.Patches; + +namespace AgainstRomeModifier.Core.Services; + +internal sealed class PatchRestoreService +{ + private readonly ILogger _logger; + + internal PatchRestoreService(ILogger logger) => _logger = logger; + + internal void RestoreCategories( + string gamePath, + BackupManager? backupManager, + FileRollbackScope rollback, + IEnumerable categories, + EndlessAiOrchestrator? sharedOrchestrator = null, + bool saveOrchestrator = true) + { + HashSet requested = categories.ToHashSet(); + bool restoreStats = requested.Contains(FeatureCategory.Stats); + bool restoreCompat = requested.Contains(FeatureCategory.Compat); + bool restoreLanguage = requested.Contains(FeatureCategory.Language); + var patchedFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); + EndlessAiOrchestrator? orchestrator = null; + + if (restoreCompat) + { + string exePath = Path.Combine(gamePath, "Against_Rome.exe"); + byte[] exeBytes = File.ReadAllBytes(exePath); + bool keepRomanEndless = !restoreStats && + ExePatchModel.GetRomanEndlessPatchState(exeBytes) == ExeRomanEndlessPatchState.Patched; + if (ExeFeaturePatcher.Apply(exeBytes, false, false, false, keepRomanEndless, 1, _logger)) + patchedFiles[exePath] = exeBytes; + + orchestrator = sharedOrchestrator ?? new EndlessAiOrchestrator(); + foreach (EndlessAiModule module in orchestrator.UserModules) + orchestrator.ApplyModule(gamePath, module, false); + orchestrator.ApplyMandatoryRepair(gamePath); + } + + if (restoreStats) + { + string exePath = Path.Combine(gamePath, "Against_Rome.exe"); + byte[] exeBytes = patchedFiles.TryGetValue(exePath, out byte[]? pendingExe) + ? pendingExe + : File.ReadAllBytes(exePath); + bool exeChanged = false; + if (!restoreCompat) + exeChanged |= ExeFeaturePatcher.ApplyRomanEndless(exeBytes, false, _logger); + exeChanged |= ExeFeaturePatcher.ApplyCiviProduce20(exeBytes, false, _logger); + exeChanged |= ExeFeaturePatcher.ApplyUnitRecruit20(exeBytes, false, _logger); + if (exeChanged) + patchedFiles[exePath] = exeBytes; + + RestoreStatsFiles(gamePath, + backupManager ?? throw new ArgumentNullException(nameof(backupManager)), rollback); + + orchestrator ??= sharedOrchestrator ?? new EndlessAiOrchestrator(); + FoodHealingFeature.Apply(gamePath, false, backupManager, orchestrator, _logger); + CiviProduce20Feature.RestoreAkNpcOriginal(gamePath, orchestrator); + } + + foreach ((string path, byte[] bytes) in patchedFiles) + SafeFileWriter.WriteAllBytes(path, bytes, rollback); + + if (orchestrator != null && saveOrchestrator) + orchestrator.SaveAll(gamePath, rollback); + if (restoreLanguage) + new LanguagePackFeature(_logger).Apply(gamePath, false, rollback); + if (restoreCompat) + new DgVoodooFeature(_logger).Apply(gamePath, false, rollback); + } + + private void RestoreStatsFiles(string gamePath, BackupManager backupManager, FileRollbackScope rollback) + { + RestoreMemoryFile(backupManager, "SYSTEM/cl_script.ini", Path.Combine(gamePath, @"SYSTEM\cl_script.ini"), rollback); + RestoreMemoryFile(backupManager, "SYSTEM/cl_epara.ini", Path.Combine(gamePath, @"SYSTEM\cl_epara.ini"), rollback); + RestoreMemoryFile(backupManager, "SYSTEM/CLAK/cl_scint.ini", Path.Combine(gamePath, @"SYSTEM\CLAK\cl_scint.ini"), rollback); + RestoreMemoryFile(backupManager, "SYSTEM/ress.ini", Path.Combine(gamePath, @"SYSTEM\ress.ini"), rollback); + RestoreMemoryFile(backupManager, "SYSTEM/DATA_MP/DEFAULTS/objdef.dau", Path.Combine(gamePath, @"SYSTEM\DATA_MP\DEFAULTS\objdef.dau"), rollback); + RestoreMemoryFile(backupManager, "SYSTEM/DATA_MP/DEFAULTS/partgeo.dau", Path.Combine(gamePath, @"SYSTEM\DATA_MP\DEFAULTS\partgeo.dau"), rollback); + + foreach ((string key, byte[] bytes) in backupManager.BackupFiles) + { + if (!key.StartsWith("MAPS/", StringComparison.OrdinalIgnoreCase) || + !key.EndsWith("team.dat", StringComparison.OrdinalIgnoreCase)) + continue; + + string destination = Path.Combine(gamePath, key.Replace('/', '\\')); + if (!File.Exists(destination)) continue; + byte[] restoredBytes = TeamDatPatcher.GetPatchedBytes(bytes, new TeamDatOptions(false)); + SafeFileWriter.WriteAllBytes(destination, restoredBytes, rollback); + _logger.Log(string.Format(Loc.Get("SvcLogRestoredPopulation"), destination)); + } + } + + private void RestoreMemoryFile( + BackupManager backupManager, + string key, + string destination, + FileRollbackScope rollback) + { + if (!backupManager.BackupFiles.TryGetValue(key, out byte[]? bytes)) return; + SafeFileWriter.WriteAllBytes(destination, bytes, rollback); + _logger.Log(string.Format(Loc.Get("SvcLogRestoredFile"), destination)); + } +} diff --git a/src/Core/Services/SaveBackupManifestCodec.cs b/src/Core/Services/SaveBackupManifestCodec.cs new file mode 100644 index 0000000..18a95b7 --- /dev/null +++ b/src/Core/Services/SaveBackupManifestCodec.cs @@ -0,0 +1,35 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using System.Text.Json; + +namespace AgainstRomeModifier.Core.Services; + +internal sealed record SaveBackupManifest(string OrigFolder, string Title, string Level, string BackupTime); + +internal static class SaveBackupManifestCodec +{ + internal const string EntryName = "manifest.json"; + + internal static SaveBackupManifest? Read(ZipArchive archive) + { + ZipArchiveEntry? entry = archive.GetEntry(EntryName); + if (entry == null) return null; + using Stream stream = entry.Open(); + using var reader = new StreamReader(stream, Encoding.UTF8); + return JsonSerializer.Deserialize(reader.ReadToEnd()); + } + + internal static string Write(string folder, string title, string level, DateTime backupTime) => + JsonSerializer.Serialize( + new SaveBackupManifest(folder, title, level, + backupTime.ToString("o", CultureInfo.InvariantCulture)), + new JsonSerializerOptions { WriteIndented = true }); + + internal static string FormatTime(string value) + { + return DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTime time) + ? time.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + : value; + } +} diff --git a/src/Core/Services/SaveBackupService.cs b/src/Core/Services/SaveBackupService.cs new file mode 100644 index 0000000..4bb2073 --- /dev/null +++ b/src/Core/Services/SaveBackupService.cs @@ -0,0 +1,283 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using AgainstRomeModifier.Core.Patches; + +namespace AgainstRomeModifier.Core.Services; + +internal sealed record GameSaveInfo(string Folder, string Title, string Level, DateTime LastWriteTime, bool Parsed); +internal sealed record SaveBackupInfo(string FileName, string Title, string Level, string BackupTime, + string OrigFolder, DateTime LastWriteTime, bool Parsed); +internal sealed record SaveBackupCatalog(IReadOnlyList Saves, IReadOnlyList Backups); +internal sealed record SaveRestoreResult(IReadOnlyList CleanupWarnings); + +internal sealed class SaveBackupService +{ + private readonly string _backupDirectory; + private readonly Dictionary _cache = new(StringComparer.OrdinalIgnoreCase); + private readonly object _cacheLock = new(); + + internal SaveBackupService(string backupDirectory) => + _backupDirectory = Path.GetFullPath(backupDirectory); + + internal SaveBackupCatalog Scan(string gamePath) + { + var saves = new List(); + string saveRoot = GetSaveRoot(gamePath); + if (Directory.Exists(saveRoot)) + { + foreach (string directory in Directory.GetDirectories(saveRoot)) + { + string folder = Path.GetFileName(directory); + string saveIni = Path.Combine(directory, "save.ini"); + if (!File.Exists(saveIni)) continue; + bool parsed; + string title; + string level; + try { parsed = TryReadSaveInfo(File.ReadAllBytes(saveIni), out title, out level); } + catch { parsed = false; title = ""; level = ""; } + saves.Add(new GameSaveInfo(folder, title, level, Directory.GetLastWriteTime(directory), parsed)); + } + } + + Directory.CreateDirectory(_backupDirectory); + var backups = new List(); + foreach (string path in Directory.GetFiles(_backupDirectory, "*.zip")) + backups.Add(GetOrReadBackup(path)); + return new SaveBackupCatalog(saves, backups); + } + + internal byte[]? ReadSavePreview(string gamePath, string folder) + { + string directory = ResolveSaveDirectory(gamePath, folder); + string preview = Path.Combine(directory, "savepic.tga"); + return File.Exists(preview) ? File.ReadAllBytes(preview) : null; + } + + internal byte[]? ReadBackupPreview(string fileName) + { + string path = ResolveBackupPath(fileName); + if (!File.Exists(path)) return null; + using ZipArchive archive = ZipFile.OpenRead(path); + ZipArchiveEntry? entry = archive.GetEntry("savepic.tga"); + if (entry == null) return null; + using Stream stream = entry.Open(); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return memory.ToArray(); + } + + internal string CreateBackup(string gamePath, string folder, string title, string level, DateTime? now = null) + { + string source = ResolveSaveDirectory(gamePath, folder); + if (!Directory.Exists(source)) throw new DirectoryNotFoundException(source); + Directory.CreateDirectory(_backupDirectory); + + DateTime backupTime = now ?? DateTime.Now; + string fileName = $"Backup_{folder}_{backupTime:yyyyMMdd_HHmmss}.zip"; + string destination = ResolveBackupPath(fileName); + string temporary = destination + ".tmp"; + try + { + if (File.Exists(temporary)) File.Delete(temporary); + using (ZipArchive archive = ZipFile.Open(temporary, ZipArchiveMode.Create)) + { + foreach (string file in Directory.GetFiles(source, "*", SearchOption.AllDirectories)) + { + string entryName = Path.GetRelativePath(source, file).Replace('\\', '/'); + if (string.Equals(entryName, SaveBackupManifestCodec.EntryName, StringComparison.OrdinalIgnoreCase)) + continue; + archive.CreateEntryFromFile(file, entryName, CompressionLevel.Optimal); + } + ZipArchiveEntry manifestEntry = archive.CreateEntry( + SaveBackupManifestCodec.EntryName, CompressionLevel.Optimal); + using var writer = new StreamWriter(manifestEntry.Open(), Encoding.UTF8); + writer.Write(SaveBackupManifestCodec.Write(folder, title, level, backupTime)); + } + File.Move(temporary, destination); + lock (_cacheLock) _cache.Remove(fileName); + return fileName; + } + catch + { + try { if (File.Exists(temporary)) File.Delete(temporary); } catch { } + throw; + } + } + + internal SaveRestoreResult RestoreBackup(string gamePath, string fileName, string folder, + Action? onRollbackFailure = null) + { + string archivePath = ResolveBackupPath(fileName); + if (!File.Exists(archivePath)) throw new FileNotFoundException("找不到備份檔案。", archivePath); + string saveRoot = GetSaveRoot(gamePath); + Directory.CreateDirectory(saveRoot); + string destination = ResolveSaveDirectory(gamePath, folder); + string temporary = Path.Combine(saveRoot, ".restore_tmp_" + Guid.NewGuid().ToString("N")); + string oldDirectory = Path.Combine(saveRoot, ".restore_old_" + Guid.NewGuid().ToString("N")); + bool movedOld = false; + bool movedNew = false; + var warnings = new List(); + + try + { + ZipFile.ExtractToDirectory(archivePath, temporary); + if (!File.Exists(Path.Combine(temporary, "save.ini"))) + throw new InvalidDataException("備份壓縮檔缺少 save.ini,已停止還原。"); + if (Directory.Exists(destination)) + { + Directory.Move(destination, oldDirectory); + movedOld = true; + } + Directory.Move(temporary, destination); + movedNew = true; + } + catch (Exception) + { + try + { + if (movedNew && Directory.Exists(destination)) Directory.Delete(destination, true); + if (movedOld && Directory.Exists(oldDirectory) && !Directory.Exists(destination)) + Directory.Move(oldDirectory, destination); + } + catch (Exception rollbackError) + { + // 回復原存檔失敗只回報給呼叫端記錄;對外仍拋出原始的還原例外。 + onRollbackFailure?.Invoke(rollbackError.Message); + } + throw; + } + finally + { + try { if (Directory.Exists(temporary)) Directory.Delete(temporary, true); } + catch (Exception error) { warnings.Add(error.Message); } + } + + if (movedOld && Directory.Exists(oldDirectory)) + { + try { Directory.Delete(oldDirectory, true); } + catch (Exception error) { warnings.Add(error.Message); } + } + return new SaveRestoreResult(warnings); + } + + internal void DeleteSave(string gamePath, string folder) + { + string directory = ResolveSaveDirectory(gamePath, folder); + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + + internal void DeleteBackup(string fileName) + { + string path = ResolveBackupPath(fileName); + if (File.Exists(path)) File.Delete(path); + lock (_cacheLock) _cache.Remove(fileName); + } + + internal static bool IsSimpleName(string value) => + !string.IsNullOrWhiteSpace(value) && + string.Equals(value, Path.GetFileName(value), StringComparison.Ordinal) && + value.IndexOfAny(Path.GetInvalidFileNameChars()) < 0; + + private SaveBackupInfo GetOrReadBackup(string path) + { + string fileName = Path.GetFileName(path); + DateTime lastWrite = File.GetLastWriteTime(path); + lock (_cacheLock) + if (_cache.TryGetValue(fileName, out SaveBackupInfo? cached) && cached.LastWriteTime == lastWrite) + return cached; + + string title = "", level = "", folder = "", backupTime = ""; + bool parsed = true; + try + { + using ZipArchive archive = ZipFile.OpenRead(path); + SaveBackupManifest? manifest = SaveBackupManifestCodec.Read(archive); + if (manifest != null) + { + title = manifest.Title ?? ""; + level = manifest.Level ?? ""; + folder = manifest.OrigFolder ?? ""; + backupTime = SaveBackupManifestCodec.FormatTime(manifest.BackupTime ?? ""); + } + ZipArchiveEntry? saveIni = archive.GetEntry("save.ini"); + if (saveIni != null && (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(level))) + { + using Stream stream = saveIni.Open(); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + bool saveParsed = TryReadSaveInfo(memory.ToArray(), out string parsedTitle, out string parsedLevel); + parsed &= saveParsed; + if (string.IsNullOrEmpty(title)) title = parsedTitle; + if (string.IsNullOrEmpty(level)) level = parsedLevel; + } + } + catch + { + parsed = false; + } + + if ((string.IsNullOrEmpty(folder) || string.IsNullOrEmpty(backupTime)) && + TryParseLegacyFileName(fileName, out string legacyFolder, out string legacyTime)) + { + if (string.IsNullOrEmpty(folder)) folder = legacyFolder; + if (string.IsNullOrEmpty(backupTime)) backupTime = legacyTime; + } + if (string.IsNullOrEmpty(backupTime)) backupTime = File.GetCreationTime(path).ToString("yyyy-MM-dd HH:mm:ss"); + + var result = new SaveBackupInfo(fileName, title, level, backupTime, folder, lastWrite, parsed); + lock (_cacheLock) _cache[fileName] = result; + return result; + } + + private static bool TryReadSaveInfo(byte[] bytes, out string title, out string level) + { + title = ""; + level = ""; + try + { + byte[] decompressed = GameLZSS.DecompressPfil(bytes); + string text = PatchText.GameEncoding.GetString(decompressed); + string[] lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + for (int index = 0; index < lines.Length; index++) + { + string line = lines[index].Trim(); + if (line.Contains("[orglevelname]") && index + 1 < lines.Length) level = lines[index + 1].Trim(); + if (line.Contains("[titel]") && index + 1 < lines.Length) title = lines[index + 1].Trim(); + } + return true; + } + catch { return false; } + } + + private static bool TryParseLegacyFileName(string fileName, out string folder, out string backupTime) + { + folder = ""; + backupTime = ""; + string name = Path.GetFileNameWithoutExtension(fileName); + if (!name.StartsWith("Backup_", StringComparison.OrdinalIgnoreCase)) return false; + int timeSeparator = name.LastIndexOf('_'); + int dateSeparator = timeSeparator < 0 ? -1 : name.LastIndexOf('_', timeSeparator - 1); + if (dateSeparator < "Backup_".Length) return false; + string date = name.Substring(dateSeparator + 1, timeSeparator - dateSeparator - 1); + string time = name.Substring(timeSeparator + 1); + if (date.Length != 8 || time.Length != 6) return false; + folder = name.Substring("Backup_".Length, dateSeparator - "Backup_".Length); + backupTime = $"{date[..4]}-{date.Substring(4, 2)}-{date.Substring(6, 2)} {time[..2]}:{time.Substring(2, 2)}:{time.Substring(4, 2)}"; + return true; + } + + private static string GetSaveRoot(string gamePath) => Path.Combine(Path.GetFullPath(gamePath), "SAVE"); + + private static string ResolveSaveDirectory(string gamePath, string folder) + { + if (!IsSimpleName(folder)) throw new InvalidDataException("存檔資料夾名稱無效。"); + return Path.Combine(GetSaveRoot(gamePath), folder); + } + + private string ResolveBackupPath(string fileName) + { + if (!IsSimpleName(fileName)) throw new InvalidDataException("備份檔名無效。"); + return Path.Combine(_backupDirectory, fileName); + } +} diff --git a/src/Core/Services/TroopPresetCodec.cs b/src/Core/Services/TroopPresetCodec.cs new file mode 100644 index 0000000..32ef6b8 --- /dev/null +++ b/src/Core/Services/TroopPresetCodec.cs @@ -0,0 +1,84 @@ +using System.Globalization; +using System.Text; + +namespace AgainstRomeModifier.Core.Services; + +internal sealed record TroopPresetParseResult(IReadOnlyDictionary Stats, int SkippedLines); + +internal static class TroopPresetCodec +{ + internal static TroopPresetParseResult Parse(IEnumerable lines, Func defaultStats) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + int skipped = 0; + foreach (string sourceLine in lines) + { + string line = sourceLine.Trim(); + if (string.IsNullOrEmpty(line) || line.StartsWith('#') || line.StartsWith(';')) continue; + int separator = line.IndexOf('='); + if (separator < 1) continue; + string key = line[..separator].Trim(); + string[] values = line[(separator + 1)..].Split(','); + if (values.Length < 4) + { + skipped++; + continue; + } + + double[] parsed = new double[Math.Min(values.Length, 9)]; + bool valid = true; + for (int index = 0; index < parsed.Length; index++) + { + if (!double.TryParse(values[index].Trim(), NumberStyles.Any, + CultureInfo.InvariantCulture, out parsed[index])) + { + valid = false; + break; + } + } + if (!valid) + { + skipped++; + continue; + } + + double[] fallback = defaultStats(key); + double speed = 0; + double sight = values.Length == 6 ? parsed[4] : parsed.Length > 5 ? parsed[5] : 0; + double reload = values.Length == 6 ? parsed[5] : parsed.Length > 6 ? parsed[6] : 0; + double range = parsed.Length > 7 ? parsed[7] : 0; + double spellRadius = parsed.Length > 8 ? parsed[8] : 0; + if (values.Length < 9 && values.Length != 6) + { + if (values.Length <= 4) speed = At(fallback, 4); + if (values.Length <= 5) sight = At(fallback, 5); + if (values.Length <= 6) reload = At(fallback, 6); + if (values.Length <= 7) range = At(fallback, 7); + if (values.Length <= 8) spellRadius = At(fallback, 8); + } + result[key] = new[] { parsed[0], parsed[1], parsed[2], parsed[3], speed, sight, reload, range, spellRadius }; + } + return new TroopPresetParseResult(result, skipped); + } + + internal static string Write(IReadOnlyDictionary stats, DateTime generatedAt) + { + var builder = new StringBuilder(); + builder.AppendLine("# Against Rome Modifier - Custom Troop Preset File (independent modifiers removed)"); + builder.AppendLine($"# Generated on: {generatedAt:yyyy-MM-dd HH:mm:ss}"); + builder.AppendLine("# Format: UnitKey=HP,Dmg,VW,AW,Sight,Relt"); + builder.AppendLine(); + foreach ((string key, double[] values) in stats) + { + builder.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0}={1},{2},{3},{4},{5},{6}", + key, Format(values, 0), Format(values, 1), Format(values, 2), Format(values, 3), + Format(values, 5), Format(values, 6))); + } + return builder.ToString(); + } + + private static double At(double[] values, int index) => index < values.Length ? values[index] : 0; + + // 以最短往返格式輸出,保留使用者輸入的完整小數精度,不得四捨五入。 + private static string Format(double[] values, int index) => At(values, index).ToString(CultureInfo.InvariantCulture); +} diff --git a/src/Core/Services/UnitBaselineCatalog.cs b/src/Core/Services/UnitBaselineCatalog.cs new file mode 100644 index 0000000..036c16b --- /dev/null +++ b/src/Core/Services/UnitBaselineCatalog.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using AgainstRomeModifier.Core.Features; +using AgainstRomeModifier.Core.Patches; + +namespace AgainstRomeModifier.Core.Services; + +internal sealed class UnitBaselineCatalog +{ + private const string ObjdefKey = "SYSTEM/DATA_MP/DEFAULTS/objdef.dau"; + private readonly IReadOnlyDictionary _backupFiles; + private readonly object _rowsLock = new(); + private Dictionary? _unitRows; + + internal UnitBaselineCatalog(IReadOnlyDictionary backupFiles) => + _backupFiles = backupFiles; + + internal void Reset() + { + lock (_rowsLock) + _unitRows = null; + } + + internal Dictionary GetUnitRows() + { + lock (_rowsLock) + { + if (_unitRows != null) return _unitRows; + + _unitRows = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!_backupFiles.TryGetValue(ObjdefKey, out byte[]? dauBytes)) return _unitRows; + + byte[] decompressed = GameLZSS.DecompressPfil(dauBytes); + string text = PatchText.GameEncoding.GetString(decompressed); + string lineEnding = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + string[] lines = text.Split(new[] { lineEnding }, StringSplitOptions.None); + for (int index = 2; index < lines.Length; index++) + { + string line = lines[index]; + if (line.Length < 100) continue; + string[] columns = PatchText.ParseCsvLine(line); + if (columns.Length < 192) continue; + + string name = columns[(int)ObjdefIndex.Name].Trim(); + if (TroopConfig.UnitMeta.ContainsKey(name) || name == "FigZivMan00_Zivilist") + _unitRows[name] = columns; + } + + return _unitRows; + } + } + + internal double[] GetOriginalStats(string key) + { + Dictionary rows = GetUnitRows(); + if (!rows.TryGetValue(key, out string[]? columns)) + return new double[9]; + + double.TryParse(columns[(int)ObjdefIndex.Hp].Trim(), NumberStyles.Any, + CultureInfo.InvariantCulture, out double hp); + double.TryParse(columns[(int)ObjdefIndex.Vw].Trim(), NumberStyles.Any, + CultureInfo.InvariantCulture, out double vw); + double.TryParse(columns[(int)ObjdefIndex.Aw].Trim(), NumberStyles.Any, + CultureInfo.InvariantCulture, out double aw); + + string unitType = TroopConfig.UnitMeta.TryGetValue(key, out var metadata) + ? metadata.UnitType + : "melee_inf"; + UnitStatParser.GetMeleeAndRangedDamage(columns, unitType, out double meleeDamage, out double rangedDamage); + UnitStatParser.GetMeleeAndRangedReload(columns, unitType, out double meleeReload, out double rangedReload); + + double damage = meleeDamage; + double reload = meleeReload; + if (unitType is "ranged_inf" or "ranged_cav") + { + damage = rangedDamage; + reload = rangedReload; + } + else if (unitType == "siege") + { + damage = Math.Max(meleeDamage, rangedDamage); + reload = Math.Max(meleeReload, rangedReload); + } + + double.TryParse(columns[(int)ObjdefIndex.Moves].Trim(), NumberStyles.Any, + CultureInfo.InvariantCulture, out double originalMoves); + double speed = originalMoves > 0 ? Math.Round(originalMoves * 2.0, 1) : 0; + double.TryParse(columns[(int)ObjdefIndex.Sirad].Trim(), NumberStyles.Any, + CultureInfo.InvariantCulture, out double sight); + double range = UnitStatParser.GetMaximumRange(columns, unitType); + double spellRadius = UnitStatParser.SupportsConfigurableSpellRadius(key) ? 500 : 0; + + return new[] { hp, damage, vw, aw, speed, sight, reload, range, spellRadius }; + } + + internal double[] GetDefaultBalancedStats(string key) => + TroopConfig.BalancedUnitStats.TryGetValue(key, out double[]? stats) + ? (double[])stats.Clone() + : GetOriginalStats(key); + + internal double[] GetBaseStatsForUnit(string key, PatchProfile profile) + { + double[] original = GetOriginalStats(key); + double[] baseline = profile.Balance ? GetDefaultBalancedStats(key) : original; + if (!profile.Balance || profile.CustomUnitStats == null || + !profile.CustomUnitStats.TryGetValue(key, out double[]? custom) || custom == null) + return baseline; + + bool ignoreRange = TroopConfig.UnitMeta.TryGetValue(key, out var metadata) && + ((profile.RangedRange3x && TroopConfig.SupportsRangedRange3x(metadata.UnitType)) || + (profile.SpellEntireMap && metadata.UnitType == "priest")); + bool supportsSpellRadius = UnitStatParser.SupportsConfigurableSpellRadius(key); + return UnitStatParser.MergeLayers( + baseline, + custom, + supportsSpellRadius, + profile.UnitMovementSpeed2x, + ignoreRange, + profile.SpellRange3x && supportsSpellRadius, + metadata?.UnitType == "priest"); + } +} diff --git a/src/Core/Services/UnitStatParser.cs b/src/Core/Services/UnitStatParser.cs new file mode 100644 index 0000000..7689e8b --- /dev/null +++ b/src/Core/Services/UnitStatParser.cs @@ -0,0 +1,131 @@ +using System; +using System.Globalization; +using AgainstRomeModifier.Core.Patches; + +namespace AgainstRomeModifier.Core.Services; + +internal static class UnitStatParser +{ + internal static bool SupportsConfigurableSpellRadius(string key) => + key.Equals("FigKelPri00_Priester", StringComparison.OrdinalIgnoreCase) || + key.Equals("FigHunPri00_Priester", StringComparison.OrdinalIgnoreCase); + + internal static double[] MergeLayers( + double[] fallback, + double[] custom, + bool supportsSpellRadius, + bool ignoreMovementSpeed = false, + bool ignoreRange = false, + bool ignoreSpellRadius = false, + bool removePriestSight = false) + { + ArgumentNullException.ThrowIfNull(fallback); + ArgumentNullException.ThrowIfNull(custom); + + double[] layered = new double[9]; + for (int index = 0; index < layered.Length; index++) + { + if (index == 8 && !supportsSpellRadius) + { + layered[index] = 0; + continue; + } + + if (index is 4 or 7 or 8 || (index == 5 && removePriestSight)) + { + layered[index] = fallback.Length > index ? fallback[index] : 0; + continue; + } + + layered[index] = custom.Length > index + ? custom[index] + : (fallback.Length > index ? fallback[index] : 0); + } + + return layered; + } + + internal static void GetMeleeAndRangedDamage( + string[] columns, + string unitType, + out double meleeDamage, + out double rangedDamage) + { + meleeDamage = 0; + rangedDamage = 0; + for (int weapon = 1; weapon <= 8; weapon++) + { + int activeIndex = (int)ObjdefIndex.Weapon1Akti + (weapon - 1) * 8; + int damageIndex = activeIndex + 1; + int typeIndex = (int)ObjdefIndex.Weapon1Dtyp + (weapon - 1); + if (activeIndex >= columns.Length || damageIndex >= columns.Length || typeIndex >= columns.Length || + columns[activeIndex].Trim() != "1") + continue; + + double.TryParse(columns[damageIndex].Trim(), NumberStyles.Any, + CultureInfo.InvariantCulture, out double value); + bool ranged = IsRangedWeapon(columns[typeIndex].Trim(), unitType); + if (ranged) + rangedDamage = Math.Max(rangedDamage, value); + else + meleeDamage = Math.Max(meleeDamage, value); + } + } + + internal static void GetMeleeAndRangedReload( + string[] columns, + string unitType, + out double meleeReload, + out double rangedReload) + { + meleeReload = 0; + rangedReload = 0; + for (int weapon = 1; weapon <= 8; weapon++) + { + int activeIndex = (int)ObjdefIndex.Weapon1Akti + (weapon - 1) * 8; + int reloadIndex = activeIndex + 6; + int typeIndex = (int)ObjdefIndex.Weapon1Dtyp + (weapon - 1); + if (activeIndex >= columns.Length || reloadIndex >= columns.Length || typeIndex >= columns.Length || + columns[activeIndex].Trim() != "1") + continue; + + double.TryParse(columns[reloadIndex].Trim(), NumberStyles.Any, + CultureInfo.InvariantCulture, out double value); + if (value <= 0) continue; + + bool ranged = IsRangedWeapon(columns[typeIndex].Trim(), unitType); + if (ranged) + rangedReload = rangedReload == 0 ? value : Math.Min(rangedReload, value); + else + meleeReload = meleeReload == 0 ? value : Math.Min(meleeReload, value); + } + } + + internal static double GetMaximumRange(string[] columns, string unitType) + { + if (unitType == "priest") + return double.TryParse(columns[(int)ObjdefIndex.Sirad].Trim(), NumberStyles.Any, + CultureInfo.InvariantCulture, out double sight) ? sight : 0; + + double maximum = 0; + for (int weapon = 1; weapon <= 8; weapon++) + { + int activeIndex = (int)ObjdefIndex.Weapon1Akti + (weapon - 1) * 8; + int minimumIndex = (int)ObjdefIndex.Weapon1RangeMin + (weapon - 1) * 8; + int maximumIndex = (int)ObjdefIndex.Weapon1RangeMax + (weapon - 1) * 8; + if (maximumIndex >= columns.Length || columns[activeIndex].Trim() != "1") continue; + + if (double.TryParse(columns[minimumIndex].Trim(), NumberStyles.Any, + CultureInfo.InvariantCulture, out double minimumRange)) + maximum = Math.Max(maximum, minimumRange); + if (double.TryParse(columns[maximumIndex].Trim(), NumberStyles.Any, + CultureInfo.InvariantCulture, out double maximumRange)) + maximum = Math.Max(maximum, maximumRange); + } + + return maximum; + } + + private static bool IsRangedWeapon(string damageType, string unitType) => + damageType is "1" or "2" or "3" or "4" || unitType == "siege"; +} diff --git a/src/Core/Services/UnitStatsEditorService.cs b/src/Core/Services/UnitStatsEditorService.cs new file mode 100644 index 0000000..9b43624 --- /dev/null +++ b/src/Core/Services/UnitStatsEditorService.cs @@ -0,0 +1,23 @@ +namespace AgainstRomeModifier.Core.Services; + +internal sealed class UnitStatsEditorService +{ + private readonly BackupManager _backup; + + internal UnitStatsEditorService(BackupManager backup) => _backup = backup; + + internal double[] GetOriginal(string key) => _backup.GetOriginalStats(key); + internal double[] GetBalanced(string key) => _backup.GetDefaultBalancedStats(key); + + internal double[] NormalizeIndependentFields(string key, double[] stats) + { + double[] normalized = (double[])stats.Clone(); + double[] original = GetOriginal(key); + foreach (int index in new[] { 4, 7, 8 }) + if (index < normalized.Length && index < original.Length) normalized[index] = original[index]; + if (TroopConfig.UnitMeta.TryGetValue(key, out var metadata) && metadata.UnitType == "priest" && + normalized.Length > 5 && original.Length > 5) + normalized[5] = original[5]; + return normalized; + } +} diff --git a/src/Core/Services/UnitStatsProjectionService.cs b/src/Core/Services/UnitStatsProjectionService.cs new file mode 100644 index 0000000..c100179 --- /dev/null +++ b/src/Core/Services/UnitStatsProjectionService.cs @@ -0,0 +1,30 @@ +using AgainstRomeModifier.Core.Features; + +namespace AgainstRomeModifier.Core.Services; + +internal sealed class UnitStatsProjectionService +{ + private readonly BackupManager _backupManager; + + internal UnitStatsProjectionService(BackupManager backupManager) => _backupManager = backupManager; + + internal double[] Project(string key, PatchProfile profile) + { + double[] result = _backupManager.GetBaseStatsForUnit(key, profile); + if (!TroopConfig.UnitMeta.TryGetValue(key, out var metadata)) return result; + + if (profile.RangedRange3x && TroopConfig.SupportsRangedRange3x(metadata.UnitType)) + result[7] *= 3.0; + if (profile.UnitMovementSpeed2x) + result[4] *= 2.0; + if (metadata.UnitType == "priest") + { + if (profile.SpellEntireMap) + result[7] = 30000.0; + if (profile.SpellRange3x && UnitStatParser.SupportsConfigurableSpellRadius(key)) + result[8] *= 3.0; + } + + return result; + } +} diff --git a/src/UI/ModifierForm.Data.cs b/src/UI/ModifierForm.Data.cs index da7357f..7e5299c 100644 --- a/src/UI/ModifierForm.Data.cs +++ b/src/UI/ModifierForm.Data.cs @@ -11,6 +11,7 @@ using AgainstRomeModifier.Core.Patches; using System.Drawing.Drawing2D; using AgainstRomeModifier.Core.Features; +using AgainstRomeModifier.Core.Services; using System.Runtime.InteropServices; namespace AgainstRomeModifier { @@ -250,76 +251,6 @@ private void LoadIcons() { } } - /// - /// 獲取各兵種的平衡基礎屬性,若未啟用平衡模式,則直接返回原版屬性。 - /// - private static double[] MergeUnitStatsLayers(double[] fallback, double[] custom, bool supportsSpellRadius, - bool ignoreMovementSpeed = false, bool ignoreRange = false, bool ignoreSpellRadius = false, - bool removePriestSight = false) { - ArgumentNullException.ThrowIfNull(fallback); - ArgumentNullException.ThrowIfNull(custom); - - double[] layered = new double[9]; - for (int i = 0; i < layered.Length; i++) { - if (i == 8 && !supportsSpellRadius) { - layered[i] = 0; - continue; - } - - // 速度、射程、法術範圍不再是自訂兵種欄位,由獨立功能負責。 - if (i is 4 or 7 or 8 || (i == 5 && removePriestSight)) { - layered[i] = fallback.Length > i ? fallback[i] : 0; - continue; - } - - // Preset values are concrete overrides. Only fields omitted by an - // older/short preset inherit the active balanced or original layer. - layered[i] = custom.Length > i - ? custom[i] - : (fallback.Length > i ? fallback[i] : 0); - } - return layered; - } - - private double[] GetBaseStatsForUnit(string key, double origHp, double origDmg, double origVw, double origAw, bool forceBalance = false) { - double[] original = GetOriginalStats(key); - bool balanceEnabled = forceBalance || chkBalance.Checked; - double[] balanced = balanceEnabled ? GetDefaultBalancedStats(key) : original; - - double[] result; - if (!TroopConfig.UnitMeta.TryGetValue(key, out var meta)) return balanced; - // 與 BackupManager.GetBaseStatsForUnit 一致:自訂屬性同受平衡開關把關。 - if (balanceEnabled && customUnitStats != null && customUnitStats.TryGetValue(key, out double[]? custom) && custom != null) { - bool ignoreRange = (chkRangedRange3x.Checked && TroopConfig.SupportsRangedRange3x(meta.UnitType)) || - (chkSpellEntireMap.Checked && meta.UnitType == "priest"); - result = MergeUnitStatsLayers(balanced, custom, SupportsConfigurableSpellRadius(key), - chkUnitMovementSpeed2x.Checked, ignoreRange, - chkSpellRange3x.Checked && SupportsConfigurableSpellRadius(key), - meta.UnitType == "priest"); - } else { - result = (double[])balanced.Clone(); - } - - if (meta != null) { - bool isRanged = TroopConfig.SupportsRangedRange3x(meta.UnitType); - if (isRanged && chkRangedRange3x.Checked) { - result[7] *= 3.0; - } - if (chkUnitMovementSpeed2x.Checked) { - result[4] *= 2.0; - } - bool isPriest = meta.UnitType == "priest"; - if (isPriest) { - if (chkSpellEntireMap.Checked) { - result[7] = 30000.0; - } - if (chkSpellRange3x.Checked && SupportsConfigurableSpellRadius(key)) result[8] *= 3.0; - } - } - - return result; - } - /// /// 將裝備分類代碼轉換為易懂的中文文字說明。 /// @@ -575,7 +506,7 @@ private void LoadDefaultStatsData() { dgv.Rows.Clear(); } - + PatchProfile displayProfile = BuildCurrentPatchProfile(chkBalance.Checked); foreach (string key in TroopConfig.UnitOrder) { if (!TroopConfig.UnitMeta.ContainsKey(key)) continue; @@ -599,11 +530,11 @@ private void LoadDefaultStatsData() { double meleeDam = 0; double rangedDam = 0; - GetMeleeAndRangedDmg(cols, utype, out meleeDam, out rangedDam); + UnitStatParser.GetMeleeAndRangedDamage(cols, utype, out meleeDam, out rangedDam); double meleeRelt = 0; double rangedRelt = 0; - GetMeleeAndRangedRelt(cols, utype, out meleeRelt, out rangedRelt); + UnitStatParser.GetMeleeAndRangedReload(cols, utype, out meleeRelt, out rangedRelt); double origMoves = 0; double.TryParse(cols[(int)ObjdefIndex.Moves].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origMoves); @@ -611,7 +542,7 @@ private void LoadDefaultStatsData() { double origSight = 0; double.TryParse(cols[(int)ObjdefIndex.Sirad].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origSight); - double origRange = GetUnitMaxRange(cols, utype); + double origRange = UnitStatParser.GetMaximumRange(cols, utype); double defaultSpeed = 0; if (origMoves > 0) { @@ -666,7 +597,7 @@ private void LoadDefaultStatsData() { string styleText = Loc.GetStyleText(style); var iconImage = unitIcons.ContainsKey(key) ? unitIcons[key] : null; - double[] bases = GetBaseStatsForUnit(key, hp, origPrimaryDam, vw, aw, chkBalance.Checked); + double[] bases = unitStatsProjection.Project(key, displayProfile); double displayMeleeDam = 0; double displayRangedDam = 0; @@ -865,19 +796,19 @@ private void LoadCurrentData(bool syncUIWithFile = true) { double origMeleeDmg = 0; double origRangedDmg = 0; - GetMeleeAndRangedDmg(origCols, utype, out origMeleeDmg, out origRangedDmg); + UnitStatParser.GetMeleeAndRangedDamage(origCols, utype, out origMeleeDmg, out origRangedDmg); double curMeleeDmg = 0; double curRangedDmg = 0; - GetMeleeAndRangedDmg(cols, utype, out curMeleeDmg, out curRangedDmg); + UnitStatParser.GetMeleeAndRangedDamage(cols, utype, out curMeleeDmg, out curRangedDmg); double tempOrigMeleeRelt = 0; double tempOrigRangedRelt = 0; - GetMeleeAndRangedRelt(origCols, utype, out tempOrigMeleeRelt, out tempOrigRangedRelt); + UnitStatParser.GetMeleeAndRangedReload(origCols, utype, out tempOrigMeleeRelt, out tempOrigRangedRelt); double tempCurMeleeRelt = 0; double tempCurRangedRelt = 0; - GetMeleeAndRangedRelt(cols, utype, out tempCurMeleeRelt, out tempCurRangedRelt); + UnitStatParser.GetMeleeAndRangedReload(cols, utype, out tempCurMeleeRelt, out tempCurRangedRelt); double origMoves = 0; double.TryParse(origCols[(int)ObjdefIndex.Moves].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out origMoves); @@ -889,8 +820,8 @@ private void LoadCurrentData(bool syncUIWithFile = true) { double curSight = 0; double.TryParse(cols[(int)ObjdefIndex.Sirad].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out curSight); - double origRange = GetUnitMaxRange(origCols, utype); - double curRange = GetUnitMaxRange(cols, utype); + double origRange = UnitStatParser.GetMaximumRange(origCols, utype); + double curRange = UnitStatParser.GetMaximumRange(cols, utype); double origSpellRadius = 0; double curSpellRadius = 0; @@ -938,89 +869,6 @@ private void LoadCurrentData(bool syncUIWithFile = true) { } } - /// - /// 從兵種 CSV 行中解析出近戰傷害與遠程傷害。 - /// 遍歷 8 個武器槽位,若啟用且武器類型匹配則回傳最高傷害。 - /// - private static void GetMeleeAndRangedDmg(string[] cols, string utype, out double meleeDmg, out double rangedDmg) { - meleeDmg = 0; - rangedDmg = 0; - for (int w = 1; w <= 8; w++) { - int wAktiIdx = (int)ObjdefIndex.Weapon1Akti + (w - 1) * 8; - int wDamIdx = wAktiIdx + 1; - int wDtypIdx = (int)ObjdefIndex.Weapon1Dtyp + (w - 1); - if (wAktiIdx >= cols.Length || wDamIdx >= cols.Length || wDtypIdx >= cols.Length) { - continue; - } - if (cols[wAktiIdx].Trim() == "1") { - double damVal; - double.TryParse(cols[wDamIdx].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out damVal); - string wDtyp = cols[wDtypIdx].Trim(); - bool isRangedWeapon = (wDtyp == "1" || wDtyp == "2" || wDtyp == "3" || wDtyp == "4" || utype == "siege"); - if (isRangedWeapon) { - if (damVal > rangedDmg) rangedDmg = damVal; - } else { - if (damVal > meleeDmg) meleeDmg = damVal; - } - } - } - } - - /// - /// 從兵種 CSV 行中解析出近戰武器與遠程武器的最小冷卻時間(攻擊間隔時間)。 - /// - private static void GetMeleeAndRangedRelt(string[] cols, string utype, out double meleeRelt, out double rangedRelt) { - meleeRelt = 0; - rangedRelt = 0; - for (int w = 1; w <= 8; w++) { - int wAktiIdx = (int)ObjdefIndex.Weapon1Akti + (w - 1) * 8; - int wReltIdx = wAktiIdx + 6; - int wDtypIdx = (int)ObjdefIndex.Weapon1Dtyp + (w - 1); - if (wAktiIdx >= cols.Length || wReltIdx >= cols.Length || wDtypIdx >= cols.Length) { - continue; - } - if (cols[wAktiIdx].Trim() == "1") { - double reltVal; - double.TryParse(cols[wReltIdx].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out reltVal); - string wDtyp = cols[wDtypIdx].Trim(); - bool isRangedWeapon = (wDtyp == "1" || wDtyp == "2" || wDtyp == "3" || wDtyp == "4" || utype == "siege"); - if (isRangedWeapon) { - if (reltVal > 0 && (rangedRelt == 0 || reltVal < rangedRelt)) { - rangedRelt = reltVal; - } - } else { - if (reltVal > 0 && (meleeRelt == 0 || reltVal < meleeRelt)) { - meleeRelt = reltVal; - } - } - } - } - } - - - /// - /// 從兵種 CSV 行中解析所有啟用武器槽的最大射程。 - /// - private static double GetUnitMaxRange(string[] cols, string utype) { - double maxR = 0; - if (utype == "priest") - return double.TryParse(cols[(int)ObjdefIndex.Sirad].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double sight) ? sight : 0; - for (int w = 1; w <= 8; w++) { - int activeIndex = (int)ObjdefIndex.Weapon1Akti + (w - 1) * 8; - int rangeMinIndex = (int)ObjdefIndex.Weapon1RangeMin + (w - 1) * 8; - int rangeMaxIndex = (int)ObjdefIndex.Weapon1RangeMax + (w - 1) * 8; - if (rangeMaxIndex >= cols.Length || cols[activeIndex].Trim() != "1") continue; - - if (double.TryParse(cols[rangeMinIndex].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double minRange)) { - maxR = Math.Max(maxR, minRange); - } - if (double.TryParse(cols[rangeMaxIndex].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double maxRange)) { - maxR = Math.Max(maxR, maxRange); - } - } - return maxR; - } - /// /// 格式化數值為字串,若數值小於等於 0 則顯示為 "-"。 /// diff --git a/src/UI/ModifierForm.DataExt.cs b/src/UI/ModifierForm.DataExt.cs index 27a7a38..c1f5868 100644 --- a/src/UI/ModifierForm.DataExt.cs +++ b/src/UI/ModifierForm.DataExt.cs @@ -10,24 +10,18 @@ public static bool SupportsConfigurableSpellRadius(string key) { /// 獲取某個兵種的原始 9 大屬性值 (HP, 傷害, 防禦 VW, 戰鬥 AW, 移動速度, 視野, 攻擊冷卻, 最大射程, 法術半徑) /// public double[] GetOriginalStats(string key) { - return backupManager.GetOriginalStats(key); + return unitStatsEditorService.GetOriginal(key); } /// /// 獲取某個兵種的內建平衡 9 大屬性值 (HP, 傷害, 防禦 VW, 戰鬥 AW, 移動速度, 視野, 攻擊冷卻, 最大射程, 法術半徑) /// public double[] GetDefaultBalancedStats(string key) { - return backupManager.GetDefaultBalancedStats(key); + return unitStatsEditorService.GetBalanced(key); } public double[] NormalizeIndependentCustomFields(string key, double[] stats) { - double[] normalized = (double[])stats.Clone(); - double[] original = GetOriginalStats(key); - foreach (int index in new[] { 4, 7, 8 }) - if (index < normalized.Length && index < original.Length) normalized[index] = original[index]; - if (TroopConfig.UnitMeta.TryGetValue(key, out var meta) && meta.UnitType == "priest" && normalized.Length > 5 && original.Length > 5) - normalized[5] = original[5]; - return normalized; + return unitStatsEditorService.NormalizeIndependentFields(key, stats); } } } diff --git a/src/UI/ModifierForm.Localization.cs b/src/UI/ModifierForm.Localization.cs index c12e693..cf69987 100644 --- a/src/UI/ModifierForm.Localization.cs +++ b/src/UI/ModifierForm.Localization.cs @@ -274,7 +274,8 @@ private void CbTroopTemplate_SelectedIndexChanged(object? sender, EventArgs e) { customUnitStats = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (string key in TroopConfig.UnitOrder) { if (!TroopConfig.UnitMeta.ContainsKey(key)) continue; - customUnitStats[key] = NormalizeIndependentCustomFields(key, GetDefaultBalancedStats(key)); + customUnitStats[key] = unitStatsEditorService.NormalizeIndependentFields( + key, unitStatsEditorService.GetBalanced(key)); } presetFileSourceType = "preset"; @@ -289,7 +290,7 @@ private void CbTroopTemplate_SelectedIndexChanged(object? sender, EventArgs e) { } private void BtnTroopPreset_Click(object? sender, EventArgs e) { - using (var form = new TroopPresetForm(this, customUnitStats, unitIcons)) { + using (var form = new TroopPresetForm(unitStatsEditorService, customUnitStats, unitIcons)) { if (form.ShowDialog() == DialogResult.OK) { customUnitStats = form.CustomStats; LoadDefaultStatsData(); // 重新整理預設屬性表格 diff --git a/src/UI/ModifierForm.Patches.cs b/src/UI/ModifierForm.Patches.cs index 8c995b9..d0b6276 100644 --- a/src/UI/ModifierForm.Patches.cs +++ b/src/UI/ModifierForm.Patches.cs @@ -3,8 +3,6 @@ using System.Linq; using System.Text; using System.Windows.Forms; -using System.Threading.Tasks; -using AgainstRomeModifier.Core.Patches; using AgainstRomeModifier.Core.Services; using AgainstRomeModifier.Core.Features; @@ -16,29 +14,30 @@ public partial class ModifierForm { private void BuildFeatureToggleMap() { featureToggles = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["FocusLoss"] = chkFocusLoss, ["FastCiviProduction"] = chkFastCiviProduction, - ["InfiniteMorale"] = chkInfiniteMorale, ["FreeProduction"] = chkFreeProd, - ["FreeUpgrade"] = chkFreeUpgrade, ["NoSpellCost"] = chkNoSpellCost, - ["MaxPopulation"] = chkMaxPopulation, ["Balance"] = chkBalance, - ["RangedRange3x"] = chkRangedRange3x, ["UnitMovementSpeed2x"] = chkUnitMovementSpeed2x, - ["SpellEntireMap"] = chkSpellEntireMap, ["SpellRange3x"] = chkSpellRange3x, - ["ProjectileArcHeight"] = chkProjectileArcHeight, - ["RomanEndless"] = chkRomanEndless, - ["HousingCapacity20x"] = chkHousingCapacity20x, ["StorageCapacity10x"] = chkStorageCapacity10x, - ["HqHp10x"] = chkHqHp10x, ["FastBuildUpgradeRepair"] = chkFastBuildUpgradeRepair, - ["FoodHealing10x"] = chkFoodHealing10x, ["CiviProduce20"] = chkCiviProduce20, - ["UnitRecruit20"] = chkUnitRecruit20, - ["VillageBuildRange"] = chkVillageBuildRange, - ["NoSpellAltar"] = chkNoSpellAltar, ["DgVoodoo"] = chkDgVoodoo, - ["ToEnglish"] = chkToEng, - ["SpellDamage5x"] = chkSpellDamage5x, - ["SpellHealing10x"] = chkSpellHealing10x, - ["SpellResurrection"] = chkSpellResurrection, - ["GeneralSkills"] = chkGeneralSkills, - ["LeaderGlory"] = chkLeaderGlory, - ["EndlessAi.M1"] = chkAiM1, ["EndlessAi.M2"] = chkAiM2, ["EndlessAi.M3"] = chkAiM3, - ["EndlessAi.M4"] = chkAiM4, ["EndlessAi.M5"] = chkAiM5, ["EndlessAi.M6"] = chkAiM6, + [FeatureKeys.FocusLoss.Id] = chkFocusLoss, [FeatureKeys.FastCiviProduction.Id] = chkFastCiviProduction, + [FeatureKeys.InfiniteMorale.Id] = chkInfiniteMorale, [FeatureKeys.FreeProduction.Id] = chkFreeProd, + [FeatureKeys.FreeUpgrade.Id] = chkFreeUpgrade, [FeatureKeys.NoSpellCost.Id] = chkNoSpellCost, + [FeatureKeys.MaxPopulation.Id] = chkMaxPopulation, [FeatureKeys.Balance.Id] = chkBalance, + [FeatureKeys.RangedRange3x.Id] = chkRangedRange3x, [FeatureKeys.UnitMovementSpeed2x.Id] = chkUnitMovementSpeed2x, + [FeatureKeys.SpellEntireMap.Id] = chkSpellEntireMap, [FeatureKeys.SpellRange3x.Id] = chkSpellRange3x, + [FeatureKeys.ProjectileArcHeight.Id] = chkProjectileArcHeight, + [FeatureKeys.RomanEndless.Id] = chkRomanEndless, + [FeatureKeys.HousingCapacity20x.Id] = chkHousingCapacity20x, [FeatureKeys.StorageCapacity10x.Id] = chkStorageCapacity10x, + [FeatureKeys.HqHp10x.Id] = chkHqHp10x, [FeatureKeys.FastBuildUpgradeRepair.Id] = chkFastBuildUpgradeRepair, + [FeatureKeys.FoodHealing10x.Id] = chkFoodHealing10x, [FeatureKeys.CiviProduce20.Id] = chkCiviProduce20, + [FeatureKeys.UnitRecruit20.Id] = chkUnitRecruit20, + [FeatureKeys.VillageBuildRange.Id] = chkVillageBuildRange, + [FeatureKeys.NoSpellAltar.Id] = chkNoSpellAltar, [FeatureKeys.DgVoodoo.Id] = chkDgVoodoo, + [FeatureKeys.ToEnglish.Id] = chkToEng, + [FeatureKeys.SpellDamage5x.Id] = chkSpellDamage5x, + [FeatureKeys.SpellHealing10x.Id] = chkSpellHealing10x, + [FeatureKeys.SpellResurrection.Id] = chkSpellResurrection, + [FeatureKeys.GeneralSkills.Id] = chkGeneralSkills, + [FeatureKeys.LeaderGlory.Id] = chkLeaderGlory, + [FeatureKeys.EndlessAiM1.Id] = chkAiM1, [FeatureKeys.EndlessAiM2.Id] = chkAiM2, [FeatureKeys.EndlessAiM3.Id] = chkAiM3, + [FeatureKeys.EndlessAiM4.Id] = chkAiM4, [FeatureKeys.EndlessAiM5.Id] = chkAiM5, [FeatureKeys.EndlessAiM6.Id] = chkAiM6, }; + FeatureRegistry.ValidateToggleIds(featureToggles.Keys); } private void ResetTogglesForCategory(FeatureCategory category, string gamePath) { @@ -51,6 +50,15 @@ private void ResetTogglesForCategory(FeatureCategory category, string gamePath) } } + private PatchProfile BuildCurrentPatchProfile(bool forceBalance = false) { + var profile = new PatchProfile(); + foreach (var (id, toggle) in featureToggles) profile.Set(id, FeatureValue.Of(toggle.Checked)); + if (forceBalance) profile.Balance = true; + profile.Set(FeatureKeys.GameSpeed, chkGameSpeed.Checked ? 10 : 1); + profile.Set(FeatureKeys.CustomUnitStats, customUnitStats); + return profile; + } + /// /// 記錄日誌訊息並寫入至本地 modifier_log.txt 檔案。 /// 每次程式執行的第一筆日誌前檢查檔案大小,超過 5MB 即輪替為 modifier_log.old.txt, @@ -131,28 +139,20 @@ private async void BtnApply_Click(object? sender, EventArgs e) { return; } - FileRollbackScope? rollback = null; try { DialogResult confirm = MessageBox.Show(Loc.Get("MsgConfirmApply"), Loc.Get("TitleConfirm"), MessageBoxButtons.YesNo, MessageBoxIcon.Question); if (confirm != DialogResult.Yes) return; SetActionButtonsEnabled(false); Log(Loc.Get("LogStartApply")); - rollback = new FileRollbackScope(); - Log("已建立修改前檔案回復點。"); - var profile = new PatchProfile(); - foreach (var (id, toggle) in featureToggles) profile.Set(id, FeatureValue.Of(toggle.Checked)); - profile.Set("GameSpeed", FeatureValue.Of(chkGameSpeed.Checked ? 10 : 1)); - profile.Set("CustomUnitStats", FeatureValue.Of(this.customUnitStats)); + PatchProfile profile = BuildCurrentPatchProfile(); - await Task.Run(() => { - patchEngine.ApplyPatches(gamePath, profile, backupManager, rollback); - }); - - rollback.Commit(); - rollback.Dispose(); - rollback = null; + await patchOperationRunner.ExecuteAsync( + rollback => patchEngine.ApplyPatches(gamePath, profile, backupManager, rollback), + "已建立修改前檔案回復點。", + "套用失敗,開始回復已修改的檔案。", + "檔案回復流程已完成。"); Log(Loc.Get("LogApplyAllSuccess")); MessageBox.Show(Loc.Get("MsgApplySuccess"), Loc.Get("TitleTips"), MessageBoxButtons.OK, MessageBoxIcon.Information); try { @@ -161,15 +161,9 @@ await Task.Run(() => { Log("Reload current data failed after successful apply: " + uiEx.Message); } } catch (Exception ex) { - if (rollback != null && !rollback.IsCommitted) { - Log("套用失敗,開始回復已修改的檔案。"); - rollback.RestoreAll(Log); - Log("檔案回復流程已完成。"); - } Log(Loc.Get("MsgApplyFailed") + ex.Message + "\r\n" + ex.StackTrace); MessageBox.Show(Loc.Get("MsgApplyFailed") + ex.Message, Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { - rollback?.Dispose(); SetActionButtonsEnabled(true); } } @@ -186,18 +180,14 @@ private async void RestoreAll() { if (!backupManager.EnsureBackupLoadedForGamePath(gamePath)) { return; } - FileRollbackScope? rollback = null; SetActionButtonsEnabled(false); try { Log(Loc.Get("LogStartRestoreAll")); - rollback = new FileRollbackScope(); - Log("已建立還原前檔案回復點。"); - await Task.Run(() => { - patchEngine.RestoreOriginalFiles(gamePath, backupManager, rollback); - }); - rollback.Commit(); - rollback.Dispose(); - rollback = null; + await patchOperationRunner.ExecuteAsync( + rollback => patchEngine.RestoreOriginalFiles(gamePath, backupManager, rollback), + "已建立還原前檔案回復點。", + "還原失敗,開始回復變更。", + "還原失敗後的回復流程已完成。"); ResetTogglesForCategory(FeatureCategory.Stats, gamePath); ResetTogglesForCategory(FeatureCategory.Compat, gamePath); @@ -211,15 +201,9 @@ await Task.Run(() => { Log("Reload current data failed after successful restore: " + uiEx.Message); } } catch (Exception ex) { - if (rollback != null && !rollback.IsCommitted) { - Log("還原失敗,開始回復變更。"); - rollback.RestoreAll(Log); - Log("還原失敗後的回復流程已完成。"); - } Log(Loc.Get("MsgRestoreFailed") + ex.Message + "\r\n" + ex.StackTrace); MessageBox.Show(Loc.Get("MsgRestoreFailed") + ex.Message, Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { - rollback?.Dispose(); SetActionButtonsEnabled(true); } } @@ -265,18 +249,14 @@ private async void RestoreStatsOnly() { if (!backupManager.EnsureBackupLoadedForGamePath(gamePath)) { return; } - FileRollbackScope? rollback = null; SetActionButtonsEnabled(false); try { Log(Loc.Get("LogStartRestoreStats")); - rollback = new FileRollbackScope(); - Log("已建立屬性檔案回復點。"); - await Task.Run(() => { - patchEngine.RestoreStatsOnly(gamePath, backupManager, rollback); - }); - rollback.Commit(); - rollback.Dispose(); - rollback = null; + await patchOperationRunner.ExecuteAsync( + rollback => patchEngine.RestoreStatsOnly(gamePath, backupManager, rollback), + "已建立屬性檔案回復點。", + "還原失敗,開始回復變更。", + "還原失敗後的回復流程已完成。"); ResetTogglesForCategory(FeatureCategory.Stats, gamePath); @@ -288,15 +268,9 @@ await Task.Run(() => { Log("Reload current data failed after successful stats restore: " + uiEx.Message); } } catch (Exception ex) { - if (rollback != null && !rollback.IsCommitted) { - Log("還原失敗,開始回復變更。"); - rollback.RestoreAll(Log); - Log("還原失敗後的回復流程已完成。"); - } Log(Loc.Get("MsgRestoreFailed") + ex.Message + "\r\n" + ex.StackTrace); MessageBox.Show(Loc.Get("MsgRestoreFailed") + ex.Message, Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { - rollback?.Dispose(); SetActionButtonsEnabled(true); } } @@ -307,18 +281,14 @@ private async void RestoreCompatOnly() { MessageBox.Show(Loc.Get("MsgSelectGameDir"), Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); return; } - FileRollbackScope? rollback = null; SetActionButtonsEnabled(false); try { Log(Loc.Get("LogStartRestoreCompat")); - rollback = new FileRollbackScope(); - Log("已建立相容性檔案回復點。"); - await Task.Run(() => { - patchEngine.RestoreCompatOnly(gamePath, backupManager, rollback); - }); - rollback.Commit(); - rollback.Dispose(); - rollback = null; + await patchOperationRunner.ExecuteAsync( + rollback => patchEngine.RestoreCompatOnly(gamePath, backupManager, rollback), + "已建立相容性檔案回復點。", + "還原失敗,開始回復變更。", + "還原失敗後的回復流程已完成。"); ResetTogglesForCategory(FeatureCategory.Compat, gamePath); @@ -330,15 +300,9 @@ await Task.Run(() => { Log("Reload current data failed after successful compat restore: " + uiEx.Message); } } catch (Exception ex) { - if (rollback != null && !rollback.IsCommitted) { - Log("還原失敗,開始回復變更。"); - rollback.RestoreAll(Log); - Log("還原失敗後的回復流程已完成。"); - } Log(Loc.Get("MsgRestoreFailed") + ex.Message + "\r\n" + ex.StackTrace); MessageBox.Show(Loc.Get("MsgRestoreFailed") + ex.Message, Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { - rollback?.Dispose(); SetActionButtonsEnabled(true); } } @@ -349,18 +313,14 @@ private async void RestoreLanguageOnly() { MessageBox.Show(Loc.Get("MsgSelectGameDir"), Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); return; } - FileRollbackScope? rollback = null; SetActionButtonsEnabled(false); try { Log(Loc.Get("LogStartRestoreLang")); - rollback = new FileRollbackScope(); - Log("已建立語言檔案回復點。"); - await Task.Run(() => { - patchEngine.RestoreLanguageOnly(gamePath, rollback); - }); - rollback.Commit(); - rollback.Dispose(); - rollback = null; + await patchOperationRunner.ExecuteAsync( + rollback => patchEngine.RestoreLanguageOnly(gamePath, rollback), + "已建立語言檔案回復點。", + "還原失敗,開始回復變更。", + "還原失敗後的回復流程已完成。"); ResetTogglesForCategory(FeatureCategory.Language, gamePath); @@ -372,15 +332,9 @@ await Task.Run(() => { Log("Reload current data failed after successful language restore: " + uiEx.Message); } } catch (Exception ex) { - if (rollback != null && !rollback.IsCommitted) { - Log("還原失敗,開始回復變更。"); - rollback.RestoreAll(Log); - Log("還原失敗後的回復流程已完成。"); - } Log(Loc.Get("MsgRestoreFailed") + ex.Message + "\r\n" + ex.StackTrace); MessageBox.Show(Loc.Get("MsgRestoreFailed") + ex.Message, Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { - rollback?.Dispose(); SetActionButtonsEnabled(true); } } diff --git a/src/UI/ModifierForm.SaveManager.cs b/src/UI/ModifierForm.SaveManager.cs index 02dee90..8ac95dc 100644 --- a/src/UI/ModifierForm.SaveManager.cs +++ b/src/UI/ModifierForm.SaveManager.cs @@ -1,101 +1,12 @@ using System; using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Collections.Generic; -using System.Globalization; +using System.Threading.Tasks; using System.Windows.Forms; using System.Drawing; -using System.Drawing.Drawing2D; -using System.Threading.Tasks; +using AgainstRomeModifier.Core.Services; namespace AgainstRomeModifier { public partial class ModifierForm { - private const string SaveBackupManifestName = "manifest.json"; - - private static bool IsSimpleFolderName(string folderName) { - if (string.IsNullOrWhiteSpace(folderName)) return false; - if (!string.Equals(folderName, Path.GetFileName(folderName), StringComparison.Ordinal)) return false; - return folderName.IndexOfAny(Path.GetInvalidFileNameChars()) < 0; - } - - private static void ReadSaveInfoFromBytes(byte[] raw, out string title, out string level) { - title = ""; - level = ""; - byte[] decomp = GameLZSS.DecompressPfil(raw); - string text = Encoding.GetEncoding(1251).GetString(decomp); - var lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - for (int i = 0; i < lines.Length; i++) { - string line = lines[i].Trim(); - if (line.Contains("[orglevelname]") && i + 1 < lines.Length) { - level = lines[i + 1].Trim(); - } - if (line.Contains("[titel]") && i + 1 < lines.Length) { - title = lines[i + 1].Trim(); - } - } - } - - private static Dictionary? ReadBackupManifest(ZipArchive archive) { - var entry = archive.GetEntry(SaveBackupManifestName); - if (entry == null) return null; - - using (var stream = entry.Open()) - using (var reader = new StreamReader(stream, Encoding.UTF8)) { - string json = reader.ReadToEnd(); - return JsonSerializer.Deserialize>(json); - } - } - - private static string GetManifestValue(Dictionary? manifest, string key) { - if (manifest != null && manifest.TryGetValue(key, out var value)) { - return value ?? ""; - } - return ""; - } - - private static string FormatBackupTime(string value) { - if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)) { - return dt.ToString("yyyy-MM-dd HH:mm:ss"); - } - return value; - } - - private static string CreateBackupManifestJson(string folder, string title, string level, DateTime backupTime) { - var manifest = new Dictionary { - ["OrigFolder"] = folder, - ["Title"] = title, - ["Level"] = level, - ["BackupTime"] = backupTime.ToString("o", CultureInfo.InvariantCulture) - }; - return JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }); - } - - private static bool TryParseLegacyBackupFileName(string fileName, out string origFolder, out string backupTimeStr) { - origFolder = ""; - backupTimeStr = ""; - string name = Path.GetFileNameWithoutExtension(fileName); - if (!name.StartsWith("Backup_", StringComparison.OrdinalIgnoreCase)) return false; - - int timeSep = name.LastIndexOf('_'); - if (timeSep < 0) return false; - int dateSep = name.LastIndexOf('_', timeSep - 1); - if (dateSep < "Backup_".Length) return false; - - string dateStr = name.Substring(dateSep + 1, timeSep - dateSep - 1); - string timeStr = name.Substring(timeSep + 1); - if (dateStr.Length != 8 || timeStr.Length != 6) return false; - - origFolder = name.Substring("Backup_".Length, dateSep - "Backup_".Length); - backupTimeStr = string.Format("{0}-{1}-{2} {3}:{4}:{5}", dateStr.Substring(0, 4), dateStr.Substring(4, 2), dateStr.Substring(6, 2), timeStr.Substring(0, 2), timeStr.Substring(2, 2), timeStr.Substring(4, 2)); - return true; - } - - /// - /// 建立並設定用於顯示遊戲存檔或備份存檔列表的 DataGridView 表格。 - /// private DataGridView CreateSaveGrid(bool isBackup) { var dgv = new DataGridView { AllowUserToAddRows = false, @@ -118,13 +29,11 @@ private DataGridView CreateSaveGrid(bool isBackup) { dgv.ColumnHeadersDefaultCellStyle.Font = fontJhengHei95B; dgv.ColumnHeadersHeight = 35; dgv.ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single; - dgv.DefaultCellStyle.BackColor = Color.FromArgb(20, 21, 31); dgv.DefaultCellStyle.ForeColor = Color.FromArgb(230, 235, 240); dgv.DefaultCellStyle.SelectionBackColor = Color.FromArgb(35, 37, 54); dgv.DefaultCellStyle.SelectionForeColor = Color.White; dgv.DefaultCellStyle.Font = fontJhengHei9R; - dgv.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(24, 25, 35); if (!isBackup) { @@ -148,27 +57,18 @@ private DataGridView CreateSaveGrid(bool isBackup) { dgv.Columns.Add("Folder", Loc.Get("HeaderOrigFolder")); dgv.Columns["Folder"].Width = 120; } - return dgv; } - // RefreshSavesAndBackups 進行中旗標:避免快速連點(重新整理/刪除/備份)時 - // 兩個背景掃描併發讀寫同一批表格與快取。UI 執行緒單線進入,簡單旗標即足夠。 private bool _savesRefreshInFlight; - /// - /// 重新整理並讀取遊戲存檔目錄 (SAVE) 與備份目錄 (SavesBackup) 下的資料,並將結果載入到介面表格中。 - /// private async void RefreshSavesAndBackups() { - if (_savesRefreshInFlight) return; + if (_savesRefreshInFlight || saveBackupService == null) return; _savesRefreshInFlight = true; try { dgvGameSaves.Rows.Clear(); dgvBackups.Rows.Clear(); - if (picSavePreview.Image != null) { - picSavePreview.Image.Dispose(); - picSavePreview.Image = null; - } + ReplaceSavePreview(null); lblSaveDetail.Text = ""; string gamePath = GetGamePath(); @@ -177,117 +77,21 @@ private async void RefreshSavesAndBackups() { return; } - var (savesData, backupsData) = await Task.Run(() => { - var savesList = new List(); - var backupsList = new List(); - - string saveDir = Path.Combine(gamePath, "SAVE"); - if (Directory.Exists(saveDir)) { - var dirs = Directory.GetDirectories(saveDir); - foreach (var dir in dirs) { - string folderName = Path.GetFileName(dir); - string saveIni = Path.Combine(dir, "save.ini"); - if (File.Exists(saveIni)) { - string title = ""; - string level = ""; - try { - byte[] raw = File.ReadAllBytes(saveIni); - ReadSaveInfoFromBytes(raw, out title, out level); - } catch { - title = Loc.Get("Unparsable"); - level = Loc.Get("Unknown"); - } - DateTime writeTime = Directory.GetLastWriteTime(dir); - savesList.Add(new object[] { folderName, title, level, writeTime.ToString("yyyy-MM-dd HH:mm:ss") }); - } - } - } - - string backupDir = Path.Combine(AppContext.BaseDirectory, "SavesBackup"); - if (!Directory.Exists(backupDir)) { - Directory.CreateDirectory(backupDir); - } - - var files = Directory.GetFiles(backupDir, "*.zip"); - foreach (var file in files) { - string fileName = Path.GetFileName(file); - DateTime lastWrite = File.GetLastWriteTime(file); - - BackupSaveCache? cache = null; - lock (_backupSaveCache) { - if (_backupSaveCache.TryGetValue(fileName, out var existingCache) && existingCache.LastWriteTime == lastWrite) { - cache = existingCache; - } - } - - if (cache == null) { - string title = ""; - string level = ""; - string origFolder = ""; - string backupTimeStr = ""; - try { - using (var archive = ZipFile.OpenRead(file)) { - var manifest = ReadBackupManifest(archive); - if (manifest != null) { - title = GetManifestValue(manifest, "Title"); - level = GetManifestValue(manifest, "Level"); - origFolder = GetManifestValue(manifest, "OrigFolder"); - backupTimeStr = FormatBackupTime(GetManifestValue(manifest, "BackupTime")); - } - - var entry = archive.GetEntry("save.ini"); - if (entry != null && (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(level))) { - using (var stream = entry.Open()) { - using (var ms = new MemoryStream()) { - stream.CopyTo(ms); - ReadSaveInfoFromBytes(ms.ToArray(), out var parsedTitle, out var parsedLevel); - if (string.IsNullOrEmpty(title)) title = parsedTitle; - if (string.IsNullOrEmpty(level)) level = parsedLevel; - } - } - } - } - } catch { - title = Loc.Get("Unparsable"); - level = Loc.Get("Unknown"); - } - - if ((string.IsNullOrEmpty(origFolder) || string.IsNullOrEmpty(backupTimeStr)) && - TryParseLegacyBackupFileName(fileName, out var parsedFolder, out var parsedTime)) { - if (string.IsNullOrEmpty(origFolder)) origFolder = parsedFolder; - if (string.IsNullOrEmpty(backupTimeStr)) backupTimeStr = parsedTime; - } - if (string.IsNullOrEmpty(backupTimeStr)) { - backupTimeStr = File.GetCreationTime(file).ToString("yyyy-MM-dd HH:mm:ss"); - } - if (string.IsNullOrEmpty(origFolder)) { - origFolder = Loc.Get("Unknown"); - } - - cache = new BackupSaveCache { - FileName = fileName, - Title = title, - Level = level, - OrigFolder = origFolder, - BackupTimeStr = backupTimeStr, - LastWriteTime = lastWrite - }; - lock (_backupSaveCache) { - _backupSaveCache[fileName] = cache; - } - } - - backupsList.Add(new object[] { cache.FileName, cache.Title, cache.Level, cache.BackupTimeStr, cache.OrigFolder }); - } - - return (savesList, backupsList); - }); - - foreach (var row in savesData) { - dgvGameSaves.Rows.Add(row); + SaveBackupCatalog catalog = await Task.Run(() => saveBackupService.Scan(gamePath)); + foreach (GameSaveInfo save in catalog.Saves) { + dgvGameSaves.Rows.Add( + save.Folder, + save.Parsed ? save.Title : Loc.Get("Unparsable"), + save.Parsed ? save.Level : Loc.Get("Unknown"), + save.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss")); } - foreach (var row in backupsData) { - dgvBackups.Rows.Add(row); + foreach (SaveBackupInfo backup in catalog.Backups) { + dgvBackups.Rows.Add( + backup.FileName, + backup.Parsed ? backup.Title : Loc.Get("Unparsable"), + backup.Parsed ? backup.Level : Loc.Get("Unknown"), + backup.BackupTime, + string.IsNullOrEmpty(backup.OrigFolder) ? Loc.Get("Unknown") : backup.OrigFolder); } } catch (Exception ex) { Log(Loc.Get("LogRefreshSavesFailed") + ex.Message); @@ -296,249 +100,90 @@ private async void RefreshSavesAndBackups() { } } - /// - /// 當選擇的遊戲存檔發生改變時觸發,載入並顯示該存檔的詳細文字資訊與 TGA 圖像預覽。 - /// private void DgvGameSaves_SelectionChanged(object? sender, EventArgs e) { if (dgvGameSaves.SelectedRows.Count == 0) return; try { - var row = dgvGameSaves.SelectedRows[0]; - string folder = row.Cells[0].Value?.ToString() ?? ""; - string title = row.Cells[1].Value?.ToString() ?? ""; - string level = row.Cells[2].Value?.ToString() ?? ""; - string time = row.Cells[3].Value?.ToString() ?? ""; - - lblSaveDetail.Text = string.Format( - Loc.Get("SaveDetailGameSave"), - folder, title, level, time - ); - + DataGridViewRow row = dgvGameSaves.SelectedRows[0]; + string folder = Cell(row, 0); + lblSaveDetail.Text = string.Format(Loc.Get("SaveDetailGameSave"), + folder, Cell(row, 1), Cell(row, 2), Cell(row, 3)); string gamePath = GetGamePath(); - if (string.IsNullOrEmpty(gamePath) || !Directory.Exists(gamePath)) return; - string picPath = Path.Combine(gamePath, "SAVE", folder, "savepic.tga"); - if (File.Exists(picPath)) { - byte[] bytes = File.ReadAllBytes(picPath); - if (picSavePreview.Image != null) picSavePreview.Image.Dispose(); - picSavePreview.Image = LoadTga(bytes); - } else { - if (picSavePreview.Image != null) picSavePreview.Image.Dispose(); - picSavePreview.Image = null; - } + ReplaceSavePreview(string.IsNullOrEmpty(gamePath) || !Directory.Exists(gamePath) + ? null + : saveBackupService.ReadSavePreview(gamePath, folder)); } catch (Exception ex) { Log(Loc.Get("LogRefreshSavesFailed") + ex.Message); } } - /// - /// 當選擇的備份檔案發生改變時觸發,自 zip 壓縮包中讀取 savepic.tga 圖示預覽,並顯示詳細備份資訊。 - /// private void DgvBackups_SelectionChanged(object? sender, EventArgs e) { if (dgvBackups.SelectedRows.Count == 0) return; try { - var row = dgvBackups.SelectedRows[0]; - string file = row.Cells[0].Value?.ToString() ?? ""; - string title = row.Cells[1].Value?.ToString() ?? ""; - string level = row.Cells[2].Value?.ToString() ?? ""; - string time = row.Cells[3].Value?.ToString() ?? ""; - string origFolder = row.Cells[4].Value?.ToString() ?? ""; - - lblSaveDetail.Text = string.Format( - Loc.Get("SaveDetailBackup"), - file, origFolder, title, level, time - ); - - string backupDir = Path.Combine(AppContext.BaseDirectory, "SavesBackup"); - string zipPath = Path.Combine(backupDir, file); - if (File.Exists(zipPath)) { - using (var archive = ZipFile.OpenRead(zipPath)) { - var entry = archive.GetEntry("savepic.tga"); - if (entry != null) { - using (var stream = entry.Open()) { - using (var ms = new MemoryStream()) { - stream.CopyTo(ms); - if (picSavePreview.Image != null) picSavePreview.Image.Dispose(); - picSavePreview.Image = LoadTga(ms.ToArray()); - } - } - } else { - if (picSavePreview.Image != null) picSavePreview.Image.Dispose(); - picSavePreview.Image = null; - } - } - } else { - if (picSavePreview.Image != null) picSavePreview.Image.Dispose(); - picSavePreview.Image = null; - } + DataGridViewRow row = dgvBackups.SelectedRows[0]; + string file = Cell(row, 0); + lblSaveDetail.Text = string.Format(Loc.Get("SaveDetailBackup"), + file, Cell(row, 4), Cell(row, 1), Cell(row, 2), Cell(row, 3)); + ReplaceSavePreview(saveBackupService.ReadBackupPreview(file)); } catch (Exception ex) { Log(Loc.Get("LogRefreshSavesFailed") + ex.Message); } } - /// - /// 當使用者點擊「備份此存檔」時觸發,將選取的存檔資料夾壓縮備份至 SavesBackup 目錄。 - /// private void BtnBackupSave_Click(object? sender, EventArgs e) { if (dgvGameSaves.SelectedRows.Count == 0) { MessageBox.Show(Loc.Get("MsgSelectSaveToBackup"), Loc.Get("TitleTips"), MessageBoxButtons.OK, MessageBoxIcon.Information); return; } try { - var row = dgvGameSaves.SelectedRows[0]; - string folder = (row.Cells[0].Value?.ToString() ?? "").Trim(); - if (string.IsNullOrEmpty(folder)) { + DataGridViewRow row = dgvGameSaves.SelectedRows[0]; + string folder = Cell(row, 0).Trim(); + if (!SaveBackupService.IsSimpleName(folder)) { MessageBox.Show(Loc.Get("MsgInvalidSaveDir"), Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); return; } - string title = row.Cells[1].Value?.ToString() ?? ""; - string level = row.Cells[2].Value?.ToString() ?? ""; - string gamePath = GetGamePath(); - string srcDir = Path.Combine(gamePath, "SAVE", folder); - if (!Directory.Exists(srcDir)) { - MessageBox.Show(Loc.Get("MsgNoOrigFolderToBackup"), Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); - return; - } - string backupDir = Path.Combine(AppContext.BaseDirectory, "SavesBackup"); - if (!Directory.Exists(backupDir)) { - Directory.CreateDirectory(backupDir); - } - DateTime backupTime = DateTime.Now; - string timeStr = backupTime.ToString("yyyyMMdd_HHmmss"); - string zipName = string.Format("Backup_{0}_{1}.zip", folder, timeStr); - string zipPath = Path.Combine(backupDir, zipName); - string zipTempPath = zipPath + ".tmp"; - - try { - if (File.Exists(zipTempPath)) { - File.Delete(zipTempPath); - } - using (var archive = ZipFile.Open(zipTempPath, ZipArchiveMode.Create)) { - foreach (string file in Directory.GetFiles(srcDir, "*", SearchOption.AllDirectories)) { - string entryName = Path.GetRelativePath(srcDir, file).Replace('\\', '/'); - if (string.Equals(entryName, SaveBackupManifestName, StringComparison.OrdinalIgnoreCase)) { - continue; - } - archive.CreateEntryFromFile(file, entryName, CompressionLevel.Optimal); - } - - var manifestEntry = archive.CreateEntry(SaveBackupManifestName, CompressionLevel.Optimal); - using (var writer = new StreamWriter(manifestEntry.Open(), Encoding.UTF8)) { - writer.Write(CreateBackupManifestJson(folder, title, level, backupTime)); - } - } - File.Move(zipTempPath, zipPath); - } catch { - try { - if (File.Exists(zipTempPath)) { - File.Delete(zipTempPath); - } - } catch { - } - throw; - } - Log(string.Format(Loc.Get("LogBackupSaveSuccessDetail"), folder, zipName)); + string fileName = saveBackupService.CreateBackup(GetGamePath(), folder, Cell(row, 1), Cell(row, 2)); + Log(string.Format(Loc.Get("LogBackupSaveSuccessDetail"), folder, fileName)); RefreshSavesAndBackups(); MessageBox.Show(Loc.Get("MsgBackupSaveSuccess"), Loc.Get("TitleSuccess"), MessageBoxButtons.OK, MessageBoxIcon.Information); + } catch (DirectoryNotFoundException) { + MessageBox.Show(Loc.Get("MsgNoOrigFolderToBackup"), Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); } catch (Exception ex) { Log(Loc.Get("LogBackupSaveFailedDetail") + ex.Message); MessageBox.Show(Loc.Get("MsgBackupSaveFailed") + ex.Message, Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); } } - /// - /// 當使用者點擊「還原此備份」時觸發,解壓所選的備份檔案,並覆蓋回原存檔目錄。 - /// private void BtnRestoreBackup_Click(object? sender, EventArgs e) { if (dgvBackups.SelectedRows.Count == 0) { MessageBox.Show(Loc.Get("MsgSelectBackup"), Loc.Get("TitleTips"), MessageBoxButtons.OK, MessageBoxIcon.Information); return; } try { - var row = dgvBackups.SelectedRows[0]; - string file = row.Cells[0].Value?.ToString() ?? ""; - string origFolder = row.Cells[4].Value?.ToString() ?? ""; - if (string.IsNullOrEmpty(file) || !string.Equals(file, Path.GetFileName(file), StringComparison.Ordinal)) { + DataGridViewRow row = dgvBackups.SelectedRows[0]; + string file = Cell(row, 0); + string folder = Cell(row, 4); + if (!SaveBackupService.IsSimpleName(file)) { MessageBox.Show(Loc.Get("MsgSelectBackup"), Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); return; } - if (string.IsNullOrEmpty(origFolder) || origFolder == Loc.Get("Unknown")) { + if (!SaveBackupService.IsSimpleName(folder) || folder == Loc.Get("Unknown")) { MessageBox.Show(Loc.Get("MsgCannotResolveOrigFolder"), Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); return; } - if (!IsSimpleFolderName(origFolder)) { - MessageBox.Show(Loc.Get("MsgCannotResolveOrigFolder"), Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); - return; - } - string gamePath = GetGamePath(); if (string.IsNullOrEmpty(gamePath) || !Directory.Exists(gamePath)) { MessageBox.Show(Loc.Get("MsgGamePathNotSet"), Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); return; } + if (Directory.Exists(Path.Combine(gamePath, "SAVE", folder)) && + MessageBox.Show(string.Format(Loc.Get("MsgConfirmOverwriteSave"), folder), Loc.Get("TitleWarning"), + MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes) return; - string saveRoot = Path.Combine(gamePath, "SAVE"); - Directory.CreateDirectory(saveRoot); - string destDir = Path.Combine(saveRoot, origFolder); - if (Directory.Exists(destDir)) { - var dr = MessageBox.Show(string.Format(Loc.Get("MsgConfirmOverwriteSave"), origFolder), Loc.Get("TitleWarning"), MessageBoxButtons.YesNo, MessageBoxIcon.Warning); - if (dr == DialogResult.No) return; - } - - string backupDir = Path.Combine(AppContext.BaseDirectory, "SavesBackup"); - string zipPath = Path.Combine(backupDir, file); - if (!File.Exists(zipPath)) { - throw new FileNotFoundException("找不到備份檔案。", zipPath); - } - - string tempDir = Path.Combine(saveRoot, ".restore_tmp_" + Guid.NewGuid().ToString("N")); - string oldDir = Path.Combine(saveRoot, ".restore_old_" + Guid.NewGuid().ToString("N")); - bool movedOld = false; - bool movedNew = false; - - try { - ZipFile.ExtractToDirectory(zipPath, tempDir); - if (!File.Exists(Path.Combine(tempDir, "save.ini"))) { - throw new InvalidDataException("備份壓縮檔缺少 save.ini,已停止還原。"); - } - - if (Directory.Exists(destDir)) { - Directory.Move(destDir, oldDir); - movedOld = true; - } - - Directory.Move(tempDir, destDir); - movedNew = true; - - } catch { - try { - if (movedNew && Directory.Exists(destDir)) { - Directory.Delete(destDir, true); - } - if (movedOld && Directory.Exists(oldDir) && !Directory.Exists(destDir)) { - Directory.Move(oldDir, destDir); - } - } catch (Exception rollbackEx) { - Log("還原存檔失敗後回復原存檔也失敗: " + rollbackEx.Message); - } - throw; - } finally { - try { - if (Directory.Exists(tempDir)) { - Directory.Delete(tempDir, true); - } - } catch (Exception cleanupEx) { - Log("清理還原暫存資料夾失敗: " + cleanupEx.Message); - } - } - - if (movedOld && Directory.Exists(oldDir)) { - try { - Directory.Delete(oldDir, true); - } catch (Exception cleanupEx) { - Log(Loc.Get("LogRestoreBackupCleanupFailed") + cleanupEx.Message); - } - } - - Log(string.Format(Loc.Get("LogRestoreBackupSuccessDetail"), file, origFolder)); + SaveRestoreResult result = saveBackupService.RestoreBackup(gamePath, file, folder, + message => Log("還原存檔失敗後回復原存檔也失敗: " + message)); + foreach (string warning in result.CleanupWarnings) + Log(Loc.Get("LogRestoreBackupCleanupFailed") + warning); + Log(string.Format(Loc.Get("LogRestoreBackupSuccessDetail"), file, folder)); RefreshSavesAndBackups(); MessageBox.Show(Loc.Get("MsgRestoreBackupSuccess"), Loc.Get("TitleSuccess"), MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { @@ -547,29 +192,20 @@ private void BtnRestoreBackup_Click(object? sender, EventArgs e) { } } - /// - /// 當使用者點擊「刪除存檔」時觸發,永久刪除實體硬碟上的遊戲存檔資料夾。 - /// private void BtnDeleteSave_Click(object? sender, EventArgs e) { if (dgvGameSaves.SelectedRows.Count == 0) { MessageBox.Show(Loc.Get("MsgSelectSaveToDelete"), Loc.Get("TitleTips"), MessageBoxButtons.OK, MessageBoxIcon.Information); return; } try { - var row = dgvGameSaves.SelectedRows[0]; - string folder = (row.Cells[0].Value?.ToString() ?? "").Trim(); - if (string.IsNullOrEmpty(folder)) { + string folder = Cell(dgvGameSaves.SelectedRows[0], 0).Trim(); + if (!SaveBackupService.IsSimpleName(folder)) { MessageBox.Show(Loc.Get("MsgInvalidSaveDir"), Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); return; } - var dr = MessageBox.Show(string.Format(Loc.Get("MsgConfirmDeleteSave"), folder), Loc.Get("TitleConfirmDelete"), MessageBoxButtons.YesNo, MessageBoxIcon.Warning); - if (dr == DialogResult.No) return; - - string gamePath = GetGamePath(); - string srcDir = Path.Combine(gamePath, "SAVE", folder); - if (Directory.Exists(srcDir)) { - Directory.Delete(srcDir, true); - } + if (MessageBox.Show(string.Format(Loc.Get("MsgConfirmDeleteSave"), folder), Loc.Get("TitleConfirmDelete"), + MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes) return; + saveBackupService.DeleteSave(GetGamePath(), folder); Log(string.Format(Loc.Get("LogDeleteSaveSuccessDetail"), folder)); RefreshSavesAndBackups(); MessageBox.Show(Loc.Get("MsgDeleteSaveSuccess"), Loc.Get("TitleSuccess"), MessageBoxButtons.OK, MessageBoxIcon.Information); @@ -579,32 +215,20 @@ private void BtnDeleteSave_Click(object? sender, EventArgs e) { } } - /// - /// 當使用者點擊「刪除備份」時觸發,永久刪除本地的備份 zip 檔案。 - /// private void BtnDeleteBackup_Click(object? sender, EventArgs e) { if (dgvBackups.SelectedRows.Count == 0) { MessageBox.Show(Loc.Get("MsgSelectBackupToDelete"), Loc.Get("TitleTips"), MessageBoxButtons.OK, MessageBoxIcon.Information); return; } try { - var row = dgvBackups.SelectedRows[0]; - string file = row.Cells[0].Value?.ToString() ?? ""; - if (string.IsNullOrEmpty(file) || !string.Equals(file, Path.GetFileName(file), StringComparison.Ordinal)) { + string file = Cell(dgvBackups.SelectedRows[0], 0); + if (!SaveBackupService.IsSimpleName(file)) { MessageBox.Show(Loc.Get("MsgSelectBackupToDelete"), Loc.Get("TitleTips"), MessageBoxButtons.OK, MessageBoxIcon.Information); return; } - var dr = MessageBox.Show(string.Format(Loc.Get("MsgConfirmDeleteBackup"), file), Loc.Get("TitleConfirmDelete"), MessageBoxButtons.YesNo, MessageBoxIcon.Warning); - if (dr == DialogResult.No) return; - - string backupDir = Path.Combine(AppContext.BaseDirectory, "SavesBackup"); - string zipPath = Path.Combine(backupDir, file); - if (File.Exists(zipPath)) { - File.Delete(zipPath); - lock (_backupSaveCache) { - _backupSaveCache.Remove(file); - } - } + if (MessageBox.Show(string.Format(Loc.Get("MsgConfirmDeleteBackup"), file), Loc.Get("TitleConfirmDelete"), + MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes) return; + saveBackupService.DeleteBackup(file); Log(string.Format(Loc.Get("LogDeleteBackupSuccessDetail"), file)); RefreshSavesAndBackups(); MessageBox.Show(Loc.Get("MsgDeleteBackupSuccess"), Loc.Get("TitleSuccess"), MessageBoxButtons.OK, MessageBoxIcon.Information); @@ -613,5 +237,12 @@ private void BtnDeleteBackup_Click(object? sender, EventArgs e) { MessageBox.Show(Loc.Get("MsgDeleteBackupFailed") + ex.Message, Loc.Get("TitleError"), MessageBoxButtons.OK, MessageBoxIcon.Error); } } + + private void ReplaceSavePreview(byte[]? bytes) { + picSavePreview.Image?.Dispose(); + picSavePreview.Image = bytes == null ? null : LoadTga(bytes); + } + + private static string Cell(DataGridViewRow row, int index) => row.Cells[index].Value?.ToString() ?? ""; } } diff --git a/src/UI/ModifierForm.cs b/src/UI/ModifierForm.cs index a1251e3..4a8df20 100644 --- a/src/UI/ModifierForm.cs +++ b/src/UI/ModifierForm.cs @@ -136,6 +136,10 @@ public partial class ModifierForm : Form { // 核心解耦服務實例 private AgainstRomeModifier.Core.Services.BackupManager backupManager = null!; private AgainstRomeModifier.Core.Services.PatchEngine patchEngine = null!; + private AgainstRomeModifier.Core.Services.PatchOperationRunner patchOperationRunner = null!; + private AgainstRomeModifier.Core.Services.UnitStatsProjectionService unitStatsProjection = null!; + private AgainstRomeModifier.Core.Services.UnitStatsEditorService unitStatsEditorService = null!; + private AgainstRomeModifier.Core.Services.SaveBackupService saveBackupService = null!; private class FormLogger : AgainstRomeModifier.Core.Services.ILogger { @@ -144,17 +148,6 @@ private class FormLogger : AgainstRomeModifier.Core.Services.ILogger public void Log(string message) => _form.Log(message); } - // 備份存檔快取 - private class BackupSaveCache { - public string FileName { get; set; } = ""; - public string Title { get; set; } = ""; - public string Level { get; set; } = ""; - public string OrigFolder { get; set; } = ""; - public string BackupTimeStr { get; set; } = ""; - public DateTime LastWriteTime { get; set; } - } - private Dictionary _backupSaveCache = new Dictionary(StringComparer.OrdinalIgnoreCase); - // 語系切換 UI 欄位 private Label lblSidebarLang = null!; private Button btnLangZH = null!; @@ -233,6 +226,11 @@ public ModifierForm() { var logger = new FormLogger(this); backupManager = new AgainstRomeModifier.Core.Services.BackupManager(logger); patchEngine = new AgainstRomeModifier.Core.Services.PatchEngine(logger); + patchOperationRunner = new AgainstRomeModifier.Core.Services.PatchOperationRunner(Log); + unitStatsProjection = new AgainstRomeModifier.Core.Services.UnitStatsProjectionService(backupManager); + unitStatsEditorService = new AgainstRomeModifier.Core.Services.UnitStatsEditorService(backupManager); + saveBackupService = new AgainstRomeModifier.Core.Services.SaveBackupService( + Path.Combine(AppContext.BaseDirectory, "SavesBackup")); Log(Loc.Get("LogConstructCompleted")); // 將內嵌的 Backup.zip 載入記憶體 diff --git a/src/UI/TroopPresetForm.cs b/src/UI/TroopPresetForm.cs index aa1b837..e27be3e 100644 --- a/src/UI/TroopPresetForm.cs +++ b/src/UI/TroopPresetForm.cs @@ -7,17 +7,18 @@ using System.Globalization; using System.Collections.Generic; using System.Runtime.InteropServices; +using AgainstRomeModifier.Core.Services; namespace AgainstRomeModifier { - public class TroopPresetForm : Form { + internal sealed class TroopPresetForm : Form { [DllImport("Gdi32.dll", EntryPoint = "CreateRoundRectRgn")] private static extern IntPtr CreateRoundRectRgn(int nLeftRect, int nTopRect, int nRightRect, int nBottomRect, int nWidthEllipse, int nHeightEllipse); [DllImport("gdi32.dll", EntryPoint = "DeleteObject")] private static extern bool DeleteObject(IntPtr hObject); - private ModifierForm mainForm; - private Dictionary unitIcons; + private readonly UnitStatsEditorService statsService; + private readonly Dictionary unitIcons; // 傳遞編輯後結果的 Dictionary (每個兵種有 9 個屬性值) public Dictionary CustomStats { get; private set; } @@ -46,8 +47,8 @@ public class TroopPresetForm : Form { private Font fontJhengHei9R = new Font("Microsoft JhengHei", 9F, FontStyle.Regular); private bool fontsDisposed; - public TroopPresetForm(ModifierForm mainForm, Dictionary? currentCustomStats, Dictionary unitIcons) { - this.mainForm = mainForm; + internal TroopPresetForm(UnitStatsEditorService statsService, Dictionary? currentCustomStats, Dictionary unitIcons) { + this.statsService = statsService; this.unitIcons = unitIcons; if (currentCustomStats != null) { @@ -348,8 +349,8 @@ private void ApplyCustomStatsToGrid() { // 自訂功能只保留 HP、傷害、防禦、戰鬥、視野、攻擊冷卻;速度/射程/法術欄位已移除。 double hp, dmg, vw, aw, speed, sight, relt, range, spellRadius; - var baselineStats = mainForm.GetDefaultBalancedStats(key); - var independentStats = mainForm.GetOriginalStats(key); + var baselineStats = statsService.GetBalanced(key); + var independentStats = statsService.GetOriginal(key); if (CustomStats.ContainsKey(key)) { var stats = CustomStats[key]; hp = stats[0]; @@ -404,68 +405,20 @@ private void BtnImport_Click(object? sender, EventArgs e) { if (ofd.ShowDialog() != DialogResult.OK) return; try { - string[] lines = File.ReadAllLines(ofd.FileName, Encoding.UTF8); - var loadedStats = new Dictionary(StringComparer.OrdinalIgnoreCase); - int skippedLines = 0; - - foreach (string line in lines) { - string l = line.Trim(); - if (string.IsNullOrEmpty(l) || l.StartsWith("#") || l.StartsWith(";")) continue; - if (!l.Contains("=")) continue; - - string[] kv = l.Split(new char[] { '=' }, 2); - string key = kv[0].Trim(); - string[] vals = kv[1].Split(','); - if (vals.Length < 4) { - skippedLines++; - continue; - } - - // 單行格式錯誤只略過該行並計數,不再讓一行壞資料中止整批匯入。 - double[] parsed = new double[Math.Min(vals.Length, 9)]; - bool lineValid = true; - for (int v = 0; v < parsed.Length; v++) { - if (!double.TryParse(vals[v].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out parsed[v])) { - lineValid = false; - break; - } - } - if (!lineValid) { - skippedLines++; - continue; - } - - double hp = parsed[0], dmg = parsed[1], vw = parsed[2], aw = parsed[3]; - double speed = 0; - double sight = vals.Length == 6 ? parsed[4] : (parsed.Length > 5 ? parsed[5] : 0); - double relt = vals.Length == 6 ? parsed[5] : (parsed.Length > 6 ? parsed[6] : 0); - double range = parsed.Length > 7 ? parsed[7] : 0; - double spellRadius = parsed.Length > 8 ? parsed[8] : 0; - - if (vals.Length < 9 && vals.Length != 6) { - double[] defStats = mainForm.GetDefaultBalancedStats(key); - if (vals.Length <= 4) speed = defStats.Length > 4 ? defStats[4] : 0; - if (vals.Length <= 5) sight = defStats.Length > 5 ? defStats[5] : 0; - if (vals.Length <= 6) relt = defStats.Length > 6 ? defStats[6] : 0; - if (vals.Length <= 7) range = defStats.Length > 7 ? defStats[7] : 0; - if (vals.Length <= 8) spellRadius = defStats.Length > 8 ? defStats[8] : 0; - } - - loadedStats[key] = new double[] { hp, dmg, vw, aw, speed, sight, relt, range, spellRadius }; - } + TroopPresetParseResult parsedPreset = TroopPresetCodec.Parse( + File.ReadLines(ofd.FileName, Encoding.UTF8), statsService.GetBalanced); foreach (var dgv in factionGrids.Values) { for (int i = 0; i < dgv.Rows.Count; i++) { string key = dgv.Rows[i].Cells["Key"].Value?.ToString() ?? ""; - if (loadedStats.ContainsKey(key)) { - var stats = loadedStats[key]; + if (parsedPreset.Stats.TryGetValue(key, out double[]? stats)) { // 以 0.## 呈現而非取整:保留檔案中的小數精度,套用時直接由表格讀回。 dgv.Rows[i].Cells["Hp"].Value = stats[0].ToString("0.##", CultureInfo.InvariantCulture); dgv.Rows[i].Cells["Dmg"].Value = stats[1].ToString("0.##", CultureInfo.InvariantCulture); dgv.Rows[i].Cells["VW"].Value = stats[2].ToString("0.##", CultureInfo.InvariantCulture); dgv.Rows[i].Cells["AW"].Value = stats[3].ToString("0.##", CultureInfo.InvariantCulture); dgv.Rows[i].Cells["Sight"].Value = TroopConfig.UnitMeta.TryGetValue(key, out var importedMeta) && importedMeta.UnitType == "priest" - ? mainForm.GetOriginalStats(key)[5].ToString("0.##", CultureInfo.InvariantCulture) + ? statsService.GetOriginal(key)[5].ToString("0.##", CultureInfo.InvariantCulture) : stats[5].ToString("0.##", CultureInfo.InvariantCulture); dgv.Rows[i].Cells["Relt"].Value = stats[6].ToString("0.##", CultureInfo.InvariantCulture); } @@ -476,10 +429,10 @@ private void BtnImport_Click(object? sender, EventArgs e) { this.LoadedFileName = Path.GetFileName(ofd.FileName); string doneMsg = isEn ? "Custom troop stats successfully imported!" : "自訂兵種屬性匯入成功!"; - if (skippedLines > 0) { + if (parsedPreset.SkippedLines > 0) { doneMsg += isEn - ? string.Format("\n({0} malformed line(s) were skipped.)", skippedLines) - : string.Format("\n(有 {0} 行格式錯誤已略過。)", skippedLines); + ? string.Format("\n({0} malformed line(s) were skipped.)", parsedPreset.SkippedLines) + : string.Format("\n(有 {0} 行格式錯誤已略過。)", parsedPreset.SkippedLines); } MessageBox.Show(doneMsg, isEn ? "Import Completed" : "匯入完成", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { @@ -502,28 +455,21 @@ private void BtnExport_Click(object? sender, EventArgs e) { if (sfd.ShowDialog() != DialogResult.OK) return; try { - StringBuilder sb = new StringBuilder(); - sb.AppendLine("# Against Rome Modifier - Custom Troop Preset File (independent modifiers removed)"); - sb.AppendLine(string.Format("# Generated on: {0}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))); - sb.AppendLine("# Format: UnitKey=HP,Dmg,VW,AW,Sight,Relt"); - sb.AppendLine(); - + var stats = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var dgv in factionGrids.Values) { for (int i = 0; i < dgv.Rows.Count; i++) { string key = dgv.Rows[i].Cells["Key"].Value?.ToString() ?? "FigUnknown"; - string hp = dgv.Rows[i].Cells["Hp"].Value?.ToString() ?? "0"; - string dmg = dgv.Rows[i].Cells["Dmg"].Value?.ToString() ?? "0"; - string vw = dgv.Rows[i].Cells["VW"].Value?.ToString() ?? "0"; - string aw = dgv.Rows[i].Cells["AW"].Value?.ToString() ?? "0"; - string sight = dgv.Rows[i].Cells["Sight"].Value?.ToString() ?? "0"; - string relt = dgv.Rows[i].Cells["Relt"].Value?.ToString() ?? "0"; - - sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0}={1},{2},{3},{4},{5},{6}", - key, hp, dmg, vw, aw, sight, relt)); + if (!TryReadValidatedCell(dgv.Rows[i], "Hp", out double hp) || + !TryReadValidatedCell(dgv.Rows[i], "Dmg", out double damage) || + !TryReadValidatedCell(dgv.Rows[i], "VW", out double vw) || + !TryReadValidatedCell(dgv.Rows[i], "AW", out double aw) || + !TryReadValidatedCell(dgv.Rows[i], "Sight", out double sight) || + !TryReadValidatedCell(dgv.Rows[i], "Relt", out double reload)) return; + stats[key] = new[] { hp, damage, vw, aw, 0, sight, reload, 0, 0 }; } } - File.WriteAllText(sfd.FileName, sb.ToString(), Encoding.UTF8); + File.WriteAllText(sfd.FileName, TroopPresetCodec.Write(stats, DateTime.Now), Encoding.UTF8); // 寫檔成功後才更新檔名狀態,避免失敗時殘留假狀態。 this.LoadedFileName = Path.GetFileName(sfd.FileName); MessageBox.Show(isEn ? "Custom troop stats successfully exported!" : "自訂兵種屬性匯出成功!", isEn ? "Export Completed" : "匯出完成", MessageBoxButtons.OK, MessageBoxIcon.Information); @@ -549,12 +495,12 @@ private void BtnApply_Click(object? sender, EventArgs e) { if (!TryReadValidatedCell(dgv.Rows[i], "AW", out double aw)) return; if (!TryReadValidatedCell(dgv.Rows[i], "Sight", out double sight)) return; if (!TryReadValidatedCell(dgv.Rows[i], "Relt", out double relt)) return; - double[] original = mainForm.GetOriginalStats(key); + double[] original = statsService.GetOriginal(key); double speed = original.Length > 4 ? original[4] : 0; double range = original.Length > 7 ? original[7] : 0; double spellRadius = original.Length > 8 ? original[8] : 0; - CustomStats[key] = mainForm.NormalizeIndependentCustomFields(key, + CustomStats[key] = statsService.NormalizeIndependentFields(key, new double[] { hp, dmg, vw, aw, speed, sight, relt, range, spellRadius }); } } diff --git a/tests/AgainstRomeModifier.Tests/BackupInfrastructureTests.cs b/tests/AgainstRomeModifier.Tests/BackupInfrastructureTests.cs new file mode 100644 index 0000000..235a463 --- /dev/null +++ b/tests/AgainstRomeModifier.Tests/BackupInfrastructureTests.cs @@ -0,0 +1,98 @@ +using System.IO.Compression; +using System.Text; +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Tests; + +public sealed class BackupInfrastructureTests +{ + [Fact] + public void Zip_loader_reads_safe_entries_and_rejects_parent_segments() + { + using MemoryStream safeZip = CreateZip(("SYSTEM/test.ini", "value")); + IReadOnlyDictionary files = BackupSourceLoader.LoadZip(safeZip); + Assert.Equal("value", Encoding.UTF8.GetString(files["SYSTEM/test.ini"])); + + using MemoryStream unsafeZip = CreateZip(("../escape.bin", "bad")); + InvalidDataException error = Assert.Throws( + () => BackupSourceLoader.LoadZip(unsafeZip)); + Assert.Contains("unsafe entry path", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Validator_preserves_optional_partgeo_rule() + { + var files = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Against_Rome.exe"] = [], + ["SYSTEM/cl_script.ini"] = [], + ["SYSTEM/cl_epara.ini"] = [], + ["SYSTEM/ress.ini"] = [], + ["SYSTEM/DATA_MP/DEFAULTS/objdef.dau"] = [], + ["SYSTEM/CLMK/icon.ini"] = [], + ["SYSTEM/CLAK/cl_scint.ini"] = [], + ["SYSTEM/CLAK/SCRIPT/ak_anfuehrer.bci"] = [], + ["MAPS/ENDL_001/team.dat"] = [] + }; + + Assert.Empty(BackupValidator.FindMissing(files)); + Assert.DoesNotContain("SYSTEM/DATA_MP/DEFAULTS/partgeo.dau", + BackupValidator.FindMissing(files)); + } + + [Fact] + public void Original_file_validators_fail_closed_for_invalid_payloads() + { + byte[] invalid = [1, 2, 3, 4]; + + Assert.False(OriginalFileValidator.IsExeOriginal(invalid)); + Assert.False(OriginalFileValidator.IsObjdefOriginal(invalid)); + Assert.False(OriginalFileValidator.IsPartgeoOriginal(invalid)); + } + + [RequiresBackupZipFact] + public void Original_file_validators_accept_known_backup_baselines() + { + using BackupZipGameFixture fixture = BackupZipGameFixture.Create(); + + Assert.True(OriginalFileValidator.IsExeOriginal( + fixture.Backup.GetBackupBytes("Against_Rome.exe"))); + Assert.True(OriginalFileValidator.IsObjdefOriginal( + fixture.Backup.GetBackupBytes("SYSTEM/DATA_MP/DEFAULTS/objdef.dau"))); + if (fixture.Backup.HasFile("SYSTEM/DATA_MP/DEFAULTS/partgeo.dau")) + { + Assert.True(OriginalFileValidator.IsPartgeoOriginal( + fixture.Backup.GetBackupBytes("SYSTEM/DATA_MP/DEFAULTS/partgeo.dau"))); + } + } + + [Fact] + public void Clean_epara_baseline_round_trips_through_pfil() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + byte[] decompressed = GameLZSS.DecompressPfil(CleanEparaBaseline.CreateBytes()); + string text = Encoding.GetEncoding(1251).GetString(decompressed); + + Assert.Equal(BackupManager.GetCleanEparaText(), text); + string normalized = text.Replace("\r\n", "\n", StringComparison.Ordinal); + Assert.Contains("[ProjectileInitSpeedFactor]\n1.5", normalized, StringComparison.Ordinal); + Assert.Contains("[ProjectileVarianceOnMove]\n0.5", normalized, StringComparison.Ordinal); + } + + private static MemoryStream CreateZip(params (string Path, string Content)[] entries) + { + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach ((string path, string content) in entries) + { + ZipArchiveEntry entry = archive.CreateEntry(path); + using Stream destination = entry.Open(); + destination.Write(Encoding.UTF8.GetBytes(content)); + } + } + stream.Position = 0; + return stream; + } +} diff --git a/tests/AgainstRomeModifier.Tests/ClScriptPatchTests.cs b/tests/AgainstRomeModifier.Tests/ClScriptPatchTests.cs index 34beba4..f1cf60d 100644 --- a/tests/AgainstRomeModifier.Tests/ClScriptPatchTests.cs +++ b/tests/AgainstRomeModifier.Tests/ClScriptPatchTests.cs @@ -48,6 +48,7 @@ public void Test_ClScript_Patch_And_Detect() SpellHealing10x = true, SpellResurrection = true, GeneralSkills = true, + SpellRange3x = true, LeaderGlory = true }; byte[] patchedCompressed = AgainstRomeModifier.Core.Features.Ini.IniFeaturePatcher.BuildClScript(backupManager, profile); @@ -87,6 +88,7 @@ public void Test_ClScript_Patch_And_Detect() Assert.True(detectedOptions.SpellHealing10x, "Spell Healing 10x should be detected as true"); Assert.True(detectedOptions.SpellResurrection, "Spell Resurrection should be detected as true"); Assert.True(detectedOptions.GeneralSkills, "General Skills should be detected as true"); + Assert.True(detectedOptions.SpellRange3x, "Spell Range 3x should be detected as true"); Assert.True(detectedOptions.LeaderGlory, "Leader Glory should be detected as true"); } finally diff --git a/tests/AgainstRomeModifier.Tests/ExeFeatureDetectorTests.cs b/tests/AgainstRomeModifier.Tests/ExeFeatureDetectorTests.cs new file mode 100644 index 0000000..f39eeec --- /dev/null +++ b/tests/AgainstRomeModifier.Tests/ExeFeatureDetectorTests.cs @@ -0,0 +1,56 @@ +using AgainstRomeModifier.Core.Features; +using AgainstRomeModifier.Core.Features.Exe; +using AgainstRomeModifier.Core.Patches; +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Tests; + +public sealed class ExeFeatureDetectorTests +{ + private const int ExeSize = 0x205000; + + [Fact] + public void Detector_reads_all_exe_backed_features_into_profile() + { + string root = Path.Combine(Path.GetTempPath(), "arm-exe-detector-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + byte[] exe = new byte[ExeSize]; + Place(exe, ExePatchModel.FocusPatchOffset, ExePatchModel.FocusPatchedBytes); + Place(exe, ExePatchModel.VillageSetterHookOffset, ExePatchModel.VillageSetterHookPatchedBytes); + Place(exe, ExePatchModel.VillageSetterCaveOffset, ExePatchModel.VillageSetterCavePatchedBytes); + foreach (var site in ExePatchModel.SpellAltarPatchSites) + Place(exe, site.Offset, site.Patched); + Place(exe, ExePatchModel.RomanEndlessPatchOffset, ExePatchModel.RomanEndlessPatchedBytes); + Place(exe, ExePatchModel.GameSpeedQpcConstOffset, BitConverter.GetBytes(4_000_000_000.0)); + Place(exe, ExePatchModel.GameSpeedTgtConstOffset, BitConverter.GetBytes(4_000_000.0)); + Place(exe, ExePatchModel.CiviProduce20PatchOffset, ExePatchModel.CiviProduce20PatchedBytes); + Place(exe, ExePatchModel.UnitRecruit20PatchOffset, ExePatchModel.UnitRecruit20PatchedBytes); + File.WriteAllBytes(Path.Combine(root, "Against_Rome.exe"), exe); + + var profile = new PatchProfile(); + ExeFeatureDetection detection = new ExeFeatureDetector(new NullLogger()).Detect(root, profile); + + Assert.True(profile.FocusLoss); + Assert.True(profile.VillageBuildRange); + Assert.True(profile.NoSpellAltar); + Assert.Equal(4, profile.GameSpeed); + Assert.True(profile.CiviProduce20); + Assert.True(profile.UnitRecruit20); + Assert.Equal(ExeRomanEndlessPatchState.Patched, detection.RomanEndlessState); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private static void Place(byte[] exe, long offset, byte[] bytes) => + Buffer.BlockCopy(bytes, 0, exe, (int)offset, bytes.Length); + + private sealed class NullLogger : ILogger + { + public void Log(string message) { } + } +} diff --git a/tests/AgainstRomeModifier.Tests/FeatureRegistryTests.cs b/tests/AgainstRomeModifier.Tests/FeatureRegistryTests.cs index 6f1ef3f..b5e7a34 100644 --- a/tests/AgainstRomeModifier.Tests/FeatureRegistryTests.cs +++ b/tests/AgainstRomeModifier.Tests/FeatureRegistryTests.cs @@ -8,11 +8,43 @@ public sealed class FeatureRegistryTests public void Registry_ids_are_unique_and_game_speed_disables_to_one() { Assert.Equal(FeatureRegistry.All.Count, FeatureRegistry.All.Select(x => x.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count()); - Assert.Equal(1, FeatureRegistry.GetDisabledValue("GameSpeed").AsInt); + Assert.Equal(1, FeatureRegistry.GetDisabledValue(FeatureKeys.GameSpeed.Id).AsInt); Assert.All(FeatureRegistry.ByCategory(FeatureCategory.Stats), feature => Assert.Equal(FeatureCategory.Stats, feature.Category)); Assert.All(FeatureRegistry.All, feature => Assert.IsAssignableFrom(feature)); } + [Fact] + public void Every_declared_feature_key_is_registered_once() + { + string[] declaredIds = typeof(FeatureKeys) + .GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) + .Select(field => Assert.IsAssignableFrom(field.GetValue(null))) + .Select(key => key.Id) + .OrderBy(id => id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + string[] registeredIds = FeatureRegistry.All + .Select(feature => feature.Id) + .OrderBy(id => id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + Assert.Equal(registeredIds, declaredIds, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void Toggle_map_validation_rejects_missing_or_specialized_features() + { + string[] validIds = FeatureRegistry.ToggleFeatures.Select(feature => feature.Id).ToArray(); + FeatureRegistry.ValidateToggleIds(validIds); + + InvalidOperationException missing = Assert.Throws( + () => FeatureRegistry.ValidateToggleIds(validIds.Where(id => !id.Equals(FeatureKeys.UnitRecruit20.Id, StringComparison.OrdinalIgnoreCase)))); + Assert.Contains(FeatureKeys.UnitRecruit20.Id, missing.Message); + + InvalidOperationException specialized = Assert.Throws( + () => FeatureRegistry.ValidateToggleIds(validIds.Append(FeatureKeys.GameSpeed.Id))); + Assert.Contains(FeatureKeys.GameSpeed.Id, specialized.Message); + } + [Fact] public void PatchProfile_supports_named_and_registry_values() { @@ -20,6 +52,8 @@ public void PatchProfile_supports_named_and_registry_values() profile.EndlessAiModules["M4"] = true; profile.NormalizeCompositeValues(); + profile.Set(FeatureKeys.UnitRecruit20, true); + Assert.True(profile.GetBool("FocusLoss")); Assert.True(profile.GetBool("Balance")); Assert.True(profile.GetBool("SpellDamage5x")); @@ -35,6 +69,8 @@ public void PatchProfile_supports_named_and_registry_values() Assert.True(profile.GetBool("RomanEndless")); Assert.True(profile.GetBool("EndlessAi.M4")); Assert.Equal(3, profile.GetInt("GameSpeed")); + Assert.True(profile.Get(FeatureKeys.UnitRecruit20)); + Assert.Equal(1, new PatchProfile().Get(FeatureKeys.GameSpeed)); } [Fact] diff --git a/tests/AgainstRomeModifier.Tests/ObjdefFeatureDetectorTests.cs b/tests/AgainstRomeModifier.Tests/ObjdefFeatureDetectorTests.cs new file mode 100644 index 0000000..463492b --- /dev/null +++ b/tests/AgainstRomeModifier.Tests/ObjdefFeatureDetectorTests.cs @@ -0,0 +1,48 @@ +using AgainstRomeModifier.Core.Features; +using AgainstRomeModifier.Core.Features.Objdef; +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Tests; + +public sealed class ObjdefFeatureDetectorTests +{ + [RequiresBackupZipFact] + public void Detector_reads_all_objdef_backed_features_from_composer_output() + { + using BackupZipGameFixture fixture = BackupZipGameFixture.Create(); + var enabled = new PatchProfile + { + RangedRange3x = true, + UnitMovementSpeed2x = true, + SpellEntireMap = false, + ProjectileArcHeight = true, + HousingCapacity20x = true, + StorageCapacity10x = true, + HqHp10x = true, + FastBuildUpgradeRepair = true, + LeaderGlory = true, + }; + byte[] patched = ObjdefFeaturePatcher.Build(fixture.Backup, enabled); + string path = Path.Combine(fixture.RootPath, "SYSTEM", "DATA_MP", "DEFAULTS", "objdef.dau"); + File.WriteAllBytes(path, patched); + + var detected = new PatchProfile(); + new ObjdefFeatureDetector(new NullLogger()).Detect(fixture.RootPath, fixture.Backup, detected); + + Assert.True(detected.RangedRange3x); + Assert.True(detected.UnitMovementSpeed2x); + Assert.False(detected.SpellEntireMap); + Assert.True(detected.ProjectileArcHeight); + Assert.True(detected.HousingCapacity20x); + Assert.True(detected.StorageCapacity10x); + Assert.True(detected.HqHp10x); + Assert.True(detected.FastBuildUpgradeRepair); + Assert.True(detected.LeaderGlory); + Assert.False(detected.Balance); + } + + private sealed class NullLogger : ILogger + { + public void Log(string message) { } + } +} diff --git a/tests/AgainstRomeModifier.Tests/PatchFileComposerTests.cs b/tests/AgainstRomeModifier.Tests/PatchFileComposerTests.cs new file mode 100644 index 0000000..b0b34d1 --- /dev/null +++ b/tests/AgainstRomeModifier.Tests/PatchFileComposerTests.cs @@ -0,0 +1,67 @@ +using AgainstRomeModifier.Core.Features; +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Tests; + +public sealed class PatchFileComposerTests +{ + [RequiresBackupZipFact] + public void Compose_builds_target_files_without_writing_them() + { + using BackupZipGameFixture fixture = BackupZipGameFixture.Create(); + string objdefPath = Path.Combine(fixture.RootPath, "SYSTEM", "DATA_MP", "DEFAULTS", "objdef.dau"); + byte[] objdefBefore = File.ReadAllBytes(objdefPath); + var profile = new PatchProfile + { + FocusLoss = true, + FreeProduction = true, + FreeUpgrade = true, + NoSpellCost = true, + MaxPopulation = true, + RomanEndless = true, + RangedRange3x = true, + HousingCapacity20x = true, + StorageCapacity10x = true, + HqHp10x = true, + FastBuildUpgradeRepair = true, + }; + + PatchFilePlan plan = new PatchFileComposer(new NullLogger()).Compose( + fixture.RootPath, fixture.Backup, profile); + + Assert.Contains(Path.Combine(fixture.RootPath, "Against_Rome.exe"), plan.Files.Keys); + Assert.Contains(objdefPath, plan.Files.Keys); + Assert.Contains(Path.Combine(fixture.RootPath, "SYSTEM", "ress.ini"), plan.Files.Keys); + Assert.Contains(Path.Combine(fixture.RootPath, "SYSTEM", "cl_script.ini"), plan.Files.Keys); + Assert.Contains(plan.Files.Keys, path => path.EndsWith("team.dat", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(objdefBefore, File.ReadAllBytes(objdefPath)); + } + + [Fact] + public void Compose_runs_injected_contributors_without_knowing_target_types() + { + var contributor = new StubContributor(); + var composer = new PatchFileComposer(new NullLogger(), new[] { contributor }); + + PatchFilePlan plan = composer.Compose("root", new BackupManager(new NullLogger()), new PatchProfile()); + + Assert.True(contributor.WasCalled); + Assert.Equal(new byte[] { 1, 2, 3 }, plan.Files[Path.Combine("root", "SYSTEM", "stub.dat")]); + } + + private sealed class StubContributor : IPatchFileContributor + { + public bool WasCalled { get; private set; } + + public void Contribute(PatchFileCompositionContext context) + { + WasCalled = true; + context.Add("SYSTEM/stub.dat", new byte[] { 1, 2, 3 }); + } + } + + private sealed class NullLogger : ILogger + { + public void Log(string message) { } + } +} diff --git a/tests/AgainstRomeModifier.Tests/PatchOperationRunnerTests.cs b/tests/AgainstRomeModifier.Tests/PatchOperationRunnerTests.cs new file mode 100644 index 0000000..883fec5 --- /dev/null +++ b/tests/AgainstRomeModifier.Tests/PatchOperationRunnerTests.cs @@ -0,0 +1,72 @@ +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Tests; + +public sealed class PatchOperationRunnerTests +{ + [Fact] + public async Task ExecuteAsync_commits_successful_operation() + { + string root = CreateTempDirectory(); + string path = Path.Combine(root, "target.bin"); + File.WriteAllText(path, "original"); + var logs = new List(); + var runner = new PatchOperationRunner(logs.Add); + try + { + await runner.ExecuteAsync( + rollback => + { + rollback.TrackFile(path); + File.WriteAllText(path, "changed"); + }, + "checkpoint", + "rollback-start", + "rollback-done"); + + Assert.Equal("changed", File.ReadAllText(path)); + Assert.Equal(["checkpoint"], logs); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task ExecuteAsync_restores_failed_operation_and_rethrows() + { + string root = CreateTempDirectory(); + string path = Path.Combine(root, "target.bin"); + File.WriteAllText(path, "original"); + var logs = new List(); + var runner = new PatchOperationRunner(logs.Add); + try + { + await Assert.ThrowsAsync(() => runner.ExecuteAsync( + rollback => + { + rollback.TrackFile(path); + File.WriteAllText(path, "changed"); + throw new InvalidOperationException("failure"); + }, + "checkpoint", + "rollback-start", + "rollback-done")); + + Assert.Equal("original", File.ReadAllText(path)); + Assert.Equal(["checkpoint", "rollback-start", "rollback-done"], logs); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private static string CreateTempDirectory() + { + string path = Path.Combine(Path.GetTempPath(), "ARM_OperationRunner_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/tests/AgainstRomeModifier.Tests/RessFeatureDetectorTests.cs b/tests/AgainstRomeModifier.Tests/RessFeatureDetectorTests.cs new file mode 100644 index 0000000..006124c --- /dev/null +++ b/tests/AgainstRomeModifier.Tests/RessFeatureDetectorTests.cs @@ -0,0 +1,41 @@ +using AgainstRomeModifier.Core.Features; +using AgainstRomeModifier.Core.Features.Ini; +using AgainstRomeModifier.Core.Patches; +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Tests; + +public sealed class RessFeatureDetectorTests +{ + [Fact] + public void Detector_reads_all_ress_backed_features_from_patcher_output() + { + string root = Path.Combine(Path.GetTempPath(), "arm-ress-detector-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(root, "SYSTEM")); + try + { + string unit = "FigRomInf00_Lanze_Schild," + string.Join(',', Enumerable.Repeat("5", 28)); + string priest = "FigGerPri00_Priester," + string.Join(',', Enumerable.Repeat("5", 28)); + string upgrade = "Ger_Kampf," + string.Join(',', Enumerable.Repeat("5", 300)); + byte[] original = SyntheticFixture.Pfil("[objres]\n" + unit + "\n" + priest + "\n[volkres]\n" + upgrade + "\n"); + byte[] patched = RessPatcher.GetPatchedBytes(original, new RessOptions(true, true, true)); + File.WriteAllBytes(Path.Combine(root, "SYSTEM", "ress.ini"), patched); + + var profile = new PatchProfile(); + new RessFeatureDetector(new NullLogger()).Detect(root, profile); + + Assert.True(profile.FreeProduction); + Assert.True(profile.FreeUpgrade); + Assert.True(profile.NoSpellCost); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private sealed class NullLogger : ILogger + { + public void Log(string message) { } + } +} diff --git a/tests/AgainstRomeModifier.Tests/RomanEndlessPatchTests.cs b/tests/AgainstRomeModifier.Tests/RomanEndlessPatchTests.cs index 4c6370e..7456e0b 100644 --- a/tests/AgainstRomeModifier.Tests/RomanEndlessPatchTests.cs +++ b/tests/AgainstRomeModifier.Tests/RomanEndlessPatchTests.cs @@ -68,7 +68,7 @@ public void Detector_recovers_each_valid_combination(bool maxPopulation, bool ro byte[] current = TeamDatPatcher.GetPatchedBytes(original, new TeamDatOptions(maxPopulation, RomanPlayer: romanPlayer)); - Assert.True(FeatureDetector.TryDetectTeamDatOptions(original, current, isEndless: true, + Assert.True(TeamDatFeatureDetector.TryDetectOptions(original, current, isEndless: true, out bool detectedMaxPopulation, out bool detectedRomanPlayer)); Assert.Equal(maxPopulation, detectedMaxPopulation); Assert.Equal(romanPlayer, detectedRomanPlayer); @@ -79,8 +79,8 @@ public void Builder_does_not_apply_roman_player_to_non_endless_maps() { byte[] original = SyntheticFixture.Pfil(OriginalText); var backup = new BackupManager(new NullLogger()); - backup.BackupFiles["MAPS/ENDL_000/DATA/team.dat"] = original; - backup.BackupFiles["MAPS/HIST_000/DATA/team.dat"] = original; + backup.SetBackupFile("MAPS/ENDL_000/DATA/team.dat", original); + backup.SetBackupFile("MAPS/HIST_000/DATA/team.dat", original); Dictionary result = MaxPopulationFeature.Build(backup, maxPopulation: false, romanEndless: true); diff --git a/tests/AgainstRomeModifier.Tests/SaveBackupServiceTests.cs b/tests/AgainstRomeModifier.Tests/SaveBackupServiceTests.cs new file mode 100644 index 0000000..4b75827 --- /dev/null +++ b/tests/AgainstRomeModifier.Tests/SaveBackupServiceTests.cs @@ -0,0 +1,105 @@ +using System.IO.Compression; +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Tests; + +public sealed class SaveBackupServiceTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "AgainstRomeSaveTests_" + Guid.NewGuid().ToString("N")); + + [Fact] + public void Create_scan_preview_restore_and_delete_round_trip() + { + string game = Path.Combine(_root, "game"); + string backupDirectory = Path.Combine(_root, "backups"); + string save = Path.Combine(game, "SAVE", "Slot_A"); + Directory.CreateDirectory(save); + byte[] original = SyntheticFixture.Pfil("[titel]\r\nOriginal title\r\n[orglevelname]\r\nENDL_000\r\n"); + File.WriteAllBytes(Path.Combine(save, "save.ini"), original); + File.WriteAllBytes(Path.Combine(save, "savepic.tga"), new byte[] { 1, 2, 3 }); + var service = new SaveBackupService(backupDirectory); + + SaveBackupCatalog before = service.Scan(game); + GameSaveInfo gameSave = Assert.Single(before.Saves); + Assert.True(gameSave.Parsed); + Assert.Equal("Original title", gameSave.Title); + + DateTime timestamp = new(2026, 7, 15, 12, 34, 56, DateTimeKind.Local); + string fileName = service.CreateBackup(game, "Slot_A", "Original title", "ENDL_000", timestamp); + SaveBackupInfo backup = Assert.Single(service.Scan(game).Backups); + Assert.Equal(fileName, backup.FileName); + Assert.Equal("Slot_A", backup.OrigFolder); + Assert.Equal("2026-07-15 12:34:56", backup.BackupTime); + Assert.Equal(new byte[] { 1, 2, 3 }, service.ReadBackupPreview(fileName)); + + File.WriteAllBytes(Path.Combine(save, "save.ini"), SyntheticFixture.Pfil("changed")); + SaveRestoreResult restore = service.RestoreBackup(game, fileName, "Slot_A"); + Assert.Empty(restore.CleanupWarnings); + Assert.Equal(original, File.ReadAllBytes(Path.Combine(save, "save.ini"))); + + service.DeleteBackup(fileName); + Assert.Empty(service.Scan(game).Backups); + service.DeleteSave(game, "Slot_A"); + Assert.False(Directory.Exists(save)); + } + + [Fact] + public void Scan_reads_legacy_file_name_when_manifest_is_absent() + { + string game = Path.Combine(_root, "game"); + string backupDirectory = Path.Combine(_root, "backups"); + Directory.CreateDirectory(backupDirectory); + string path = Path.Combine(backupDirectory, "Backup_Slot_With_Underscore_20260715_010203.zip"); + using (ZipArchive archive = ZipFile.Open(path, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry("save.ini"); + using Stream stream = entry.Open(); + byte[] bytes = SyntheticFixture.Pfil("[titel]\nLegacy\n[orglevelname]\nHIST_001\n"); + stream.Write(bytes); + } + + SaveBackupInfo backup = Assert.Single(new SaveBackupService(backupDirectory).Scan(game).Backups); + + Assert.Equal("Slot_With_Underscore", backup.OrigFolder); + Assert.Equal("2026-07-15 01:02:03", backup.BackupTime); + Assert.Equal("Legacy", backup.Title); + } + + [Fact] + public void Path_operations_reject_non_simple_names() + { + var service = new SaveBackupService(Path.Combine(_root, "backups")); + Assert.Throws(() => service.DeleteBackup("../outside.zip")); + Assert.Throws(() => service.DeleteSave(Path.Combine(_root, "game"), "../outside")); + } + + [Fact] + public void Restore_rejects_archive_without_save_ini_and_preserves_existing_save() + { + string game = Path.Combine(_root, "game"); + string backupDirectory = Path.Combine(_root, "backups"); + string save = Path.Combine(game, "SAVE", "Slot_A"); + Directory.CreateDirectory(save); + byte[] original = SyntheticFixture.Pfil("original"); + File.WriteAllBytes(Path.Combine(save, "save.ini"), original); + Directory.CreateDirectory(backupDirectory); + string badArchive = Path.Combine(backupDirectory, "bad.zip"); + using (ZipArchive archive = ZipFile.Open(badArchive, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry("unrelated.txt"); + using var writer = new StreamWriter(entry.Open()); + writer.Write("not a save"); + } + + var service = new SaveBackupService(backupDirectory); + Assert.Throws(() => service.RestoreBackup(game, "bad.zip", "Slot_A")); + + Assert.Equal(original, File.ReadAllBytes(Path.Combine(save, "save.ini"))); + Assert.Empty(Directory.GetDirectories(Path.Combine(game, "SAVE"), ".restore_*")); + } + + public void Dispose() + { + try { if (Directory.Exists(_root)) Directory.Delete(_root, true); } catch { } + } +} diff --git a/tests/AgainstRomeModifier.Tests/TroopPresetCodecTests.cs b/tests/AgainstRomeModifier.Tests/TroopPresetCodecTests.cs new file mode 100644 index 0000000..227ad17 --- /dev/null +++ b/tests/AgainstRomeModifier.Tests/TroopPresetCodecTests.cs @@ -0,0 +1,45 @@ +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Tests; + +public sealed class TroopPresetCodecTests +{ + private static readonly double[] Fallback = { 100, 10, 20, 30, 2.6, 1500, 500, 900, 500 }; + + [Fact] + public void Parse_supports_legacy_four_six_and_current_nine_column_rows() + { + string[] lines = + { + "Four=110,11,21,31", + "Six=120,12,22,32,1600,450", + "Nine=130,13,23,33,3.2,1700,400,1000,600", + "Broken=abc,1,2,3", + }; + + TroopPresetParseResult result = TroopPresetCodec.Parse(lines, _ => (double[])Fallback.Clone()); + + Assert.Equal(1, result.SkippedLines); + Assert.Equal(new double[] { 110, 11, 21, 31, 2.6, 1500, 500, 900, 500 }, result.Stats["Four"]); + Assert.Equal(new double[] { 120, 12, 22, 32, 0, 1600, 450, 0, 0 }, result.Stats["Six"]); + Assert.Equal(new double[] { 130, 13, 23, 33, 0, 1700, 400, 1000, 600 }, result.Stats["Nine"]); + } + + [Fact] + public void Write_and_parse_round_trip_the_six_editable_fields() + { + var stats = new Dictionary + { + ["UnitA"] = new double[] { 101.5, 12.25, 33, 44, 2.6, 1550, 475, 900, 500 }, + ["UnitB"] = new double[] { 101.125, 12.3456, 33.075, 44, 2.6, 1550.5, 475.25, 900, 500 }, + }; + + string text = TroopPresetCodec.Write(stats, new DateTime(2026, 7, 15, 1, 2, 3)); + TroopPresetParseResult parsed = TroopPresetCodec.Parse(text.Split('\n'), _ => (double[])Fallback.Clone()); + + Assert.Contains("# Generated on: 2026-07-15 01:02:03", text); + Assert.Equal(new double[] { 101.5, 12.25, 33, 44, 0, 1550, 475, 0, 0 }, parsed.Stats["UnitA"]); + // 匯出不得四捨五入:超過兩位小數的自訂值必須原值往返。 + Assert.Equal(new double[] { 101.125, 12.3456, 33.075, 44, 0, 1550.5, 475.25, 0, 0 }, parsed.Stats["UnitB"]); + } +} diff --git a/tests/AgainstRomeModifier.Tests/UnitBaselineCatalogTests.cs b/tests/AgainstRomeModifier.Tests/UnitBaselineCatalogTests.cs new file mode 100644 index 0000000..921a033 --- /dev/null +++ b/tests/AgainstRomeModifier.Tests/UnitBaselineCatalogTests.cs @@ -0,0 +1,36 @@ +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Tests; + +public sealed class UnitBaselineCatalogTests +{ + [RequiresBackupZipFact] + public void BackupManager_clear_invalidates_cached_unit_rows_and_stats() + { + using BackupZipGameFixture fixture = BackupZipGameFixture.Create(); + const string unitKey = "FigRomSch01_Bogen"; + + Assert.Contains(unitKey, fixture.Backup.GetBackupUnitRows().Keys); + Assert.True(fixture.Backup.GetOriginalStats(unitKey)[0] > 0); + + fixture.Backup.Clear(); + + Assert.Empty(fixture.Backup.GetBackupUnitRows()); + Assert.Equal(new double[9], fixture.Backup.GetOriginalStats(unitKey)); + } + + [RequiresBackupZipFact] + public void Auto_heal_invalidates_an_empty_unit_row_cache() + { + using BackupZipGameFixture fixture = BackupZipGameFixture.Create(); + const string unitKey = "FigRomSch01_Bogen"; + + fixture.Backup.Clear(); + Assert.Empty(fixture.Backup.GetBackupUnitRows()); + + fixture.Backup.TryAutoHealBackupFiles(fixture.RootPath); + + Assert.True(fixture.Backup.HasFile("SYSTEM/DATA_MP/DEFAULTS/objdef.dau")); + Assert.Contains(unitKey, fixture.Backup.GetBackupUnitRows().Keys); + } +} diff --git a/tests/AgainstRomeModifier.Tests/UnitStatParserTests.cs b/tests/AgainstRomeModifier.Tests/UnitStatParserTests.cs new file mode 100644 index 0000000..ad9d029 --- /dev/null +++ b/tests/AgainstRomeModifier.Tests/UnitStatParserTests.cs @@ -0,0 +1,46 @@ +using AgainstRomeModifier.Core.Patches; +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Tests; + +public sealed class UnitStatParserTests +{ + [Fact] + public void Parser_classifies_damage_reload_and_range_from_shared_weapon_slots() + { + string[] columns = Enumerable.Repeat("0", 256).ToArray(); + SetWeapon(columns, slot: 1, damageType: "0", damage: "10", reload: "2.5", minimumRange: "1", maximumRange: "50"); + SetWeapon(columns, slot: 2, damageType: "1", damage: "20", reload: "1.5", minimumRange: "5", maximumRange: "100"); + + UnitStatParser.GetMeleeAndRangedDamage(columns, "hybrid_inf", out double meleeDamage, out double rangedDamage); + UnitStatParser.GetMeleeAndRangedReload(columns, "hybrid_inf", out double meleeReload, out double rangedReload); + + Assert.Equal(10, meleeDamage); + Assert.Equal(20, rangedDamage); + Assert.Equal(2.5, meleeReload); + Assert.Equal(1.5, rangedReload); + Assert.Equal(100, UnitStatParser.GetMaximumRange(columns, "hybrid_inf")); + + columns[(int)ObjdefIndex.Sirad] = "300"; + Assert.Equal(300, UnitStatParser.GetMaximumRange(columns, "priest")); + } + + private static void SetWeapon( + string[] columns, + int slot, + string damageType, + string damage, + string reload, + string minimumRange, + string maximumRange) + { + int offset = (slot - 1) * 8; + int activeIndex = (int)ObjdefIndex.Weapon1Akti + offset; + columns[activeIndex] = "1"; + columns[activeIndex + 1] = damage; + columns[activeIndex + 6] = reload; + columns[(int)ObjdefIndex.Weapon1Dtyp + slot - 1] = damageType; + columns[(int)ObjdefIndex.Weapon1RangeMin + offset] = minimumRange; + columns[(int)ObjdefIndex.Weapon1RangeMax + offset] = maximumRange; + } +} diff --git a/tests/AgainstRomeModifier.Tests/UnitStatsProjectionServiceTests.cs b/tests/AgainstRomeModifier.Tests/UnitStatsProjectionServiceTests.cs new file mode 100644 index 0000000..5f0ab1d --- /dev/null +++ b/tests/AgainstRomeModifier.Tests/UnitStatsProjectionServiceTests.cs @@ -0,0 +1,49 @@ +using AgainstRomeModifier.Core.Features; +using AgainstRomeModifier.Core.Services; + +namespace AgainstRomeModifier.Tests; + +public sealed class UnitStatsProjectionServiceTests +{ + [RequiresBackupZipFact] + public void Project_applies_independent_display_modifiers_after_baseline_merge() + { + using BackupZipGameFixture fixture = BackupZipGameFixture.Create(); + const string key = "FigKelPri00_Priester"; + double[] original = fixture.Backup.GetOriginalStats(key); + double[] balanced = fixture.Backup.GetDefaultBalancedStats(key); + var profile = new PatchProfile + { + Balance = true, + UnitMovementSpeed2x = true, + SpellEntireMap = true, + SpellRange3x = true, + CustomUnitStats = new Dictionary { [key] = (double[])original.Clone() }, + }; + + double[] projected = new UnitStatsProjectionService(fixture.Backup).Project(key, profile); + + Assert.Equal(balanced[4] * 2.0, projected[4]); + Assert.Equal(30000.0, projected[7]); + Assert.Equal(balanced[8] * 3.0, projected[8]); + } + + [RequiresBackupZipFact] + public void Editor_normalization_restores_independent_fields_without_mutating_input() + { + using BackupZipGameFixture fixture = BackupZipGameFixture.Create(); + const string key = "FigKelPri00_Priester"; + double[] original = fixture.Backup.GetOriginalStats(key); + double[] input = { 111, 22, 33, 44, 999, 888, 77, 666, 555 }; + + double[] normalized = new UnitStatsEditorService(fixture.Backup).NormalizeIndependentFields(key, input); + + Assert.Equal(999, input[4]); + Assert.Equal(original[4], normalized[4]); + Assert.Equal(original[5], normalized[5]); + Assert.Equal(original[7], normalized[7]); + Assert.Equal(original[8], normalized[8]); + Assert.Equal(111, normalized[0]); + Assert.Equal(77, normalized[6]); + } +}