Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta.1" />
<PackageVersion Include="RustMapsApi" Version="1.0.0-beta.2" />
<PackageVersion Include="RustMapsApi" Version="1.0.0-beta.3" />
<PackageVersion Include="RustMapsApi.Assets" Version="1.0.0-beta.3" />
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.4" />
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.4" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
Expand All @@ -22,10 +23,13 @@
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageVersion Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.4" />
<!-- Pinned to override the transitive 2.1.11 pulled by Microsoft.EntityFrameworkCore.Sqlite,
whose bundled SQLite is vulnerable to CVE-2025-6965 (GHSA-2m69-gcr7-jv3q). 3.0.x bundles
SQLite >= 3.50.2. Drop once EF Core ships a build that depends on a patched SQLitePCLRaw. -->
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageVersion Include="Svg.Skia" Version="5.1.1" />
</ItemGroup>
<ItemGroup>
<!-- Test + sample -->
Expand Down
46 changes: 2 additions & 44 deletions src/RustPlusBot.Features.Map/Assets/MapIcons.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
using System.Collections.Concurrent;
using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;

namespace RustPlusBot.Features.Map.Assets;

/// <summary>
/// Loads vendored icon assets (embedded PNGs) once and serves them by marker kind or monument token.
/// Loads vendored icon assets (embedded PNGs) once and serves them by marker kind.
/// Cached images are immutable inputs the renderer draws from; never mutate them.
/// Monument and rig icons live in <see cref="MonumentIconSource"/>.
/// </summary>
/// <remarks>
/// <c>MapIcons.Marker</c> includes a <c>TravellingVendor</c> arm that returns the embedded vendor.png.
Expand All @@ -36,33 +36,6 @@ public static class MapIcons
/// <returns>The cached scaled icon, or null.</returns>
public static Image<Rgba32>? Marker(MarkerKind kind, int size) => Scaled(KeyFor(kind), size);

/// <summary>Gets the rig icon for a rig kind, or null when there is no icon for that kind.</summary>
/// <param name="kind">Which rig.</param>
/// <param name="active">Whether the rig is currently active (reserved; activation styling is applied by the renderer).</param>
/// <returns>The cached rig icon image, or null.</returns>
/// <remarks>
/// The <paramref name="active"/> parameter is reserved for future use: the renderer is responsible
/// for overlaying activation styling; this method always returns the base icon regardless of state.
/// </remarks>
#pragma warning disable RCS1163, IDE0060 // Unused parameter — 'active' is part of the public API contract; activation styling is applied by the renderer, not here.
public static Image<Rgba32>? Rig(RigKind kind, bool active) => kind switch
{
RigKind.Small => Load("oilrig"),
RigKind.Large => Load("largeoilrig"),
_ => null,
};
#pragma warning restore RCS1163, IDE0060

/// <summary>Gets the rig icon scaled to fit a square box, or null when the kind has no icon.</summary>
/// <param name="kind">Which rig.</param>
/// <param name="active">Whether the rig is active (reserved; styling is applied by the renderer).</param>
/// <param name="size">The box edge length in pixels.</param>
/// <returns>The cached scaled icon, or null.</returns>
#pragma warning disable RCS1163, IDE0060 // 'active' is part of the API contract; styling is the renderer's job.
public static Image<Rgba32>? Rig(RigKind kind, bool active, int size) =>
Scaled(kind switch { RigKind.Small => "oilrig", RigKind.Large => "largeoilrig", _ => null }, size);
#pragma warning restore RCS1163, IDE0060

/// <summary>Gets the travelling vendor icon.</summary>
/// <returns>The cached vendor icon image, or null.</returns>
public static Image<Rgba32>? Vendor() => Load("vendor");
Expand All @@ -76,21 +49,6 @@ public static class MapIcons
/// <returns>The cached scaled icon, or null.</returns>
public static Image<Rgba32>? Player(int size) => Scaled("player", size);

/// <summary>Gets the icon for a monument token, or null when the token is unmapped or the file is missing.</summary>
/// <param name="token">The Rust+ monument protobuf token.</param>
/// <returns>The cached icon image, or null.</returns>
public static Image<Rgba32>? Monument(string token)
{
var key = MonumentIconMap.IconKeyFor(token);
return key is null ? null : Load(key);
}

/// <summary>Gets the monument icon scaled to fit a square box, or null for unmapped tokens.</summary>
/// <param name="token">The Rust+ monument protobuf token.</param>
/// <param name="size">The box edge length in pixels.</param>
/// <returns>The cached scaled icon, or null.</returns>
public static Image<Rgba32>? Monument(string token, int size) => Scaled(MonumentIconMap.IconKeyFor(token), size);

private static string? KeyFor(MarkerKind kind) => kind switch
{
MarkerKind.CargoShip => "cargo",
Expand Down
49 changes: 0 additions & 49 deletions src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs

This file was deleted.

127 changes: 127 additions & 0 deletions src/RustPlusBot.Features.Map/Assets/MonumentIconSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using RustMapsApi.V4.Assets;
using RustMapsApi.V4.Models;
using RustPlusBot.Abstractions.Events;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SkiaSharp;
using Svg.Skia;

namespace RustPlusBot.Features.Map.Assets;

/// <summary>
/// Serves monument icons from the RustMaps asset package: resolves a Rust+ token to a
/// <see cref="MonumentType"/>, rasterizes the SVG at the requested size, and caches the result
/// (including null misses). Rasterization runs exactly once per (type, size), even when concurrent
/// first requests race. Cached images are immutable inputs the renderer draws from; never mutate them.
/// Register as a singleton.
/// </summary>
/// <param name="assets">The RustMaps monument asset source.</param>
/// <param name="logger">Logs tokens that resolve to no icon, once per token per process.</param>
public sealed partial class MonumentIconSource(IMonumentAssetSource assets, ILogger<MonumentIconSource> logger)
{
private readonly ConcurrentDictionary<(MonumentType Type, int Size), Lazy<Image<Rgba32>?>> _cache = new();
private readonly ConcurrentDictionary<string, byte> _reported = new(StringComparer.Ordinal);

/// <summary>Gets the icon for a monument token scaled to a square box, or null when no icon exists.</summary>
/// <param name="token">The Rust+ monument protobuf token (or prefab name).</param>
/// <param name="size">The box edge length in pixels.</param>
/// <returns>The cached icon, or null (reported once per token).</returns>
public Image<Rgba32>? Monument(string token, int size)
{
var type = MonumentTokenMap.TypeFor(token);
if (type is null)
{
ReportOnce(token, type: null);
return null;
}

return For(type.Value, token, size);
}

/// <summary>Gets the rig icon scaled to a square box, or null for an unknown kind.</summary>
/// <param name="kind">Which rig.</param>
/// <param name="size">The box edge length in pixels.</param>
/// <returns>The cached rig icon, or null.</returns>
public Image<Rgba32>? Rig(RigKind kind, int size) => kind switch
{
RigKind.Small => For(MonumentType.OilrigSmall, "oil_rig_small", size),
RigKind.Large => For(MonumentType.OilrigLarge, "large_oil_rig", size),
_ => null,
};

private Image<Rgba32>? For(MonumentType type, string token, int size)
{
// GetOrAdd's value factory may run more than once under a first-request race; the Lazy wrapper
// (ExecutionAndPublication) guarantees Rasterize itself runs exactly once per key, so no losing
// image is leaked and the rasterization-failure Warning fires at most once per (type, size).
var image = _cache
.GetOrAdd((type, size), key => new Lazy<Image<Rgba32>?>(() => Rasterize(key.Type, key.Size)))
.Value;
if (image is null)
{
ReportOnce(token, type);
}

return image;
}

private void ReportOnce(string token, MonumentType? type)
{
if (_reported.TryAdd(token, 0))
{
LogNoIcon(token, type);
}
}

private Image<Rgba32>? Rasterize(MonumentType type, int size)
{
if (!assets.TryGetAsset(type, out var asset))
{
return null;
}

try
{
using var svg = new SKSvg();
using (var stream = asset.OpenStream())
{
if (svg.Load(stream) is null)
{
return null;
}
}

var bounds = svg.Picture!.CullRect;
using var bitmap = new SKBitmap(new SKImageInfo(size, size, SKColorType.Rgba8888, SKAlphaType.Premul));
using (var canvas = new SKCanvas(bitmap))
{
canvas.Clear(SKColors.Transparent);
canvas.Scale(size / bounds.Width, size / bounds.Height);
canvas.Translate(-bounds.Left, -bounds.Top);
canvas.DrawPicture(svg.Picture);
}

using var skImage = SKImage.FromBitmap(bitmap);
using var png = skImage.Encode(SKEncodedImageFormat.Png, 100);
using var ms = png.AsStream();
return Image.Load<Rgba32>(ms);
}
#pragma warning disable CA1031 // Broad catch: a bad SVG must degrade to "no icon", never break a render.
catch (Exception ex)
#pragma warning restore CA1031
{
LogRasterFailed(type, ex);
return null;
}
}

[LoggerMessage(Level = LogLevel.Information,
Message = "No monument icon for token \"{Token}\" (mapped type: {Type}); skipped.")]
private partial void LogNoIcon(string token, MonumentType? type);

[LoggerMessage(Level = LogLevel.Warning,
Message = "Rasterizing the {Type} icon failed; the monument renders without an icon.")]
private partial void LogRasterFailed(MonumentType type, Exception ex);
}
85 changes: 85 additions & 0 deletions src/RustPlusBot.Features.Map/Assets/MonumentTokenMap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using RustMapsApi.V4.Models;

namespace RustPlusBot.Features.Map.Assets;

/// <summary>
/// Maps a Rust+ monument protobuf token to the RustMaps <see cref="MonumentType"/> whose icon
/// represents it. Unknown tokens return null; the caller decides how to report them.
/// </summary>
/// <remarks>
/// Token list sourced from the Rust+ app protocol as catalogued by the previous render map plus the
/// rustplusplus and rustplus.py bot projects. Swamps and underwater labs have no fixed token — Rust+
/// sends prefab names — so those two families match by prefix. Several <see cref="MonumentType"/>
/// values share one asset (e.g. both harbors → Harbor); where the exact variant is unknowable from
/// the token, the pick is arbitrary but fixed.
/// </remarks>
public static class MonumentTokenMap
{
private static readonly Dictionary<string, MonumentType> Map = new(StringComparer.Ordinal)
{
["AbandonedMilitaryBase"] = MonumentType.MilitaryBaseA,
["airfield_display_name"] = MonumentType.Airfield,
["arctic_base_a"] = MonumentType.ArcticResearchBaseA,
["arctic_base_b"] = MonumentType.ArcticResearchBaseA,
["bandit_camp"] = MonumentType.BanditTown,
["dome_monument_name"] = MonumentType.SphereTank,
["excavator"] = MonumentType.Excavator,
["ferryterminal"] = MonumentType.FerryTerminal1,
["fishing_village_display_name"] = MonumentType.FishingVillageA,
["large_fishing_village_display_name"] = MonumentType.FishingVillageB,
["gas_station"] = MonumentType.Gasstation,
["harbor_display_name"] = MonumentType.HarborLarge,
["harbor_2_display_name"] = MonumentType.HarborSmall,
["jungle_ziggurat"] = MonumentType.JungleZigguratA,
["junkyard_display_name"] = MonumentType.Junkyard,
["large_oil_rig"] = MonumentType.OilrigLarge,
["oil_rig_small"] = MonumentType.OilrigSmall,
["launchsite"] = MonumentType.LaunchSite,
["lighthouse_display_name"] = MonumentType.Lighthouse,
["military_tunnels_display_name"] = MonumentType.MilitaryTunnels,
["mining_outpost_display_name"] = MonumentType.Warehouse,
["mining_quarry_hqm_display_name"] = MonumentType.HqmQuarry,
["mining_quarry_stone_display_name"] = MonumentType.StoneQuarry,
["mining_quarry_sulfur_display_name"] = MonumentType.SulfurQuarry,
["missile_silo_monument"] = MonumentType.NuclearMissileSilo,
["outpost"] = MonumentType.Outpost,
["power_plant_display_name"] = MonumentType.Powerplant,
["radtown"] = MonumentType.Radtown,
["satellite_dish_display_name"] = MonumentType.SatelliteDish,
["sewer_display_name"] = MonumentType.SewerBranch,
["stables_a"] = MonumentType.StablesA,
["stables_b"] = MonumentType.StablesB,
["supermarket"] = MonumentType.Supermarket,
["train_tunnel_display_name"] = MonumentType.TunnelEntrance,
["train_tunnel_link_display_name"] = MonumentType.TunnelEntranceTransition,
["train_yard_display_name"] = MonumentType.Trainyard,
["water_treatment_plant_display_name"] = MonumentType.WaterTreatment,
};

/// <summary>All exactly-mapped tokens (excludes the prefix families); exposed for drift-guard tests.</summary>
internal static IReadOnlyCollection<string> KnownTokens => Map.Keys;

/// <summary>Gets the RustMaps monument type for a Rust+ token, or null when unmapped.</summary>
/// <param name="token">The Rust+ monument protobuf token (or prefab name).</param>
/// <returns>The monument type whose asset represents the token, or null.</returns>
public static MonumentType? TypeFor(string? token)
{
if (string.IsNullOrEmpty(token))
{
return null;
}

if (Map.TryGetValue(token, out var type))
{
return type;
}

// Rust+ sends prefab names (not fixed tokens) for these families.
if (token.StartsWith("swamp", StringComparison.Ordinal))
{
return MonumentType.SwampC;
}

return token.StartsWith("underwater_lab", StringComparison.Ordinal) ? MonumentType.UnderwaterA : null;
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed src/RustPlusBot.Features.Map/Assets/icons/dome.png
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed src/RustPlusBot.Features.Map/Assets/icons/oilrig.png
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Diff not rendered.
Binary file removed src/RustPlusBot.Features.Map/Assets/icons/stable.png
Diff not rendered.
Diff not rendered.
Diff not rendered.
Diff not rendered.
Diff not rendered.
Diff not rendered.
Diff not rendered.
Diff not rendered.
Diff not rendered.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Abstractions.Map;
using RustPlusBot.Features.Map.Assets;
using RustPlusBot.Features.Map.Composing;
using RustPlusBot.Features.Map.Hosting;
using RustPlusBot.Features.Map.Posting;
Expand All @@ -26,6 +27,8 @@ public static IServiceCollection AddMap(this IServiceCollection services, IConfi
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);

services.AddRustMapsAssets();
services.AddSingleton<MonumentIconSource>();
services.AddSingleton<MapRenderer>();

// RustMaps is NOT a base-map source (the #map render draws its own layers on the Rust+ tile).
Expand Down
Loading
Loading