Skip to content

Commit def4d25

Browse files
HandyS11claude
andauthored
Map icons: RustMaps monument art via RustMapsApi.Assets (runtime SVG raster) (#46)
* Map icons: add RustMapsApi.Assets + Svg.Skia, bump RustMapsApi to beta.3 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Map icons: MonumentTokenMap (Rust+ token → RustMaps MonumentType) with drift guard Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Map icons: MonumentIconSource — Svg.Skia raster of RustMaps assets, cached, log-once misses Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Map icons: Lazy-wrap the raster cache (exactly-once per key) + cover null-cache and raster-failure paths Review fixes on MonumentIconSource: GetOrAdd's value factory could race under concurrent first requests, leaking a losing Image and double-firing the rasterization Warning — Lazy (ExecutionAndPublication) now guarantees one Rasterize per (type, size). Two new tests: an assetless mapped type caches null (one TryGetAsset call, one Info log across repeats), and a failing rasterization (bogus MonumentAsset via reflection — its ctor is internal and unvalidated) warns exactly once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Map icons: render monuments/rigs from RustMaps assets; drop 35 vendored PNGs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Map icons: formatter pass Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Map icons: surface no-icon Info in prod logs + prefix-family drift guards Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f9897fd commit def4d25

49 files changed

Lines changed: 467 additions & 145 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Packages.props

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
1414
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
1515
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta.1" />
16-
<PackageVersion Include="RustMapsApi" Version="1.0.0-beta.2" />
16+
<PackageVersion Include="RustMapsApi" Version="1.0.0-beta.3" />
17+
<PackageVersion Include="RustMapsApi.Assets" Version="1.0.0-beta.3" />
1718
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.4" />
1819
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.4" />
1920
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
@@ -22,10 +23,13 @@
2223
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
2324
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
2425
<PackageVersion Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
26+
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
27+
<PackageVersion Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.4" />
2528
<!-- Pinned to override the transitive 2.1.11 pulled by Microsoft.EntityFrameworkCore.Sqlite,
2629
whose bundled SQLite is vulnerable to CVE-2025-6965 (GHSA-2m69-gcr7-jv3q). 3.0.x bundles
2730
SQLite >= 3.50.2. Drop once EF Core ships a build that depends on a patched SQLitePCLRaw. -->
2831
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
32+
<PackageVersion Include="Svg.Skia" Version="5.1.1" />
2933
</ItemGroup>
3034
<ItemGroup>
3135
<!-- Test + sample -->

src/RustPlusBot.Features.Map/Assets/MapIcons.cs

Lines changed: 2 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
using System.Collections.Concurrent;
22
using RustPlusBot.Abstractions.Connections;
3-
using RustPlusBot.Abstractions.Events;
43
using SixLabors.ImageSharp;
54
using SixLabors.ImageSharp.PixelFormats;
65
using SixLabors.ImageSharp.Processing;
76

87
namespace RustPlusBot.Features.Map.Assets;
98

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

39-
/// <summary>Gets the rig icon for a rig kind, or null when there is no icon for that kind.</summary>
40-
/// <param name="kind">Which rig.</param>
41-
/// <param name="active">Whether the rig is currently active (reserved; activation styling is applied by the renderer).</param>
42-
/// <returns>The cached rig icon image, or null.</returns>
43-
/// <remarks>
44-
/// The <paramref name="active"/> parameter is reserved for future use: the renderer is responsible
45-
/// for overlaying activation styling; this method always returns the base icon regardless of state.
46-
/// </remarks>
47-
#pragma warning disable RCS1163, IDE0060 // Unused parameter — 'active' is part of the public API contract; activation styling is applied by the renderer, not here.
48-
public static Image<Rgba32>? Rig(RigKind kind, bool active) => kind switch
49-
{
50-
RigKind.Small => Load("oilrig"),
51-
RigKind.Large => Load("largeoilrig"),
52-
_ => null,
53-
};
54-
#pragma warning restore RCS1163, IDE0060
55-
56-
/// <summary>Gets the rig icon scaled to fit a square box, or null when the kind has no icon.</summary>
57-
/// <param name="kind">Which rig.</param>
58-
/// <param name="active">Whether the rig is active (reserved; styling is applied by the renderer).</param>
59-
/// <param name="size">The box edge length in pixels.</param>
60-
/// <returns>The cached scaled icon, or null.</returns>
61-
#pragma warning disable RCS1163, IDE0060 // 'active' is part of the API contract; styling is the renderer's job.
62-
public static Image<Rgba32>? Rig(RigKind kind, bool active, int size) =>
63-
Scaled(kind switch { RigKind.Small => "oilrig", RigKind.Large => "largeoilrig", _ => null }, size);
64-
#pragma warning restore RCS1163, IDE0060
65-
6639
/// <summary>Gets the travelling vendor icon.</summary>
6740
/// <returns>The cached vendor icon image, or null.</returns>
6841
public static Image<Rgba32>? Vendor() => Load("vendor");
@@ -76,21 +49,6 @@ public static class MapIcons
7649
/// <returns>The cached scaled icon, or null.</returns>
7750
public static Image<Rgba32>? Player(int size) => Scaled("player", size);
7851

79-
/// <summary>Gets the icon for a monument token, or null when the token is unmapped or the file is missing.</summary>
80-
/// <param name="token">The Rust+ monument protobuf token.</param>
81-
/// <returns>The cached icon image, or null.</returns>
82-
public static Image<Rgba32>? Monument(string token)
83-
{
84-
var key = MonumentIconMap.IconKeyFor(token);
85-
return key is null ? null : Load(key);
86-
}
87-
88-
/// <summary>Gets the monument icon scaled to fit a square box, or null for unmapped tokens.</summary>
89-
/// <param name="token">The Rust+ monument protobuf token.</param>
90-
/// <param name="size">The box edge length in pixels.</param>
91-
/// <returns>The cached scaled icon, or null.</returns>
92-
public static Image<Rgba32>? Monument(string token, int size) => Scaled(MonumentIconMap.IconKeyFor(token), size);
93-
9452
private static string? KeyFor(MarkerKind kind) => kind switch
9553
{
9654
MarkerKind.CargoShip => "cargo",

src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs

Lines changed: 0 additions & 49 deletions
This file was deleted.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
using System.Collections.Concurrent;
2+
using Microsoft.Extensions.Logging;
3+
using RustMapsApi.V4.Assets;
4+
using RustMapsApi.V4.Models;
5+
using RustPlusBot.Abstractions.Events;
6+
using SixLabors.ImageSharp;
7+
using SixLabors.ImageSharp.PixelFormats;
8+
using SkiaSharp;
9+
using Svg.Skia;
10+
11+
namespace RustPlusBot.Features.Map.Assets;
12+
13+
/// <summary>
14+
/// Serves monument icons from the RustMaps asset package: resolves a Rust+ token to a
15+
/// <see cref="MonumentType"/>, rasterizes the SVG at the requested size, and caches the result
16+
/// (including null misses). Rasterization runs exactly once per (type, size), even when concurrent
17+
/// first requests race. Cached images are immutable inputs the renderer draws from; never mutate them.
18+
/// Register as a singleton.
19+
/// </summary>
20+
/// <param name="assets">The RustMaps monument asset source.</param>
21+
/// <param name="logger">Logs tokens that resolve to no icon, once per token per process.</param>
22+
public sealed partial class MonumentIconSource(IMonumentAssetSource assets, ILogger<MonumentIconSource> logger)
23+
{
24+
private readonly ConcurrentDictionary<(MonumentType Type, int Size), Lazy<Image<Rgba32>?>> _cache = new();
25+
private readonly ConcurrentDictionary<string, byte> _reported = new(StringComparer.Ordinal);
26+
27+
/// <summary>Gets the icon for a monument token scaled to a square box, or null when no icon exists.</summary>
28+
/// <param name="token">The Rust+ monument protobuf token (or prefab name).</param>
29+
/// <param name="size">The box edge length in pixels.</param>
30+
/// <returns>The cached icon, or null (reported once per token).</returns>
31+
public Image<Rgba32>? Monument(string token, int size)
32+
{
33+
var type = MonumentTokenMap.TypeFor(token);
34+
if (type is null)
35+
{
36+
ReportOnce(token, type: null);
37+
return null;
38+
}
39+
40+
return For(type.Value, token, size);
41+
}
42+
43+
/// <summary>Gets the rig icon scaled to a square box, or null for an unknown kind.</summary>
44+
/// <param name="kind">Which rig.</param>
45+
/// <param name="size">The box edge length in pixels.</param>
46+
/// <returns>The cached rig icon, or null.</returns>
47+
public Image<Rgba32>? Rig(RigKind kind, int size) => kind switch
48+
{
49+
RigKind.Small => For(MonumentType.OilrigSmall, "oil_rig_small", size),
50+
RigKind.Large => For(MonumentType.OilrigLarge, "large_oil_rig", size),
51+
_ => null,
52+
};
53+
54+
private Image<Rgba32>? For(MonumentType type, string token, int size)
55+
{
56+
// GetOrAdd's value factory may run more than once under a first-request race; the Lazy wrapper
57+
// (ExecutionAndPublication) guarantees Rasterize itself runs exactly once per key, so no losing
58+
// image is leaked and the rasterization-failure Warning fires at most once per (type, size).
59+
var image = _cache
60+
.GetOrAdd((type, size), key => new Lazy<Image<Rgba32>?>(() => Rasterize(key.Type, key.Size)))
61+
.Value;
62+
if (image is null)
63+
{
64+
ReportOnce(token, type);
65+
}
66+
67+
return image;
68+
}
69+
70+
private void ReportOnce(string token, MonumentType? type)
71+
{
72+
if (_reported.TryAdd(token, 0))
73+
{
74+
LogNoIcon(token, type);
75+
}
76+
}
77+
78+
private Image<Rgba32>? Rasterize(MonumentType type, int size)
79+
{
80+
if (!assets.TryGetAsset(type, out var asset))
81+
{
82+
return null;
83+
}
84+
85+
try
86+
{
87+
using var svg = new SKSvg();
88+
using (var stream = asset.OpenStream())
89+
{
90+
if (svg.Load(stream) is null)
91+
{
92+
return null;
93+
}
94+
}
95+
96+
var bounds = svg.Picture!.CullRect;
97+
using var bitmap = new SKBitmap(new SKImageInfo(size, size, SKColorType.Rgba8888, SKAlphaType.Premul));
98+
using (var canvas = new SKCanvas(bitmap))
99+
{
100+
canvas.Clear(SKColors.Transparent);
101+
canvas.Scale(size / bounds.Width, size / bounds.Height);
102+
canvas.Translate(-bounds.Left, -bounds.Top);
103+
canvas.DrawPicture(svg.Picture);
104+
}
105+
106+
using var skImage = SKImage.FromBitmap(bitmap);
107+
using var png = skImage.Encode(SKEncodedImageFormat.Png, 100);
108+
using var ms = png.AsStream();
109+
return Image.Load<Rgba32>(ms);
110+
}
111+
#pragma warning disable CA1031 // Broad catch: a bad SVG must degrade to "no icon", never break a render.
112+
catch (Exception ex)
113+
#pragma warning restore CA1031
114+
{
115+
LogRasterFailed(type, ex);
116+
return null;
117+
}
118+
}
119+
120+
[LoggerMessage(Level = LogLevel.Information,
121+
Message = "No monument icon for token \"{Token}\" (mapped type: {Type}); skipped.")]
122+
private partial void LogNoIcon(string token, MonumentType? type);
123+
124+
[LoggerMessage(Level = LogLevel.Warning,
125+
Message = "Rasterizing the {Type} icon failed; the monument renders without an icon.")]
126+
private partial void LogRasterFailed(MonumentType type, Exception ex);
127+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using RustMapsApi.V4.Models;
2+
3+
namespace RustPlusBot.Features.Map.Assets;
4+
5+
/// <summary>
6+
/// Maps a Rust+ monument protobuf token to the RustMaps <see cref="MonumentType"/> whose icon
7+
/// represents it. Unknown tokens return null; the caller decides how to report them.
8+
/// </summary>
9+
/// <remarks>
10+
/// Token list sourced from the Rust+ app protocol as catalogued by the previous render map plus the
11+
/// rustplusplus and rustplus.py bot projects. Swamps and underwater labs have no fixed token — Rust+
12+
/// sends prefab names — so those two families match by prefix. Several <see cref="MonumentType"/>
13+
/// values share one asset (e.g. both harbors → Harbor); where the exact variant is unknowable from
14+
/// the token, the pick is arbitrary but fixed.
15+
/// </remarks>
16+
public static class MonumentTokenMap
17+
{
18+
private static readonly Dictionary<string, MonumentType> Map = new(StringComparer.Ordinal)
19+
{
20+
["AbandonedMilitaryBase"] = MonumentType.MilitaryBaseA,
21+
["airfield_display_name"] = MonumentType.Airfield,
22+
["arctic_base_a"] = MonumentType.ArcticResearchBaseA,
23+
["arctic_base_b"] = MonumentType.ArcticResearchBaseA,
24+
["bandit_camp"] = MonumentType.BanditTown,
25+
["dome_monument_name"] = MonumentType.SphereTank,
26+
["excavator"] = MonumentType.Excavator,
27+
["ferryterminal"] = MonumentType.FerryTerminal1,
28+
["fishing_village_display_name"] = MonumentType.FishingVillageA,
29+
["large_fishing_village_display_name"] = MonumentType.FishingVillageB,
30+
["gas_station"] = MonumentType.Gasstation,
31+
["harbor_display_name"] = MonumentType.HarborLarge,
32+
["harbor_2_display_name"] = MonumentType.HarborSmall,
33+
["jungle_ziggurat"] = MonumentType.JungleZigguratA,
34+
["junkyard_display_name"] = MonumentType.Junkyard,
35+
["large_oil_rig"] = MonumentType.OilrigLarge,
36+
["oil_rig_small"] = MonumentType.OilrigSmall,
37+
["launchsite"] = MonumentType.LaunchSite,
38+
["lighthouse_display_name"] = MonumentType.Lighthouse,
39+
["military_tunnels_display_name"] = MonumentType.MilitaryTunnels,
40+
["mining_outpost_display_name"] = MonumentType.Warehouse,
41+
["mining_quarry_hqm_display_name"] = MonumentType.HqmQuarry,
42+
["mining_quarry_stone_display_name"] = MonumentType.StoneQuarry,
43+
["mining_quarry_sulfur_display_name"] = MonumentType.SulfurQuarry,
44+
["missile_silo_monument"] = MonumentType.NuclearMissileSilo,
45+
["outpost"] = MonumentType.Outpost,
46+
["power_plant_display_name"] = MonumentType.Powerplant,
47+
["radtown"] = MonumentType.Radtown,
48+
["satellite_dish_display_name"] = MonumentType.SatelliteDish,
49+
["sewer_display_name"] = MonumentType.SewerBranch,
50+
["stables_a"] = MonumentType.StablesA,
51+
["stables_b"] = MonumentType.StablesB,
52+
["supermarket"] = MonumentType.Supermarket,
53+
["train_tunnel_display_name"] = MonumentType.TunnelEntrance,
54+
["train_tunnel_link_display_name"] = MonumentType.TunnelEntranceTransition,
55+
["train_yard_display_name"] = MonumentType.Trainyard,
56+
["water_treatment_plant_display_name"] = MonumentType.WaterTreatment,
57+
};
58+
59+
/// <summary>All exactly-mapped tokens (excludes the prefix families); exposed for drift-guard tests.</summary>
60+
internal static IReadOnlyCollection<string> KnownTokens => Map.Keys;
61+
62+
/// <summary>Gets the RustMaps monument type for a Rust+ token, or null when unmapped.</summary>
63+
/// <param name="token">The Rust+ monument protobuf token (or prefab name).</param>
64+
/// <returns>The monument type whose asset represents the token, or null.</returns>
65+
public static MonumentType? TypeFor(string? token)
66+
{
67+
if (string.IsNullOrEmpty(token))
68+
{
69+
return null;
70+
}
71+
72+
if (Map.TryGetValue(token, out var type))
73+
{
74+
return type;
75+
}
76+
77+
// Rust+ sends prefab names (not fixed tokens) for these families.
78+
if (token.StartsWith("swamp", StringComparison.Ordinal))
79+
{
80+
return MonumentType.SwampC;
81+
}
82+
83+
return token.StartsWith("underwater_lab", StringComparison.Ordinal) ? MonumentType.UnderwaterA : null;
84+
}
85+
}
-30.8 KB
Binary file not shown.
-35.1 KB
Binary file not shown.
-36.8 KB
Binary file not shown.
-30.4 KB
Binary file not shown.
-40.1 KB
Binary file not shown.

0 commit comments

Comments
 (0)