commit 9bcd4dcfe1a1c143d6ef199ce58cb6ceaa07cdcf Author: iisaev Date: Sun Oct 18 12:41:37 2026 +0300 init mod diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..34e95d9 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,2 @@ +mod_resource_atlas_name = Resource Atlas +mod_resource_atlas_desc = Cross-mod resource map: who requests, overrides or patches another mod's resources, and whether manifest depends declare it. diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..9195eca --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,2 @@ +mod_resource_atlas_name = Карта ресурсов +mod_resource_atlas_desc = Карта ресурсов между модами: кто запрашивает, переопределяет или патчит ресурсы другого мода и объявлено ли это в depends манифеста. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..e51f818 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "resource_atlas", + "name": "Resource Atlas", + "version": "0.1.0", + "author": "ct", + "description": "Cross-mod resource map for developers and AI agents: which mod requests resources (sounds, textures, scenes, entities, actions, locale keys, res:// files) provided by another mod, which mod overrides or patches another mod's resources, and whether each link is backed by manifest depends. Activated by the --resource-atlas user arg; writes report.md/report.json and prints [TEST_RESULT].", + "category": "tools, debug", + "sources": ["src/**/*.cs"], + "depends": ["validator>=0.1"] +} diff --git a/src/AtlasModel.cs b/src/AtlasModel.cs new file mode 100644 index 0000000..f2333be --- /dev/null +++ b/src/AtlasModel.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; + +namespace Cthangover.ResourceAtlas +{ + /// One resource a mod makes available under a resolver key. + public sealed class AtlasProvision + { + /// Resource kind: validator kinds (background, sound, character, scene...) plus texture, shader, action, entity_kind, locale, scene_file. + public string Kind { get; init; } + + /// Resolver key inside the kind (texture basename, sound path, entity id, "lang:key"...). + public string Id { get; init; } + + public string ModId { get; init; } + + public string File { get; init; } + } + + /// One place where a mod asks for a resource by key. + public sealed class AtlasRequest + { + public string Kind { get; init; } + + public string Id { get; init; } + + public string ModId { get; init; } + + public string File { get; init; } + + public int Line { get; init; } + + /// Channel the request was found in: data, code, scenario, res, entity_data. + public string Via { get; init; } + + /// Provider named by the reference itself (res://mods/<id>/...); null when resolved by key. + public string ExplicitProvider { get; init; } + } + + /// An intentional edit of another mod's resource through patches/. + public sealed class AtlasPatch + { + public string Kind { get; init; } + + public string Id { get; init; } + + public string ModId { get; init; } + + public string File { get; init; } + + /// Explicit target owner from the patch header, or null to resolve by key. + public string ExplicitOwner { get; init; } + } + + public enum AtlasSeverity + { + Info, + Warning, + Error, + } + + public enum AtlasFindingKind + { + /// A mod uses another mod's resource and declares that mod in its depends closure. + DeclaredUse, + + /// A mod uses another mod's resource without a depends path to it — breaks once that mod is absent. + UndeclaredUse, + + /// No loaded mod provides the requested key. + Unresolved, + + /// The winner of a shared key depends on the losers — the override is intended and ordered. + DeclaredOverride, + + /// Several unrelated mods provide one key; the winner is decided by incidental load order. + UnorderedOverride, + + /// A mod patches another mod's resource and depends on it. + DeclaredPatch, + + /// A mod patches another mod's resource without depending on it. + UndeclaredPatch, + + /// A patch targets a key no other loaded mod provides. + PatchTargetMissing, + + /// + /// Inside a game: the key is provided only by an installed mod that is + /// not part of the game — the isolated game will not find it. + /// + OutsideGame, + } + + public sealed class AtlasFinding + { + public AtlasFindingKind Kind { get; init; } + + public AtlasSeverity Severity { get; init; } + + /// Mod the finding is attributed to (requester, overriding winner, patcher). + public string ModId { get; init; } + + /// The other side: providers, overridden mods or patch owners. + public IReadOnlyList OtherMods { get; init; } = new List(); + + public string ResourceKind { get; init; } + + public string ResourceId { get; init; } + + public string File { get; init; } + + public int Line { get; init; } + + public string Message { get; init; } + } +} diff --git a/src/ResourceAtlasBuilder.cs b/src/ResourceAtlasBuilder.cs new file mode 100644 index 0000000..792b610 --- /dev/null +++ b/src/ResourceAtlasBuilder.cs @@ -0,0 +1,611 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using Cthangover.Core.Mods; +using Cthangover.Validator; + +namespace Cthangover.ResourceAtlas +{ + /// + /// Builds the cross-mod resource map. Keys follow the runtime resolvers so + /// the atlas answers the same question the game does — "which mod serves + /// this key?": + /// + /// validator graph — entities, scene definitions, interactives and + /// the background/avatar/music/sound/effect assets, plus every reference + /// found in scenarios and data; + /// textures — basename across ModConfig.TextureGroups (one + /// global namespace, like TextureResolver); + /// shaders (shaders/ path without extension), .tscn + /// scenes (path under scenes/), locale keys per language, scenario + /// actions and entity kinds registered by mod code; + /// requests from C# literals (PlaySound, PlayMusic, + /// PlayAmbient, Textures.Resolve, Shaders.Resolve), + /// res://mods/<id>/ paths, scenario action commands and + /// entity data needing a kind; + /// patches — patches/scenes/**.tscn and patches/<kind>.json. + /// + /// A link between two mods is "declared" when the dependant has the other + /// mod in its transitive depends; for shared keys the later mod in + /// load order wins, which is only deterministic along a depends path. + /// + public static class ResourceAtlasBuilder + { + private static readonly Regex ResModReference = new(@"res://mods/([A-Za-z0-9_]+)/([^""'\s)\]>,;]+)", RegexOptions.Compiled); + private static readonly Regex ScenarioAction = new(@"^\s*action\s+([a-z_]+\.[a-z_]+)", RegexOptions.Compiled); + private static readonly Regex CSharpActionName = new(@"Name\s*=>\s*""([a-z_]+\.[a-z_]+)""", RegexOptions.Compiled); + private static readonly Regex GdActionName = new(@"func\s+get_name\s*\(\s*\)\s*:\s*\n\s*return\s+""([a-z_]+\.[a-z_]+)""", RegexOptions.Compiled); + private static readonly Regex EntityKindAttribute = new(@"EntityKind\(""([a-z_]+)""", RegexOptions.Compiled); + private static readonly Regex PatchTarget = new(@"\[ext_resource[^\]]*type=""PackedScene""[^\]]*path=""([^""]+)""[^\]]*id=""([^""]+)""", RegexOptions.Compiled); + private static readonly Regex RootInstance = new(@"\[node name=""[^""]+""\s+instance=ExtResource\(""([^""]+)""\)\]", RegexOptions.Compiled); + + private static readonly (string Kind, Regex Pattern)[] CodeRequests = + { + ("sound", new Regex(@"PlaySound\(\s*""([^""]+)""(?:\s*,\s*(\d+))?", RegexOptions.Compiled)), + ("music", new Regex(@"Play(?:Music|Ambient)\(\s*""([^""]+)""", RegexOptions.Compiled)), + ("texture", new Regex(@"Textures\.Resolve[A-Za-z]*\(\s*""([^""]+)""", RegexOptions.Compiled)), + ("shader", new Regex(@"Shaders\.Resolve[A-Za-z]*\(\s*""([^""]+)""", RegexOptions.Compiled)), + ("locale_key", new Regex(@"\b(?:Translate|Tr)\(\s*""([a-z_]+(?:/[A-Za-z0-9_]+)+)""", RegexOptions.Compiled)), + ("locale_key", new Regex(@"\bLocale\s*=\s*""([a-z_]+(?:/[A-Za-z0-9_]+)+)""", RegexOptions.Compiled)), + }; + + private static readonly HashSet TextExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".tscn", ".tres", ".json", ".scenario", ".gd", ".cs", ".gdshader", + }; + + /// Registered scenario actions with the + /// name of the assembly that declares them: a mod assembly is named by + /// its mod id, anything else is the kernel and always available. + /// Installed mods that are not loaded (the + /// atlas runs inside a game); with they + /// only contribute provisions, used to explain keys the game misses. + public static ResourceAtlasResult Build( + IReadOnlyList modsInLoadOrder, + ValidationGraph graph, + IReadOnlyCollection<(string Name, string Assembly)> runtimeActions = null, + IReadOnlyList outsideMods = null, + ValidationGraph outsideGraph = null) + { + var result = new ResourceAtlasResult(); + foreach (var mod in modsInLoadOrder) + { + result.LoadOrder.Add(mod.Id); + result.Depends[mod.Id] = (mod as ModInfo)?.Manifest?.DependencyIds ?? new List(); + } + + foreach (var id in result.LoadOrder) + result.Closures[id] = Closure(id, result.Depends); + + foreach (var declaration in graph.Declarations) + result.Provisions.Add(new AtlasProvision { Kind = declaration.Kind, Id = declaration.Id, ModId = declaration.ModId, File = declaration.File }); + + foreach (var reference in graph.References) + { + if (reference.Kind == ValidationKind.ScenePath) + continue; // manifest scene_path is always mod-relative + result.Requests.Add(new AtlasRequest + { + Kind = reference.Kind, Id = reference.TargetId, ModId = reference.ModId, + File = reference.File, Line = reference.Line, Via = "data", + }); + } + + foreach (var mod in modsInLoadOrder) + ScanMod(mod, result, result.Provisions, collectRequests: true); + + foreach (var mod in outsideMods ?? Array.Empty()) + ScanMod(mod, result, result.OutsideProvisions, collectRequests: false); + foreach (var declaration in outsideGraph?.Declarations ?? Enumerable.Empty()) + result.OutsideProvisions.Add(new AtlasProvision { Kind = declaration.Kind, Id = declaration.Id, ModId = declaration.ModId, File = declaration.File }); + + foreach (var (name, assembly) in runtimeActions ?? Array.Empty<(string, string)>()) + { + if (result.Depends.ContainsKey(assembly ?? string.Empty)) + Provide(result, "action", name, assembly, ""); + else + result.KernelKeys.Add(("action", name)); + } + + Analyze(result); + return result; + } + + /// + /// Indexes one mod. Provisions go to + /// (loaded or outside list); requests and patches are collected only + /// for loaded mods. + /// + private static void ScanMod(IModInfo mod, ResourceAtlasResult result, List provisions, bool collectRequests) + { + var provider = mod.FileProvider; + if (provider == null) + return; + + var target = collectRequests ? result : new ResourceAtlasResult(); + void Provide(ResourceAtlasResult _, string kind, string id, string modId, string file) + => provisions.Add(new AtlasProvision { Kind = kind, Id = id, ModId = modId, File = file }); + + var textureExtensions = ModConfig.Instance.GetTextureExtensionSet(); + foreach (var group in ModConfig.Instance.TextureGroups) + { + foreach (var file in ModFileReader.ListFiles(provider, group, true)) + { + if (ModFileReader.HasExtension(file, textureExtensions)) + Provide(result, "texture", Path.GetFileNameWithoutExtension(file), mod.Id, file); + } + } + + foreach (var file in ModFileReader.ListFiles(provider, "shaders", true)) + { + if (file.EndsWith(".gdshader", StringComparison.OrdinalIgnoreCase)) + Provide(result, "shader", StripExtension(file.Substring("shaders/".Length)), mod.Id, file); + } + + foreach (var file in ModFileReader.ListFiles(provider, "scenes", true)) + { + if (file.EndsWith(".tscn", StringComparison.OrdinalIgnoreCase)) + Provide(result, "scene_file", file.Substring("scenes/".Length), mod.Id, file); + } + + foreach (var file in ModFileReader.ListFiles(provider, "locale", true)) + { + if (file.EndsWith(".properties", StringComparison.OrdinalIgnoreCase)) + ScanLocale(provider, file, mod.Id, provisions); + } + + foreach (var file in ModFileReader.ListFiles(provider, string.Empty, true)) + { + if (!TextExtensions.Contains(Path.GetExtension(file)) || IsToolingPath(file)) + continue; + + var text = provider.ReadFileText(file); + if (string.IsNullOrEmpty(text)) + continue; + text = text.Replace("\r\n", "\n"); + var isCode = file.EndsWith(".cs", StringComparison.OrdinalIgnoreCase); + if (isCode) + text = StripCSharpComments(text); + + ScanResReferences(text, file, mod.Id, target); + + if (isCode) + ScanCode(text, file, mod.Id, target, provisions); + else if (file.EndsWith(".gd", StringComparison.OrdinalIgnoreCase)) + { + foreach (Match match in GdActionName.Matches(text)) + Provide(result, "action", match.Groups[1].Value, mod.Id, file); + } + else if (file.EndsWith(".scenario", StringComparison.OrdinalIgnoreCase)) + ScanScenario(text, file, mod.Id, target); + else if (file.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + ScanJson(text, file, mod.Id, target); + else if (file.StartsWith("patches/scenes/", StringComparison.Ordinal) && file.EndsWith(".tscn", StringComparison.OrdinalIgnoreCase)) + ScanScenePatch(text, file, mod.Id, target); + } + } + + /// Tests and build output are not shipped content; their literals must not count as requests. + private static bool IsToolingPath(string file) + => file.StartsWith("tests/", StringComparison.Ordinal) + || file.StartsWith("test_suites/", StringComparison.Ordinal) // mounted as their own mod (__) + || file.StartsWith("source_code/", StringComparison.Ordinal) + || file.Contains("/bin/", StringComparison.Ordinal) + || file.Contains("/obj/", StringComparison.Ordinal); + + private static void ScanLocale(IModFileProvider provider, string file, string modId, List provisions) + { + var language = Path.GetFileNameWithoutExtension(file); + var underscore = language.LastIndexOf('_'); + if (underscore >= 0) + language = language.Substring(underscore + 1); + + var text = provider.ReadFileText(file) ?? string.Empty; + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim(); + if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal) || line.StartsWith("!", StringComparison.Ordinal)) + continue; + var separator = line.IndexOf('='); + if (separator <= 0) + continue; + var key = line.Substring(0, separator).Trim(); + provisions.Add(new AtlasProvision { Kind = "locale", Id = $"{language}:{key}", ModId = modId, File = file }); + // Language-agnostic key: what code asks for (Translate/Tr/Locale); + // overrides are analysed per language on "locale" only. + provisions.Add(new AtlasProvision { Kind = "locale_key", Id = key, ModId = modId, File = file }); + } + } + + private static void ScanResReferences(string text, string file, string modId, ResourceAtlasResult result) + { + foreach (Match match in ResModReference.Matches(text)) + { + result.Requests.Add(new AtlasRequest + { + Kind = "file", Id = $"{match.Groups[1].Value}/{match.Groups[2].Value}", ModId = modId, + File = file, Line = LineOf(text, match.Index), Via = "res", ExplicitProvider = match.Groups[1].Value, + }); + } + } + + private static void ScanCode(string text, string file, string modId, ResourceAtlasResult result, List provisions) + { + foreach (Match match in CSharpActionName.Matches(text)) + provisions.Add(new AtlasProvision { Kind = "action", Id = match.Groups[1].Value, ModId = modId, File = file }); + + foreach (Match match in EntityKindAttribute.Matches(text)) + provisions.Add(new AtlasProvision { Kind = "entity_kind", Id = match.Groups[1].Value, ModId = modId, File = file }); + + foreach (var (kind, pattern) in CodeRequests) + { + foreach (Match match in pattern.Matches(text)) + { + // PlaySound(id, n) with n > 1 picks id_1..id_n at random (AudioService). + var variations = match.Groups.Count > 2 && int.TryParse(match.Groups[2].Value, out var n) ? n : 1; + var keys = variations > 1 + ? Enumerable.Range(1, variations).Select(i => $"{match.Groups[1].Value}_{i}") + : new[] { match.Groups[1].Value }; + foreach (var key in keys) + { + result.Requests.Add(new AtlasRequest + { + Kind = kind, Id = key, ModId = modId, + File = file, Line = LineOf(text, match.Index), Via = "code", + }); + } + } + } + } + + private static void ScanScenario(string text, string file, string modId, ResourceAtlasResult result) + { + var lines = text.Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + var match = ScenarioAction.Match(lines[i]); + if (match.Success) + { + result.Requests.Add(new AtlasRequest + { + Kind = "action", Id = match.Groups[1].Value, ModId = modId, + File = file, Line = i + 1, Via = "scenario", + }); + } + } + } + + private static void ScanJson(string text, string file, string modId, ResourceAtlasResult result) + { + if (file == "manifest.json" || file.StartsWith("collections/", StringComparison.Ordinal)) + return; + + JsonDocument document; + try + { + document = JsonDocument.Parse(text, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }); + } + catch (JsonException) + { + return; + } + + using (document) + { + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + return; + + var isPatch = file.StartsWith("patches/", StringComparison.Ordinal); + if (isPatch) + { + ScanEntityPatch(root, file, modId, result); + return; + } + + if (root.TryGetProperty("Kind", out var kind) && kind.ValueKind == JsonValueKind.String) + { + result.Requests.Add(new AtlasRequest + { + Kind = "entity_kind", Id = kind.GetString(), ModId = modId, File = file, Line = 0, Via = "entity_data", + }); + } + } + } + + /// patches/<kind>.json or patches/<kind>/*.json: each item id (or *) patches entities of that kind. + private static void ScanEntityPatch(JsonElement root, string file, string modId, ResourceAtlasResult result) + { + var relative = file.Substring("patches/".Length); + var kind = relative.Contains('/') ? relative.Substring(0, relative.IndexOf('/')) : StripExtension(relative); + if (!root.TryGetProperty("Items", out var items) || items.ValueKind != JsonValueKind.Array) + return; + + foreach (var item in items.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + continue; + var id = JsonReadHelper.GetString(item, "Id", "ID", "id"); + if (!string.IsNullOrEmpty(id)) + result.Patches.Add(new AtlasPatch { Kind = kind, Id = id, ModId = modId, File = file }); + } + } + + /// A scene patch is an inherited scene: the root instance names the patched target. + private static void ScanScenePatch(string text, string file, string modId, ResourceAtlasResult result) + { + var root = RootInstance.Match(text); + if (!root.Success) + return; + + foreach (Match match in PatchTarget.Matches(text)) + { + if (match.Groups[2].Value != root.Groups[1].Value) + continue; + + var target = match.Groups[1].Value; + if (target.StartsWith("res://", StringComparison.Ordinal)) + target = target.Substring("res://".Length); + + string owner = null; + if (target.StartsWith("mods/", StringComparison.Ordinal)) + { + var parts = target.Split('/', 3); + owner = parts.Length > 1 ? parts[1] : null; + target = parts.Length > 2 ? parts[2] : string.Empty; + } + + if (target.StartsWith("scenes/", StringComparison.Ordinal)) + target = target.Substring("scenes/".Length); + + result.Patches.Add(new AtlasPatch { Kind = "scene_file", Id = target, ModId = modId, File = file, ExplicitOwner = owner }); + } + } + + private static void Analyze(ResourceAtlasResult result) + { + var loadIndex = result.LoadOrder.Select((id, index) => (id, index)).ToDictionary(p => p.id, p => p.index, StringComparer.Ordinal); + var providers = result.Provisions + .Where(p => loadIndex.ContainsKey(p.ModId)) + .GroupBy(p => (p.Kind, p.Id)) + .ToDictionary(g => g.Key, g => g.Select(p => p.ModId).Distinct().OrderBy(m => loadIndex[m]).ToList()); + + foreach (var request in result.Requests) + AnalyzeRequest(result, request, providers, loadIndex); + + foreach (var pair in providers.Where(p => p.Value.Count > 1 && p.Key.Kind != "locale_key")) + AnalyzeOverride(result, pair.Key.Kind, pair.Key.Id, pair.Value); + + foreach (var patch in result.Patches) + AnalyzePatch(result, patch, providers, loadIndex); + } + + private static void AnalyzeRequest( + ResourceAtlasResult result, + AtlasRequest request, + Dictionary<(string, string), List> providers, + Dictionary loadIndex) + { + List candidates; + if (request.ExplicitProvider != null) + candidates = loadIndex.ContainsKey(request.ExplicitProvider) ? new List { request.ExplicitProvider } : new List(); + else if (!providers.TryGetValue((request.Kind, request.Id), out candidates)) + candidates = new List(); + + if (candidates.Contains(request.ModId) || result.KernelKeys.Contains((request.Kind, request.Id))) + return; + + if (candidates.Count == 0) + { + var outside = result.OutsideProvisions + .Where(p => p.Kind == request.Kind && p.Id == request.Id) + .Select(p => p.ModId).Distinct().ToList(); + if (request.ExplicitProvider != null && !loadIndex.ContainsKey(request.ExplicitProvider) + && result.OutsideProvisions.Any(p => p.ModId == request.ExplicitProvider)) + outside = new List { request.ExplicitProvider }; + if (outside.Count > 0) + { + result.Findings.Add(new AtlasFinding + { + Kind = AtlasFindingKind.OutsideGame, Severity = AtlasSeverity.Error, ModId = request.ModId, + OtherMods = outside, + ResourceKind = request.Kind, ResourceId = request.Id, File = request.File, Line = request.Line, + Message = $"ресурс есть только в моде {string.Join("/", outside)}, который не входит в игру — добавьте мод в коллекцию или перенесите ресурс", + }); + return; + } + + result.Findings.Add(new AtlasFinding + { + Kind = AtlasFindingKind.Unresolved, Severity = AtlasSeverity.Warning, ModId = request.ModId, + ResourceKind = request.Kind, ResourceId = request.Id, File = request.File, Line = request.Line, + Message = request.ExplicitProvider != null + ? $"ссылается на мод '{request.ExplicitProvider}', который не загружен" + : "ни один загруженный мод не предоставляет этот ключ", + }); + return; + } + + var closure = result.Closures[request.ModId]; + var declared = candidates.Any(closure.Contains); + result.Findings.Add(new AtlasFinding + { + Kind = declared ? AtlasFindingKind.DeclaredUse : AtlasFindingKind.UndeclaredUse, + Severity = declared ? AtlasSeverity.Info : AtlasSeverity.Error, + ModId = request.ModId, + OtherMods = candidates, + ResourceKind = request.Kind, ResourceId = request.Id, File = request.File, Line = request.Line, + Message = declared + ? $"использует ресурс мода {string.Join("/", candidates.Where(closure.Contains))} ({request.Via})" + : $"использует ресурс мода {string.Join("/", candidates)} ({request.Via}), но его нет в depends — добавьте зависимость или перенесите ресурс", + }); + } + + /// + /// The last provider in load order wins. A loser is overridden on purpose + /// only when the winner depends on it; scene definitions merge instead + /// (the first provider is the base, later ones extend it), which is + /// equally order-sensitive. + /// + private static void AnalyzeOverride(ResourceAtlasResult result, string kind, string id, List ordered) + { + var merges = kind == ValidationKind.Scene; + var winner = merges ? ordered[0] : ordered[^1]; + foreach (var other in ordered.Where(m => m != winner)) + { + var later = merges ? other : winner; + var earlier = merges ? winner : other; + var declared = result.Closures[later].Contains(earlier); + result.Findings.Add(new AtlasFinding + { + Kind = declared ? AtlasFindingKind.DeclaredOverride : AtlasFindingKind.UnorderedOverride, + Severity = declared ? AtlasSeverity.Info : AtlasSeverity.Error, + ModId = later, + OtherMods = new List { earlier }, + ResourceKind = kind, ResourceId = id, + File = result.Provisions.First(p => p.Kind == kind && p.Id == id && p.ModId == later).File, + Message = (merges ? $"расширяет определение мода {earlier}" : $"переопределяет ресурс мода {earlier}") + + (declared ? string.Empty : " без зависимости между ними — результат зависит от случайного порядка загрузки; переименуйте ключ или объявите depends"), + }); + } + } + + private static void AnalyzePatch( + ResourceAtlasResult result, + AtlasPatch patch, + Dictionary<(string, string), List> providers, + Dictionary loadIndex) + { + List owners; + if (patch.ExplicitOwner != null) + owners = loadIndex.ContainsKey(patch.ExplicitOwner) ? new List { patch.ExplicitOwner } : new List(); + else if (patch.Id == "*") + owners = providers.Where(p => p.Key.Item1 == patch.Kind).SelectMany(p => p.Value).Distinct().ToList(); + else if (!providers.TryGetValue((patch.Kind, patch.Id), out owners)) + owners = new List(); + + owners = owners.Where(o => o != patch.ModId).ToList(); + if (owners.Count == 0) + { + result.Findings.Add(new AtlasFinding + { + Kind = AtlasFindingKind.PatchTargetMissing, Severity = AtlasSeverity.Warning, ModId = patch.ModId, + ResourceKind = patch.Kind, ResourceId = patch.Id, File = patch.File, + Message = "патч не находит цель ни в одном другом загруженном моде", + }); + return; + } + + var closure = result.Closures[patch.ModId]; + var undeclared = owners.Where(o => !closure.Contains(o)).ToList(); + result.Findings.Add(new AtlasFinding + { + Kind = undeclared.Count == 0 ? AtlasFindingKind.DeclaredPatch : AtlasFindingKind.UndeclaredPatch, + Severity = undeclared.Count == 0 ? AtlasSeverity.Info : AtlasSeverity.Error, + ModId = patch.ModId, + OtherMods = owners, + ResourceKind = patch.Kind, ResourceId = patch.Id, File = patch.File, + Message = undeclared.Count == 0 + ? $"патчит ресурс мода {string.Join("/", owners)}" + : $"патчит ресурс мода {string.Join("/", undeclared)} без зависимости от него — порядок применения патча не гарантирован", + }); + } + + private static void Provide(ResourceAtlasResult result, string kind, string id, string modId, string file) + => result.Provisions.Add(new AtlasProvision { Kind = kind, Id = id, ModId = modId, File = file }); + + private static HashSet Closure(string modId, Dictionary> depends) + { + var visible = new HashSet(StringComparer.Ordinal); + var pending = new Stack(depends.TryGetValue(modId, out var direct) ? direct : new List()); + while (pending.Count > 0) + { + var id = pending.Pop(); + if (visible.Add(id) && depends.TryGetValue(id, out var next)) + { + foreach (var dependency in next) + pending.Push(dependency); + } + } + + return visible; + } + + private static string StripExtension(string path) + { + var dot = path.LastIndexOf('.'); + var slash = path.LastIndexOf('/'); + return dot > slash ? path.Substring(0, dot) : path; + } + + /// + /// Blanks C# comments while keeping line breaks (line numbers stay + /// valid) and string/char literals intact — a naive // cut would + /// truncate "res://...". + /// + private static string StripCSharpComments(string text) + { + var chars = text.ToCharArray(); + var i = 0; + while (i < chars.Length) + { + var c = chars[i]; + var next = i + 1 < chars.Length ? chars[i + 1] : '\0'; + if (c == '/' && next == '/') + { + while (i < chars.Length && chars[i] != '\n') + chars[i++] = ' '; + } + else if (c == '/' && next == '*') + { + while (i < chars.Length && !(chars[i] == '*' && i + 1 < chars.Length && chars[i + 1] == '/')) + { + if (chars[i] != '\n') + chars[i] = ' '; + i++; + } + if (i < chars.Length) + { + chars[i++] = ' '; + chars[i++] = ' '; + } + } + else if (c == '"' || c == '\'') + { + var verbatim = c == '"' && i > 0 && (chars[i - 1] == '@' || (chars[i - 1] == '$' && i > 1 && chars[i - 2] == '@')); + i++; + while (i < chars.Length && chars[i] != c && chars[i] != '\n') + { + if (!verbatim && chars[i] == '\\') + i++; + else if (verbatim && chars[i] == '"' && i + 1 < chars.Length && chars[i + 1] == '"') + i++; + i++; + } + i++; + } + else + { + i++; + } + } + + return new string(chars); + } + + private static int LineOf(string text, int index) + { + var line = 1; + for (var i = 0; i < index && i < text.Length; i++) + { + if (text[i] == '\n') + line++; + } + + return line; + } + } +} diff --git a/src/ResourceAtlasDriverNode.cs b/src/ResourceAtlasDriverNode.cs new file mode 100644 index 0000000..22f329c --- /dev/null +++ b/src/ResourceAtlasDriverNode.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Mods; +using Cthangover.Core.Utils; +using Cthangover.Validator; +using Godot; + +namespace Cthangover.ResourceAtlas +{ + /// + /// Runs one atlas pass after the boot: snapshots the loaded mods in load + /// order, builds the validator graph and the atlas on a worker thread, + /// writes the report, logs every error as [FAIL] and prints + /// [TEST_RESULT] name=resource_atlas passed=<clean mods> + /// failed=<mods with errors> before quitting with 0/1. A watchdog + /// ends a hung pass with a failure instead of blocking the test run. + /// + public partial class ResourceAtlasDriverNode : Node + { + private const string ResultName = "resource_atlas"; + + private readonly string directory; + private readonly float timeoutSeconds; + private double elapsed; + private bool started; + private bool finished; + private Task pass; + + public ResourceAtlasDriverNode() + { + } + + public ResourceAtlasDriverNode(string directory, float timeoutSeconds) + { + this.directory = directory; + this.timeoutSeconds = timeoutSeconds; + Name = "ResourceAtlasDriver"; + } + + public override void _Process(double delta) + { + if (finished) + return; + + elapsed += delta; + if (elapsed > timeoutSeconds) + { + Finish(null, $"timeout after {timeoutSeconds:F0}s"); + return; + } + + if (!started) + { + if (!Boot.IsReady) + return; + + started = true; + ValidatorService.WarmUp(); + var mods = SnapshotMods(); + var actions = ScenarioActionFactory.Instance.GetAll() + .Select(a => (a.Name, a.GetType().Assembly.GetName().Name)) + .ToList(); + GameLogger.Log("RESOURCE_ATLAS", $"building atlas for {mods.Count} mod(s)"); + var loaded = new HashSet(mods.Select(m => m.Id), StringComparer.Ordinal); + var outside = ModRegistry.Instance.AllDiscoveredMods.Values.Where(m => !loaded.Contains(m.Id)).ToList(); + if (outside.Count > 0) + GameLogger.Log("RESOURCE_ATLAS", $"{outside.Count} installed mod(s) are outside the loaded set; keys only they provide are errors"); + pass = Task.Run(() => + { + var context = ValidatorService.Analyze(mods, null, CancellationToken.None); + var outsideContext = outside.Count > 0 ? ValidatorService.Analyze(outside, null, CancellationToken.None) : null; + return ResourceAtlasBuilder.Build(mods, context.Graph, actions, outside, outsideContext?.Graph); + }); + return; + } + + if (pass is { IsCompleted: true }) + { + if (pass.IsFaulted) + Finish(null, $"atlas pass failed: {pass.Exception?.GetBaseException().Message}"); + else + Finish(pass.Result, null); + } + } + + private static List SnapshotMods() + { + var registry = ModRegistry.Instance; + var result = new List(); + foreach (var id in registry.GetOrderedModIds()) + { + if (registry.Mods.TryGetValue(id, out var info) && info != null) + result.Add(info); + } + + return result; + } + + private void Finish(ResourceAtlasResult result, string failure) + { + if (finished) + return; + finished = true; + + var passed = 0; + var failed = 0; + if (result != null) + { + try + { + ResourceAtlasReportWriter.Write(result, directory); + } + catch (Exception ex) + { + failure = $"report write failed: {ex.Message}"; + } + + foreach (var error in result.Errors + .GroupBy(e => (e.Kind, e.ModId, Others: string.Join(",", e.OtherMods), e.ResourceKind, e.ResourceId)) + .Select(g => g.First())) + { + GameLogger.Log("RESOURCE_ATLAS", + $"[FAIL] {error.ModId} -> {string.Join("/", error.OtherMods)}: {error.ResourceKind} '{error.ResourceId}' {error.Message} ({error.File}{(error.Line > 0 ? ":" + error.Line : string.Empty)})"); + } + + var failing = result.FailingMods(); + failed = failing.Count; + passed = result.LoadOrder.Count - failed; + GameLogger.Log("RESOURCE_ATLAS", + $"atlas: {result.Errors.Count()} error(s), {result.Warnings.Count()} warning(s); report: {Path.Combine(directory, "report.md")}"); + } + + if (failure != null) + { + failed++; + GameLogger.Log("RESOURCE_ATLAS", $"[FAIL] {failure}", LogLevel.Error); + } + + GameLogger.Log("RESOURCE_ATLAS", $"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}"); + GetTree().Quit(failed == 0 ? 0 : 1); + } + } +} diff --git a/src/ResourceAtlasInitializer.cs b/src/ResourceAtlasInitializer.cs new file mode 100644 index 0000000..82d186d --- /dev/null +++ b/src/ResourceAtlasInitializer.cs @@ -0,0 +1,52 @@ +using System.Linq; +using Cthangover.Core.Mods; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.ResourceAtlas +{ + /// + /// Activates the atlas only on demand: the --resource-atlas user + /// arg (after --) queues , + /// which waits for the boot, writes the report and quits. Optional + /// --atlas-out=<dir> (res://, user:// or absolute) sets the + /// report directory, --timeout=<s> the watchdog. + /// + public class ResourceAtlasInitializer : IModInitializer + { + public const string Flag = "--resource-atlas"; + private const string OutPrefix = "--atlas-out="; + private const string DefaultOut = "user://resource_atlas"; + + private static bool queued; + + public void OnModLoaded(string modId) { } + + public void OnModResourcesReady() + { + if (queued) + return; + + var args = OS.GetCmdlineUserArgs(); + if (!args.Contains(Flag)) + return; + + var output = args.FirstOrDefault(a => a.StartsWith(OutPrefix, System.StringComparison.Ordinal))?.Substring(OutPrefix.Length); + if (string.IsNullOrWhiteSpace(output)) + output = DefaultOut; + + queued = true; + var directory = ProjectSettings.GlobalizePath(output); + GameLogger.Log("RESOURCE_ATLAS", $"resource atlas requested, report directory '{directory}'"); + (Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new ResourceAtlasDriverNode(directory, ParseTimeout(args))); + } + + private static float ParseTimeout(string[] args) + { + var raw = args.FirstOrDefault(a => a.StartsWith("--timeout=", System.StringComparison.Ordinal))?.Substring("--timeout=".Length); + return float.TryParse(raw, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds) && seconds > 0 + ? seconds + : 120f; + } + } +} diff --git a/src/ResourceAtlasReportWriter.cs b/src/ResourceAtlasReportWriter.cs new file mode 100644 index 0000000..fbf4066 --- /dev/null +++ b/src/ResourceAtlasReportWriter.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace Cthangover.ResourceAtlas +{ + /// + /// Writes report.md (read first: errors with the fix, then the + /// mod-to-mod picture) and report.json (every finding, for tools). + /// Repeated occurrences of one link are folded into a count with the first + /// location, so the markdown stays readable for agents and people. + /// + public static class ResourceAtlasReportWriter + { + private const int Samples = 5; + + public static void Write(ResourceAtlasResult result, string directory) + { + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "report.md"), BuildMarkdown(result), new UTF8Encoding(false)); + File.WriteAllText(Path.Combine(directory, "report.json"), BuildJson(result), new UTF8Encoding(false)); + } + + public static string BuildMarkdown(ResourceAtlasResult result) + { + var md = new StringBuilder(); + var errors = Fold(result.Errors).ToList(); + var warnings = result.Warnings.ToList(); + + md.AppendLine("# Карта ресурсов модов"); + md.AppendLine(); + md.AppendLine($"Создано: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC · модов: {result.LoadOrder.Count} · " + + $"ресурсов: {result.Provisions.Count} · запросов: {result.Requests.Count} · патчей: {result.Patches.Count}"); + md.AppendLine(); + md.AppendLine($"**Ошибок: {errors.Count}** (моды: {string.Join(", ", result.FailingMods()).OrEmpty("нет")}) · " + + $"предупреждений: {warnings.Count}"); + md.AppendLine(); + md.AppendLine("Как читать: связь «A → B» значит, что мод A берёт ресурс мода B по ключу. Связь корректна, " + + "когда B есть в транзитивных `depends` мода A. При совпадении ключа выигрывает мод, загруженный позже, " + + "и это детерминировано только вдоль цепочки `depends`."); + md.AppendLine(); + + md.AppendLine("## Ошибки"); + md.AppendLine(); + if (errors.Count == 0) + md.AppendLine("Нет."); + foreach (var group in errors.GroupBy(e => e.First.Kind)) + { + md.AppendLine($"### {Title(group.Key)}"); + md.AppendLine(); + foreach (var error in group) + { + var f = error.First; + md.AppendLine($"- `{f.ModId}` → `{string.Join("`, `", f.OtherMods)}`: {f.ResourceKind} `{f.ResourceId}` — {f.Message}. " + + $"{Location(f)}{(error.Count > 1 ? $" (+{error.Count - 1})" : string.Empty)}"); + } + md.AppendLine(); + } + + AppendLinks(md, result); + AppendOverrides(md, result); + AppendPatches(md, result); + AppendUnresolved(md, warnings); + AppendModTable(md, result); + + md.AppendLine("## Порядок загрузки"); + md.AppendLine(); + md.AppendLine(string.Join(" → ", result.LoadOrder.Select(m => $"`{m}`"))); + return md.ToString(); + } + + private static void AppendLinks(StringBuilder md, ResourceAtlasResult result) + { + md.AppendLine("## Связи между модами (кто чьи ресурсы использует)"); + md.AppendLine(); + var links = result.Findings + .Where(f => f.Kind is AtlasFindingKind.DeclaredUse or AtlasFindingKind.UndeclaredUse) + .SelectMany(f => f.OtherMods.Select(other => (f, other))) + .GroupBy(x => (x.f.ModId, x.other)) + .OrderBy(g => g.Key.ModId, StringComparer.Ordinal).ThenBy(g => g.Key.other, StringComparer.Ordinal) + .ToList(); + if (links.Count == 0) + { + md.AppendLine("Нет."); + md.AppendLine(); + return; + } + + md.AppendLine("| Мод | Берёт ресурсы у | В depends | Запросов | Виды | Примеры |"); + md.AppendLine("|---|---|---|---|---|---|"); + foreach (var link in links) + { + var declared = result.Closures[link.Key.ModId].Contains(link.Key.other); + var kinds = string.Join(", ", link.Select(x => x.f.ResourceKind).Distinct()); + var samples = string.Join(", ", link.Select(x => $"`{x.f.ResourceId}`").Distinct().Take(Samples)); + md.AppendLine($"| `{link.Key.ModId}` | `{link.Key.other}` | {(declared ? "да" : "**нет**")} | {link.Count()} | {kinds} | {samples} |"); + } + md.AppendLine(); + } + + private static void AppendOverrides(StringBuilder md, ResourceAtlasResult result) + { + md.AppendLine("## Переопределения (один ключ у нескольких модов)"); + md.AppendLine(); + var overrides = result.Findings + .Where(f => f.Kind is AtlasFindingKind.DeclaredOverride or AtlasFindingKind.UnorderedOverride) + .GroupBy(f => (f.ModId, Other: f.OtherMods[0], f.Kind)) + .OrderBy(g => g.Key.Kind).ThenBy(g => g.Key.ModId, StringComparer.Ordinal) + .ToList(); + if (overrides.Count == 0) + { + md.AppendLine("Нет."); + md.AppendLine(); + return; + } + + md.AppendLine("| Побеждает | Перекрывает | Упорядочено depends | Ключей | Виды | Примеры |"); + md.AppendLine("|---|---|---|---|---|---|"); + foreach (var group in overrides) + { + var kinds = string.Join(", ", group.Select(f => f.ResourceKind).Distinct()); + var samples = string.Join(", ", group.Select(f => $"`{f.ResourceId}`").Distinct().Take(Samples)); + var ordered = group.Key.Kind == AtlasFindingKind.DeclaredOverride ? "да" : "**нет**"; + md.AppendLine($"| `{group.Key.ModId}` | `{group.Key.Other}` | {ordered} | {group.Count()} | {kinds} | {samples} |"); + } + md.AppendLine(); + } + + private static void AppendPatches(StringBuilder md, ResourceAtlasResult result) + { + md.AppendLine("## Патчи чужих ресурсов"); + md.AppendLine(); + var patches = result.Findings + .Where(f => f.Kind is AtlasFindingKind.DeclaredPatch or AtlasFindingKind.UndeclaredPatch) + .ToList(); + if (patches.Count == 0) + { + md.AppendLine("Нет."); + md.AppendLine(); + return; + } + + md.AppendLine("| Мод | Патчит мод | В depends | Ресурс | Файл |"); + md.AppendLine("|---|---|---|---|---|"); + foreach (var patch in patches.OrderBy(p => p.ModId, StringComparer.Ordinal)) + { + var declared = patch.Kind == AtlasFindingKind.DeclaredPatch ? "да" : "**нет**"; + md.AppendLine($"| `{patch.ModId}` | `{string.Join("`, `", patch.OtherMods)}` | {declared} | {patch.ResourceKind} `{patch.ResourceId}` | `{patch.File}` |"); + } + md.AppendLine(); + } + + private static void AppendUnresolved(StringBuilder md, List warnings) + { + md.AppendLine("## Предупреждения: ключ не найден ни в одном моде"); + md.AppendLine(); + if (warnings.Count == 0) + { + md.AppendLine("Нет."); + md.AppendLine(); + return; + } + + foreach (var group in Fold(warnings).OrderBy(w => w.First.ModId, StringComparer.Ordinal)) + { + var f = group.First; + md.AppendLine($"- `{f.ModId}`: {f.ResourceKind} `{f.ResourceId}` — {f.Message}. {Location(f)}{(group.Count > 1 ? $" (+{group.Count - 1})" : string.Empty)}"); + } + md.AppendLine(); + } + + private static void AppendModTable(StringBuilder md, ResourceAtlasResult result) + { + md.AppendLine("## Сводка по модам"); + md.AppendLine(); + md.AppendLine("| Мод | depends | Берёт у | Его ресурсы берут | Перекрывает | Ошибок |"); + md.AppendLine("|---|---|---|---|---|---|"); + var uses = result.Findings.Where(f => f.Kind is AtlasFindingKind.DeclaredUse or AtlasFindingKind.UndeclaredUse).ToList(); + var overrides = result.Findings.Where(f => f.Kind is AtlasFindingKind.DeclaredOverride or AtlasFindingKind.UnorderedOverride).ToList(); + foreach (var mod in result.LoadOrder) + { + var takesFrom = uses.Where(f => f.ModId == mod).SelectMany(f => f.OtherMods).Distinct().OrderBy(m => m, StringComparer.Ordinal); + var usedBy = uses.Where(f => f.OtherMods.Contains(mod)).Select(f => f.ModId).Distinct().OrderBy(m => m, StringComparer.Ordinal); + var covers = overrides.Where(f => f.ModId == mod).SelectMany(f => f.OtherMods).Distinct().OrderBy(m => m, StringComparer.Ordinal); + var errors = result.Errors.Count(f => f.ModId == mod); + md.AppendLine($"| `{mod}` | {Join(result.Depends[mod])} | {Join(takesFrom)} | {Join(usedBy)} | {Join(covers)} | {(errors > 0 ? $"**{errors}**" : "0")} |"); + } + md.AppendLine(); + } + + public static string BuildJson(ResourceAtlasResult result) + { + var report = new + { + generatedAt = DateTime.UtcNow.ToString("O"), + summary = new + { + mods = result.LoadOrder.Count, + provisions = result.Provisions.Count, + requests = result.Requests.Count, + patches = result.Patches.Count, + errors = result.Errors.Count(), + warnings = result.Warnings.Count(), + failingMods = result.FailingMods(), + }, + loadOrder = result.LoadOrder, + mods = result.LoadOrder.Select(m => new { id = m, depends = result.Depends[m], closure = result.Closures[m].OrderBy(x => x, StringComparer.Ordinal) }), + findings = result.Findings.Select(f => new + { + severity = f.Severity.ToString().ToLowerInvariant(), + kind = f.Kind.ToString(), + mod = f.ModId, + otherMods = f.OtherMods, + resourceKind = f.ResourceKind, + resourceId = f.ResourceId, + file = f.File, + line = f.Line, + message = f.Message, + }), + }; + + return JsonSerializer.Serialize(report, new JsonSerializerOptions + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + } + + /// Groups repeated occurrences of one logical finding (same mods, kind and key). + private static IEnumerable<(AtlasFinding First, int Count)> Fold(IEnumerable findings) + => findings + .GroupBy(f => (f.Kind, f.ModId, Others: string.Join(",", f.OtherMods), f.ResourceKind, f.ResourceId)) + .Select(g => (g.First(), g.Count())); + + private static string Title(AtlasFindingKind kind) => kind switch + { + AtlasFindingKind.UndeclaredUse => "Используется чужой ресурс без зависимости в depends", + AtlasFindingKind.UnorderedOverride => "Переопределение без порядка (победитель случаен)", + AtlasFindingKind.UndeclaredPatch => "Патч чужого ресурса без зависимости", + AtlasFindingKind.OutsideGame => "Ресурс есть только в модах вне игры", + _ => kind.ToString(), + }; + + private static string Location(AtlasFinding finding) + => string.IsNullOrEmpty(finding.File) + ? string.Empty + : finding.Line > 0 ? $"`{finding.File}:{finding.Line}`" : $"`{finding.File}`"; + + private static string Join(IEnumerable ids) + { + var list = ids.ToList(); + return list.Count == 0 ? "—" : string.Join(", ", list); + } + + private static string OrEmpty(this string value, string fallback) + => string.IsNullOrEmpty(value) ? fallback : value; + } +} diff --git a/src/ResourceAtlasResult.cs b/src/ResourceAtlasResult.cs new file mode 100644 index 0000000..faaa4f7 --- /dev/null +++ b/src/ResourceAtlasResult.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Cthangover.ResourceAtlas +{ + /// + /// Everything the atlas learned in one pass: load order and depends + /// closures, every provision/request/patch, and the findings derived from + /// them. Report writers and the test driver read only this object. + /// + public sealed class ResourceAtlasResult + { + public List LoadOrder { get; } = new(); + + public Dictionary> Depends { get; } = new(StringComparer.Ordinal); + + public Dictionary> Closures { get; } = new(StringComparer.Ordinal); + + public List Provisions { get; } = new(); + + /// Provisions of installed mods that are not loaded (only when the atlas runs inside a game). + public List OutsideProvisions { get; } = new(); + + public List Requests { get; } = new(); + + public List Patches { get; } = new(); + + public List Findings { get; } = new(); + + /// Keys the kernel provides (e.g. scenario actions from Core); always available, never a cross-mod link. + public HashSet<(string Kind, string Id)> KernelKeys { get; } = new(); + + public IEnumerable Errors => Findings.Where(f => f.Severity == AtlasSeverity.Error); + + public IEnumerable Warnings => Findings.Where(f => f.Severity == AtlasSeverity.Warning); + + /// Mods with at least one error finding, in load order. + public List FailingMods() + { + var failing = new HashSet(Errors.Select(f => f.ModId), StringComparer.Ordinal); + return LoadOrder.Where(failing.Contains).ToList(); + } + } +}