diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cba12eb..c9c3a2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -40,8 +40,7 @@ jobs: exit 1 fi - - name: Setup Resonite environment - id: resonite + - name: Setup current Resonite environment uses: resonite-modding-group/setup-resonite-env-action@v0.1.0 with: steam-user: ${{ secrets.STEAMUSER }} @@ -65,9 +64,9 @@ jobs: https://github.com/resonite-modding-group/ResoniteModLoader/releases/latest/download/0Harmony.dll - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Cache NuGet packages uses: actions/cache@v4 @@ -112,14 +111,12 @@ jobs: set -euo pipefail artifacts_dir=release-artifacts mkdir -p "$artifacts_dir" - cp src/ReferenceReplacement/bin/Release/net9.0/ReferenceReplacement.dll "$artifacts_dir/ReferenceReplacement.dll" - if [ -f src/ReferenceReplacement/bin/Release/net9.0/ReferenceReplacement.pdb ]; then - cp src/ReferenceReplacement/bin/Release/net9.0/ReferenceReplacement.pdb "$artifacts_dir/ReferenceReplacement.pdb" - fi + cp src/ReferenceReplacement/bin/Release/net10.0/ReferenceReplacement.dll "$artifacts_dir/ReferenceReplacement.dll" + cp src/ReferenceReplacement/bin/Release/net10.0/ReferenceReplacement.pdb "$artifacts_dir/ReferenceReplacement.pdb" - name: Upload release artifacts if: startsWith(github.ref, 'refs/tags/v') - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: reference-replacement path: release-artifacts @@ -129,11 +126,12 @@ jobs: runs-on: ubuntu-latest needs: build if: startsWith(github.ref, 'refs/tags/v') + timeout-minutes: 10 permissions: contents: write steps: - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: reference-replacement path: release-artifacts diff --git a/Directory.Build.props b/Directory.Build.props index 92915b9..c301b98 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,9 +1,15 @@ + net10.0 + enable + latest true latest-all enable true + false + false + false @@ -16,4 +22,22 @@ $([System.IO.Path]::GetFullPath('$(ResonitePath)')) + + + + $([MSBuild]::ValueOrDefault('$(IsTestProject)','false')) + + + + + + $(ResolvedResonitePath)/FrooxEngine.dll + + + $(ResolvedResonitePath)/Elements.Core.dll + + + $(ResolvedResonitePath)/Renderite.Shared.dll + + diff --git a/README.md b/README.md index 41c5c48..af17476 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A [ResoniteModLoader](https://github.com/resonite-modding-group/ResoniteModLoade Launch the tool via `Create New > Editor > Reference Replacement (Mod)` in the Dev Create menu. +Reference discovery traverses nested sync members, lists, dictionaries, slot components, and child slots while avoiding duplicate visits. Matches with an incompatible target type are reported but never replaced. + ## Installation 1. Install [ResoniteModLoader](https://github.com/resonite-modding-group/ResoniteModLoader). @@ -12,7 +14,9 @@ Launch the tool via `Create New > Editor > Reference Replacement (Mod)` in the D ## Build & Hot Reload -1. Install the .NET 9 SDK. +1. Install the .NET 10 SDK. 2. `dotnet build ReferenceReplacement.sln` auto-detects the Resonite install next to this repo, the default Steam Windows path, then the default Steam Linux path. If the game lives elsewhere, pass `-p:ResonitePath="/absolute/path/to/Resonite"` so the build can find `FrooxEngine.dll`, `Elements.Core.dll`, `Libraries/ResoniteModLoader.dll`, and `rml_libs/0Harmony.dll`. 3. Set `CopyToMods=true` when invoking `dotnet build` to copy the compiled DLL into `$(ResonitePath)/rml_mods` after each build. 4. Drop `ResoniteHotReloadLib.dll` (and `ResoniteHotReloadLibCore.dll`) into `$(ResonitePath)/rml_libs` and build with `-p:EnableResoniteHotReloadLib=true` if you want the Dev Tool’s **Hot Reload Mods** panel to reload this mod without restarting Resonite. Leave the property unset on machines without the DLL. + +CI provisions the current Resonite assembly set with `setup-resonite-env`, then runs restore, formatting, build, and tests against those runtime assemblies before publishing a tagged release. diff --git a/src/ReferenceReplacement/Logic/ReferenceMatchCollector.cs b/src/ReferenceReplacement/Logic/ReferenceMatchCollector.cs new file mode 100644 index 0000000..31b826f --- /dev/null +++ b/src/ReferenceReplacement/Logic/ReferenceMatchCollector.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; + +using FrooxEngine; + +namespace ReferenceReplacement.Logic; + +internal sealed class ReferenceMatchCollector +{ + private readonly IWorldElement _source; + private readonly IWorldElement _target; + private readonly HashSet _visitedRefs = new(); + private readonly List _matches = new(); + + internal ReferenceMatchCollector(IWorldElement source, IWorldElement target) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _target = target ?? throw new ArgumentNullException(nameof(target)); + } + + public int IncompatibleCount { get; private set; } + public string? LastHitPath { get; private set; } + + public void TryCapture(ISyncRef candidate, string path) + { + if (!_visitedRefs.Add(candidate)) + { + return; + } + + IWorldElement? current = candidate.Target; + if (current == null) + { + return; + } + + if (!ReferenceEquals(current, _source) && current.ReferenceID != _source.ReferenceID) + { + return; + } + + Type? requiredType = candidate.TargetType; + if (requiredType != null && !requiredType.IsInstanceOfType(_target)) + { + IncompatibleCount++; + return; + } + + LastHitPath = path; + _matches.Add(new SyncReferenceMatch(candidate, path)); + } + + public ReferenceScanResult BuildResult(int visitedMembers) + { + return new ReferenceScanResult(_matches.ToArray(), IncompatibleCount, visitedMembers, LastHitPath); + } +} diff --git a/src/ReferenceReplacement/Logic/ReferenceScanner.cs b/src/ReferenceReplacement/Logic/ReferenceScanner.cs index b5ecf10..09dd71f 100644 --- a/src/ReferenceReplacement/Logic/ReferenceScanner.cs +++ b/src/ReferenceReplacement/Logic/ReferenceScanner.cs @@ -1,7 +1,5 @@ using System; -using System.Collections; using System.Collections.Generic; -using System.Reflection; using System.Runtime.CompilerServices; using FrooxEngine; @@ -10,327 +8,37 @@ namespace ReferenceReplacement.Logic; internal static class ReferenceScanner { - public static ReferenceScanResult Scan(Slot root, IWorldElement source, IWorldElement target, Slot? excludedSlot = null) + public static ReferenceScanResult Scan( + Slot root, + IWorldElement source, + IWorldElement target, + Slot? excludedSlot = null, + ITraversalExceptionFilter? exceptionFilter = null) { ArgumentNullException.ThrowIfNull(root); - ReferenceScanSession session = new(source, target, excludedSlot); - session.VisitSlot(root, TraversalPath.FromSlot(root)); - return session.BuildResult(); - } - - internal static ReferenceScanResult Scan(HierarchyBlueprint blueprintRoot, IWorldElement source, IWorldElement target) - { - ArgumentNullException.ThrowIfNull(blueprintRoot); + TraversalCursor cursor = TraversalCursor.FromSlot(root); + ReferenceMatchCollector collector = new(source, target); + ReferenceTraversal traversal = new(exceptionFilter ?? DefaultTraversalExceptionFilter.Instance, collector); + traversal.TraverseSlot(root, cursor, excludedSlot); - ReferenceScanSession session = new(source, target, excludedSlot: null); - session.VisitBlueprint(blueprintRoot, new(blueprintRoot.Label)); - return session.BuildResult(); + return collector.BuildResult(traversal.VisitedMembers); } - private sealed class ReferenceScanSession + internal static ReferenceScanResult Scan( + HierarchyBlueprint blueprintRoot, + IWorldElement source, + IWorldElement target, + ITraversalExceptionFilter? exceptionFilter = null) { - private readonly IWorldElement _source; - private readonly IWorldElement _target; - private readonly Slot? _excludedSlot; - private readonly List _matches = new(); - private readonly HashSet _visitedRefs = new(); - private readonly HashSet _visitedEnumerables = new(ReferenceEqualityComparer.Instance); - - private int _visitedMembers; - private int _incompatibleCount; - private string? _lastPath; - - internal ReferenceScanSession(IWorldElement source, IWorldElement target, Slot? excludedSlot) - { - _source = source ?? throw new ArgumentNullException(nameof(source)); - _target = target ?? throw new ArgumentNullException(nameof(target)); - _excludedSlot = excludedSlot; - } - - internal void VisitSlot(Slot? slot, TraversalPath path) - { - if (slot == null || ReferenceEquals(slot, _excludedSlot)) - { - return; - } - - VisitWorker(slot, path); - VisitComponents(slot, path); - VisitChildren(slot, path); - } - - internal void VisitBlueprint(HierarchyBlueprint node, TraversalPath path) - { - foreach (ISyncMember member in node.Members) - { - VisitMember(member, path.NextMember(member.Name)); - } - - foreach (HierarchyBlueprint child in node.Children) - { - VisitBlueprint(child, path.NextChild(child.Label)); - } - } - - internal ReferenceScanResult BuildResult() - { - return new ReferenceScanResult(_matches.ToArray(), _incompatibleCount, _visitedMembers, _lastPath); - } - - private void VisitComponents(Slot slot, TraversalPath parentPath) - { - foreach (Component component in slot.Components) - { - VisitWorker(component, parentPath.NextComponent(component)); - } - } - - private void VisitChildren(Slot slot, TraversalPath parentPath) - { - foreach (Slot child in slot.Children) - { - VisitSlot(child, parentPath.NextChild(TraversalPath.DescribeSlot(child))); - } - } - - private void VisitWorker(Worker worker, TraversalPath path) - { - if (worker == null) - { - return; - } - - foreach (ISyncMember member in worker.SyncMembers) - { - VisitMember(member, path.NextMember(member.Name)); - } - } - - private void VisitMember(ISyncMember member, TraversalPath path) - { - if (member == null) - { - return; - } - - _visitedMembers++; - - if (TryCapture(member, path)) - { - return; - } - - if (member is IEnumerable enumerable && ShouldVisitEnumerable(enumerable)) - { - VisitEnumerable(enumerable, path); - } - - VisitKnownCollections(member, path); - } - - private void VisitKnownCollections(ISyncMember member, TraversalPath path) - { - switch (member) - { - case ISyncList syncList: - VisitEnumerableProperty(() => syncList.Elements, path, nameof(ISyncList.Elements)); - break; - case ISyncBag syncBag: - VisitEnumerableProperty(() => syncBag.Elements, path, nameof(ISyncBag.Elements)); - VisitEnumerableProperty(() => syncBag.Values, path, nameof(ISyncBag.Values)); - break; - case ISyncDictionary syncDictionary: - VisitEnumerableProperty(() => syncDictionary.BoxedEntries, path, nameof(ISyncDictionary.BoxedEntries)); - VisitEnumerableProperty(() => syncDictionary.Values, path, nameof(ISyncDictionary.Values)); - break; - case ISyncArray syncArray: - VisitSyncArray(syncArray, path); - break; - } - } - - private void VisitEnumerableProperty(Func accessor, TraversalPath parentPath, string propertyName) - { - IEnumerable? enumerable; - try - { - enumerable = accessor(); - } - catch (Exception ex) when (ShouldIgnore(ex)) - { - return; - } - - if (enumerable == null) - { - return; - } - - if (ShouldVisitEnumerable(enumerable)) - { - VisitEnumerable(enumerable, parentPath.NextProperty(propertyName)); - } - } - - private void VisitSyncArray(ISyncArray array, TraversalPath parentPath) - { - int count; - try - { - count = array.Count; - } - catch (Exception ex) when (ShouldIgnore(ex)) - { - return; - } - - TraversalPath itemsPath = parentPath.NextProperty("Items"); - for (int index = 0; index < count; index++) - { - object? element; - try - { - element = array.GetElement(index); - } - catch (Exception ex) when (ShouldIgnore(ex)) - { - continue; - } - - VisitValue(element, itemsPath.NextIndex(index)); - } - } - - private void VisitEnumerable(IEnumerable enumerable, TraversalPath path) - { - int index = 0; - foreach (object? item in enumerable) - { - VisitValue(item, path.NextIndex(index)); - index++; - } - } - - private void VisitValue(object? value, TraversalPath path) - { - if (value == null) - { - return; - } - - if (value is ISyncRef syncRef) - { - TryCapture(syncRef, path); - return; - } - - if (value is DictionaryEntry entry && entry.Value is ISyncRef entryRef) - { - TryCapture(entryRef, path); - return; - } - - if (value is ISyncMember member) - { - VisitMember(member, path); - return; - } - - if (value is IEnumerable enumerable && ShouldVisitEnumerable(enumerable)) - { - VisitEnumerable(enumerable, path); - return; - } - - ISyncRef? extracted = TryExtractSyncRef(value); - if (extracted != null) - { - TryCapture(extracted, path); - } - } - - private bool TryCapture(ISyncMember member, TraversalPath path) - { - if (member is not ISyncRef syncRef) - { - return false; - } - - TryCapture(syncRef, path); - return true; - } - - private void TryCapture(ISyncRef syncRef, TraversalPath path) - { - if (!_visitedRefs.Add(syncRef)) - { - return; - } - - if (!MatchesSource(syncRef)) - { - return; - } - - if (!SupportsTarget(syncRef)) - { - _incompatibleCount++; - return; - } - - _lastPath = path.Value; - _matches.Add(new SyncReferenceMatch(syncRef, path.Value)); - } - - private bool MatchesSource(ISyncRef syncRef) - { - IWorldElement? current = syncRef.Target; - if (current == null) - { - return false; - } - - return ReferenceEquals(current, _source) || current.ReferenceID == _source.ReferenceID; - } - - private bool SupportsTarget(ISyncRef syncRef) - { - Type? requiredType = syncRef.TargetType; - return requiredType == null || requiredType.IsInstanceOfType(_target); - } - - private bool ShouldVisitEnumerable(IEnumerable enumerable) - { - if (enumerable == null || enumerable is string) - { - return false; - } - - return _visitedEnumerables.Add(enumerable); - } - - private static bool ShouldIgnore(Exception ex) - { - return ex is NotSupportedException or InvalidOperationException; - } - - private static ISyncRef? TryExtractSyncRef(object candidate) - { - Type type = candidate.GetType(); - if (!type.IsGenericType || !type.Name.StartsWith("KeyValuePair", StringComparison.Ordinal)) - { - return null; - } + ArgumentNullException.ThrowIfNull(blueprintRoot); - PropertyInfo? valueProperty = type.GetProperty("Value"); - if (valueProperty == null) - { - return null; - } + TraversalCursor cursor = TraversalCursor.FromLabel(blueprintRoot.Label); + ReferenceMatchCollector collector = new(source, target); + ReferenceTraversal traversal = new(exceptionFilter ?? DefaultTraversalExceptionFilter.Instance, collector); + traversal.TraverseBlueprint(blueprintRoot, cursor); - return valueProperty.GetValue(candidate) as ISyncRef; - } + return collector.BuildResult(traversal.VisitedMembers); } } diff --git a/src/ReferenceReplacement/Logic/ReferenceTraversal.cs b/src/ReferenceReplacement/Logic/ReferenceTraversal.cs new file mode 100644 index 0000000..af3a40e --- /dev/null +++ b/src/ReferenceReplacement/Logic/ReferenceTraversal.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; + +using FrooxEngine; + +namespace ReferenceReplacement.Logic; + +internal sealed class ReferenceTraversal +{ + private readonly ITraversalExceptionFilter _exceptionFilter; + private readonly ReferenceMatchCollector _collector; + private readonly HashSet _visitedEnumerables = new(ReferenceEqualityComparer.Instance); + + internal ReferenceTraversal(ITraversalExceptionFilter exceptionFilter, ReferenceMatchCollector collector) + { + _exceptionFilter = exceptionFilter ?? throw new ArgumentNullException(nameof(exceptionFilter)); + _collector = collector ?? throw new ArgumentNullException(nameof(collector)); + } + + public int VisitedMembers { get; private set; } + + public void TraverseSlot(Slot root, TraversalCursor cursor, Slot? excludedSlot) + { + VisitSlot(root, cursor, excludedSlot); + } + + public void TraverseBlueprint(HierarchyBlueprint root, TraversalCursor cursor) + { + VisitBlueprint(root, cursor); + } + + private void VisitSlot(Slot? slot, TraversalCursor cursor, Slot? excludedSlot) + { + if (slot == null || ReferenceEquals(slot, excludedSlot)) + { + return; + } + + VisitWorker(slot, cursor); + VisitComponents(slot, cursor); + VisitChildren(slot, cursor, excludedSlot); + } + + private void VisitBlueprint(HierarchyBlueprint node, TraversalCursor cursor) + { + foreach (ISyncMember member in node.Members) + { + VisitMember(member, cursor, member.Name); + } + + foreach (HierarchyBlueprint child in node.Children) + { + using IDisposable scope = cursor.PushChild(child.Label); + VisitBlueprint(child, cursor); + } + } + + private void VisitComponents(Slot slot, TraversalCursor parentCursor) + { + foreach (Component component in slot.Components) + { + using TraversalCursor.SegmentScope scope = parentCursor.PushComponent(component); + VisitWorker(component, parentCursor); + } + } + + private void VisitChildren(Slot slot, TraversalCursor parentCursor, Slot? excludedSlot) + { + foreach (Slot child in slot.Children) + { + using IDisposable scope = parentCursor.PushChild(TraversalPath.DescribeSlot(child)); + VisitSlot(child, parentCursor, excludedSlot); + } + } + + private void VisitWorker(Worker worker, TraversalCursor cursor) + { + if (worker == null) + { + return; + } + + foreach (ISyncMember member in worker.SyncMembers) + { + VisitMember(member, cursor, member.Name); + } + } + + private void VisitMember(ISyncMember member, TraversalCursor cursor, string memberName) + { + if (member == null) + { + return; + } + + VisitedMembers++; + + using TraversalCursor.SegmentScope scope = cursor.PushMember(memberName); + if (TryCapture(member, cursor)) + { + return; + } + + if (member is IEnumerable enumerable && ShouldVisitEnumerable(enumerable)) + { + VisitEnumerable(enumerable, cursor); + return; + } + + if (SyncCollectionTraversal.TryVisitKnownCollections( + member, + cursor, + _exceptionFilter, + ShouldVisitEnumerable, + VisitEnumerable, + VisitValue)) + { + return; + } + } + + private void VisitEnumerable(IEnumerable enumerable, TraversalCursor cursor) + { + int index = 0; + foreach (object? item in enumerable) + { + using TraversalCursor.SegmentScope scope = cursor.PushIndex(index); + VisitValue(item, cursor); + index++; + } + } + + private void VisitValue(object? value, TraversalCursor cursor) + { + if (value == null) + { + return; + } + + if (value is ISyncRef syncRef) + { + TryCapture(syncRef, cursor); + return; + } + + if (value is DictionaryEntry entry && entry.Value is ISyncRef entryRef) + { + TryCapture(entryRef, cursor); + return; + } + + if (value is ISyncMember member) + { + VisitMember(member, cursor, member.Name); + return; + } + + if (value is IEnumerable enumerable && ShouldVisitEnumerable(enumerable)) + { + VisitEnumerable(enumerable, cursor); + return; + } + + ISyncRef? extracted = TryExtractSyncRef(value); + if (extracted != null) + { + TryCapture(extracted, cursor); + } + } + + private bool TryCapture(ISyncMember member, TraversalCursor cursor) + { + if (member is not ISyncRef syncRef) + { + return false; + } + + TryCapture(syncRef, cursor); + return true; + } + + private void TryCapture(ISyncRef syncRef, TraversalCursor cursor) + { + _collector.TryCapture(syncRef, cursor.Snapshot()); + } + + private bool ShouldVisitEnumerable(IEnumerable? enumerable) + { + if (enumerable == null || enumerable is string) + { + return false; + } + + return _visitedEnumerables.Add(enumerable); + } + + private static ISyncRef? TryExtractSyncRef(object candidate) + { + Type type = candidate.GetType(); + if (!type.IsGenericType || !type.Name.StartsWith("KeyValuePair", StringComparison.Ordinal)) + { + return null; + } + + PropertyInfo? valueProperty = type.GetProperty("Value"); + if (valueProperty == null) + { + return null; + } + + return valueProperty.GetValue(candidate) as ISyncRef; + } +} diff --git a/src/ReferenceReplacement/Logic/SyncCollectionTraversal.cs b/src/ReferenceReplacement/Logic/SyncCollectionTraversal.cs new file mode 100644 index 0000000..98f0076 --- /dev/null +++ b/src/ReferenceReplacement/Logic/SyncCollectionTraversal.cs @@ -0,0 +1,102 @@ +using System.Collections; + +using FrooxEngine; + +namespace ReferenceReplacement.Logic; + +internal static class SyncCollectionTraversal +{ + internal static bool TryVisitKnownCollections( + ISyncMember member, + TraversalCursor cursor, + ITraversalExceptionFilter exceptionFilter, + Func shouldVisitEnumerable, + Action visitEnumerable, + Action visitValue) + { + switch (member) + { + case ISyncList syncList: + VisitEnumerableProperty(syncList, static s => s.Elements, cursor, nameof(ISyncList.Elements), exceptionFilter, shouldVisitEnumerable, visitEnumerable); + return true; + case ISyncBag syncBag: + VisitEnumerableProperty(syncBag, static s => s.Elements, cursor, nameof(ISyncBag.Elements), exceptionFilter, shouldVisitEnumerable, visitEnumerable); + VisitEnumerableProperty(syncBag, static s => s.Values, cursor, nameof(ISyncBag.Values), exceptionFilter, shouldVisitEnumerable, visitEnumerable); + return true; + case ISyncDictionary syncDictionary: + VisitEnumerableProperty(syncDictionary, static d => d.BoxedEntries, cursor, nameof(ISyncDictionary.BoxedEntries), exceptionFilter, shouldVisitEnumerable, visitEnumerable); + VisitEnumerableProperty(syncDictionary, static d => d.Values, cursor, nameof(ISyncDictionary.Values), exceptionFilter, shouldVisitEnumerable, visitEnumerable); + return true; + case ISyncArray syncArray: + VisitSyncArray(syncArray, cursor, exceptionFilter, visitValue); + return true; + default: + return false; + } + } + + private static void VisitEnumerableProperty( + T owner, + Func accessor, + TraversalCursor parentCursor, + string propertyName, + ITraversalExceptionFilter exceptionFilter, + Func shouldVisitEnumerable, + Action visitEnumerable) + { + IEnumerable? enumerable; + try + { + enumerable = accessor(owner); + } + catch (Exception ex) when (exceptionFilter.ShouldIgnore(ex)) + { + return; + } + + if (enumerable == null) + { + return; + } + + if (shouldVisitEnumerable(enumerable)) + { + using TraversalCursor.SegmentScope scope = parentCursor.PushProperty(propertyName); + visitEnumerable(enumerable, parentCursor); + } + } + + private static void VisitSyncArray( + ISyncArray array, + TraversalCursor parentCursor, + ITraversalExceptionFilter exceptionFilter, + Action visitValue) + { + int count; + try + { + count = array.Count; + } + catch (Exception ex) when (exceptionFilter.ShouldIgnore(ex)) + { + return; + } + + using TraversalCursor.SegmentScope scope = parentCursor.PushProperty("Items"); + for (int index = 0; index < count; index++) + { + object? element; + try + { + element = array.GetElement(index); + } + catch (Exception ex) when (exceptionFilter.ShouldIgnore(ex)) + { + continue; + } + + using TraversalCursor.SegmentScope indexScope = parentCursor.PushIndex(index); + visitValue(element, parentCursor); + } + } +} diff --git a/src/ReferenceReplacement/Logic/TraversalCursor.cs b/src/ReferenceReplacement/Logic/TraversalCursor.cs new file mode 100644 index 0000000..0dd8ed5 --- /dev/null +++ b/src/ReferenceReplacement/Logic/TraversalCursor.cs @@ -0,0 +1,62 @@ +using System.Text; + +using FrooxEngine; + +namespace ReferenceReplacement.Logic; + +internal sealed class TraversalCursor +{ + private readonly StringBuilder _builder; + + private TraversalCursor(string rootLabel) + { + _builder = new StringBuilder(rootLabel); + } + + public static TraversalCursor FromSlot(Slot slot) => new(TraversalPath.DescribeSlot(slot)); + + public static TraversalCursor FromLabel(string label) => new(label ?? string.Empty); + + public SegmentScope PushComponent(Component component) => Push($"::{component.GetType().Name}"); + + public SegmentScope PushChild(string label) => Push($"/{label}"); + + public SegmentScope PushMember(string memberName) => Push($".{memberName}"); + + public SegmentScope PushProperty(string propertyName) => Push($".{propertyName}"); + + public SegmentScope PushIndex(int index) => Push($"[{index}]"); + + public string Snapshot() => _builder.ToString(); + + private SegmentScope Push(string segment) + { + int previousLength = _builder.Length; + _builder.Append(segment); + return new SegmentScope(_builder, previousLength); + } + + internal sealed class SegmentScope : IDisposable + { + private readonly StringBuilder _builder; + private readonly int _previousLength; + private bool _disposed; + + public SegmentScope(StringBuilder builder, int previousLength) + { + _builder = builder; + _previousLength = previousLength; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _builder.Length = _previousLength; + _disposed = true; + } + } +} diff --git a/src/ReferenceReplacement/Logic/TraversalExceptionFilter.cs b/src/ReferenceReplacement/Logic/TraversalExceptionFilter.cs new file mode 100644 index 0000000..cad6d8f --- /dev/null +++ b/src/ReferenceReplacement/Logic/TraversalExceptionFilter.cs @@ -0,0 +1,18 @@ +using System; + +namespace ReferenceReplacement.Logic; + +internal interface ITraversalExceptionFilter +{ + bool ShouldIgnore(Exception ex); +} + +internal sealed class DefaultTraversalExceptionFilter : ITraversalExceptionFilter +{ + public static DefaultTraversalExceptionFilter Instance { get; } = new(); + + public bool ShouldIgnore(Exception ex) + { + return ex is NotSupportedException or InvalidOperationException; + } +} diff --git a/src/ReferenceReplacement/ReferenceReplacement.csproj b/src/ReferenceReplacement/ReferenceReplacement.csproj index f574d28..7929511 100644 --- a/src/ReferenceReplacement/ReferenceReplacement.csproj +++ b/src/ReferenceReplacement/ReferenceReplacement.csproj @@ -1,22 +1,15 @@  - - net9.0 - enable - enable - 12.0 - false - false - - - - false - - $(DefineConstants);USE_RESONITE_HOT_RELOAD_LIB + + + <_Parameter1>ReferenceReplacement.Tests + + + $(ResolvedResonitePath)/Libraries/ResoniteModLoader.dll @@ -26,30 +19,12 @@ $(ResolvedResonitePath)/rml_libs/0Harmony.dll false - - $(ResolvedResonitePath)/FrooxEngine.dll - false - - - $(ResolvedResonitePath)/Elements.Core.dll - false - - - $(ResolvedResonitePath)/Renderite.Shared.dll - false - $(ResolvedResonitePath)/rml_libs/ResoniteHotReloadLib.dll false - - - <_Parameter1>ReferenceReplacement.Tests - - - diff --git a/src/ReferenceReplacement/ReferenceReplacementMod.cs b/src/ReferenceReplacement/ReferenceReplacementMod.cs index f4a29bd..03a2694 100644 --- a/src/ReferenceReplacement/ReferenceReplacementMod.cs +++ b/src/ReferenceReplacement/ReferenceReplacementMod.cs @@ -15,7 +15,7 @@ namespace ReferenceReplacement; public class ReferenceReplacementMod : ResoniteMod { - public const string VersionTag = "0.1.0"; + public static readonly string VersionTag = GitVersionInformation.FullSemVer; private const string HarmonyId = "com.nekometer.esnya.reference-replacement"; private const string CreationMenuCategory = "Editor"; private const string CreationMenuLabel = "Reference Replacement (Mod)"; diff --git a/tests/ReferenceReplacement.Tests/ReferenceReplacement.Tests.csproj b/tests/ReferenceReplacement.Tests/ReferenceReplacement.Tests.csproj index 0e2cd3b..621fc71 100644 --- a/tests/ReferenceReplacement.Tests/ReferenceReplacement.Tests.csproj +++ b/tests/ReferenceReplacement.Tests/ReferenceReplacement.Tests.csproj @@ -1,12 +1,5 @@  - - net9.0 - enable - enable - false - - @@ -20,19 +13,4 @@ - - - $(ResolvedResonitePath)/FrooxEngine.dll - true - - - $(ResolvedResonitePath)/Elements.Core.dll - true - - - $(ResolvedResonitePath)/Renderite.Shared.dll - true - - - diff --git a/tests/ReferenceReplacement.Tests/ReferenceScannerTests.cs b/tests/ReferenceReplacement.Tests/ReferenceScannerTests.cs index 2662a14..748ca19 100644 --- a/tests/ReferenceReplacement.Tests/ReferenceScannerTests.cs +++ b/tests/ReferenceReplacement.Tests/ReferenceScannerTests.cs @@ -110,6 +110,55 @@ public void ScanTraversesSyncArrayItems() Assert.Equal("Root.Array.Items[0]", match.Path); } + [Fact] + public void ScanSkipsCollectionsThatThrowIgnoredExceptions() + { + FakeWorldElement source = new("Source"); + FakeWorldElement replacement = new("Replacement"); + FakeThrowingSyncArray throwingArray = new("Array"); + HierarchyBlueprint blueprint = HierarchyBlueprint.Create("Root", new ISyncMember[] { throwingArray }, Array.Empty()); + + ReferenceScanResult result = ReferenceScanner.Scan(blueprint, source, replacement); + + Assert.Empty(result.Matches); + Assert.Equal(0, result.IncompatibleCount); + Assert.Equal(1, result.VisitedMembers); // member counted even if contents skipped + } + + [Fact] + public void ScanExtractsSyncRefFromKeyValuePair() + { + FakeWorldElement source = new("Source"); + FakeWorldElement replacement = new("Replacement"); + FakeSyncRef nestedRef = new("Nested", typeof(FakeWorldElement), source); + KeyValuePair pair = new("Key", nestedRef); + FakeSyncEnumerable enumerableMember = new("Collection", pair); + HierarchyBlueprint blueprint = HierarchyBlueprint.Create("Root", new ISyncMember[] { enumerableMember }, Array.Empty()); + + ReferenceScanResult result = ReferenceScanner.Scan(blueprint, source, replacement); + + SyncReferenceMatch match = Assert.Single(result.Matches); + Assert.Equal("Root.Collection[0]", match.Path); + } + + [Fact] + public void ScanDoesNotDuplicateMatches() + { + FakeWorldElement source = new("Source"); + FakeWorldElement replacement = new("Replacement"); + FakeSyncRef sharedRef = new("Shared", typeof(FakeWorldElement), source); + HierarchyBlueprint blueprint = HierarchyBlueprint.Create( + "Root", + new ISyncMember[] { sharedRef, sharedRef }, + Array.Empty()); + + ReferenceScanResult result = ReferenceScanner.Scan(blueprint, source, replacement); + + SyncReferenceMatch match = Assert.Single(result.Matches); + Assert.Equal("Root.Shared", match.Path); + Assert.Equal(2, result.VisitedMembers); + } + private class FakeWorldElement : IWorldElement { public FakeWorldElement(string name) @@ -282,11 +331,18 @@ public FakeSyncList(string name, params ISyncMember[] elements) : base(name) } public int Count => _elements.Count; + public Type ElementType => typeof(ISyncMember); public IEnumerable Elements => _elements; public ISyncMember GetElement(int index) => _elements[index]; public int IndexOfElement(ISyncMember element) => _elements.IndexOf(element); public ISyncMember AddElement() => throw new NotSupportedException(); + public ISyncMember InsertElement(int index) + { + _ = index; + throw new NotSupportedException(); + } + public void RemoveElement(int index) => throw new NotSupportedException(); public ISyncMember MoveElementToIndex(int oldIndex, int newIndex) => throw new NotSupportedException(); @@ -328,6 +384,13 @@ public FakeSyncDictionary(string name, params KeyValuePair[ public IEnumerable> BoxedEntries => _entries; public IEnumerable Values => _entries.Select(entry => entry.Value); + public Type KeyType => typeof(object); + + public ISyncMember Add(object key) + { + _ = key; + throw new NotSupportedException(); + } public ISyncMember TryGetMember(object key) { @@ -339,6 +402,38 @@ public ISyncMember TryGetMember(object key) throw new KeyNotFoundException(); } + public bool ContainsKey(object key) => _lookup.ContainsKey(key); + + public bool RemoveByKey(object key) + { + if (!_lookup.Remove(key)) + { + return false; + } + + int index = _entries.FindIndex(entry => Equals(entry.Key, key)); + if (index >= 0) + { + _entries.RemoveAt(index); + } + + return true; + } + + public int RemoveAll(Predicate> predicate) + { + return _entries.RemoveAll(entry => + { + if (!predicate(entry)) + { + return false; + } + + _lookup.Remove(entry.Key); + return true; + }); + } + public event SyncDictionaryElementEvent? ElementAdded { add { } @@ -365,4 +460,15 @@ public FakeSyncArray(string name, params object?[] items) : base(name) public object GetElement(int index) => _items[index]!; } + + private sealed class FakeThrowingSyncArray : FakeSyncMemberBase, ISyncArray + { + public FakeThrowingSyncArray(string name) : base(name) + { + } + + public int Count => throw new NotSupportedException(); + + public object GetElement(int index) => throw new NotSupportedException(); + } }