From 6987033d6b0fec1a6625d0ef1865b3c0a0cc60f9 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 03:21:41 +0100 Subject: [PATCH] fix(core): encode a float as its shortest decimal A float was widened to double before encoding, so every float watch, argument default and result read as the double nearest it: gravity -9.81 showed as -9.8100004196167, a radius of 0.1 as 0.10000000149011612, in `get`, `help` and call results alike. Agents read these, and copy them back into `set` and `call`. Codec now encodes the shortest decimal that reads back as the same float (std::to_chars), as a double. Decoding is unchanged, and so is every value: 0.1 decodes to the float 0.1f came from. A double is encoded as before. Co-Authored-By: Claude Opus 5.5 --- interfaces/Crafter.Debug.Core-Codec.cppm | 15 ++++++++++++++- tests/ShouldResolveWatchPaths/main.cpp | 2 +- tests/ShouldRoundTripAggregates/main.cpp | 11 ++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/interfaces/Crafter.Debug.Core-Codec.cppm b/interfaces/Crafter.Debug.Core-Codec.cppm index 5c6c23c..9e38323 100644 --- a/interfaces/Crafter.Debug.Core-Codec.cppm +++ b/interfaces/Crafter.Debug.Core-Codec.cppm @@ -192,7 +192,20 @@ export namespace Crafter::Debug { template struct Codec { - static Value Encode(T value) { return Value(static_cast(value)); } + static Value Encode(T value) { + // A float as the shortest decimal that reads back as it: 0.1f + // widened is 0.10000000149011612, which is what an agent would + // otherwise read for every float. + if constexpr (sizeof(T) < sizeof(double)) { + if (std::isfinite(value)) { + std::array text{}; + const auto written = std::to_chars(text.data(), text.data() + text.size(), value); + double shortest = 0; + if (written.ec == std::errc() && std::from_chars(text.data(), written.ptr, shortest).ec == std::errc()) return Value(shortest); + } + } + return Value(static_cast(value)); + } static void Decode(const Value& value, T& out, DecodeContext& context) { if (value.IsNumber()) { diff --git a/tests/ShouldResolveWatchPaths/main.cpp b/tests/ShouldResolveWatchPaths/main.cpp index 23a95fd..3a48747 100644 --- a/tests/ShouldResolveWatchPaths/main.cpp +++ b/tests/ShouldResolveWatchPaths/main.cpp @@ -51,7 +51,7 @@ int main() { }); Watch("score", [] { return 1234; }); - Check(registry().Resolve("physics.gravity").value.AsNumber() == static_cast(-9.81F), "a variable watch reads the variable"); + Check(registry().Resolve("physics.gravity").value.AsNumber() == -9.81, "a variable watch reads the variable, a float as its shortest decimal"); Check(registry().Resolve("bodies[1].pos.y").value.AsNumber() == 4, "a path reads into a watched value"); Check(registry().Resolve("bodies[1]").value.Find("id")->AsInteger() == 2, "an index reads an item"); diff --git a/tests/ShouldRoundTripAggregates/main.cpp b/tests/ShouldRoundTripAggregates/main.cpp index be30d8e..f6e0abe 100644 --- a/tests/ShouldRoundTripAggregates/main.cpp +++ b/tests/ShouldRoundTripAggregates/main.cpp @@ -6,7 +6,9 @@ // maps, a scoped enum, Required) through Value and its text, with no // hand-written serialization. Fields the input leaves out keep their default // member initializers, null clears an optional, and an enum also accepts its -// integer. Guards the reflection that command arguments and results rely on. +// integer. A float encodes as its shortest decimal, 0.1 rather than the +// 0.10000000149011612 it widens to, which every float watch used to show. +// Guards the reflection that command arguments and results rely on. import std; import Crafter.Debug.Core; using namespace Crafter::Debug; @@ -74,6 +76,13 @@ int main() { Check(!partial.target.has_value(), "null clears an optional"); Check(partial.mode == Game::Mode::Build, "an enum accepts its integer"); + Check(Encode(0.1F).Dump() == "0.1" && Encode(-9.81F).Dump() == "-9.81" && Encode(4.0F).Dump() == "4.0", + std::format("a float reads as its shortest decimal ({}, {})", Encode(0.1F).Dump(), Encode(-9.81F).Dump())); + float tenth = 0; + DecodeContext tenthContext; + Decode(Value::Parse(Encode(0.1F).Dump()), tenth, tenthContext); + Check(tenthContext.Ok() && tenth == 0.1F && Encode(0.1).Dump() == "0.1", "and decodes to the same float; a double is unchanged"); + float nan = 0; DecodeContext nanContext; Decode(Encode(std::numeric_limits::quiet_NaN()), nan, nanContext);