Add a map renderer (#3613)

Co-authored-by: github-actions <github-actions@users.noreply.github.com>
This commit is contained in:
Javier Guardia Fernández
2022-01-07 18:54:06 +01:00
committed by GitHub
parent 4b732dda8f
commit c30c8020e8
11 changed files with 696 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<OutputPath>..\bin\Content.MapRenderer\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Content.IntegrationTests\Content.IntegrationTests.csproj" />
<ProjectReference Include="..\RobustToolbox\Robust.UnitTesting\Robust.UnitTesting.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.4" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,32 @@
using System.IO;
using System.Reflection;
namespace Content.MapRenderer.Extensions
{
public static class DirectoryExtensions
{
public static DirectoryInfo RepositoryRoot()
{
// space-station-14/bin/Content.MapRenderer/Content.MapRenderer.dll
var currentLocation = Assembly.GetExecutingAssembly().Location;
// space-station-14
return Directory.GetParent(currentLocation)!.Parent!.Parent!;
}
public static DirectoryInfo Resources()
{
return new DirectoryInfo($"{RepositoryRoot()}{Path.DirectorySeparatorChar}Resources");
}
public static DirectoryInfo Maps()
{
return new DirectoryInfo($"{Resources()}{Path.DirectorySeparatorChar}Maps");
}
public static DirectoryInfo MapImages()
{
return new DirectoryInfo($"{Resources()}{Path.DirectorySeparatorChar}MapImages");
}
}
}

View File

@@ -0,0 +1,20 @@
#nullable enable
using System;
using System.Diagnostics.CodeAnalysis;
namespace Content.MapRenderer.Extensions
{
public static class EnvironmentExtensions
{
public static bool TryGetVariable(string key, [NotNullWhen(true)] out string? value)
{
return (value = Environment.GetEnvironmentVariable(key)) != null;
}
public static string GetVariableOrThrow(string key)
{
return Environment.GetEnvironmentVariable(key) ??
throw new ArgumentException($"No environment variable found with key {key}");
}
}
}

View File

@@ -0,0 +1,20 @@
using Robust.Client.GameObjects;
namespace Content.MapRenderer.Painters
{
public class EntityData
{
public EntityData(SpriteComponent sprite, float x, float y)
{
Sprite = sprite;
X = x;
Y = y;
}
public SpriteComponent Sprite { get; }
public float X { get; }
public float Y { get; }
}
}

View File

@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using Content.Shared.SubFloor;
using Robust.Client.ResourceManagement;
using Robust.Shared.GameObjects;
using Robust.Shared.Timing;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using static Robust.Client.Graphics.RSI.State;
using static Robust.UnitTesting.RobustIntegrationTest;
namespace Content.MapRenderer.Painters;
public class EntityPainter
{
private readonly IResourceCache _cResourceCache;
private readonly Dictionary<(string path, string state), Image> _images;
private readonly Image _errorImage;
private readonly IEntityManager _sEntityManager;
public EntityPainter(ClientIntegrationInstance client, ServerIntegrationInstance server)
{
_cResourceCache = client.ResolveDependency<IResourceCache>();
_sEntityManager = server.ResolveDependency<IEntityManager>();
_images = new Dictionary<(string path, string state), Image>();
_errorImage = Image.Load<Rgba32>(_cResourceCache.ContentFileRead("/Textures/error.rsi/error.png"));
}
public void Run(Image canvas, List<EntityData> entities)
{
var stopwatch = new Stopwatch();
stopwatch.Start();
// TODO cache this shit what are we insane
entities.Sort(Comparer<EntityData>.Create((x, y) => x.Sprite.DrawDepth.CompareTo(y.Sprite.DrawDepth)));
foreach (var entity in entities)
{
Run(canvas, entity);
}
Console.WriteLine($"{nameof(GridPainter)} painted {entities.Count} entities in {(int) stopwatch.Elapsed.TotalMilliseconds} ms");
}
public void Run(Image canvas, EntityData entity)
{
if (_sEntityManager.HasComponent<SubFloorHideComponent>(entity.Sprite.Owner))
{
return;
}
if (!entity.Sprite.Visible || entity.Sprite.ContainerOccluded)
{
return;
}
var worldRotation = _sEntityManager.GetComponent<TransformComponent>(entity.Sprite.Owner).WorldRotation;
foreach (var layer in entity.Sprite.AllLayers)
{
if (!layer.Visible)
{
continue;
}
if (!layer.RsiState.IsValid)
{
continue;
}
var rsi = layer.ActualRsi;
Image image;
if (rsi == null || rsi.Path == null || !rsi.TryGetState(layer.RsiState, out var state))
{
image = _errorImage;
}
else
{
var key = (rsi.Path!.ToString(), state.StateId.Name!);
if (!_images.TryGetValue(key, out image!))
{
var stream = _cResourceCache.ContentFileRead($"{rsi.Path}/{state.StateId}.png");
image = Image.Load<Rgba32>(stream);
_images[key] = image;
}
}
image = image.CloneAs<Rgba32>();
var directions = entity.Sprite.GetLayerDirectionCount(layer);
// TODO add support for 8 directions and animations (delays)
if (directions != 1 && directions != 8)
{
double xStart, xEnd, yStart, yEnd;
switch (directions)
{
case 4:
{
var dir = layer.EffectiveDirection(worldRotation);
(xStart, xEnd, yStart, yEnd) = dir switch
{
// Only need the first tuple as doubles for the compiler to recognize it
Direction.South => (0d, 0.5d, 0d, 0.5d),
Direction.East => (0, 0.5, 0.5, 1),
Direction.North => (0.5, 1, 0, 0.5),
Direction.West => (0.5, 1, 0.5, 1),
_ => throw new ArgumentOutOfRangeException(nameof(dir))
};
break;
}
default:
throw new ArgumentOutOfRangeException();
}
var x = (int) (image.Width * xStart);
var width = (int) (image.Width * xEnd) - x;
var y = (int) (image.Height * yStart);
var height = (int) (image.Height * yEnd) - y;
image.Mutate(o => o.Crop(new Rectangle(x, y, width, height)));
}
var colorMix = entity.Sprite.Color * layer.Color;
var imageColor = Color.FromRgba(colorMix.RByte, colorMix.GByte, colorMix.BByte, colorMix.AByte);
var coloredImage = new Image<Rgba32>(image.Width, image.Height);
coloredImage.Mutate(o => o.BackgroundColor(imageColor));
image.Mutate(o => o
.DrawImage(coloredImage, PixelColorBlendingMode.Multiply, PixelAlphaCompositionMode.SrcAtop, 1)
.Resize(32, 32)
.Flip(FlipMode.Vertical));
var pointX = (int) entity.X;
var pointY = (int) entity.Y;
canvas.Mutate(o => o.DrawImage(image, new Point(pointX, pointY), 1));
}
}
}

View File

@@ -0,0 +1,103 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Robust.Client.GameObjects;
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Timing;
using SixLabors.ImageSharp;
using static Robust.UnitTesting.RobustIntegrationTest;
namespace Content.MapRenderer.Painters
{
public class GridPainter
{
private readonly EntityPainter _entityPainter;
private readonly IEntityManager _cEntityManager;
private readonly IMapManager _cMapManager;
private readonly IEntityManager _sEntityManager;
private readonly ConcurrentDictionary<GridId, List<EntityData>> _entities;
public GridPainter(ClientIntegrationInstance client, ServerIntegrationInstance server)
{
_entityPainter = new EntityPainter(client, server);
_cEntityManager = client.ResolveDependency<IEntityManager>();
_cMapManager = client.ResolveDependency<IMapManager>();
_sEntityManager = server.ResolveDependency<IEntityManager>();
_entities = GetEntities();
}
public void Run(Image gridCanvas, IMapGrid grid)
{
var stopwatch = new Stopwatch();
stopwatch.Start();
if (!_entities.TryGetValue(grid.Index, out var entities))
{
Console.WriteLine($"No entities found on grid {grid.Index}");
return;
}
_entityPainter.Run(gridCanvas, entities);
Console.WriteLine($"{nameof(GridPainter)} painted grid {grid.Index} in {(int) stopwatch.Elapsed.TotalMilliseconds} ms");
}
private ConcurrentDictionary<GridId, List<EntityData>> GetEntities()
{
var stopwatch = new Stopwatch();
stopwatch.Start();
var components = new ConcurrentDictionary<GridId, List<EntityData>>();
foreach (var entity in _sEntityManager.GetEntities())
{
if (!_sEntityManager.HasComponent<ISpriteComponent>(entity))
{
continue;
}
var prototype = _sEntityManager.GetComponent<MetaDataComponent>(entity).EntityPrototype;
if (prototype == null)
{
continue;
}
if (!_cEntityManager.TryGetComponent(entity, out SpriteComponent sprite))
{
throw new InvalidOperationException(
$"No sprite component found on an entity for which a server sprite component exists. Prototype id: {prototype.ID}");
}
var xOffset = 0;
var yOffset = 0;
var tileSize = 1;
var transform = _sEntityManager.GetComponent<TransformComponent>(entity);
if (_cMapManager.TryGetGrid(transform.GridID, out var grid))
{
xOffset = (int) Math.Abs(grid.LocalBounds.Left);
yOffset = (int) Math.Abs(grid.LocalBounds.Bottom);
tileSize = grid.TileSize;
}
var position = transform.LocalPosition;
var x = ((float) Math.Floor(position.X) + xOffset) * tileSize * TilePainter.TileImageSize;
var y = ((float) Math.Floor(position.Y) + yOffset) * tileSize * TilePainter.TileImageSize;
var data = new EntityData(sprite, x, y);
components.GetOrAdd(transform.GridID, _ => new List<EntityData>()).Add(data);
}
Console.WriteLine($"Found {components.Values.Sum(l => l.Count)} entities on {components.Count} grids in {(int) stopwatch.Elapsed.TotalMilliseconds} ms");
return components;
}
}
}

View File

@@ -0,0 +1,151 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Content.IntegrationTests;
using Content.Shared.CCVar;
using Robust.Client.GameObjects;
using Robust.Server.GameObjects;
using Robust.Server.Player;
using Robust.Shared;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Maths;
using Robust.Shared.Timing;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SpriteComponent = Robust.Server.GameObjects.SpriteComponent;
namespace Content.MapRenderer.Painters
{
public class MapPainter : ContentIntegrationTest
{
public async IAsyncEnumerable<Image> Paint(string map)
{
var stopwatch = new Stopwatch();
stopwatch.Start();
var clientOptions = new ClientContentIntegrationOption
{
CVarOverrides =
{
[CVars.NetPVS.Name] = "false"
},
Pool = false,
FailureLogLevel = LogLevel.Fatal
};
var serverOptions = new ServerContentIntegrationOption
{
CVarOverrides =
{
[CCVars.GameMap.Name] = map,
[CVars.NetPVS.Name] = "false"
},
Pool = false,
FailureLogLevel = LogLevel.Fatal
};
var (client, server) = await StartConnectedServerClientPair(clientOptions, serverOptions);
await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync());
await RunTicksSync(client, server, 10);
await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync());
Console.WriteLine($"Loaded client and server in {(int) stopwatch.Elapsed.TotalMilliseconds} ms");
stopwatch.Restart();
var cEntityManager = client.ResolveDependency<IClientEntityManager>();
var cPlayerManager = client.ResolveDependency<Robust.Client.Player.IPlayerManager>();
await client.WaitPost(() =>
{
if (cEntityManager.TryGetComponent(cPlayerManager.LocalPlayer!.ControlledEntity!, out Robust.Client.GameObjects.SpriteComponent? sprite))
{
sprite.Visible = false;
}
});
var sEntityManager = server.ResolveDependency<IServerEntityManager>();
var sPlayerManager = server.ResolveDependency<IPlayerManager>();
await server.WaitPost(() =>
{
if (sEntityManager.TryGetComponent(sPlayerManager.ServerSessions.Single().AttachedEntity!, out SpriteComponent? sprite))
{
sprite.Visible = false;
}
});
await RunTicksSync(client, server, 10);
await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync());
var sMapManager = server.ResolveDependency<IMapManager>();
var tilePainter = new TilePainter(client, server);
var entityPainter = new GridPainter(client, server);
IMapGrid[] grids = null!;
await server.WaitPost(() =>
{
var playerEntity = sPlayerManager.ServerSessions.Single().AttachedEntity;
if (playerEntity.HasValue)
{
sEntityManager.DeleteEntity(playerEntity.Value);
}
grids = sMapManager.GetAllMapGrids(new MapId(1)).ToArray();
foreach (var grid in grids)
{
grid.WorldRotation = Angle.Zero;
}
});
await RunTicksSync(client, server, 10);
await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync());
foreach (var grid in grids)
{
var tileXSize = grid.TileSize * TilePainter.TileImageSize;
var tileYSize = grid.TileSize * TilePainter.TileImageSize;
var bounds = grid.LocalBounds;
var left = bounds.Left;
var right = bounds.Right;
var top = bounds.Top;
var bottom = bounds.Bottom;
var w = (int) Math.Ceiling(right - left) * tileXSize;
var h = (int) Math.Ceiling(top - bottom) * tileYSize;
var gridCanvas = new Image<Rgba32>(w, h);
await server.WaitPost(() =>
{
tilePainter.Run(gridCanvas, grid);
entityPainter.Run(gridCanvas, grid);
gridCanvas.Mutate(e => e.Flip(FlipMode.Vertical));
});
yield return gridCanvas;
}
// We don't care if it fails as we have already saved the images.
try
{
await OneTimeTearDown();
}
catch
{
// ignored
}
}
}
}

View File

@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Shared.Map;
using Robust.Shared.Timing;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using static Robust.UnitTesting.RobustIntegrationTest;
namespace Content.MapRenderer.Painters
{
public class TilePainter
{
private const string TilesPath = "/Textures/Tiles/";
public const int TileImageSize = EyeManager.PixelsPerMeter;
private readonly ITileDefinitionManager _sTileDefinitionManager;
private readonly IResourceCache _cResourceCache;
public TilePainter(ClientIntegrationInstance client, ServerIntegrationInstance server)
{
_sTileDefinitionManager = server.ResolveDependency<ITileDefinitionManager>();
_cResourceCache = client.ResolveDependency<IResourceCache>();
}
public void Run(Image gridCanvas, IMapGrid grid)
{
var stopwatch = new Stopwatch();
stopwatch.Start();
var bounds = grid.LocalBounds;
var xOffset = Math.Abs(bounds.Left);
var yOffset = Math.Abs(bounds.Bottom);
var tileSize = grid.TileSize * TileImageSize;
var images = GetTileImages(_sTileDefinitionManager, _cResourceCache, tileSize);
var i = 0;
grid.GetAllTiles().AsParallel().ForAll(tile =>
{
var x = (int) (tile.X + xOffset);
var y = (int) (tile.Y + yOffset);
var sprite = _sTileDefinitionManager[tile.Tile.TypeId].SpriteName;
var image = images[sprite];
gridCanvas.Mutate(o => o.DrawImage(image, new Point(x * tileSize, y * tileSize), 1));
i++;
});
Console.WriteLine($"{nameof(TilePainter)} painted {i} tiles on grid {grid.Index} in {(int) stopwatch.Elapsed.TotalMilliseconds} ms");
}
private Dictionary<string, Image> GetTileImages(
ITileDefinitionManager tileDefinitionManager,
IResourceCache resourceCache,
int tileSize)
{
var stopwatch = new Stopwatch();
stopwatch.Start();
var images = new Dictionary<string, Image>();
foreach (var definition in tileDefinitionManager)
{
var sprite = definition.SpriteName;
if (string.IsNullOrEmpty(sprite))
{
continue;
}
using var stream = resourceCache.ContentFileRead($"{TilesPath}{sprite}.png");
Image tileImage = Image.Load<Rgba32>(stream);
if (tileImage.Width != tileSize || tileImage.Height != tileSize)
{
throw new NotSupportedException($"Unable to use tiles with a dimension other than {tileSize}x{tileSize}.");
}
images[sprite] = tileImage;
}
Console.WriteLine($"Indexed all tile images in {(int) stopwatch.Elapsed.TotalMilliseconds} ms");
return images;
}
}
}

View File

@@ -0,0 +1,102 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Content.MapRenderer.Extensions;
using Content.MapRenderer.Painters;
using SixLabors.ImageSharp;
namespace Content.MapRenderer
{
internal class Program
{
private const string MapsAddedEnvKey = "FILES_ADDED";
private const string MapsModifiedEnvKey = "FILES_MODIFIED";
private static readonly MapPainter MapPainter = new();
#pragma warning disable CA1825
private static readonly string[] ForceRender = {"saltern"};
#pragma warning restore CA1825
internal static async Task Main()
{
await Run();
}
private static async Task Run()
{
// var created = Environment.GetEnvironmentVariable(MapsAddedEnvKey);
// var modified = Environment.GetEnvironmentVariable(MapsModifiedEnvKey);
//
// var yamlStream = new YamlStream();
//
// if (created != null)
// {
// yamlStream.Load(new StringReader(created));
// }
//
// if (modified != null)
// {
// yamlStream.Load(new StringReader(modified));
// }
//
// var files = new YamlSequenceNode();
//
// foreach (var doc in yamlStream.Documents)
// {
// var filesModified = (YamlSequenceNode) doc.RootNode;
//
// foreach (var node in filesModified)
// {
// files.Add(node);
// }
// }
var maps = new List<string>(ForceRender);
// foreach (var node in files)
// {
// var fileName = node.AsString();
//
// if (!fileName.StartsWith("Resources/Maps/") ||
// !fileName.EndsWith("yml"))
// {
// continue;
// }
//
// maps.Add(fileName);
// }
Console.WriteLine($"Creating images for {maps.Count} maps");
var mapNames = new List<string>();
foreach (var map in maps)
{
Console.WriteLine($"Painting map {map}");
await foreach (var grid in MapPainter.Paint(map))
{
var directory = DirectoryExtensions.MapImages().FullName;
Directory.CreateDirectory(directory);
var fileName = Path.GetFileNameWithoutExtension(map);
var savePath = $"{directory}{Path.DirectorySeparatorChar}{fileName}.png";
Console.WriteLine($"Writing grid of size {grid.Width}x{grid.Height} to {savePath}");
await grid.SaveAsPngAsync(savePath);
grid.Dispose();
mapNames.Add(fileName);
}
}
var mapNamesString = $"[{string.Join(',', mapNames.Select(s => $"\"{s}\""))}]";
Console.WriteLine($@"::set-output name=map_names::{mapNamesString}");
Console.WriteLine($"Created {maps.Count} map images.");
}
}
}

View File

@@ -100,6 +100,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pow3r", "Pow3r\Pow3r.csproj
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Content.Shared.Database", "Content.Shared.Database\Content.Shared.Database.csproj", "{8842381D-3426-4BA8-93DA-599AB14D88E9}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Content.Shared.Database", "Content.Shared.Database\Content.Shared.Database.csproj", "{8842381D-3426-4BA8-93DA-599AB14D88E9}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Content.MapRenderer", "Content.MapRenderer\Content.MapRenderer.csproj", "{199BBEA1-7627-434B-B6F6-0F52A7C0E1E0}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -233,6 +235,10 @@ Global
{8842381D-3426-4BA8-93DA-599AB14D88E9}.Debug|Any CPU.Build.0 = Debug|Any CPU {8842381D-3426-4BA8-93DA-599AB14D88E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8842381D-3426-4BA8-93DA-599AB14D88E9}.Release|Any CPU.ActiveCfg = Release|Any CPU {8842381D-3426-4BA8-93DA-599AB14D88E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8842381D-3426-4BA8-93DA-599AB14D88E9}.Release|Any CPU.Build.0 = Release|Any CPU {8842381D-3426-4BA8-93DA-599AB14D88E9}.Release|Any CPU.Build.0 = Release|Any CPU
{199BBEA1-7627-434B-B6F6-0F52A7C0E1E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{199BBEA1-7627-434B-B6F6-0F52A7C0E1E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{199BBEA1-7627-434B-B6F6-0F52A7C0E1E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{199BBEA1-7627-434B-B6F6-0F52A7C0E1E0}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@@ -257,6 +257,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=Pullable/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Pullable/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Reparenting/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Reparenting/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Ruinable/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Ruinable/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=saltern/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=sandboxing/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=sandboxing/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Serilog/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Serilog/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=singulo/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=singulo/@EntryIndexedValue">True</s:Boolean>