diff --git a/MaiChartManager.Tests/MaiChartManager.Tests.csproj b/MaiChartManager.Tests/MaiChartManager.Tests.csproj index 06dcf030..3941a31c 100644 --- a/MaiChartManager.Tests/MaiChartManager.Tests.csproj +++ b/MaiChartManager.Tests/MaiChartManager.Tests.csproj @@ -1,6 +1,8 @@ - net10.0 + true + net10.0-windows10.0.17763.0 + net10.0 enable enable false diff --git a/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs b/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs new file mode 100644 index 00000000..6411b22f --- /dev/null +++ b/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs @@ -0,0 +1,338 @@ +using MaiChartManager.Services; + +namespace MaiChartManager.Tests.Services; + +[CollectionDefinition("Static settings", DisableParallelization = true)] +public sealed class StaticSettingsCollection; + +[Collection("Static settings")] +public sealed class ResourceJunctionServiceTests : IDisposable +{ + private readonly string root = Path.Combine(Path.GetTempPath(), $"mcm resource links & {Guid.NewGuid():N}"); + private readonly string sourceRoot; + private readonly string targetRoot; + + public ResourceJunctionServiceTests() + { + sourceRoot = Path.Combine(root, "source"); + targetRoot = Path.Combine(root, "target"); + Directory.CreateDirectory(sourceRoot); + Directory.CreateDirectory(targetRoot); + foreach (var name in ResourceJunctionService.ResourceNames) + Directory.CreateDirectory(Path.Combine(sourceRoot, name)); + } + + [Fact] + public void FixedScopeContainsOnlyThreeResourceDirectories() + { + Assert.Equal(["AssetBundleImages", "MovieData", "SoundData"], ResourceJunctionService.ResourceNames); + Assert.Throws(() => ((IList)ResourceJunctionService.ResourceNames)[0] = "OtherDirectory"); + } + + [Fact] + public void AutoSelectionAcceptsGameRootAndPackageAndChoosesMostFiles() + { + var targetGame = CreateGame("target-game", [0, 0, 0]); + var smallerGame = CreateGame("smaller-game", [1, 1, 1]); + var largerGame = CreateGame("larger-game", [2, 3, 4]); + var service = new ResourceJunctionService( + () => Path.Combine(targetGame, "Package"), + () => [targetGame, Path.Combine(smallerGame, "Package"), largerGame]); + + var overview = service.AutoSelectSource(); + + Assert.Equal(ResourceSourceSelectionMode.Automatic, overview.SelectionMode); + Assert.Equal(Path.Combine(largerGame, "Package", "Sinmai_Data", "StreamingAssets", "A000"), overview.SourceRoot); + Assert.Equal(9, overview.TotalFileCount); + Assert.Equal([2L, 3L, 4L], overview.FileCounts.Select(item => item.FileCount)); + } + + [Fact] + public void AutoSelectionRejectsIncompleteCandidatesAndCurrentGame() + { + var targetGame = CreateGame("target-game-invalid", [5, 5, 5]); + var incompleteGame = CreateGame("incomplete-game", [1, 1, 1]); + Directory.Delete(Path.Combine(incompleteGame, "Package", "Sinmai_Data", "StreamingAssets", "A000", "MovieData"), true); + var service = new ResourceJunctionService( + () => Path.Combine(targetGame, "Package"), + () => [targetGame, incompleteGame, Path.Combine(root, "missing")]); + + var overview = service.AutoSelectSource(); + + Assert.Equal(ResourceSourceSelectionMode.None, overview.SelectionMode); + Assert.Null(overview.SourceRoot); + Assert.All(overview.Items, item => Assert.Equal(ResourceJunctionStatus.SourceMissing, item.Status)); + } + + [Fact] + public void AutoSelectionRequiresManualChoiceWhenHighestCountsTie() + { + var targetGame = CreateGame("target-game-tie", [0, 0, 0]); + var firstGame = CreateGame("first-game-tie", [1, 2, 3]); + var secondGame = CreateGame("second-game-tie", [3, 2, 1]); + var service = new ResourceJunctionService( + () => targetGame, + () => [firstGame, secondGame]); + + var overview = service.AutoSelectSource(); + + Assert.Equal(ResourceSourceSelectionMode.Tie, overview.SelectionMode); + Assert.Null(overview.SourceRoot); + Assert.NotNull(overview.Detail); + } + + [Fact] + public void AutoSelectionFindsSiblingForDirectSinmaiDataLayout() + { + var targetGame = CreateDirectGame("target-direct", [0, 0, 0]); + var sourceGame = CreateDirectGame("source-direct", [2, 2, 2]); + var previousGamePath = StaticSettings.GamePath; + var previousConfig = StaticSettings.Config; + try + { + StaticSettings.GamePath = targetGame; + StaticSettings.Config = new Config(); + + var overview = new ResourceJunctionService().AutoSelectSource(); + + Assert.Equal(ResourceSourceSelectionMode.Automatic, overview.SelectionMode); + Assert.Equal(Path.Combine(sourceGame, "Sinmai_Data", "StreamingAssets", "A000"), overview.SourceRoot); + Assert.Equal(6, overview.TotalFileCount); + } + finally + { + StaticSettings.GamePath = previousGamePath; + StaticSettings.Config = previousConfig; + } + } + + [Fact] + public void AutoSelectionRejectsResourceDirectoryJunctions() + { + if (!OperatingSystem.IsWindows()) return; + var targetGame = CreateGame("target-game-reparse", [0, 0, 0]); + var sourceGame = CreateGame("source-game-reparse", [1, 1, 1]); + var resourcePath = Path.Combine(sourceGame, "Package", "Sinmai_Data", "StreamingAssets", "A000", "MovieData"); + var linkedDirectory = Path.Combine(root, "linked-resource"); + Directory.Delete(resourcePath, true); + Directory.CreateDirectory(linkedDirectory); + CreateJunction(linkedDirectory, resourcePath); + try + { + var service = new ResourceJunctionService(() => targetGame, () => [sourceGame]); + + var overview = service.AutoSelectSource(); + + Assert.Equal(ResourceSourceSelectionMode.None, overview.SelectionMode); + Assert.Null(overview.SourceRoot); + } + finally + { + Directory.Delete(resourcePath, false); + } + } + + [Fact] + public void AutoSelectionDoesNotTraverseNestedReparsePoints() + { + if (!OperatingSystem.IsWindows()) return; + var targetGame = CreateGame("target-game-nested-reparse", [0, 0, 0]); + var sourceGame = CreateGame("source-game-nested-reparse", [1, 1, 1]); + var externalDirectory = Path.Combine(root, "external-resource-files"); + Directory.CreateDirectory(externalDirectory); + File.WriteAllText(Path.Combine(externalDirectory, "outside.dat"), "outside"); + var nestedJunction = Path.Combine( + sourceGame, + "Package", + "Sinmai_Data", + "StreamingAssets", + "A000", + "AssetBundleImages", + "external-link"); + CreateJunction(externalDirectory, nestedJunction); + try + { + var service = new ResourceJunctionService(() => targetGame, () => [sourceGame]); + + var overview = service.AutoSelectSource(); + + Assert.Equal(3, overview.TotalFileCount); + } + finally + { + Directory.Delete(nestedJunction, false); + } + } + + [Fact] + public void ManualSelectionOverridesAutomaticSelectionForSession() + { + var targetGame = CreateGame("target-game-manual", [0, 0, 0]); + var automaticGame = CreateGame("automatic-game", [4, 4, 4]); + var manualGame = CreateGame("manual-game", [1, 1, 1]); + var service = new ResourceJunctionService( + () => targetGame, + () => [automaticGame, manualGame]); + + service.AutoSelectSource(); + var overview = service.SelectManualSource(Path.Combine(manualGame, "Package")); + + Assert.Equal(ResourceSourceSelectionMode.Manual, overview.SelectionMode); + Assert.Equal(Path.Combine(manualGame, "Package", "Sinmai_Data", "StreamingAssets", "A000"), overview.SourceRoot); + Assert.Equal(3, overview.TotalFileCount); + } + + [Fact] + public void ManualSelectionRejectsCurrentGame() + { + var targetGame = CreateGame("target-game-self", [0, 0, 0]); + var service = new ResourceJunctionService(() => targetGame, () => []); + + Assert.Throws(() => service.SelectManualSource(targetGame)); + } + + [Fact] + public void ManualTargetSelectionIsSessionOnlyAndKeepsDistinctSource() + { + var configuredTarget = CreateGame("configured-target", [0, 0, 0]); + var manualTarget = CreateGame("manual-target", [0, 0, 0]); + var sourceGame = CreateGame("source-for-manual-target", [1, 1, 1]); + var configuredTargetReads = 0; + var service = new ResourceJunctionService( + () => + { + configuredTargetReads++; + return configuredTarget; + }, + () => [sourceGame]); + service.AutoSelectSource(); + var readsBeforeManualSelection = configuredTargetReads; + + var overview = service.SelectManualTarget(manualTarget); + + Assert.Equal(Path.Combine(manualTarget, "Package", "Sinmai_Data", "StreamingAssets", "A000"), overview.TargetRoot); + Assert.Equal(Path.Combine(sourceGame, "Package", "Sinmai_Data", "StreamingAssets", "A000"), overview.SourceRoot); + Assert.Equal(readsBeforeManualSelection, configuredTargetReads); + } + + [Fact] + public void SelectingCurrentSourceAsTargetClearsSource() + { + var configuredTarget = CreateGame("configured-target-clear", [0, 0, 0]); + var sourceGame = CreateGame("source-becomes-target", [1, 1, 1]); + var service = new ResourceJunctionService(() => configuredTarget, () => [sourceGame]); + service.AutoSelectSource(); + + var overview = service.SelectManualTarget(sourceGame); + + Assert.Equal(ResourceSourceSelectionMode.None, overview.SelectionMode); + Assert.Null(overview.SourceRoot); + Assert.NotNull(overview.Detail); + } + + [Fact] + public void ExistingRealDirectoriesAreConflicts() + { + if (!OperatingSystem.IsWindows()) return; + foreach (var name in ResourceJunctionService.ResourceNames) + Directory.CreateDirectory(Path.Combine(targetRoot, name)); + + var result = new ResourceJunctionService(sourceRoot, targetRoot).Inspect(); + + Assert.All(result, item => Assert.Equal(ResourceJunctionStatus.Conflict, item.Status)); + } + + [Fact] + public void CreateAndRemoveOnlyVerifiedJunctions() + { + if (!OperatingSystem.IsWindows()) return; + var sourceFile = Path.Combine(sourceRoot, ResourceJunctionService.ResourceNames[0], "source.txt"); + File.WriteAllText(sourceFile, "source remains unchanged"); + var service = new ResourceJunctionService(sourceRoot, targetRoot); + + var created = service.CreateLinks(); + var inspected = service.Inspect(); + var removed = service.RemoveLinks(); + + Assert.All(created, item => Assert.Equal(ResourceJunctionStatus.Created, item.Status)); + Assert.All(inspected, item => Assert.Equal(ResourceJunctionStatus.AlreadyLinked, item.Status)); + Assert.All(removed, item => Assert.Equal(ResourceJunctionStatus.Removed, item.Status)); + Assert.True(File.Exists(sourceFile)); + Assert.Equal("source remains unchanged", File.ReadAllText(sourceFile)); + } + + [Fact] + public void WrongJunctionTargetIsNotRemoved() + { + if (!OperatingSystem.IsWindows()) return; + var wrongSource = Path.Combine(root, "wrong-source"); + Directory.CreateDirectory(wrongSource); + var target = Path.Combine(targetRoot, ResourceJunctionService.ResourceNames[0]); + CreateJunction(wrongSource, target); + var service = new ResourceJunctionService(sourceRoot, targetRoot); + + var result = service.RemoveLinks(); + + Assert.Equal(ResourceJunctionStatus.WrongTarget, result[0].Status); + Assert.True(Directory.Exists(target)); + } + + public void Dispose() + { + if (!Directory.Exists(root)) return; + var service = new ResourceJunctionService(sourceRoot, targetRoot); + service.RemoveLinks(); + var wrongTarget = Path.Combine(targetRoot, ResourceJunctionService.ResourceNames[0]); + if (Directory.Exists(wrongTarget) && (File.GetAttributes(wrongTarget) & FileAttributes.ReparsePoint) != 0) + Directory.Delete(wrongTarget, false); + Directory.Delete(root, true); + } + + private static void CreateJunction(string source, string target) + { + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("/d"); + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("mklink"); + startInfo.ArgumentList.Add("/J"); + startInfo.ArgumentList.Add(target); + startInfo.ArgumentList.Add(source); + using var process = System.Diagnostics.Process.Start(startInfo)!; + process.WaitForExit(); + Assert.Equal(0, process.ExitCode); + } + + private string CreateGame(string name, int[] fileCounts) + { + var gameRoot = Path.Combine(root, name); + var a000 = Path.Combine(gameRoot, "Package", "Sinmai_Data", "StreamingAssets", "A000"); + for (var resourceIndex = 0; resourceIndex < ResourceJunctionService.ResourceNames.Count; resourceIndex++) + { + var resourceRoot = Path.Combine(a000, ResourceJunctionService.ResourceNames[resourceIndex]); + var nestedRoot = Path.Combine(resourceRoot, "nested"); + Directory.CreateDirectory(nestedRoot); + for (var fileIndex = 0; fileIndex < fileCounts[resourceIndex]; fileIndex++) + File.WriteAllText(Path.Combine(nestedRoot, $"{fileIndex}.dat"), "test"); + } + return gameRoot; + } + + private string CreateDirectGame(string name, int[] fileCounts) + { + var gameRoot = Path.Combine(root, name); + var a000 = Path.Combine(gameRoot, "Sinmai_Data", "StreamingAssets", "A000"); + for (var resourceIndex = 0; resourceIndex < ResourceJunctionService.ResourceNames.Count; resourceIndex++) + { + var resourceRoot = Path.Combine(a000, ResourceJunctionService.ResourceNames[resourceIndex]); + Directory.CreateDirectory(resourceRoot); + for (var fileIndex = 0; fileIndex < fileCounts[resourceIndex]; fileIndex++) + File.WriteAllText(Path.Combine(resourceRoot, $"{fileIndex}.dat"), "test"); + } + return gameRoot; + } +} diff --git a/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs new file mode 100644 index 00000000..18bc9631 --- /dev/null +++ b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs @@ -0,0 +1,89 @@ +using MaiChartManager.Platform; +using MaiChartManager.Services; +using Microsoft.AspNetCore.Mvc; + +namespace MaiChartManager.Controllers.Tools; + +[ApiController] +[Route("MaiChartManagerServlet/[action]Api")] +public class ResourceJunctionController(ResourceJunctionService service, IDesktopDialogService dialogService) : ControllerBase +{ + private const string LocalActionHeader = "X-MCM-Local-Action"; + private const string LocalActionValue = "resource-junction"; + + [HttpGet] + public ActionResult GetResourceJunctionStatus() + { + if (StaticSettings.Config.Export) return Forbid(); + return Ok(service.GetOverview()); + } + + [HttpPost] + public ActionResult AutoSelectResourceJunctionSource() + { + if (RejectUnavailableLocalAction() is { } rejection) return rejection; + return Ok(service.AutoSelectSource()); + } + + [HttpPost] + public ActionResult SelectResourceJunctionSource() + { + if (RejectUnavailableLocalAction() is { } rejection) return rejection; + + var path = dialogService.PickFolder( + Locale.ResourceManager.GetString("SelectResourceJunctionSourceFolder", Locale.Culture)); + if (path is null) return Ok(service.GetOverview()); + try + { + return Ok(service.SelectManualSource(path)); + } + catch (ArgumentException e) + { + return BadRequest(e.Message); + } + catch (InvalidOperationException e) + { + return BadRequest(e.Message); + } + } + + [HttpPost] + public ActionResult SelectResourceJunctionTarget() + { + if (RejectUnavailableLocalAction() is { } rejection) return rejection; + + var path = dialogService.PickFolder( + Locale.ResourceManager.GetString("SelectResourceJunctionTargetFolder", Locale.Culture)); + if (path is null) return Ok(service.GetOverview()); + try + { + return Ok(service.SelectManualTarget(path)); + } + catch (ArgumentException e) + { + return BadRequest(e.Message); + } + } + + [HttpPost] + public ActionResult CreateResourceJunctions() + { + if (RejectUnavailableLocalAction() is { } rejection) return rejection; + var items = service.CreateLinks(); + return Ok(service.GetOverview() with { Items = items }); + } + + [HttpPost] + public ActionResult RemoveResourceJunctions() + { + if (RejectUnavailableLocalAction() is { } rejection) return rejection; + var items = service.RemoveLinks(); + return Ok(service.GetOverview() with { Items = items }); + } + + private ActionResult? RejectUnavailableLocalAction() + { + if (StaticSettings.Config.Export) return Forbid(); + return Request.Headers[LocalActionHeader] != LocalActionValue ? BadRequest() : null; + } +} diff --git a/MaiChartManager/Front/src/client/apiGen.ts b/MaiChartManager/Front/src/client/apiGen.ts index ae3b8c81..f0ede6e7 100644 --- a/MaiChartManager/Front/src/client/apiGen.ts +++ b/MaiChartManager/Front/src/client/apiGen.ts @@ -23,6 +23,26 @@ export enum ShiftMethod { NoShift = "NoShift", } +export enum ResourceSourceSelectionMode { + None = "None", + Automatic = "Automatic", + Manual = "Manual", + Tie = "Tie", +} + +export enum ResourceJunctionStatus { + Ready = "Ready", + Created = "Created", + AlreadyLinked = "AlreadyLinked", + Removed = "Removed", + SourceMissing = "SourceMissing", + TargetRootMissing = "TargetRootMissing", + Conflict = "Conflict", + WrongTarget = "WrongTarget", + Failed = "Failed", + Unsupported = "Unsupported", +} + export enum PubKeyId { None = "None", Local = "Local", @@ -389,6 +409,31 @@ export interface RequestPurchaseResult { status?: number; } +export interface ResourceDirectoryFileCount { + name?: string | null; + /** @format int64 */ + fileCount?: number; +} + +export interface ResourceJunctionItem { + name?: string | null; + source?: string | null; + target?: string | null; + status?: ResourceJunctionStatus; + detail?: string | null; +} + +export interface ResourceJunctionOverview { + sourceRoot?: string | null; + targetRoot?: string | null; + selectionMode?: ResourceSourceSelectionMode; + fileCounts?: ResourceDirectoryFileCount[] | null; + /** @format int64 */ + totalFileCount?: number; + detail?: string | null; + items?: ResourceJunctionItem[] | null; +} + export interface Section { path?: string | null; entries?: Entry[] | null; @@ -2670,6 +2715,96 @@ export class Api< ...params, }), + /** + * No description + * + * @tags ResourceJunction + * @name GetResourceJunctionStatus + * @request GET:/MaiChartManagerServlet/GetResourceJunctionStatusApi + */ + GetResourceJunctionStatus: (params: RequestParams = {}) => + this.request({ + path: `/MaiChartManagerServlet/GetResourceJunctionStatusApi`, + method: "GET", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags ResourceJunction + * @name AutoSelectResourceJunctionSource + * @request POST:/MaiChartManagerServlet/AutoSelectResourceJunctionSourceApi + */ + AutoSelectResourceJunctionSource: (params: RequestParams = {}) => + this.request({ + path: `/MaiChartManagerServlet/AutoSelectResourceJunctionSourceApi`, + method: "POST", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags ResourceJunction + * @name SelectResourceJunctionSource + * @request POST:/MaiChartManagerServlet/SelectResourceJunctionSourceApi + */ + SelectResourceJunctionSource: (params: RequestParams = {}) => + this.request({ + path: `/MaiChartManagerServlet/SelectResourceJunctionSourceApi`, + method: "POST", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags ResourceJunction + * @name SelectResourceJunctionTarget + * @request POST:/MaiChartManagerServlet/SelectResourceJunctionTargetApi + */ + SelectResourceJunctionTarget: (params: RequestParams = {}) => + this.request({ + path: `/MaiChartManagerServlet/SelectResourceJunctionTargetApi`, + method: "POST", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags ResourceJunction + * @name CreateResourceJunctions + * @request POST:/MaiChartManagerServlet/CreateResourceJunctionsApi + */ + CreateResourceJunctions: (params: RequestParams = {}) => + this.request({ + path: `/MaiChartManagerServlet/CreateResourceJunctionsApi`, + method: "POST", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags ResourceJunction + * @name RemoveResourceJunctions + * @request POST:/MaiChartManagerServlet/RemoveResourceJunctionsApi + */ + RemoveResourceJunctions: (params: RequestParams = {}) => + this.request({ + path: `/MaiChartManagerServlet/RemoveResourceJunctionsApi`, + method: "POST", + format: "json", + ...params, + }), + /** * No description * diff --git a/MaiChartManager/Front/src/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index 1123dc84..29f937aa 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -532,6 +532,39 @@ tools: videoConvertError: Video conversion error imageToAb: Image to AssetBundle imageToAbError: Image to AB conversion error + resourceJunction: + label: Game Resource Links + title: Game Resource Links + source: Read-only source + target: Target directory + noSource: No source was selected automatically. Select one manually + selectSource: Select source manually + selectTarget: Select target directory + fileCounts: Source resource file counts + total: Total + refresh: Refresh status + create: Create links + remove: Remove links + loading: Reading status... + requestFailed: Game resource link operation failed + createConfirm: Create the three resource Junctions only where the target is missing? + removeConfirm: Remove only Junctions that point to the current source? Source files will not be deleted. + selection: + None: Not selected + Automatic: Automatically selected + Manual: Manually selected + Tie: File count tie; select manually + status: + Ready: Ready + Created: Created + AlreadyLinked: Linked correctly + Removed: Removed + SourceMissing: Source missing + TargetRootMissing: Target root missing + Conflict: Target is not a Junction + WrongTarget: Wrong Junction target + Failed: Failed + Unsupported: Unsupported system pvConvert: label: Convert PV single: Single convert diff --git a/MaiChartManager/Front/src/locales/zh-TW.yaml b/MaiChartManager/Front/src/locales/zh-TW.yaml index d8f6f558..9c47f521 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -477,6 +477,39 @@ tools: videoConvertError: 影片轉換出錯 imageToAb: 圖片轉 AssetBundle imageToAbError: 圖片轉 AB 出錯 + resourceJunction: + label: 遊戲資源連結 + title: 遊戲資源連結 + source: 唯讀來源目錄 + target: 目標目錄 + noSource: 未自動選出來源目錄,請手動選擇 + selectSource: 手動選擇來源目錄 + selectTarget: 選擇目標目錄 + fileCounts: 來源目錄資源檔案數 + total: 合計 + refresh: 重新整理狀態 + create: 建立連結 + remove: 移除連結 + loading: 正在讀取狀態… + requestFailed: 遊戲資源連結操作失敗 + createConfirm: 只會在目標位置不存在時建立三個資源 Junction,確認繼續? + removeConfirm: 只會移除正確指向目前來源目錄的 Junction,不會刪除來源檔案,確認繼續? + selection: + None: 未選擇 + Automatic: 自動選擇 + Manual: 手動選擇 + Tie: 檔案數並列,請手動選擇 + status: + Ready: 可建立 + Created: 已建立 + AlreadyLinked: 已正確連結 + Removed: 已移除 + SourceMissing: 來源目錄缺失 + TargetRootMissing: 目標根目錄缺失 + Conflict: 目標存在且不是 Junction + WrongTarget: Junction 指向錯誤 + Failed: 操作失敗 + Unsupported: 目前系統不支援 pvConvert: label: 轉換 PV single: 單個轉換 diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index 7b6c090e..d164b0d2 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -471,6 +471,39 @@ tools: videoConvertError: 视频转换出错 imageToAb: 图片转 AssetBundle imageToAbError: 图片转 AB 出错 + resourceJunction: + label: 游戏资源链接 + title: 游戏资源链接 + source: 只读源目录 + target: 目标目录 + noSource: 未自动选出源目录,请手动选择 + selectSource: 手动选择源目录 + selectTarget: 选择目标目录 + fileCounts: 源目录资源文件数 + total: 合计 + refresh: 刷新状态 + create: 建立链接 + remove: 移除链接 + loading: 正在读取状态… + requestFailed: 游戏资源链接操作失败 + createConfirm: 只会在目标位置不存在时建立三个资源 Junction,确认继续? + removeConfirm: 只会移除正确指向当前源目录的 Junction,不会删除源文件,确认继续? + selection: + None: 未选择 + Automatic: 自动选择 + Manual: 手动选择 + Tie: 文件数并列,请手动选择 + status: + Ready: 可建立 + Created: 已建立 + AlreadyLinked: 已正确链接 + Removed: 已移除 + SourceMissing: 源目录缺失 + TargetRootMissing: 目标根目录缺失 + Conflict: 目标存在且不是 Junction + WrongTarget: Junction 指向错误 + Failed: 操作失败 + Unsupported: 当前系统不支持 pvConvert: label: 转换 PV single: 单个转换 diff --git a/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx b/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx new file mode 100644 index 00000000..18f6378a --- /dev/null +++ b/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx @@ -0,0 +1,192 @@ +import api from '@/client/api'; +import { ResourceJunctionOverview, ResourceJunctionStatus } from '@/client/apiGen'; +import { Button, Modal, addToast, showTransactionalDialog } from '@munet/ui'; +import { computed, defineComponent, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +export default defineComponent({ + setup(_, { expose }) { + const { t } = useI18n(); + const show = ref(false); + const loading = ref(false); + const overview = ref(); + const items = computed(() => overview.value?.items ?? []); + + const canCreate = computed(() => items.value.some(item => ( + item.status === ResourceJunctionStatus.Ready || item.status === ResourceJunctionStatus.Removed + ))); + const canRemove = computed(() => items.value.some(item => ( + item.status === ResourceJunctionStatus.AlreadyLinked || item.status === ResourceJunctionStatus.Created + ))); + + const errorDetail = (error: unknown) => { + if (!error || typeof error !== 'object') return typeof error === 'string' ? error : undefined; + const payload = 'error' in error ? error.error : error; + if (typeof payload === 'string') return payload; + if (!payload || typeof payload !== 'object') return error instanceof Error ? error.message : undefined; + const detail = payload as { message?: unknown; error?: unknown }; + if (typeof detail.message === 'string') return detail.message; + return typeof detail.error === 'string' ? detail.error : undefined; + }; + + const request = async (action: 'auto' | 'status' | 'manual' | 'manualTarget' | 'create' | 'remove') => { + loading.value = true; + try { + const writeParams = { headers: { 'X-MCM-Local-Action': 'resource-junction' } }; + const response = action === 'auto' + ? await api.AutoSelectResourceJunctionSource(writeParams) + : action === 'status' + ? await api.GetResourceJunctionStatus() + : action === 'manual' + ? await api.SelectResourceJunctionSource(writeParams) + : action === 'manualTarget' + ? await api.SelectResourceJunctionTarget(writeParams) + : action === 'create' + ? await api.CreateResourceJunctions(writeParams) + : await api.RemoveResourceJunctions(writeParams); + overview.value = response.data; + } catch (error) { + console.error(error); + const detail = errorDetail(error); + addToast({ + message: detail ? `${t('tools.resourceJunction.requestFailed')}: ${detail}` : t('tools.resourceJunction.requestFailed'), + type: 'error', + }); + } finally { + loading.value = false; + } + }; + + const run = async (action: 'create' | 'remove') => { + const removing = action === 'remove'; + const confirmed = await showTransactionalDialog( + t('common.confirm'), + t(removing ? 'tools.resourceJunction.removeConfirm' : 'tools.resourceJunction.createConfirm'), + [ + { text: t('common.confirm'), action: true }, + { text: t('common.cancel'), action: false }, + ], + ); + if (!confirmed) return; + await request(action); + }; + + const trigger = () => { + show.value = true; + overview.value = undefined; + request('auto'); + }; + expose({ trigger }); + + const statusClass = (status?: ResourceJunctionStatus) => { + if ( + status === ResourceJunctionStatus.Created + || status === ResourceJunctionStatus.AlreadyLinked + || status === ResourceJunctionStatus.Removed + ) return 'text-green-700'; + if (status === ResourceJunctionStatus.Ready) return 'text-blue-700'; + return 'text-red-700'; + }; + + return () => ( + +
+
+
+
+
+
{t('tools.resourceJunction.source')}
+ {overview.value?.selectionMode && ( + + {t(`tools.resourceJunction.selection.${overview.value.selectionMode}`)} + + )} +
+
+ {overview.value?.sourceRoot ?? t('tools.resourceJunction.noSource')} +
+
+ +
+
+
+
{t('tools.resourceJunction.target')}
+
{overview.value?.targetRoot}
+
+ +
+ {!!overview.value?.fileCounts?.length && ( +
+
{t('tools.resourceJunction.fileCounts')}
+
+ {overview.value.fileCounts.map(item => ( + {item.name}: {item.fileCount} + ))} + {t('tools.resourceJunction.total')}: {overview.value.totalFileCount} +
+
+ )} + {overview.value?.detail &&
{overview.value.detail}
} +
+ +
+ {items.value.map((item, index) => ( +
0 && 'border-t border-t-solid border-t-gray-200', + ]} + > +
+
{item.name}
+ {item.detail &&
{item.detail}
} +
+
+ {t(`tools.resourceJunction.status.${item.status}`)} +
+
+ ))} + {!items.value.length && ( +
{t('tools.resourceJunction.loading')}
+ )} +
+ +
+ + + +
+
+
+ ); + }, +}); diff --git a/MaiChartManager/Front/src/views/Tools/index.tsx b/MaiChartManager/Front/src/views/Tools/index.tsx index 77a16b6d..f5c2fde5 100644 --- a/MaiChartManager/Front/src/views/Tools/index.tsx +++ b/MaiChartManager/Front/src/views/Tools/index.tsx @@ -3,6 +3,7 @@ import { addToast } from '@munet/ui'; import { defineComponent, ref } from 'vue'; import ImageToAbModal from '@/views/Tools/ImageToAbModal'; import PvConvertDropMenu from '@/views/Tools/PvConvertDropMenu'; +import ResourceJunctionModal from '@/views/Tools/ResourceJunctionModal'; import { useI18n } from 'vue-i18n'; interface ToolCard { @@ -15,6 +16,7 @@ interface ToolCard { export default defineComponent({ setup() { const imageToAbRef = ref<{ trigger: () => void }>(); + const resourceJunctionRef = ref<{ trigger: () => void }>(); const { t } = useI18n(); const handleAudioConvert = async () => { @@ -41,6 +43,11 @@ export default defineComponent({ labelKey: 'tools.imageToAb', action: () => imageToAbRef.value?.trigger(), }, + { + icon: 'i-mdi-link-variant', + labelKey: 'tools.resourceJunction.label', + action: () => resourceJunctionRef.value?.trigger(), + }, ]; const renderToolCard = (tool: ToolCard) => ( @@ -67,8 +74,10 @@ export default defineComponent({ {renderToolCard(tools[0])} {renderToolCard(tools[1])} + {renderToolCard(tools[2])} + ); }, diff --git a/MaiChartManager/Locale.resx b/MaiChartManager/Locale.resx index 6c7f2324..adb2171d 100644 --- a/MaiChartManager/Locale.resx +++ b/MaiChartManager/Locale.resx @@ -334,4 +334,10 @@ If you notice any issues with the conversion result, you can try testing it in A Unsupported file format + + Select a source game directory or Package directory + + + Select a target game directory or Package directory + diff --git a/MaiChartManager/Locale.zh-Hans.resx b/MaiChartManager/Locale.zh-Hans.resx index c8536ba3..67a5e5cf 100644 --- a/MaiChartManager/Locale.zh-Hans.resx +++ b/MaiChartManager/Locale.zh-Hans.resx @@ -326,4 +326,10 @@ 不支持的文件格式 + + 选择源游戏目录或 Package 目录 + + + 选择目标游戏目录或 Package 目录 + diff --git a/MaiChartManager/Locale.zh-Hant.resx b/MaiChartManager/Locale.zh-Hant.resx index 72dbff24..1e2efd88 100644 --- a/MaiChartManager/Locale.zh-Hant.resx +++ b/MaiChartManager/Locale.zh-Hant.resx @@ -326,4 +326,10 @@ 不支援的檔案格式 + + 選擇來源遊戲目錄或 Package 目錄 + + + 選擇目標遊戲目錄或 Package 目錄 + diff --git a/MaiChartManager/ServerManager.cs b/MaiChartManager/ServerManager.cs index e00445c6..5d264f4f 100644 --- a/MaiChartManager/ServerManager.cs +++ b/MaiChartManager/ServerManager.cs @@ -104,6 +104,7 @@ public static Task StartApp(bool export, Action? onStart = null, bool se .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddEndpointsApiExplorer() .AddSwaggerGen(options => { options.CustomSchemaIds(type => type.Name == "Config" ? type.FullName : type.Name); }) .Configure(x => diff --git a/MaiChartManager/Services/ResourceJunctionService.cs b/MaiChartManager/Services/ResourceJunctionService.cs new file mode 100644 index 00000000..48cc8531 --- /dev/null +++ b/MaiChartManager/Services/ResourceJunctionService.cs @@ -0,0 +1,479 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace MaiChartManager.Services; + +public enum ResourceJunctionStatus +{ + Ready, + Created, + AlreadyLinked, + Removed, + SourceMissing, + TargetRootMissing, + Conflict, + WrongTarget, + Failed, + Unsupported, +} + +public enum ResourceSourceSelectionMode +{ + None, + Automatic, + Manual, + Tie, +} + +public record ResourceJunctionItem( + string Name, + string Source, + string Target, + ResourceJunctionStatus Status, + string? Detail = null); + +public record ResourceDirectoryFileCount(string Name, long FileCount); + +public record ResourceJunctionOverview( + string? SourceRoot, + string? TargetRoot, + ResourceSourceSelectionMode SelectionMode, + IReadOnlyList FileCounts, + long TotalFileCount, + string? Detail, + IReadOnlyList Items); + +public class ResourceJunctionService +{ + public static IReadOnlyList ResourceNames { get; } = + Array.AsReadOnly(["AssetBundleImages", "MovieData", "SoundData"]); + + private const uint IoReparseTagMountPoint = 0xA0000003; + private readonly Func targetPathProvider; + private readonly Func> candidatePathProvider; + private readonly bool pathsAreA000Roots; + private string? selectedSourceRoot; + private string? selectedTargetRoot; + private ResourceSourceSelectionMode selectionMode; + private IReadOnlyList selectedFileCounts = []; + private string? selectionDetail; + + public ResourceJunctionService() + : this(() => StaticSettings.GamePath, GetDefaultCandidatePaths, false) + { + } + + public ResourceJunctionService(string sourceRoot, string targetRoot) + : this(() => targetRoot, () => [], true) + { + selectedSourceRoot = NormalizePath(sourceRoot); + selectionMode = ResourceSourceSelectionMode.Manual; + selectedFileCounts = CountResourceFiles(selectedSourceRoot); + } + + public ResourceJunctionService(Func targetPathProvider, Func> candidatePathProvider) + : this(targetPathProvider, candidatePathProvider, false) + { + } + + private ResourceJunctionService( + Func targetPathProvider, + Func> candidatePathProvider, + bool pathsAreA000Roots) + { + this.targetPathProvider = targetPathProvider; + this.candidatePathProvider = candidatePathProvider; + this.pathsAreA000Roots = pathsAreA000Roots; + } + + public ResourceJunctionOverview AutoSelectSource() + { + var targetRoot = GetTargetRoot(); + if (targetRoot is null) + return ClearSelection(ResourceSourceSelectionMode.None, "The current game directory is invalid."); + + var candidates = candidatePathProvider() + .Select(TryResolveA000Root) + .Where(path => path is not null && !SamePath(path, targetRoot)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(path => TryCreateCandidate(path!)) + .Where(candidate => candidate is not null) + .Cast() + .OrderByDescending(candidate => candidate.TotalFileCount) + .ToArray(); + + if (candidates.Length == 0) + return ClearSelection(ResourceSourceSelectionMode.None, "No valid source game directory was found in game path history or adjacent directories."); + + var best = candidates[0]; + if (candidates.Skip(1).Any(candidate => candidate.TotalFileCount == best.TotalFileCount)) + return ClearSelection(ResourceSourceSelectionMode.Tie, "Multiple source game directories have the same highest file count. Select one manually."); + + selectedSourceRoot = best.Root; + selectedFileCounts = best.FileCounts; + selectionMode = ResourceSourceSelectionMode.Automatic; + selectionDetail = null; + return GetOverview(); + } + + public ResourceJunctionOverview SelectManualSource(string path) + { + var targetRoot = GetTargetRoot() ?? throw new InvalidOperationException("The current game directory is invalid."); + var sourceRoot = TryResolveA000Root(path) + ?? throw new ArgumentException("The selected folder is not a valid game root or Package directory.", nameof(path)); + if (SamePath(sourceRoot, targetRoot)) + throw new ArgumentException("The source game directory must differ from the current game directory.", nameof(path)); + + var candidate = TryCreateCandidate(sourceRoot) + ?? throw new ArgumentException("The selected source must contain three readable, real resource directories.", nameof(path)); + selectedSourceRoot = candidate.Root; + selectedFileCounts = candidate.FileCounts; + selectionMode = ResourceSourceSelectionMode.Manual; + selectionDetail = null; + return GetOverview(); + } + + public ResourceJunctionOverview SelectManualTarget(string path) + { + var targetRoot = TryResolveA000Root(path) + ?? throw new ArgumentException("The selected folder is not a valid game root or Package directory.", nameof(path)); + + selectedTargetRoot = targetRoot; + if (selectedSourceRoot is not null && SamePath(selectedSourceRoot, targetRoot)) + return ClearSelection(ResourceSourceSelectionMode.None, "The source must differ from the selected target. Select a source directory again."); + + return GetOverview(); + } + + public ResourceJunctionOverview GetOverview() + { + var targetRoot = GetTargetRoot(); + var items = selectedSourceRoot is null || targetRoot is null + ? BuildUnavailableItems(targetRoot) + : ResourceNames.Select(name => Inspect(name, selectedSourceRoot, targetRoot)).ToArray(); + return new( + selectedSourceRoot, + targetRoot, + selectionMode, + selectedFileCounts, + selectedFileCounts.Sum(item => item.FileCount), + selectionDetail, + items); + } + + public IReadOnlyList Inspect() + { + return GetOverview().Items; + } + + public IReadOnlyList CreateLinks() + { + var sourceRoot = selectedSourceRoot; + var targetRoot = GetTargetRoot(); + if (sourceRoot is null || targetRoot is null) return BuildUnavailableItems(targetRoot); + + return ResourceNames.Select(name => + { + var item = Inspect(name, sourceRoot, targetRoot); + if (item.Status != ResourceJunctionStatus.Ready) return item; + + try + { + CreateJunction(item.Source, item.Target); + var verified = Inspect(name, sourceRoot, targetRoot); + return verified.Status == ResourceJunctionStatus.AlreadyLinked + ? verified with { Status = ResourceJunctionStatus.Created } + : verified with { Status = ResourceJunctionStatus.Failed, Detail = "Junction was created but verification failed." }; + } + catch (Exception e) + { + return item with { Status = ResourceJunctionStatus.Failed, Detail = e.Message }; + } + }).ToArray(); + } + + public IReadOnlyList RemoveLinks() + { + var sourceRoot = selectedSourceRoot; + var targetRoot = GetTargetRoot(); + if (sourceRoot is null || targetRoot is null) return BuildUnavailableItems(targetRoot); + + return ResourceNames.Select(name => + { + var item = Inspect(name, sourceRoot, targetRoot); + if (item.Status != ResourceJunctionStatus.AlreadyLinked) return item; + + try + { + Directory.Delete(item.Target, false); + var verified = Inspect(name, sourceRoot, targetRoot); + return verified.Status == ResourceJunctionStatus.Ready + ? verified with { Status = ResourceJunctionStatus.Removed } + : verified with { Status = ResourceJunctionStatus.Failed, Detail = "Junction removal could not be verified." }; + } + catch (Exception e) + { + return item with { Status = ResourceJunctionStatus.Failed, Detail = e.Message }; + } + }).ToArray(); + } + + private ResourceJunctionOverview ClearSelection(ResourceSourceSelectionMode mode, string detail) + { + selectedSourceRoot = null; + selectedFileCounts = []; + selectionMode = mode; + selectionDetail = detail; + return GetOverview(); + } + + private string? GetTargetRoot() + { + if (selectedTargetRoot is not null) return selectedTargetRoot; + var path = targetPathProvider(); + if (string.IsNullOrWhiteSpace(path)) return null; + if (pathsAreA000Roots) return Directory.Exists(path) ? NormalizePath(path) : null; + return TryResolveA000Root(path); + } + + private static string? TryResolveA000Root(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + try + { + var fullPath = NormalizePath(path); + var direct = Path.Combine(fullPath, "Sinmai_Data", "StreamingAssets", "A000"); + if (Directory.Exists(direct)) return NormalizePath(direct); + + var package = Path.Combine(fullPath, "Package", "Sinmai_Data", "StreamingAssets", "A000"); + return Directory.Exists(package) ? NormalizePath(package) : null; + } + catch (Exception) + { + return null; + } + } + + private static IEnumerable GetDefaultCandidatePaths() + { + foreach (var historyPath in StaticSettings.Config.HistoryPath) + yield return historyPath; + + var currentPath = StaticSettings.GamePath; + if (string.IsNullOrWhiteSpace(currentPath)) yield break; + + string? gameRoot; + try + { + var fullPath = NormalizePath(currentPath); + gameRoot = Directory.Exists(Path.Combine(fullPath, "Sinmai_Data", "StreamingAssets", "A000")) + ? string.Equals(Path.GetFileName(fullPath), "Package", StringComparison.OrdinalIgnoreCase) + ? Directory.GetParent(fullPath)?.FullName + : fullPath + : Directory.Exists(Path.Combine(fullPath, "Package", "Sinmai_Data", "StreamingAssets", "A000")) + ? fullPath + : null; + } + catch (Exception) + { + yield break; + } + + var parent = gameRoot is null ? null : Directory.GetParent(gameRoot)?.FullName; + if (parent is null || !Directory.Exists(parent)) yield break; + + IEnumerable siblings; + try + { + siblings = Directory.EnumerateDirectories(parent).ToArray(); + } + catch (Exception) + { + yield break; + } + + foreach (var sibling in siblings) + yield return sibling; + } + + private static ResourceSourceCandidate? TryCreateCandidate(string root) + { + try + { + var counts = CountResourceFiles(root); + return new(root, counts, counts.Sum(item => item.FileCount)); + } + catch (Exception) + { + return null; + } + } + + private static IReadOnlyList CountResourceFiles(string root) + { + return ResourceNames.Select(name => + { + var directory = new DirectoryInfo(Path.Combine(root, name)); + if (!directory.Exists || (directory.Attributes & FileAttributes.ReparsePoint) != 0) + throw new IOException($"{name} is missing or is a reparse point."); + return new ResourceDirectoryFileCount(name, CountFilesWithoutReparsePoints(directory)); + }).ToArray(); + } + + private static long CountFilesWithoutReparsePoints(DirectoryInfo root) + { + var count = 0L; + var pending = new Stack(); + pending.Push(root); + while (pending.TryPop(out var directory)) + { + foreach (var entry in directory.EnumerateFileSystemInfos()) + { + if ((entry.Attributes & FileAttributes.ReparsePoint) != 0) continue; + if (entry is DirectoryInfo child) + pending.Push(child); + else + count++; + } + } + return count; + } + + private IReadOnlyList BuildUnavailableItems(string? targetRoot) + { + var status = targetRoot is null ? ResourceJunctionStatus.TargetRootMissing : ResourceJunctionStatus.SourceMissing; + return ResourceNames.Select(name => new ResourceJunctionItem( + name, + selectedSourceRoot is null ? "" : Path.Combine(selectedSourceRoot, name), + targetRoot is null ? "" : Path.Combine(targetRoot, name), + status, + selectionDetail)).ToArray(); + } + + private static ResourceJunctionItem Inspect(string name, string sourceRoot, string targetRoot) + { + var source = Path.Combine(sourceRoot, name); + var target = Path.Combine(targetRoot, name); + + if (!OperatingSystem.IsWindows()) + return new(name, source, target, ResourceJunctionStatus.Unsupported, "Junctions are only supported on Windows."); + if (!Directory.Exists(source)) + return new(name, source, target, ResourceJunctionStatus.SourceMissing); + if (!Directory.Exists(targetRoot)) + return new(name, source, target, ResourceJunctionStatus.TargetRootMissing); + + var entry = FindTargetEntry(targetRoot, name); + if (entry is null) + return new(name, source, target, ResourceJunctionStatus.Ready); + if (!TryGetReparseTag(target, out var tag) || tag != IoReparseTagMountPoint) + return new(name, source, target, ResourceJunctionStatus.Conflict, "The target exists and is not a Junction."); + + try + { + var destination = entry.ResolveLinkTarget(false)?.FullName; + if (destination is not null && SamePath(destination, source)) + return new(name, source, target, ResourceJunctionStatus.AlreadyLinked); + return new(name, source, target, ResourceJunctionStatus.WrongTarget, destination); + } + catch (Exception e) + { + return new(name, source, target, ResourceJunctionStatus.WrongTarget, e.Message); + } + } + + private static FileSystemInfo? FindTargetEntry(string targetRoot, string name) + { + return new DirectoryInfo(targetRoot) + .EnumerateFileSystemInfos(name, SearchOption.TopDirectoryOnly) + .FirstOrDefault(entry => string.Equals(entry.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + private static bool SamePath(string left, string right) + { + return string.Equals(NormalizePath(left), NormalizePath(right), StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizePath(string path) + { + if (path.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) + path = @"\\" + path[8..]; + else if (path.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) + path = path[4..]; + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + } + + private static void CreateJunction(string source, string target) + { + var startInfo = new ProcessStartInfo + { + FileName = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add("/d"); + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("mklink"); + startInfo.ArgumentList.Add("/J"); + startInfo.ArgumentList.Add(target); + startInfo.ArgumentList.Add(source); + + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start mklink."); + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new IOException((error.Length > 0 ? error : output).Trim()); + } + + private static bool TryGetReparseTag(string path, out uint tag) + { + tag = 0; + using var handle = CreateFile( + path, + 0, + 0x00000001 | 0x00000002 | 0x00000004, + IntPtr.Zero, + 3, + 0x00200000 | 0x02000000, + IntPtr.Zero); + if (handle.IsInvalid) return false; + + if (!GetFileInformationByHandleEx(handle, 9, out var info, (uint)Marshal.SizeOf())) + return false; + tag = info.ReparseTag; + return true; + } + + private record ResourceSourceCandidate( + string Root, + IReadOnlyList FileCounts, + long TotalFileCount); + + [StructLayout(LayoutKind.Sequential)] + private struct FileAttributeTagInfo + { + public uint FileAttributes; + public uint ReparseTag; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandleEx( + SafeFileHandle file, + int fileInformationClass, + out FileAttributeTagInfo fileInformation, + uint bufferSize); +} diff --git a/docs/resource-junction-manager.md b/docs/resource-junction-manager.md new file mode 100644 index 00000000..a093a6c8 --- /dev/null +++ b/docs/resource-junction-manager.md @@ -0,0 +1,41 @@ +# 游戏资源链接 + +## 目录选择 + +目标目录默认来自 MaiChartManager 当前配置的游戏目录,并解析到: + +```text +Package\Sinmai_Data\StreamingAssets\A000 +``` + +打开工具时会自动选择源目录。候选包括配置中的游戏目录历史,以及当前游戏所在父目录下的直接子目录。候选必须满足: + +- 与当前目标游戏不同。 +- 可以从游戏根目录或 `Package` 目录解析到 `A000`。 +- `AssetBundleImages`、`MovieData`、`SoundData` 三个目录全部存在、可读取,且自身不是重解析点。 + +工具递归统计三个目录的文件数,以总数最多的候选作为只读源。最高总数并列时不自动选择,用户须手动指定。手动选择在当前程序会话内覆盖自动结果,不修改游戏目录配置。 + +目标目录也可在工具内手动选择。手动目标仅在当前程序会话内生效,不修改 MaiChartManager 当前游戏目录,不触发游戏数据重新加载。若新目标与当前源相同,工具会清空源选择并要求重新选择,禁止同目录自链接。 + +## 固定范围 + +仅检查和操作: + +- `AssetBundleImages` +- `MovieData` +- `SoundData` + +## 安全规则 + +- 不写入、移动或删除源目录中的任何内容。 +- 自动发现和文件计数只读,不扫描整块磁盘,也不会进入资源目录内部的重解析点。 +- 建立操作只处理目标位置不存在的项目;普通目录、文件、符号链接和错误 Junction 均拒绝覆盖。 +- 移除操作只处理正确指向当前所选源目录的 Windows Junction,并使用非递归目录删除。 +- 每次写操作前重新检查目标类型和指向,操作后再次验证状态。 +- 写操作和手动目录选择仅限本地桌面模式,并要求专用请求头;远程导出模式返回拒绝。 +- Linux 构建只返回不支持,不执行 Junction 操作。 + +## 使用 + +打开“工具”中的“游戏资源链接”。界面会自动选择源目录,并以当前游戏作为默认目标,显示三类资源及合计文件数,然后读取三项链接状态。源和目标各自的选择按钮位于对应目录右侧,均可选择游戏根目录或 `Package` 目录。建立和移除操作均会弹出二次确认。目录校验失败时,界面会在通用失败提示后显示后端返回的具体原因。