This commit is contained in:
iisaev committed 2026-10-18 12:41:37 +03:00
commit 9bcd4dcfe1
9 files changed
+1249

No files matched your search

+2
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
mod_resource_atlas_name = Карта ресурсов
mod_resource_atlas_desc = Карта ресурсов между модами: кто запрашивает, переопределяет или патчит ресурсы другого мода и объявлено ли это в depends манифеста.
+10
View File
@@ -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"]
}
+116
View File
@@ -0,0 +1,116 @@
using System.Collections.Generic;
namespace Cthangover.ResourceAtlas
{
/// <summary>One resource a mod makes available under a resolver key.</summary>
public sealed class AtlasProvision
{
/// <summary>Resource kind: validator kinds (background, sound, character, scene...) plus texture, shader, action, entity_kind, locale, scene_file.</summary>
public string Kind { get; init; }
/// <summary>Resolver key inside the kind (texture basename, sound path, entity id, "lang:key"...).</summary>
public string Id { get; init; }
public string ModId { get; init; }
public string File { get; init; }
}
/// <summary>One place where a mod asks for a resource by key.</summary>
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; }
/// <summary>Channel the request was found in: data, code, scenario, res, entity_data.</summary>
public string Via { get; init; }
/// <summary>Provider named by the reference itself (<c>res://mods/&lt;id&gt;/...</c>); null when resolved by key.</summary>
public string ExplicitProvider { get; init; }
}
/// <summary>An intentional edit of another mod's resource through <c>patches/</c>.</summary>
public sealed class AtlasPatch
{
public string Kind { get; init; }
public string Id { get; init; }
public string ModId { get; init; }
public string File { get; init; }
/// <summary>Explicit target owner from the patch header, or null to resolve by key.</summary>
public string ExplicitOwner { get; init; }
}
public enum AtlasSeverity
{
Info,
Warning,
Error,
}
public enum AtlasFindingKind
{
/// <summary>A mod uses another mod's resource and declares that mod in its depends closure.</summary>
DeclaredUse,
/// <summary>A mod uses another mod's resource without a depends path to it — breaks once that mod is absent.</summary>
UndeclaredUse,
/// <summary>No loaded mod provides the requested key.</summary>
Unresolved,
/// <summary>The winner of a shared key depends on the losers — the override is intended and ordered.</summary>
DeclaredOverride,
/// <summary>Several unrelated mods provide one key; the winner is decided by incidental load order.</summary>
UnorderedOverride,
/// <summary>A mod patches another mod's resource and depends on it.</summary>
DeclaredPatch,
/// <summary>A mod patches another mod's resource without depending on it.</summary>
UndeclaredPatch,
/// <summary>A patch targets a key no other loaded mod provides.</summary>
PatchTargetMissing,
/// <summary>
/// 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.
/// </summary>
OutsideGame,
}
public sealed class AtlasFinding
{
public AtlasFindingKind Kind { get; init; }
public AtlasSeverity Severity { get; init; }
/// <summary>Mod the finding is attributed to (requester, overriding winner, patcher).</summary>
public string ModId { get; init; }
/// <summary>The other side: providers, overridden mods or patch owners.</summary>
public IReadOnlyList<string> OtherMods { get; init; } = new List<string>();
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; }
}
}
+611
View File
@@ -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
{
/// <summary>
/// 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?":
/// <list type="bullet">
/// <item>validator graph — entities, scene definitions, interactives and
/// the background/avatar/music/sound/effect assets, plus every reference
/// found in scenarios and data;</item>
/// <item>textures — basename across <c>ModConfig.TextureGroups</c> (one
/// global namespace, like <c>TextureResolver</c>);</item>
/// <item>shaders (<c>shaders/</c> path without extension), <c>.tscn</c>
/// scenes (path under <c>scenes/</c>), locale keys per language, scenario
/// actions and entity kinds registered by mod code;</item>
/// <item>requests from C# literals (<c>PlaySound</c>, <c>PlayMusic</c>,
/// <c>PlayAmbient</c>, <c>Textures.Resolve</c>, <c>Shaders.Resolve</c>),
/// <c>res://mods/&lt;id&gt;/</c> paths, scenario <c>action</c> commands and
/// entity data needing a kind;</item>
/// <item>patches — <c>patches/scenes/**.tscn</c> and <c>patches/&lt;kind&gt;.json</c>.</item>
/// </list>
/// A link between two mods is "declared" when the dependant has the other
/// mod in its transitive <c>depends</c>; for shared keys the later mod in
/// load order wins, which is only deterministic along a depends path.
/// </summary>
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<string> TextExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".tscn", ".tres", ".json", ".scenario", ".gd", ".cs", ".gdshader",
};
/// <param name="runtimeActions">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.</param>
/// <param name="outsideMods">Installed mods that are not loaded (the
/// atlas runs inside a game); with <paramref name="outsideGraph"/> they
/// only contribute provisions, used to explain keys the game misses.</param>
public static ResourceAtlasResult Build(
IReadOnlyList<IModInfo> modsInLoadOrder,
ValidationGraph graph,
IReadOnlyCollection<(string Name, string Assembly)> runtimeActions = null,
IReadOnlyList<IModInfo> 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<string>();
}
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<IModInfo>())
ScanMod(mod, result, result.OutsideProvisions, collectRequests: false);
foreach (var declaration in outsideGraph?.Declarations ?? Enumerable.Empty<ValidationDeclaration>())
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, "<runtime>");
else
result.KernelKeys.Add(("action", name));
}
Analyze(result);
return result;
}
/// <summary>
/// Indexes one mod. Provisions go to <paramref name="provisions"/>
/// (loaded or outside list); requests and patches are collected only
/// for loaded mods.
/// </summary>
private static void ScanMod(IModInfo mod, ResourceAtlasResult result, List<AtlasProvision> 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);
}
}
/// <summary>Tests and build output are not shipped content; their literals must not count as requests.</summary>
private static bool IsToolingPath(string file)
=> file.StartsWith("tests/", StringComparison.Ordinal)
|| file.StartsWith("test_suites/", StringComparison.Ordinal) // mounted as their own mod (<host>__<suite>)
|| 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<AtlasProvision> 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<AtlasProvision> 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",
});
}
}
}
/// <summary><c>patches/&lt;kind&gt;.json</c> or <c>patches/&lt;kind&gt;/*.json</c>: each item id (or <c>*</c>) patches entities of that kind.</summary>
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 });
}
}
/// <summary>A scene patch is an inherited scene: the root instance names the patched target.</summary>
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<string>> providers,
Dictionary<string, int> loadIndex)
{
List<string> candidates;
if (request.ExplicitProvider != null)
candidates = loadIndex.ContainsKey(request.ExplicitProvider) ? new List<string> { request.ExplicitProvider } : new List<string>();
else if (!providers.TryGetValue((request.Kind, request.Id), out candidates))
candidates = new List<string>();
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<string> { 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 — добавьте зависимость или перенесите ресурс",
});
}
/// <summary>
/// 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.
/// </summary>
private static void AnalyzeOverride(ResourceAtlasResult result, string kind, string id, List<string> 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<string> { 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<string>> providers,
Dictionary<string, int> loadIndex)
{
List<string> owners;
if (patch.ExplicitOwner != null)
owners = loadIndex.ContainsKey(patch.ExplicitOwner) ? new List<string> { patch.ExplicitOwner } : new List<string>();
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<string>();
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<string> Closure(string modId, Dictionary<string, List<string>> depends)
{
var visible = new HashSet<string>(StringComparer.Ordinal);
var pending = new Stack<string>(depends.TryGetValue(modId, out var direct) ? direct : new List<string>());
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;
}
/// <summary>
/// Blanks C# comments while keeping line breaks (line numbers stay
/// valid) and string/char literals intact — a naive <c>//</c> cut would
/// truncate <c>"res://..."</c>.
/// </summary>
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;
}
}
}
+149
View File
@@ -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
{
/// <summary>
/// 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 <c>[FAIL]</c> and prints
/// <c>[TEST_RESULT] name=resource_atlas passed=&lt;clean mods&gt;
/// failed=&lt;mods with errors&gt;</c> before quitting with 0/1. A watchdog
/// ends a hung pass with a failure instead of blocking the test run.
/// </summary>
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<ResourceAtlasResult> 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<string>(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<IModInfo> SnapshotMods()
{
var registry = ModRegistry.Instance;
var result = new List<IModInfo>();
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);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.ResourceAtlas
{
/// <summary>
/// Activates the atlas only on demand: the <c>--resource-atlas</c> user
/// arg (after <c>--</c>) queues <see cref="ResourceAtlasDriverNode"/>,
/// which waits for the boot, writes the report and quits. Optional
/// <c>--atlas-out=&lt;dir&gt;</c> (res://, user:// or absolute) sets the
/// report directory, <c>--timeout=&lt;s&gt;</c> the watchdog.
/// </summary>
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;
}
}
}
+262
View File
@@ -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
{
/// <summary>
/// Writes <c>report.md</c> (read first: errors with the fix, then the
/// mod-to-mod picture) and <c>report.json</c> (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.
/// </summary>
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<AtlasFinding> 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,
});
}
/// <summary>Groups repeated occurrences of one logical finding (same mods, kind and key).</summary>
private static IEnumerable<(AtlasFinding First, int Count)> Fold(IEnumerable<AtlasFinding> 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<string> 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;
}
}
+45
View File
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Cthangover.ResourceAtlas
{
/// <summary>
/// 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.
/// </summary>
public sealed class ResourceAtlasResult
{
public List<string> LoadOrder { get; } = new();
public Dictionary<string, List<string>> Depends { get; } = new(StringComparer.Ordinal);
public Dictionary<string, HashSet<string>> Closures { get; } = new(StringComparer.Ordinal);
public List<AtlasProvision> Provisions { get; } = new();
/// <summary>Provisions of installed mods that are not loaded (only when the atlas runs inside a game).</summary>
public List<AtlasProvision> OutsideProvisions { get; } = new();
public List<AtlasRequest> Requests { get; } = new();
public List<AtlasPatch> Patches { get; } = new();
public List<AtlasFinding> Findings { get; } = new();
/// <summary>Keys the kernel provides (e.g. scenario actions from Core); always available, never a cross-mod link.</summary>
public HashSet<(string Kind, string Id)> KernelKeys { get; } = new();
public IEnumerable<AtlasFinding> Errors => Findings.Where(f => f.Severity == AtlasSeverity.Error);
public IEnumerable<AtlasFinding> Warnings => Findings.Where(f => f.Severity == AtlasSeverity.Warning);
/// <summary>Mods with at least one error finding, in load order.</summary>
public List<string> FailingMods()
{
var failing = new HashSet<string>(Errors.Select(f => f.ModId), StringComparer.Ordinal);
return LoadOrder.Where(failing.Contains).ToList();
}
}
}