diff --git a/LibreMetaverse.Tests/GLTFMaterialUploadTests.cs b/LibreMetaverse.Tests/GLTFMaterialUploadTests.cs index 8a317774..09150bc0 100644 --- a/LibreMetaverse.Tests/GLTFMaterialUploadTests.cs +++ b/LibreMetaverse.Tests/GLTFMaterialUploadTests.cs @@ -25,9 +25,12 @@ */ using System; +using System.Linq; using System.Net; +using System.Text; using System.Threading.Tasks; using LibreMetaverse.Assets; +using LibreMetaverse.StructuredData; using LibreMetaverse.Tests.TestHelpers; using NUnit.Framework; @@ -38,9 +41,9 @@ namespace LibreMetaverse.Tests /// Verified against the reference viewer (LLMaterialEditor::updateInventoryItem in /// llmaterialeditor.cpp): a two-phase upload using the same LLBufferedAssetUploadInfo shape as /// UpdateNotecardAgentInventory/UpdateNotecardTaskInventory -- POST {"item_id"} (plus "task_id" - /// for the task variant) to the capability, which returns an "uploader" URL that the material's - /// GLTF JSON is then POSTed to; a final "state":"complete" response carries the new asset UUID - /// under "new_asset". + /// for the task variant) to the capability, which returns an "uploader" URL. The second POST + /// sends a binary LLSD map containing version, type, and the material JSON in data; a final + /// "state":"complete" response carries the new asset UUID under "new_asset". /// [TestFixture] public class GLTFMaterialUploadTests @@ -66,7 +69,7 @@ public void TearDown() } [Test] - public async Task RequestUpdateMaterialAgentInventoryAsync_HappyPath_PostsMetadataThenJsonAndReturnsNewAsset() + public async Task RequestUpdateMaterialAgentInventoryAsync_HappyPath_PostsWrappedAssetAndReturnsNewAsset() { var itemId = UUID.Random(); var newAsset = UUID.Random(); @@ -129,7 +132,7 @@ public void RequestUpdateMaterialAgentInventoryAsync_NoCapability_Throws() } [Test] - public async Task RequestUpdateMaterialAgentInventoryAsync_UploadedJsonRoundTripsMaterial() + public async Task RequestUpdateMaterialAgentInventoryAsync_UploadedAssetIsBinaryLlsdWrappedGltf() { var itemId = UUID.Random(); var newAsset = UUID.Random(); @@ -139,15 +142,32 @@ public async Task RequestUpdateMaterialAgentInventoryAsync_UploadedJsonRoundTrip _client.AddHttpResponse(new Uri(UploaderUrl), HttpStatusCode.OK, $"{{\"state\":\"complete\",\"new_asset\":\"{newAsset}\"}}", "application/json"); - var material = new AssetMaterial { Name = "My Material" }; + var material = new AssetMaterial { Name = "My Material \u0394" }; material.SetBaseColorFactor(new Color4(0.2f, 0.4f, 0.6f, 1f)); + var expectedJson = material.ToJson(); await _client.Inventory.RequestUpdateMaterialAgentInventoryAsync(material, itemId); - var uploadedJson = _client.CapturedRequests[1].Body; + var uploadedAsset = _client.CapturedRequestBodies[1]; + var binaryHeader = Encoding.ASCII.GetBytes("\n"); + Assert.That(uploadedAsset.Take(binaryHeader.Length), Is.EqualTo(binaryHeader)); + Assert.That(Encoding.ASCII.GetString(uploadedAsset).IndexOf( + "", binaryHeader.Length, StringComparison.Ordinal), Is.EqualTo(-1)); + + var asset = OSDParser.DeserializeLLSDBinary(uploadedAsset); + Assert.That(asset, Is.TypeOf()); + var assetMap = (OSDMap)asset; + Assert.That(assetMap.Count, Is.EqualTo(3)); + Assert.That(assetMap["version"].Type, Is.EqualTo(OSDType.String)); + Assert.That(assetMap["version"].AsString(), Is.EqualTo("1.1")); + Assert.That(assetMap["type"].Type, Is.EqualTo(OSDType.String)); + Assert.That(assetMap["type"].AsString(), Is.EqualTo("GLTF 2.0")); + Assert.That(assetMap["data"].Type, Is.EqualTo(OSDType.String)); + Assert.That(assetMap["data"].AsString(), Is.EqualTo(expectedJson)); + var roundTripped = new AssetMaterial(UUID.Random(), - System.Text.Encoding.UTF8.GetBytes(uploadedJson)); + Encoding.UTF8.GetBytes(assetMap["data"].AsString())); - Assert.That(roundTripped.Name, Is.EqualTo("My Material")); + Assert.That(roundTripped.Name, Is.EqualTo("My Material \u0394")); Assert.That(roundTripped.BaseColorFactor, Is.EqualTo(material.BaseColorFactor)); } } diff --git a/LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs b/LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs index cca37560..a0b1fde4 100644 --- a/LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs +++ b/LibreMetaverse.Tests/TestHelpers/FakeGridClient.cs @@ -19,6 +19,9 @@ internal class FakeHttpMessageHandler : HttpMessageHandler /// All requests received, in order. public List<(HttpMethod Method, Uri Uri, string Body)> CapturedRequests { get; } = new List<(HttpMethod, Uri, string)>(); + /// Raw request bodies received, in the same order as . + public List CapturedRequestBodies { get; } = new List(); + /// Register a response matched by exact URI string. public void AddResponse(Uri uri, HttpStatusCode status, string content, string mediaType = "application/json") { @@ -34,11 +37,23 @@ public void AddResponseForPath(string path, HttpStatusCode status, string conten protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var body = string.Empty; + var bodyBytes = Array.Empty(); if (request?.Content != null) + { +#if NET5_0_OR_GREATER + body = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + bodyBytes = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); +#else body = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + bodyBytes = await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false); +#endif + } if (request?.RequestUri != null) + { CapturedRequests.Add((request.Method, request.RequestUri, body)); + CapturedRequestBodies.Add(bodyBytes); + } // Exact match if (request?.RequestUri != null && _responses.TryGetValue(request.RequestUri.ToString(), out var entry)) @@ -103,6 +118,9 @@ public void AddHttpResponseForPath(string path, HttpStatusCode status, string co /// All HTTP requests received by this client, in order. public List<(HttpMethod Method, Uri Uri, string Body)> CapturedRequests => _fakeHandler.CapturedRequests; + /// Raw HTTP request bodies received, in the same order as . + public List CapturedRequestBodies => _fakeHandler.CapturedRequestBodies; + /// /// Injects an arbitrary named capability URI into CurrentSim's Caps, creating a fake /// CurrentSim/Caps pair first if none exists yet. Use for capabilities not covered by diff --git a/LibreMetaverse/Inventory/InventoryManager.Async.cs b/LibreMetaverse/Inventory/InventoryManager.Async.cs index 94dbf2f6..5933fb9a 100644 --- a/LibreMetaverse/Inventory/InventoryManager.Async.cs +++ b/LibreMetaverse/Inventory/InventoryManager.Async.cs @@ -302,11 +302,10 @@ private void SendCopyFromNotecardPacket(UUID objectID, UUID notecardID, UUID fol /// Saves a GLTF material to an existing agent inventory item via the /// UpdateMaterialAgentInventory capability. Mirrors /// LLMaterialEditor::updateInventoryItem's agent-inventory branch (llmaterialeditor.cpp): a - /// two-phase upload where the metadata POST (item_id) returns an "uploader" URL that the - /// material's minified GLTF JSON is then POSTed to. + /// two-phase upload where the metadata POST (item_id) returns an "uploader" URL. The second + /// POST sends a binary LLSD map containing version, type, and the material JSON in data. /// - /// The material to save; its JSON encoding () - /// is what gets uploaded + /// The material to save /// UUID of the existing inventory item to update /// Cancellation token /// Optional upload progress reporter @@ -319,7 +318,7 @@ private void SendCopyFromNotecardPacket(UUID objectID, UUID notecardID, UUID fol if (cap == null) throw new InvalidOperationException("UpdateMaterialAgentInventory capability is not currently available"); - var data = System.Text.Encoding.UTF8.GetBytes(material.ToJson()); + var data = EncodeMaterialAsset(material); var query = new OSDMap { { "item_id", OSD.FromUUID(materialItemID) } }; try { @@ -333,10 +332,10 @@ private void SendCopyFromNotecardPacket(UUID objectID, UUID notecardID, UUID fol /// /// Saves a GLTF material to an existing task (object) inventory item via the /// UpdateMaterialTaskInventory capability. Mirrors - /// LLMaterialEditor::updateInventoryItem's task-inventory branch (llmaterialeditor.cpp). + /// LLMaterialEditor::updateInventoryItem's task-inventory branch (llmaterialeditor.cpp) and + /// uses the same two-phase binary LLSD material envelope as the agent-inventory path. /// - /// The material to save; its JSON encoding () - /// is what gets uploaded + /// The material to save /// UUID of the existing task-inventory item to update /// UUID of the object (task) containing the item /// Cancellation token @@ -350,7 +349,7 @@ private void SendCopyFromNotecardPacket(UUID objectID, UUID notecardID, UUID fol if (cap == null) throw new InvalidOperationException("UpdateMaterialTaskInventory capability is not currently available"); - var data = System.Text.Encoding.UTF8.GetBytes(material.ToJson()); + var data = EncodeMaterialAsset(material); var query = new OSDMap { { "item_id", OSD.FromUUID(materialItemID) }, { "task_id", OSD.FromUUID(taskID) } }; try { @@ -361,6 +360,17 @@ private void SendCopyFromNotecardPacket(UUID objectID, UUID notecardID, UUID fol catch (Exception ex) { return (false, ex.Message, UUID.Zero, UUID.Zero); } } + private static byte[] EncodeMaterialAsset(AssetMaterial material) + { + var asset = new OSDMap + { + ["version"] = OSD.FromString("1.1"), + ["type"] = OSD.FromString("GLTF 2.0"), + ["data"] = OSD.FromString(material.ToJson()) + }; + return OSDParser.SerializeLLSDBinary(asset); + } + public async Task<(bool uploadSuccess, string uploadStatus, bool compileSuccess, List? compileMessages, UUID itemID, UUID assetID)> RequestUpdateScriptAgentInventoryAsync( byte[] data, UUID itemID, bool mono, CancellationToken cancellationToken = default, IProgress? progress = null) {