diff --git a/Directory.Packages.props b/Directory.Packages.props
index b795f3c..4a05189 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,11 +12,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
+
\ No newline at end of file
diff --git a/SharpEmu.slnx b/SharpEmu.slnx
index 3c45742..045ee20 100644
--- a/SharpEmu.slnx
+++ b/SharpEmu.slnx
@@ -12,4 +12,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
+
diff --git a/src/SharpEmu.Libs/Json/JsonExports.cs b/src/SharpEmu.Libs/Json/JsonExports.cs
index 78c65d5..c62262b 100644
--- a/src/SharpEmu.Libs/Json/JsonExports.cs
+++ b/src/SharpEmu.Libs/Json/JsonExports.cs
@@ -78,6 +78,30 @@ public static class JsonExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
+ // sce::Json::Initializer::setGlobalNullAccessCallback(const Value& (*)(ValueType, const Value*, void*), void*)
+ // Registers the guest hook invoked when a Value is accessed as the wrong type. Quake calls it
+ // during kexPSNWebAPI::Initialize and treats a non-zero return as a fatal init failure.
+ [SysAbiExport(
+ Nid = "+drDFyAS6u4",
+ ExportName = "_ZN3sce4Json11Initializer27setGlobalNullAccessCallbackEPFRKNS0_5ValueENS0_9ValueTypeEPS3_PvES7_",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceJson")]
+ public static int InitializerSetGlobalNullAccessCallback(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ if (thisAddress == 0)
+ {
+ ctx[CpuRegister.Rax] = unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
+ }
+
+ JsonObjectHeap.GlobalNullAccessCallback = ctx[CpuRegister.Rsi];
+ JsonObjectHeap.GlobalNullAccessCallbackContext = ctx[CpuRegister.Rdx];
+ TraceJson("Initializer.setGlobalNullAccessCallback", thisAddress, ctx[CpuRegister.Rsi]);
+ ctx[CpuRegister.Rax] = 0;
+ return (int)OrbisGen2Result.ORBIS_GEN2_OK;
+ }
+
[SysAbiExport(
Nid = "WSOuge5IsCg",
ExportName = "_ZN3sce4Json14InitParameter2C1Ev",
diff --git a/src/SharpEmu.Libs/Json/JsonValueExports.cs b/src/SharpEmu.Libs/Json/JsonValueExports.cs
new file mode 100644
index 0000000..f781a24
--- /dev/null
+++ b/src/SharpEmu.Libs/Json/JsonValueExports.cs
@@ -0,0 +1,234 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE;
+
+namespace SharpEmu.Libs.Json;
+
+// sce::Json::Value and sce::Json::String constructors, setters and destructors. Prospero titles
+// (Quake among them) build a Value tree and populate it through these before serializing it for a
+// web request; without them the imports resolve to nothing and the guest faults on the call. The
+// payload is modelled in JsonObjectHeap; here we only translate the C++ ABI (registers in, `this`
+// back out) and never write the guest object, so a stack-allocated Value/String is left intact.
+//
+// Only the complete-object variants (C1/D1) are bound — those are what standalone locals emit and
+// what the observed Prospero imports use. The base-object variants (C2/D2) are left for a title
+// that actually imports them.
+public static class JsonValueExports
+{
+ private const int MaxStringLength = 0x10000;
+
+ private static int ReturnThis(CpuContext ctx, ulong thisAddress)
+ {
+ ctx[CpuRegister.Rax] = thisAddress;
+ return (int)OrbisGen2Result.ORBIS_GEN2_OK;
+ }
+
+ private static int ReturnVoid(CpuContext ctx)
+ {
+ ctx[CpuRegister.Rax] = 0;
+ return (int)OrbisGen2Result.ORBIS_GEN2_OK;
+ }
+
+ private static double ReadDoubleArg(CpuContext ctx)
+ {
+ ctx.GetXmmRegister(0, out var low, out _);
+ return BitConverter.Int64BitsToDouble(unchecked((long)low));
+ }
+
+ private static string ReadCString(CpuContext ctx, ulong address) =>
+ ctx.TryReadNullTerminatedUtf8(address, MaxStringLength, out var text) ? text : string.Empty;
+
+ // ---- sce::Json::Value constructors ----
+
+ [SysAbiExport(Nid = "qBMjqyBn3OM", ExportName = "_ZN3sce4Json5ValueC1Ev",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueDefaultConstructor(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.Null);
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "UeuWT+yNdCQ", ExportName = "_ZN3sce4Json5ValueC1Eb",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueBooleanConstructor(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromBoolean((ctx[CpuRegister.Rsi] & 0xFF) != 0));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "0lLK8+kDqmE", ExportName = "_ZN3sce4Json5ValueC1El",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueIntegerConstructor(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromInteger(unchecked((long)ctx[CpuRegister.Rsi])));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "x4AUdbhpRB0", ExportName = "_ZN3sce4Json5ValueC1Em",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueUnsignedConstructor(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromUnsignedInteger(ctx[CpuRegister.Rsi]));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "sOmU4vnx3s0", ExportName = "_ZN3sce4Json5ValueC1Ed",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueRealConstructor(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromReal(ReadDoubleArg(ctx)));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "b9V6fmppLXY", ExportName = "_ZN3sce4Json5ValueC1EPKc",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueCStringConstructor(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromString(ReadCString(ctx, ctx[CpuRegister.Rsi])));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "CbrT3dwDILo", ExportName = "_ZN3sce4Json5ValueC1ENS0_9ValueTypeE",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueTypeConstructor(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromExplicitType(unchecked((uint)ctx[CpuRegister.Rsi])));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "sZIoMRGO+jk", ExportName = "_ZN3sce4Json5ValueC1ERKNS0_6StringE",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueStringConstructor(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromString(JsonObjectHeap.GetStringOrEmpty(ctx[CpuRegister.Rsi])));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "WTtYf+cNnXI", ExportName = "_ZN3sce4Json5ValueD1Ev",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueDestructor(CpuContext ctx)
+ {
+ JsonObjectHeap.RemoveValue(ctx[CpuRegister.Rdi]);
+ return ReturnVoid(ctx);
+ }
+
+ // ---- sce::Json::Value setters (return Value&, i.e. `this`) ----
+
+ [SysAbiExport(Nid = "5yHuiWXo2gg", ExportName = "_ZN3sce4Json5Value3setEb",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueSetBoolean(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromBoolean((ctx[CpuRegister.Rsi] & 0xFF) != 0));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "QxVVYhP-mvg", ExportName = "_ZN3sce4Json5Value3setEl",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueSetInteger(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromInteger(unchecked((long)ctx[CpuRegister.Rsi])));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "SIe1ZmW7e7s", ExportName = "_ZN3sce4Json5Value3setEm",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueSetUnsigned(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromUnsignedInteger(ctx[CpuRegister.Rsi]));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "BSmWDIkV4w4", ExportName = "_ZN3sce4Json5Value3setEd",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueSetReal(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromReal(ReadDoubleArg(ctx)));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "IKQimvG9Wqs", ExportName = "_ZN3sce4Json5Value3setENS0_9ValueTypeE",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueSetType(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromExplicitType(unchecked((uint)ctx[CpuRegister.Rsi])));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "n6FC+l9DU70", ExportName = "_ZN3sce4Json5Value3setEPKc",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueSetCString(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromString(ReadCString(ctx, ctx[CpuRegister.Rsi])));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "6l3Bv2gysNc", ExportName = "_ZN3sce4Json5Value3setERKNS0_6StringE",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueSetString(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.FromString(JsonObjectHeap.GetStringOrEmpty(ctx[CpuRegister.Rsi])));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "FIjXN2TkuTs", ExportName = "_ZN3sce4Json5Value5clearEv",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int ValueClear(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetValue(thisAddress, JsonValueState.Null);
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ // ---- sce::Json::String ----
+
+ [SysAbiExport(Nid = "9KUZFjI1IxA", ExportName = "_ZN3sce4Json6StringC1EPKc",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int StringCStringConstructor(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetString(thisAddress, ReadCString(ctx, ctx[CpuRegister.Rsi]));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "qSmqLXXCPas", ExportName = "_ZN3sce4Json6StringC1Ev",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int StringDefaultConstructor(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetString(thisAddress, string.Empty);
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "0CAesfH963Q", ExportName = "_ZN3sce4Json6StringC1ERKS1_",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int StringCopyConstructor(CpuContext ctx)
+ {
+ var thisAddress = ctx[CpuRegister.Rdi];
+ JsonObjectHeap.SetString(thisAddress, JsonObjectHeap.GetStringOrEmpty(ctx[CpuRegister.Rsi]));
+ return ReturnThis(ctx, thisAddress);
+ }
+
+ [SysAbiExport(Nid = "cG1VE2HMl6c", ExportName = "_ZN3sce4Json6StringD1Ev",
+ Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceJson")]
+ public static int StringDestructor(CpuContext ctx)
+ {
+ JsonObjectHeap.RemoveString(ctx[CpuRegister.Rdi]);
+ return ReturnVoid(ctx);
+ }
+}
diff --git a/src/SharpEmu.Libs/Json/JsonValueModel.cs b/src/SharpEmu.Libs/Json/JsonValueModel.cs
new file mode 100644
index 0000000..b5415d8
--- /dev/null
+++ b/src/SharpEmu.Libs/Json/JsonValueModel.cs
@@ -0,0 +1,110 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Collections.Concurrent;
+
+namespace SharpEmu.Libs.Json;
+
+// sce::Json::Value is an opaque variant type (null / bool / signed / unsigned / real / string /
+// array / object). Games build one, populate it through set()/ctors and later serialize it. We
+// model the payload host-side keyed by the guest `this` pointer instead of writing into the guest
+// object: the object is often stack-allocated and its real byte layout is unknown, so writing a
+// guessed layout risks smashing an adjacent stack canary (the same failure the AudioOut2 context
+// param sizing note in this project already ran into). The guest reaches the payload only through
+// libSceJson methods, so shadowing by address is enough for the build path.
+internal enum JsonValueKind : byte
+{
+ Null = 0,
+ Boolean = 1,
+ Integer = 2,
+ UInteger = 3,
+ Real = 4,
+ String = 5,
+
+ // set(ValueType) / Value(ValueType): the guest chose the type itself. We keep its raw enum
+ // value verbatim rather than mapping it, because the canonical ValueType constants are not
+ // known from clean-room evidence and round-tripping the guest's own value is what matters.
+ ExplicitType = 6,
+}
+
+internal readonly struct JsonValueState
+{
+ private JsonValueState(
+ JsonValueKind kind,
+ bool boolean = false,
+ long integer = 0,
+ ulong unsignedInteger = 0,
+ double real = 0,
+ string? text = null,
+ uint explicitType = 0)
+ {
+ Kind = kind;
+ Boolean = boolean;
+ Integer = integer;
+ UnsignedInteger = unsignedInteger;
+ Real = real;
+ Text = text;
+ ExplicitType = explicitType;
+ }
+
+ public JsonValueKind Kind { get; }
+ public bool Boolean { get; }
+ public long Integer { get; }
+ public ulong UnsignedInteger { get; }
+ public double Real { get; }
+ public string? Text { get; }
+ public uint ExplicitType { get; }
+
+ public static JsonValueState Null { get; } = new(JsonValueKind.Null);
+
+ public static JsonValueState FromBoolean(bool value) => new(JsonValueKind.Boolean, boolean: value);
+
+ public static JsonValueState FromInteger(long value) => new(JsonValueKind.Integer, integer: value);
+
+ public static JsonValueState FromUnsignedInteger(ulong value) =>
+ new(JsonValueKind.UInteger, unsignedInteger: value);
+
+ public static JsonValueState FromReal(double value) => new(JsonValueKind.Real, real: value);
+
+ public static JsonValueState FromString(string value) => new(JsonValueKind.String, text: value);
+
+ public static JsonValueState FromExplicitType(uint value) =>
+ new(JsonValueKind.ExplicitType, explicitType: value);
+}
+
+// Shared host-side heap for the libSceJson object shadows. Keyed by the guest object address;
+// constructors overwrite and destructors remove, so guest stack-address reuse stays correct.
+internal static class JsonObjectHeap
+{
+ public static ConcurrentDictionary Values { get; } = new();
+
+ public static ConcurrentDictionary Strings { get; } = new();
+
+ // Guest function the library should call when a Value is read as the wrong type. This HLE
+ // never dereferences missing members (shadows degrade to defaults), so the hook is stored for
+ // fidelity but not invoked.
+ public static ulong GlobalNullAccessCallback;
+
+ public static ulong GlobalNullAccessCallbackContext;
+
+ public static void SetValue(ulong address, JsonValueState state) => Values[address] = state;
+
+ public static void RemoveValue(ulong address) => Values.TryRemove(address, out _);
+
+ public static void SetString(ulong address, string text) => Strings[address] = text;
+
+ public static void RemoveString(ulong address) => Strings.TryRemove(address, out _);
+
+ // A missing shadow (temporary the compiler built without an out-of-line ctor, or a copy we did
+ // not track) degrades to the empty string rather than faulting.
+ public static string GetStringOrEmpty(ulong address) =>
+ Strings.TryGetValue(address, out var text) ? text : string.Empty;
+
+ internal static void ResetForTests()
+ {
+ Values.Clear();
+ Strings.Clear();
+ GlobalNullAccessCallback = 0;
+ GlobalNullAccessCallbackContext = 0;
+ }
+}
diff --git a/src/SharpEmu.Libs/SharpEmu.Libs.csproj b/src/SharpEmu.Libs/SharpEmu.Libs.csproj
index df6de17..4d19ca1 100644
--- a/src/SharpEmu.Libs/SharpEmu.Libs.csproj
+++ b/src/SharpEmu.Libs/SharpEmu.Libs.csproj
@@ -8,6 +8,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
+
+
diff --git a/tests/SharpEmu.Libs.Tests/FakeCpuMemory.cs b/tests/SharpEmu.Libs.Tests/FakeCpuMemory.cs
new file mode 100644
index 0000000..defe7f7
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/FakeCpuMemory.cs
@@ -0,0 +1,68 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE;
+
+namespace SharpEmu.Libs.Tests;
+
+// A single contiguous guest region backed by a byte[]. Enough to hand C strings and small
+// structures to HLE exports under test without a live guest.
+internal sealed class FakeCpuMemory : ICpuMemory
+{
+ private readonly ulong _base;
+ private readonly byte[] _storage;
+
+ public FakeCpuMemory(ulong baseAddress, int size)
+ {
+ _base = baseAddress;
+ _storage = new byte[size];
+ }
+
+ public bool TryRead(ulong virtualAddress, Span destination)
+ {
+ if (!TryResolve(virtualAddress, destination.Length, out var offset))
+ {
+ return false;
+ }
+
+ _storage.AsSpan(offset, destination.Length).CopyTo(destination);
+ return true;
+ }
+
+ public bool TryWrite(ulong virtualAddress, ReadOnlySpan source)
+ {
+ if (!TryResolve(virtualAddress, source.Length, out var offset))
+ {
+ return false;
+ }
+
+ source.CopyTo(_storage.AsSpan(offset, source.Length));
+ return true;
+ }
+
+ public ulong WriteCString(ulong virtualAddress, string text)
+ {
+ var bytes = System.Text.Encoding.UTF8.GetBytes(text);
+ TryWrite(virtualAddress, bytes);
+ TryWrite(virtualAddress + (ulong)bytes.Length, stackalloc byte[] { 0 });
+ return virtualAddress;
+ }
+
+ private bool TryResolve(ulong virtualAddress, int length, out int offset)
+ {
+ offset = 0;
+ if (virtualAddress < _base)
+ {
+ return false;
+ }
+
+ var relative = virtualAddress - _base;
+ if (relative + (ulong)length > (ulong)_storage.Length)
+ {
+ return false;
+ }
+
+ offset = (int)relative;
+ return true;
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Json/JsonExportRegistrationTests.cs b/tests/SharpEmu.Libs.Tests/Json/JsonExportRegistrationTests.cs
new file mode 100644
index 0000000..a6cb064
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Json/JsonExportRegistrationTests.cs
@@ -0,0 +1,80 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE;
+using SharpEmu.Libs.Json;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Json;
+
+// These NIDs came back "unresolved" in the Quake (PPSA01880) import log right before its
+// access violation. This asserts they now resolve to the Json handlers and dispatch cleanly,
+// which is the plumbing the direct-call tests cannot cover.
+[Collection("JsonObjectHeap")]
+public sealed class JsonExportRegistrationTests
+{
+ private static readonly (string Nid, string Name)[] ExpectedExports =
+ {
+ ("qBMjqyBn3OM", "_ZN3sce4Json5ValueC1Ev"),
+ ("5yHuiWXo2gg", "_ZN3sce4Json5Value3setEb"),
+ ("QxVVYhP-mvg", "_ZN3sce4Json5Value3setEl"),
+ ("SIe1ZmW7e7s", "_ZN3sce4Json5Value3setEm"),
+ ("BSmWDIkV4w4", "_ZN3sce4Json5Value3setEd"),
+ ("IKQimvG9Wqs", "_ZN3sce4Json5Value3setENS0_9ValueTypeE"),
+ ("6l3Bv2gysNc", "_ZN3sce4Json5Value3setERKNS0_6StringE"),
+ ("9KUZFjI1IxA", "_ZN3sce4Json6StringC1EPKc"),
+ ("cG1VE2HMl6c", "_ZN3sce4Json6StringD1Ev"),
+ ("+drDFyAS6u4", "_ZN3sce4Json11Initializer27setGlobalNullAccessCallbackEPFRKNS0_5ValueENS0_9ValueTypeEPS3_PvES7_"),
+ };
+
+ private static ModuleManager CreateRegisteredManager()
+ {
+ var manager = new ModuleManager();
+ manager.RegisterFromAssembly(typeof(JsonValueExports).Assembly, Generation.Gen5);
+ return manager;
+ }
+
+ [Fact]
+ public void QuakeUnresolvedJsonNids_ResolveToJsonExports()
+ {
+ var manager = CreateRegisteredManager();
+
+ foreach (var (nid, name) in ExpectedExports)
+ {
+ Assert.True(manager.TryGetExport(nid, out var export), $"NID {nid} did not register.");
+ Assert.Equal(name, export.Name);
+ Assert.Equal("libSceJson", export.LibraryName);
+ }
+ }
+
+ [Fact]
+ public void SetGlobalNullAccessCallback_StoresHookAndReturnsOk()
+ {
+ JsonObjectHeap.ResetForTests();
+ var manager = CreateRegisteredManager();
+ var ctx = new CpuContext(new FakeCpuMemory(0x1_0000_0000, 0x1000), Generation.Gen5);
+ ctx[CpuRegister.Rdi] = 0x1_0000_0000; // Initializer instance
+ ctx[CpuRegister.Rsi] = 0x8_0012_3456; // guest callback
+ ctx[CpuRegister.Rdx] = 0x1_0000_0800; // user context
+
+ Assert.True(manager.TryDispatch("+drDFyAS6u4", ctx, out var result));
+ Assert.Equal(OrbisGen2Result.ORBIS_GEN2_OK, result);
+ Assert.Equal(0UL, ctx[CpuRegister.Rax]);
+ Assert.Equal(0x8_0012_3456UL, JsonObjectHeap.GlobalNullAccessCallback);
+ Assert.Equal(0x1_0000_0800UL, JsonObjectHeap.GlobalNullAccessCallbackContext);
+ }
+
+ [Fact]
+ public void DispatchValueConstructor_RunsHandlerAndReturnsThis()
+ {
+ JsonObjectHeap.ResetForTests();
+ var manager = CreateRegisteredManager();
+ var ctx = new CpuContext(new FakeCpuMemory(0x1_0000_0000, 0x1000), Generation.Gen5);
+ ctx[CpuRegister.Rdi] = 0x1_0000_0000;
+
+ Assert.True(manager.TryDispatch("qBMjqyBn3OM", ctx, out var result));
+ Assert.Equal(OrbisGen2Result.ORBIS_GEN2_OK, result);
+ Assert.Equal(0x1_0000_0000UL, ctx[CpuRegister.Rax]);
+ Assert.Equal(JsonValueKind.Null, JsonObjectHeap.Values[0x1_0000_0000].Kind);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Json/JsonValueExportsTests.cs b/tests/SharpEmu.Libs.Tests/Json/JsonValueExportsTests.cs
new file mode 100644
index 0000000..77b7225
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Json/JsonValueExportsTests.cs
@@ -0,0 +1,188 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE;
+using SharpEmu.Libs.Json;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Json;
+
+// JsonObjectHeap is shared static state; both Json test classes join one collection so xUnit
+// does not run them in parallel against it.
+[Collection("JsonObjectHeap")]
+public sealed class JsonValueExportsTests
+{
+ private const ulong ThisAddress = 0x1_0000_0000;
+ private const ulong StringAddress = 0x1_0000_1000;
+ private const ulong TextAddress = 0x1_0000_2000;
+
+ private readonly FakeCpuMemory _memory = new(0x1_0000_0000, 0x10000);
+ private readonly CpuContext _ctx;
+
+ public JsonValueExportsTests()
+ {
+ JsonObjectHeap.ResetForTests();
+ _ctx = new CpuContext(_memory, Generation.Gen5);
+ }
+
+ [Fact]
+ public void ValueDefaultConstructor_RegistersNullAndReturnsThis()
+ {
+ _ctx[CpuRegister.Rdi] = ThisAddress;
+
+ JsonValueExports.ValueDefaultConstructor(_ctx);
+
+ Assert.Equal(ThisAddress, _ctx[CpuRegister.Rax]);
+ Assert.Equal(JsonValueKind.Null, JsonObjectHeap.Values[ThisAddress].Kind);
+ }
+
+ [Theory]
+ [InlineData(0UL, false)]
+ [InlineData(1UL, true)]
+ [InlineData(0xFFFF_FF00UL, false)] // only the low byte is the bool; 0x00 low byte => false
+ public void ValueSetBoolean_StoresLowByte(ulong raw, bool expected)
+ {
+ _ctx[CpuRegister.Rdi] = ThisAddress;
+ _ctx[CpuRegister.Rsi] = raw;
+
+ JsonValueExports.ValueSetBoolean(_ctx);
+
+ var state = JsonObjectHeap.Values[ThisAddress];
+ Assert.Equal(JsonValueKind.Boolean, state.Kind);
+ Assert.Equal(expected, state.Boolean);
+ Assert.Equal(ThisAddress, _ctx[CpuRegister.Rax]);
+ }
+
+ [Fact]
+ public void ValueSetInteger_RoundTripsSignedValue()
+ {
+ _ctx[CpuRegister.Rdi] = ThisAddress;
+ _ctx[CpuRegister.Rsi] = unchecked((ulong)-42L);
+
+ JsonValueExports.ValueSetInteger(_ctx);
+
+ var state = JsonObjectHeap.Values[ThisAddress];
+ Assert.Equal(JsonValueKind.Integer, state.Kind);
+ Assert.Equal(-42L, state.Integer);
+ }
+
+ [Fact]
+ public void ValueSetUnsigned_RoundTripsFullWidth()
+ {
+ _ctx[CpuRegister.Rdi] = ThisAddress;
+ _ctx[CpuRegister.Rsi] = ulong.MaxValue;
+
+ JsonValueExports.ValueSetUnsigned(_ctx);
+
+ var state = JsonObjectHeap.Values[ThisAddress];
+ Assert.Equal(JsonValueKind.UInteger, state.Kind);
+ Assert.Equal(ulong.MaxValue, state.UnsignedInteger);
+ }
+
+ [Fact]
+ public void ValueSetReal_ReadsDoubleFromXmm0()
+ {
+ _ctx[CpuRegister.Rdi] = ThisAddress;
+ _ctx.SetXmmRegister(0, unchecked((ulong)BitConverter.DoubleToInt64Bits(3.14159)), 0);
+
+ JsonValueExports.ValueSetReal(_ctx);
+
+ var state = JsonObjectHeap.Values[ThisAddress];
+ Assert.Equal(JsonValueKind.Real, state.Kind);
+ Assert.Equal(3.14159, state.Real, precision: 10);
+ }
+
+ [Fact]
+ public void ValueSetCString_ReadsGuestString()
+ {
+ _memory.WriteCString(TextAddress, "hello json");
+ _ctx[CpuRegister.Rdi] = ThisAddress;
+ _ctx[CpuRegister.Rsi] = TextAddress;
+
+ JsonValueExports.ValueSetCString(_ctx);
+
+ var state = JsonObjectHeap.Values[ThisAddress];
+ Assert.Equal(JsonValueKind.String, state.Kind);
+ Assert.Equal("hello json", state.Text);
+ }
+
+ [Fact]
+ public void ValueSetType_KeepsRawGuestEnumValue()
+ {
+ _ctx[CpuRegister.Rdi] = ThisAddress;
+ _ctx[CpuRegister.Rsi] = 7;
+
+ JsonValueExports.ValueSetType(_ctx);
+
+ var state = JsonObjectHeap.Values[ThisAddress];
+ Assert.Equal(JsonValueKind.ExplicitType, state.Kind);
+ Assert.Equal(7u, state.ExplicitType);
+ }
+
+ [Fact]
+ public void StringConstructThenValueSetString_CopiesText()
+ {
+ _memory.WriteCString(TextAddress, "from string object");
+ _ctx[CpuRegister.Rdi] = StringAddress;
+ _ctx[CpuRegister.Rsi] = TextAddress;
+ JsonValueExports.StringCStringConstructor(_ctx);
+
+ Assert.Equal("from string object", JsonObjectHeap.Strings[StringAddress]);
+ Assert.Equal(StringAddress, _ctx[CpuRegister.Rax]);
+
+ _ctx[CpuRegister.Rdi] = ThisAddress;
+ _ctx[CpuRegister.Rsi] = StringAddress;
+ JsonValueExports.ValueSetString(_ctx);
+
+ var state = JsonObjectHeap.Values[ThisAddress];
+ Assert.Equal(JsonValueKind.String, state.Kind);
+ Assert.Equal("from string object", state.Text);
+ }
+
+ [Fact]
+ public void ValueSetString_MissingStringShadow_DegradesToEmpty()
+ {
+ _ctx[CpuRegister.Rdi] = ThisAddress;
+ _ctx[CpuRegister.Rsi] = StringAddress; // never constructed
+
+ JsonValueExports.ValueSetString(_ctx);
+
+ var state = JsonObjectHeap.Values[ThisAddress];
+ Assert.Equal(JsonValueKind.String, state.Kind);
+ Assert.Equal(string.Empty, state.Text);
+ }
+
+ [Fact]
+ public void Destructors_RemoveShadowState()
+ {
+ _ctx[CpuRegister.Rdi] = ThisAddress;
+ JsonValueExports.ValueDefaultConstructor(_ctx);
+ _ctx[CpuRegister.Rdi] = StringAddress;
+ JsonValueExports.StringDefaultConstructor(_ctx);
+
+ Assert.True(JsonObjectHeap.Values.ContainsKey(ThisAddress));
+ Assert.True(JsonObjectHeap.Strings.ContainsKey(StringAddress));
+
+ _ctx[CpuRegister.Rdi] = ThisAddress;
+ JsonValueExports.ValueDestructor(_ctx);
+ _ctx[CpuRegister.Rdi] = StringAddress;
+ JsonValueExports.StringDestructor(_ctx);
+
+ Assert.False(JsonObjectHeap.Values.ContainsKey(ThisAddress));
+ Assert.False(JsonObjectHeap.Strings.ContainsKey(StringAddress));
+ Assert.Equal(0UL, _ctx[CpuRegister.Rax]);
+ }
+
+ [Fact]
+ public void ValueSetCString_FaultingPointer_DegradesToEmptyString()
+ {
+ _ctx[CpuRegister.Rdi] = ThisAddress;
+ _ctx[CpuRegister.Rsi] = 0xDEAD_0000_0000; // outside the mapped region
+
+ JsonValueExports.ValueSetCString(_ctx);
+
+ var state = JsonObjectHeap.Values[ThisAddress];
+ Assert.Equal(JsonValueKind.String, state.Kind);
+ Assert.Equal(string.Empty, state.Text);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj b/tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj
new file mode 100644
index 0000000..f96d936
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SharpEmu.Libs.Tests/packages.lock.json b/tests/SharpEmu.Libs.Tests/packages.lock.json
new file mode 100644
index 0000000..879d342
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/packages.lock.json
@@ -0,0 +1,212 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "Microsoft.NET.Test.Sdk": {
+ "type": "Direct",
+ "requested": "[17.14.1, )",
+ "resolved": "17.14.1",
+ "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==",
+ "dependencies": {
+ "Microsoft.CodeCoverage": "17.14.1",
+ "Microsoft.TestPlatform.TestHost": "17.14.1"
+ }
+ },
+ "xunit": {
+ "type": "Direct",
+ "requested": "[2.9.3, )",
+ "resolved": "2.9.3",
+ "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
+ "dependencies": {
+ "xunit.analyzers": "1.18.0",
+ "xunit.assert": "2.9.3",
+ "xunit.core": "[2.9.3]"
+ }
+ },
+ "xunit.runner.visualstudio": {
+ "type": "Direct",
+ "requested": "[3.1.1, )",
+ "resolved": "3.1.1",
+ "contentHash": "gNu2zhnuwjq5vQlU4S7yK/lfaKZDLmtcu+vTjnhfTlMAUYn+Hmgu8IIX0UCwWepYkk+Szx03DHx1bDnc9Fd+9w=="
+ },
+ "Microsoft.CodeCoverage": {
+ "type": "Transitive",
+ "resolved": "17.14.1",
+ "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg=="
+ },
+ "Microsoft.DotNet.PlatformAbstractions": {
+ "type": "Transitive",
+ "resolved": "3.1.6",
+ "contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg=="
+ },
+ "Microsoft.Extensions.DependencyModel": {
+ "type": "Transitive",
+ "resolved": "9.0.9",
+ "contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA=="
+ },
+ "Microsoft.TestPlatform.ObjectModel": {
+ "type": "Transitive",
+ "resolved": "17.14.1",
+ "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ=="
+ },
+ "Microsoft.TestPlatform.TestHost": {
+ "type": "Transitive",
+ "resolved": "17.14.1",
+ "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==",
+ "dependencies": {
+ "Microsoft.TestPlatform.ObjectModel": "17.14.1",
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "Newtonsoft.Json": {
+ "type": "Transitive",
+ "resolved": "13.0.3",
+ "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
+ },
+ "Silk.NET.Core": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==",
+ "dependencies": {
+ "Microsoft.DotNet.PlatformAbstractions": "3.1.6",
+ "Microsoft.Extensions.DependencyModel": "9.0.9"
+ }
+ },
+ "Silk.NET.GLFW": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0",
+ "Ultz.Native.GLFW": "3.4.0"
+ }
+ },
+ "Silk.NET.Maths": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw=="
+ },
+ "Silk.NET.Windowing.Common": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0",
+ "Silk.NET.Maths": "2.23.0"
+ }
+ },
+ "Silk.NET.Windowing.Glfw": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==",
+ "dependencies": {
+ "Silk.NET.GLFW": "2.23.0",
+ "Silk.NET.Windowing.Common": "2.23.0"
+ }
+ },
+ "Ultz.Native.GLFW": {
+ "type": "Transitive",
+ "resolved": "3.4.0",
+ "contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
+ },
+ "xunit.abstractions": {
+ "type": "Transitive",
+ "resolved": "2.0.3",
+ "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
+ },
+ "xunit.analyzers": {
+ "type": "Transitive",
+ "resolved": "1.18.0",
+ "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
+ },
+ "xunit.assert": {
+ "type": "Transitive",
+ "resolved": "2.9.3",
+ "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
+ },
+ "xunit.core": {
+ "type": "Transitive",
+ "resolved": "2.9.3",
+ "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
+ "dependencies": {
+ "xunit.extensibility.core": "[2.9.3]",
+ "xunit.extensibility.execution": "[2.9.3]"
+ }
+ },
+ "xunit.extensibility.core": {
+ "type": "Transitive",
+ "resolved": "2.9.3",
+ "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
+ "dependencies": {
+ "xunit.abstractions": "2.0.3"
+ }
+ },
+ "xunit.extensibility.execution": {
+ "type": "Transitive",
+ "resolved": "2.9.3",
+ "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
+ "dependencies": {
+ "xunit.extensibility.core": "[2.9.3]"
+ }
+ },
+ "sharpemu.hle": {
+ "type": "Project",
+ "dependencies": {
+ "SharpEmu.Logging": "[1.0.0, )"
+ }
+ },
+ "sharpemu.libs": {
+ "type": "Project",
+ "dependencies": {
+ "SharpEmu.HLE": "[1.0.0, )",
+ "Silk.NET.Vulkan": "[2.23.0, )",
+ "Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )",
+ "Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )",
+ "Silk.NET.Windowing": "[2.23.0, )"
+ }
+ },
+ "sharpemu.logging": {
+ "type": "Project"
+ },
+ "Silk.NET.Vulkan": {
+ "type": "CentralTransitive",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0"
+ }
+ },
+ "Silk.NET.Vulkan.Extensions.EXT": {
+ "type": "CentralTransitive",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "+Oth189ksRiL6HvGCwIdnsYHawqrbO8y49u1H61z3wsfcHhQZeVDYe/wF5LD7fk3NcdgDvwFD3mLm1QWhdZySw==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0",
+ "Silk.NET.Vulkan": "2.23.0"
+ }
+ },
+ "Silk.NET.Vulkan.Extensions.KHR": {
+ "type": "CentralTransitive",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==",
+ "dependencies": {
+ "Silk.NET.Core": "2.23.0",
+ "Silk.NET.Vulkan": "2.23.0"
+ }
+ },
+ "Silk.NET.Windowing": {
+ "type": "CentralTransitive",
+ "requested": "[2.23.0, )",
+ "resolved": "2.23.0",
+ "contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==",
+ "dependencies": {
+ "Silk.NET.Windowing.Common": "2.23.0",
+ "Silk.NET.Windowing.Glfw": "2.23.0"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file