refactor(pipeline): MP1a - Content-owned upload enums; drop Silk.NET from Content
Coordinator-directed follow-up: AcDream.Content must stay Silk.NET-free (the MP1b bake tool must not ship GL binaries). The Silk.NET.OpenGL PackageReference added for the PixelFormat?/PixelType? upload hints is replaced by Content-owned UploadPixelFormat/UploadPixelType enums (UploadFormats.cs) whose underlying values are the GL ABI constants (Rgba = 0x1908, UnsignedByte = 0x1401), verified numerically identical to the Silk.NET.OpenGL members against 2.23.0. This is the one sanctioned edit to the verbatim-moved Prepare* bodies: enum literal for enum literal, numeric value identical, behavior unchanged. App casts at the single upload boundary (AddTexture call in UploadGfxObjMeshData) via lifted nullable enum conversion — value- and null-preserving. Also hardens the MP1a _sideStaged hand-off seam: List -> ConcurrentQueue. One MeshExtractor is shared by up to MaxParallelLoads (4) decode workers; the original code enqueued to the thread-safe _stagedMeshData directly, so the hand-off buffer must be thread-safe too. Drain ordering verified: side-staged entries enqueue BEFORE the top-level result, preserving the original mid-Prepare FIFO order. Verified: grep -i silk on the csproj -> no matches; deps.json has zero Silk entries; dotnet build 0 errors; full test suite green (4059 passed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
30cc1e282e
commit
1477cda60a
5 changed files with 89 additions and 50 deletions
|
|
@ -807,7 +807,13 @@ namespace AcDream.App.Rendering.Wb {
|
|||
atlasList.Add(atlasManager);
|
||||
}
|
||||
|
||||
textureIndex = atlasManager.AddTexture(batch.Key, batch.TextureData, batch.UploadPixelFormat, batch.UploadPixelType);
|
||||
// MP1a: AcDream.Content is Silk.NET-free — the extraction records
|
||||
// carry Content-owned UploadPixelFormat/UploadPixelType enums whose
|
||||
// underlying values are the GL ABI constants (numerically identical
|
||||
// to Silk.NET.OpenGL.PixelFormat/PixelType), so this lifted nullable
|
||||
// cast is value- and null-preserving.
|
||||
textureIndex = atlasManager.AddTexture(batch.Key, batch.TextureData,
|
||||
(PixelFormat?)batch.UploadPixelFormat, (PixelType?)batch.UploadPixelType);
|
||||
|
||||
if (_useModernRendering) {
|
||||
ibo = GlobalBuffer!.IBO;
|
||||
|
|
|
|||
|
|
@ -12,14 +12,12 @@
|
|||
exact versions (mirror AcDream.App.csproj — keep in lockstep). -->
|
||||
<PackageReference Include="BCnEncoder.Net.ImageSharp" Version="1.1.2" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<!-- MP1a (Task 3, compiler-demanded): MeshBatchData/TextureBatchData carry
|
||||
Silk.NET.OpenGL.PixelFormat?/PixelType? upload-format hints computed
|
||||
during CPU extraction (which format to hand the eventual GL upload).
|
||||
These are plain enums with no GL context/device/unsafe surface — the
|
||||
plan's own boundary survey says the only GL-DOMAIN reference in the
|
||||
Prepare* region is TextureAtlasManager.TextureKey; this is the same
|
||||
class of dependency (a data-only enum), not a functional GL call. -->
|
||||
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
||||
<!-- NO GL binding packages here — ever. This assembly is the GL-free
|
||||
extraction half; the MP1b bake tool references it and must not ship
|
||||
GL binaries. Upload-format hints use the Content-owned
|
||||
UploadPixelFormat / UploadPixelType enums (UploadFormats.cs),
|
||||
numerically identical to the GL ABI constants; App casts at the
|
||||
upload boundary. -->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ using System.Linq;
|
|||
using System.Numerics;
|
||||
using System.Threading;
|
||||
using CullMode = DatReaderWriter.Enums.CullMode;
|
||||
using PixelFormat = Silk.NET.OpenGL.PixelFormat;
|
||||
using PixelType = Silk.NET.OpenGL.PixelType;
|
||||
using BoundingBox = Chorizite.Core.Lib.BoundingBox;
|
||||
|
||||
namespace AcDream.Content;
|
||||
|
|
@ -50,8 +48,12 @@ public sealed class MeshExtractor {
|
|||
/// <see cref="DrainSideStaged"/> immediately after each Prepare* call and
|
||||
/// enqueues the results itself. No behavior change — same objects reach
|
||||
/// the same queue, just via an explicit hand-off instead of a shared field.
|
||||
/// ConcurrentQueue because one MeshExtractor is shared by up to
|
||||
/// MaxParallelLoads (4) decode workers — the original enqueued to the
|
||||
/// thread-safe _stagedMeshData, so the hand-off buffer must be
|
||||
/// thread-safe too.
|
||||
/// </summary>
|
||||
private readonly List<ObjectMeshData> _sideStaged = new();
|
||||
private readonly ConcurrentQueue<ObjectMeshData> _sideStaged = new();
|
||||
|
||||
public MeshExtractor(IDatReaderWriter dats, ILogger logger) {
|
||||
_dats = dats;
|
||||
|
|
@ -64,9 +66,10 @@ public sealed class MeshExtractor {
|
|||
/// <see cref="_sideStaged"/>.
|
||||
/// </summary>
|
||||
public List<ObjectMeshData> DrainSideStaged() {
|
||||
if (_sideStaged.Count == 0) return new List<ObjectMeshData>();
|
||||
var result = new List<ObjectMeshData>(_sideStaged);
|
||||
_sideStaged.Clear();
|
||||
var result = new List<ObjectMeshData>();
|
||||
while (_sideStaged.TryDequeue(out var item)) {
|
||||
result.Add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -173,13 +176,13 @@ public sealed class MeshExtractor {
|
|||
if (emitter.HwGfxObjId.DataId != 0) {
|
||||
var meshData = PrepareMeshData(emitter.HwGfxObjId.DataId, false, ct);
|
||||
if (meshData != null) {
|
||||
_sideStaged.Add(meshData);
|
||||
_sideStaged.Enqueue(meshData);
|
||||
}
|
||||
}
|
||||
if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) {
|
||||
var meshData = PrepareMeshData(emitter.GfxObjId.DataId, false, ct);
|
||||
if (meshData != null) {
|
||||
_sideStaged.Add(meshData);
|
||||
_sideStaged.Enqueue(meshData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -368,8 +371,8 @@ public sealed class MeshExtractor {
|
|||
int texWidth, texHeight;
|
||||
byte[] textureData;
|
||||
TextureFormat textureFormat;
|
||||
PixelFormat? uploadPixelFormat = null;
|
||||
PixelType? uploadPixelType = null;
|
||||
UploadPixelFormat? uploadPixelFormat = null;
|
||||
UploadPixelType? uploadPixelType = null;
|
||||
bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid);
|
||||
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
|
||||
uint paletteId = 0;
|
||||
|
|
@ -382,7 +385,7 @@ public sealed class MeshExtractor {
|
|||
texWidth = texHeight = 32;
|
||||
textureData = TextureHelpers.CreateSolidColorTexture(surface.ColorValue, texWidth, texHeight);
|
||||
textureFormat = TextureFormat.RGBA8;
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
}
|
||||
else if (_dats.Portal.TryGet<SurfaceTexture>(surface.OrigTextureId, out var surfaceTexture)) {
|
||||
var renderSurfaceId = surfaceTexture.Textures.First();
|
||||
|
|
@ -403,7 +406,7 @@ public sealed class MeshExtractor {
|
|||
if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) {
|
||||
isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
||||
textureFormat = TextureFormat.RGBA8;
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
|
||||
if (_decodedTextureCache.TryGetValue(renderSurfaceId, out textureData!)) {
|
||||
// use cached data
|
||||
|
|
@ -446,36 +449,36 @@ public sealed class MeshExtractor {
|
|||
case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
|
||||
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var paletteData))
|
||||
throw new Exception($"Unable to load Palette: 0x{renderSurface.DefaultPaletteId:X8}");
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
|
||||
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var p8PaletteData))
|
||||
throw new Exception($"Unable to load Palette: 0x{renderSurface.DefaultPaletteId:X8}");
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_A8:
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA:
|
||||
|
|
@ -486,7 +489,7 @@ public sealed class MeshExtractor {
|
|||
else {
|
||||
TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
}
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unsupported surface format: {renderSurface.Format}");
|
||||
|
|
@ -722,8 +725,8 @@ public sealed class MeshExtractor {
|
|||
int texWidth, texHeight;
|
||||
byte[] textureData;
|
||||
TextureFormat textureFormat;
|
||||
PixelFormat? uploadPixelFormat = null;
|
||||
PixelType? uploadPixelType = null;
|
||||
UploadPixelFormat? uploadPixelFormat = null;
|
||||
UploadPixelType? uploadPixelType = null;
|
||||
bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid);
|
||||
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
|
||||
uint paletteId = 0;
|
||||
|
|
@ -736,7 +739,7 @@ public sealed class MeshExtractor {
|
|||
texWidth = texHeight = 32;
|
||||
textureData = TextureHelpers.CreateSolidColorTexture(surface.ColorValue, texWidth, texHeight);
|
||||
textureFormat = TextureFormat.RGBA8;
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
}
|
||||
else if (_dats.Portal.TryGet<SurfaceTexture>(surface.OrigTextureId, out var surfaceTexture)) {
|
||||
var renderSurfaceId = surfaceTexture.Textures.First();
|
||||
|
|
@ -757,13 +760,13 @@ public sealed class MeshExtractor {
|
|||
if (_decodedTextureCache.TryGetValue(renderSurfaceId, out var cachedData)) {
|
||||
textureData = cachedData;
|
||||
textureFormat = TextureFormat.RGBA8;
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
}
|
||||
else {
|
||||
if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) {
|
||||
isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
||||
textureFormat = TextureFormat.RGBA8;
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
CompressionFormat compressionFormat = renderSurface.Format switch {
|
||||
|
|
@ -784,34 +787,34 @@ public sealed class MeshExtractor {
|
|||
case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
|
||||
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var paletteData)) return;
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
|
||||
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var p8PaletteData)) return;
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_A8:
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA:
|
||||
|
|
@ -822,7 +825,7 @@ public sealed class MeshExtractor {
|
|||
else {
|
||||
TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
}
|
||||
uploadPixelFormat = PixelFormat.Rgba;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
default: return;
|
||||
}
|
||||
|
|
@ -1144,8 +1147,8 @@ public sealed class MeshExtractor {
|
|||
},
|
||||
TextureIndex = 0,
|
||||
TextureData = transparentTexture,
|
||||
UploadPixelFormat = PixelFormat.Rgba,
|
||||
UploadPixelType = PixelType.UnsignedByte,
|
||||
UploadPixelFormat = UploadPixelFormat.Rgba,
|
||||
UploadPixelType = UploadPixelType.UnsignedByte,
|
||||
CullMode = CullMode.None
|
||||
}
|
||||
},
|
||||
|
|
@ -1161,8 +1164,8 @@ public sealed class MeshExtractor {
|
|||
IsSolid = true
|
||||
},
|
||||
TextureData = transparentTexture,
|
||||
UploadPixelFormat = PixelFormat.Rgba,
|
||||
UploadPixelType = PixelType.UnsignedByte,
|
||||
UploadPixelFormat = UploadPixelFormat.Rgba,
|
||||
UploadPixelType = UploadPixelType.UnsignedByte,
|
||||
CullMode = CullMode.None,
|
||||
IsTransparent = false // Render in opaque pass but transparent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ using System.Collections.Generic;
|
|||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using BoundingBox = Chorizite.Core.Lib.BoundingBox;
|
||||
using PixelFormat = Silk.NET.OpenGL.PixelFormat;
|
||||
using PixelType = Silk.NET.OpenGL.PixelType;
|
||||
|
||||
namespace AcDream.Content;
|
||||
|
||||
|
|
@ -104,8 +102,8 @@ public class MeshBatchData {
|
|||
public TextureKey TextureKey { get; set; }
|
||||
public int TextureIndex { get; set; }
|
||||
public byte[] TextureData { get; set; } = Array.Empty<byte>();
|
||||
public PixelFormat? UploadPixelFormat { get; set; }
|
||||
public PixelType? UploadPixelType { get; set; }
|
||||
public UploadPixelFormat? UploadPixelFormat { get; set; }
|
||||
public UploadPixelType? UploadPixelType { get; set; }
|
||||
public DatReaderWriter.Enums.CullMode CullMode { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -115,8 +113,8 @@ public class MeshBatchData {
|
|||
public class TextureBatchData {
|
||||
public TextureKey Key { get; set; }
|
||||
public byte[] TextureData { get; set; } = Array.Empty<byte>();
|
||||
public PixelFormat? UploadPixelFormat { get; set; }
|
||||
public PixelType? UploadPixelType { get; set; }
|
||||
public UploadPixelFormat? UploadPixelFormat { get; set; }
|
||||
public UploadPixelType? UploadPixelType { get; set; }
|
||||
public List<ushort> Indices { get; set; } = new();
|
||||
public DatReaderWriter.Enums.CullMode CullMode { get; set; }
|
||||
public bool IsTransparent { get; set; }
|
||||
|
|
|
|||
34
src/AcDream.Content/UploadFormats.cs
Normal file
34
src/AcDream.Content/UploadFormats.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
namespace AcDream.Content;
|
||||
|
||||
// MP1a follow-up (2026-07-05): Content-owned upload-format hint enums.
|
||||
// AcDream.Content must stay Silk.NET-free (the MP1b bake tool must not ship
|
||||
// GL binaries), so the moved MeshBatchData/TextureBatchData records carry
|
||||
// these instead of Silk.NET.OpenGL.PixelFormat/PixelType. The underlying
|
||||
// values are the OpenGL ABI constants, kept numerically identical to the
|
||||
// corresponding Silk.NET.OpenGL members (verified against Silk.NET.OpenGL
|
||||
// 2.23.0) so the App-boundary cast
|
||||
// `(Silk.NET.OpenGL.PixelFormat?)batch.UploadPixelFormat` is
|
||||
// value-preserving. GL enum constants are a stable specification ABI —
|
||||
// they cannot drift between Silk.NET versions.
|
||||
//
|
||||
// Members are ONLY the values the extraction code actually assigns
|
||||
// (see MeshExtractor.cs). Add new members with their GL constant if a
|
||||
// future decode path needs them — never renumber.
|
||||
|
||||
/// <summary>
|
||||
/// GL pixel-format upload hint computed at extraction time.
|
||||
/// Numeric values = OpenGL <c>GL_*</c> constants (= Silk.NET.OpenGL.PixelFormat).
|
||||
/// </summary>
|
||||
public enum UploadPixelFormat {
|
||||
/// <summary>GL_RGBA.</summary>
|
||||
Rgba = 0x1908,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GL pixel-type upload hint computed at extraction time.
|
||||
/// Numeric values = OpenGL <c>GL_*</c> constants (= Silk.NET.OpenGL.PixelType).
|
||||
/// </summary>
|
||||
public enum UploadPixelType {
|
||||
/// <summary>GL_UNSIGNED_BYTE.</summary>
|
||||
UnsignedByte = 0x1401,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue