From a6ac989b484c3ebd12237a9b296414a867e8d4d4 Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Fri, 24 Jul 2026 15:20:35 -0400 Subject: [PATCH 1/2] [#31] Add --hexfloat option to dump bit-exact float representations --- Documentation/command-dump.md | 10 ++++++++++ TextDumper/TextDumperTool.cs | 25 +++++++++++++++++++++---- UnityDataTool.Tests/DumpTests.cs | 24 ++++++++++++++++++++++++ UnityDataTool/Program.cs | 7 +++++-- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/Documentation/command-dump.md b/Documentation/command-dump.md index 03d4056..2e9d09a 100644 --- a/Documentation/command-dump.md +++ b/Documentation/command-dump.md @@ -15,6 +15,7 @@ UnityDataTool dump [options] | `--stdout` | Write the dump to stdout (status and errors go to stderr). Mutually exclusive with `-o`. | `false` | | `-f, --output-format ` | Output format | `text` | | `-a, --show-large-arrays` | Dump the full content of large arrays of basic data types, instead of summarizing them with a hash | `false` | +| `-x, --hexfloat` | Print the bit-exact hexadecimal representation after each float and double value | `false` | | `-i, --objectid ` | Only dump object with this ID | All objects | | `-t, --type ` | Filter by object type (ClassID number or type name) | All objects | | `-d, --typetree-data ` | Load an external TypeTree data file before processing (Unity 6.5+) | — | @@ -41,6 +42,15 @@ Dump the full content of large arrays (e.g. mesh or texture data): UnityDataTool dump /path/to/file -a ``` +Include the bit-exact hexadecimal representation of float and double values, useful for seeing tiny differences that get lost when converting to decimal (similar to the `-hexfloat` option of Unity's `binary2text` tool): +```bash +UnityDataTool dump /path/to/file -x +``` +``` +floatValue (float) 3.1415927(0x40490fdb) +doubleValue (double) 2.718281828459045(0x4005bf0a8b145769) +``` + Dump only MonoBehaviour objects by type name: ```bash UnityDataTool dump /path/to/file -t MonoBehaviour diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index 06743f3..454cfc8 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -34,6 +34,7 @@ public class DumpOptions public string Path { get; init; } public string OutputPath { get; init; } public bool ShowLargeArrays { get; init; } + public bool HexFloat { get; init; } public long ObjectId { get; init; } public string TypeFilter { get; init; } public bool ToStdout { get; init; } @@ -382,11 +383,11 @@ void DumpArray(TypeTreeNode node, ref long offset, int level) var array = ReadBasicTypeArray(dataNode, offset, arraySize); offset += dataNode.Size * arraySize; - m_StringBuilder.Append(array.GetValue(0)); + m_StringBuilder.Append(FormatArrayElement(array.GetValue(0))); for (int i = 1; i < arraySize; ++i) { m_StringBuilder.Append(", "); - m_StringBuilder.Append(array.GetValue(i)); + m_StringBuilder.Append(FormatArrayElement(array.GetValue(i))); } } @@ -608,10 +609,10 @@ string ReadValue(TypeTreeNode node, long offset) return m_Reader.ReadUInt32(offset).ToString(); case TypeCode.Single: - return m_Reader.ReadFloat(offset).ToString(); + return FormatFloat(m_Reader.ReadFloat(offset)); case TypeCode.Double: - return m_Reader.ReadDouble(offset).ToString(); + return FormatDouble(m_Reader.ReadDouble(offset)); case TypeCode.Int16: return m_Reader.ReadInt16(offset).ToString(); @@ -640,6 +641,22 @@ string ReadValue(TypeTreeNode node, long offset) } } + // With the HexFloat option, floating point values are followed by their bit-exact hexadecimal + // representation (like binary2text -hexfloat), because tiny differences between two values can + // be lost when they are converted to decimal. + string FormatFloat(float value) => + m_Options.HexFloat ? $"{value}(0x{BitConverter.SingleToUInt32Bits(value):x8})" : value.ToString(); + + string FormatDouble(double value) => + m_Options.HexFloat ? $"{value}(0x{BitConverter.DoubleToUInt64Bits(value):x16})" : value.ToString(); + + string FormatArrayElement(object value) => value switch + { + float f => FormatFloat(f), + double d => FormatDouble(d), + _ => value.ToString(), + }; + Array ReadBasicTypeArray(TypeTreeNode node, long offset, int arraySize) { // bool isn't blittable into Array.CreateInstance(typeof(bool), ...) the way other basic types diff --git a/UnityDataTool.Tests/DumpTests.cs b/UnityDataTool.Tests/DumpTests.cs index 4d7b405..3bfdda7 100644 --- a/UnityDataTool.Tests/DumpTests.cs +++ b/UnityDataTool.Tests/DumpTests.cs @@ -314,6 +314,30 @@ public async Task Dump_Stdout_AssetBundle_SerializationDemo_ContainsExpectedFiel Assert.That(output, Does.Not.Contain("293, 294, 295, 296,")); } + // The expected bit patterns are the well-known IEEE 754 representations of the + // SerializationDemo field values (also verified against python struct.pack). + [Test] + public async Task Dump_Stdout_HexFloat_PrintsBitExactRepresentation( + [Values("-x", "--hexfloat")] string options) + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + Assert.AreEqual(0, await Program.Main(new string[] { "dump", m_SerializationDemoBundlePath, "--stdout", "--type", "MonoBehaviour", options })); + } + finally + { + Console.SetOut(currentOut); + } + + var output = sw.ToString(); + + Assert.That(output, Does.Contain("floatValue (float) 3.1415927(0x40490fdb)")); + Assert.That(output, Does.Contain("doubleValue (double) 2.718281828459045(0x4005bf0a8b145769)")); + } + [Test] public async Task Dump_Stdout_ShowLargeArrays_PrintsFullArrayContent() { diff --git a/UnityDataTool/Program.cs b/UnityDataTool/Program.cs index 99c024e..9691787 100644 --- a/UnityDataTool/Program.cs +++ b/UnityDataTool/Program.cs @@ -167,6 +167,7 @@ static Command BuildDumpCommand() var pathArg = new Argument("filename", "The path of the file to dump").ExistingOnly(); var fOpt = new Option(aliases: new[] { "--output-format", "-f" }, description: "Output format", getDefaultValue: () => TextDumperTool.DumpFormat.Text); var aOpt = new Option(aliases: new[] { "--show-large-arrays", "-a" }, description: "Dump the full content of large arrays of basic data types, instead of summarizing them with a hash"); + var xOpt = new Option(aliases: new[] { "--hexfloat", "-x" }, description: "Print the bit-exact hexadecimal representation after each float and double value, e.g. 0.25(0x3e800000)"); // Former option, kept so that existing scripts don't break. Ignored because skipping // large arrays (with a hash) is now the default behavior. var sOpt = new Option(aliases: new[] { "--skip-large-arrays", "-s" }) { IsHidden = true }; @@ -182,6 +183,7 @@ static Command BuildDumpCommand() pathArg, fOpt, aOpt, + xOpt, sOpt, oOpt, objectIdOpt, @@ -201,7 +203,7 @@ static Command BuildDumpCommand() } }); dumpCommand.SetHandler( - (FileInfo fi, TextDumperTool.DumpFormat f, bool a, DirectoryInfo o, long objectId, string type, FileInfo d, bool toStdout) => + (FileInfo fi, TextDumperTool.DumpFormat f, bool a, bool x, DirectoryInfo o, long objectId, string type, FileInfo d, bool toStdout) => { var ttResult = LoadTypeTreeDataFile(d); if (ttResult != 0) return Task.FromResult(ttResult); @@ -211,13 +213,14 @@ static Command BuildDumpCommand() Path = fi.FullName, OutputPath = o.FullName, ShowLargeArrays = a, + HexFloat = x, ObjectId = objectId, TypeFilter = type, ToStdout = toStdout, }; return Task.FromResult(HandleDump(options)); }, - pathArg, fOpt, aOpt, oOpt, objectIdOpt, typeOpt, dOpt, stdoutOpt); + pathArg, fOpt, aOpt, xOpt, oOpt, objectIdOpt, typeOpt, dOpt, stdoutOpt); return dumpCommand; } From 74b670469aba8381682bea68ffe326a5580af674 Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Fri, 24 Jul 2026 16:33:47 -0400 Subject: [PATCH 2/2] [#31] Use invariant culture for float and double dump output --- TextDumper/TextDumperTool.cs | 12 +++++++++--- UnityDataTool.Tests/DumpTests.cs | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index 454cfc8..8b135d5 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.IO; using System.Text; using UnityDataTools.BinaryFormat; @@ -643,12 +644,17 @@ string ReadValue(TypeTreeNode node, long offset) // With the HexFloat option, floating point values are followed by their bit-exact hexadecimal // representation (like binary2text -hexfloat), because tiny differences between two values can - // be lost when they are converted to decimal. + // be lost when they are converted to decimal. The decimal part always uses the invariant + // culture so that dumps are identical (and diffable) across locales. string FormatFloat(float value) => - m_Options.HexFloat ? $"{value}(0x{BitConverter.SingleToUInt32Bits(value):x8})" : value.ToString(); + m_Options.HexFloat + ? string.Create(CultureInfo.InvariantCulture, $"{value}(0x{BitConverter.SingleToUInt32Bits(value):x8})") + : value.ToString(CultureInfo.InvariantCulture); string FormatDouble(double value) => - m_Options.HexFloat ? $"{value}(0x{BitConverter.DoubleToUInt64Bits(value):x16})" : value.ToString(); + m_Options.HexFloat + ? string.Create(CultureInfo.InvariantCulture, $"{value}(0x{BitConverter.DoubleToUInt64Bits(value):x16})") + : value.ToString(CultureInfo.InvariantCulture); string FormatArrayElement(object value) => value switch { diff --git a/UnityDataTool.Tests/DumpTests.cs b/UnityDataTool.Tests/DumpTests.cs index 3bfdda7..e5bab66 100644 --- a/UnityDataTool.Tests/DumpTests.cs +++ b/UnityDataTool.Tests/DumpTests.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.IO; using System.Threading.Tasks; using NUnit.Framework; @@ -316,20 +317,24 @@ public async Task Dump_Stdout_AssetBundle_SerializationDemo_ContainsExpectedFiel // The expected bit patterns are the well-known IEEE 754 representations of the // SerializationDemo field values (also verified against python struct.pack). + // Runs under a comma-decimal locale to confirm the output is culture-invariant. [Test] public async Task Dump_Stdout_HexFloat_PrintsBitExactRepresentation( [Values("-x", "--hexfloat")] string options) { using var sw = new StringWriter(); var currentOut = Console.Out; + var currentCulture = CultureInfo.CurrentCulture; try { Console.SetOut(sw); + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); Assert.AreEqual(0, await Program.Main(new string[] { "dump", m_SerializationDemoBundlePath, "--stdout", "--type", "MonoBehaviour", options })); } finally { Console.SetOut(currentOut); + CultureInfo.CurrentCulture = currentCulture; } var output = sw.ToString();