From bcf1e114efc4ad66db9b1f6ca1a0cbdb069fac44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Sun, 20 Sep 2026 13:35:43 +0200 Subject: [PATCH] Add script to debug fixed point numbers in lldb --- .gitignore | 4 ++ README.md | 20 +++---- tools/lldb_fixed.py | 123 +++++++++++++++++++++++++++++++++++++++ tools/test_lldb_fixed.py | 58 ++++++++++++++++++ 4 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 tools/lldb_fixed.py create mode 100644 tools/test_lldb_fixed.py diff --git a/.gitignore b/.gitignore index 9c66e7a..6b8fc27 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ imported_assets *_old.glb /replay.json *.replay.tmp + +# Python debugger formatter tests +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 8e6436a..918acf4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@ Fancy heavily advanced platform fighting game +## Debugging fixed-point values with lldb + +If using CodeLLDB: +```json +"preRunCommands": [ + "command script import ${workspaceFolder}/tools/lldb_fixed.py" +] +``` + ## Direct P2P Debugging ```text @@ -16,18 +25,9 @@ cargo run -- --debug-match --players 2 --local-port 7001 \ ## Production Build -Production builds use the `production` Cargo profile and do not dynamically -link Bevy or Rust. Development builds retain Bevy dynamic linking for faster -iteration. Cargo stores the production profile under `target/production`, -separate from development artifacts under `target/debug`. - ```sh cargo run --locked \ --bin process-assets --no-default-features --features debug_features cargo build --locked \ --profile production --no-default-features --features debug_features -``` - -The asset preprocessing command must be run from the repository root. It -populates `imported_assets/Default`, which is then consumed by the production -game without running the asset processor at startup. +``` \ No newline at end of file diff --git a/tools/lldb_fixed.py b/tools/lldb_fixed.py new file mode 100644 index 0000000..634d3b5 --- /dev/null +++ b/tools/lldb_fixed.py @@ -0,0 +1,123 @@ +"""LLDB summaries for Rust's `fixed` crate. + +Load this file from LLDB with: + + command script import /path/to/shinespark/tools/lldb_fixed.py + +The implementation does not use floating-point arithmetic, so displayed values +are exact even for FixedI128/FixedU128 values. +""" + +import re + +try: + import lldb +except ImportError: + # Keep the pure parsing and formatting helpers testable without LLDB. + lldb = None + + +_FIXED_TYPE_RE = re.compile(r"(?:^|::)Fixed([IU])(8|16|32|64|128)<(.+)>$") +_DIRECT_TYPENUM_RE = re.compile(r"(?:^|::)U(\d+)$") +_TYPENUM_BIT_RE = re.compile(r"(?:^|::)B([01])(?=\s*[,>])") + + +def parse_fixed_type(type_name): + """Return (signed, storage_bits, fractional_bits), or None.""" + if not type_name: + return None + + match = _FIXED_TYPE_RE.search(type_name.replace(" ", "")) + if match is None: + return None + + signed = match.group(1) == "I" + storage_bits = int(match.group(2)) + fractional_type = match.group(3) + + direct = _DIRECT_TYPENUM_RE.search(fractional_type) + if direct is not None: + fractional_bits = int(direct.group(1)) + else: + # typenum represents UInt as a binary tree. Its textual Rust + # type lists the bits from most significant to least significant. + digits = _TYPENUM_BIT_RE.findall(fractional_type) + if not digits: + return None + fractional_bits = int("".join(digits), 2) + + if fractional_bits > storage_bits: + return None + return signed, storage_bits, fractional_bits + + +def normalize_bits(raw_bits, signed, storage_bits): + """Interpret a possibly unsigned LLDB integer using the fixed type's width.""" + mask = (1 << storage_bits) - 1 + raw_bits &= mask + if signed and raw_bits >= 1 << (storage_bits - 1): + raw_bits -= 1 << storage_bits + return raw_bits + + +def format_fixed(raw_bits, fractional_bits): + """Format a scaled integer exactly as a decimal string.""" + if fractional_bits == 0: + return str(raw_bits) + + negative = raw_bits < 0 + magnitude = -raw_bits if negative else raw_bits + whole = magnitude >> fractional_bits + remainder = magnitude & ((1 << fractional_bits) - 1) + + if remainder == 0: + result = str(whole) + else: + # remainder / 2^n == (remainder * 5^n) / 10^n, which gives a finite, + # exact decimal representation without converting through a float. + decimal = str(remainder * 5**fractional_bits).zfill(fractional_bits) + result = f"{whole}.{decimal.rstrip('0')}" + + return f"-{result}" if negative else result + + +def _parse_lldb_integer(value): + text = value.GetValue() + if text is not None: + try: + return int(text, 0) + except ValueError: + pass + + # GetValueAsUnsigned is also useful on LLDB versions that do not render + # primitive values through GetValue. It is sufficient for all <= 64-bit + # fixed types; current LLDB versions render i128/u128 through GetValue. + return value.GetValueAsUnsigned() + + +def fixed_summary(value, _internal_dict): + layout = parse_fixed_type(value.GetTypeName()) + if layout is None: + return None + + signed, storage_bits, fractional_bits = layout + bits = value.GetChildMemberWithName("bits") + if not bits.IsValid(): + return None + + raw_bits = _parse_lldb_integer(bits) + raw_bits = normalize_bits(raw_bits, signed, storage_bits) + return format_fixed(raw_bits, fractional_bits) + + +def __lldb_init_module(debugger, _internal_dict): + # LLDB's type-name regex sees the concrete fixed type rather than project + # aliases such as FGi32 and FGWide. + debugger.HandleCommand( + "type summary add --python-function lldb_fixed.fixed_summary " + "--category Shinespark --regex " + "'^fixed::Fixed(I|U)(8|16|32|64|128)<.+>$'" + ) + # A dedicated category makes the formatter work even when CodeLLDB has not + # enabled rustc's standard `Rust` formatter category yet. + debugger.HandleCommand("type category enable Shinespark") diff --git a/tools/test_lldb_fixed.py b/tools/test_lldb_fixed.py new file mode 100644 index 0000000..c981eb0 --- /dev/null +++ b/tools/test_lldb_fixed.py @@ -0,0 +1,58 @@ +import unittest + +import lldb_fixed + + +class ParseFixedTypeTests(unittest.TestCase): + def test_direct_typenum_alias(self): + self.assertEqual( + lldb_fixed.parse_fixed_type("fixed::FixedI32"), + (True, 32, 16), + ) + + def test_expanded_typenum(self): + type_name = ( + "fixed::FixedI32<" + "typenum::uint::UInt<" + "typenum::uint::UInt<" + "typenum::uint::UInt<" + "typenum::uint::UInt<" + "typenum::uint::UInt, " + "typenum::bit::B0>, typenum::bit::B0>, typenum::bit::B0>, " + "typenum::bit::B0>>" + ) + self.assertEqual(lldb_fixed.parse_fixed_type(type_name), (True, 32, 16)) + + def test_unsigned_type(self): + self.assertEqual( + lldb_fixed.parse_fixed_type("fixed::FixedU64"), + (False, 64, 24), + ) + + def test_unrelated_type(self): + self.assertIsNone(lldb_fixed.parse_fixed_type("core::primitive::i32")) + + +class FormatFixedTests(unittest.TestCase): + def test_q16_16(self): + self.assertEqual(lldb_fixed.format_fixed(3 << 15, 16), "1.5") + self.assertEqual(lldb_fixed.format_fixed(-(3 << 15), 16), "-1.5") + self.assertEqual(lldb_fixed.format_fixed(1, 16), "0.0000152587890625") + + def test_integer_value_has_no_decimal_suffix(self): + self.assertEqual(lldb_fixed.format_fixed(12 << 16, 16), "12") + + def test_q96_32(self): + raw = (1 << 100) + (1 << 31) + self.assertEqual( + lldb_fixed.format_fixed(raw, 32), + "295147905179352825856.5", + ) + + def test_twos_complement_normalization(self): + self.assertEqual(lldb_fixed.normalize_bits(0xFFFF8000, True, 32), -32768) + self.assertEqual(lldb_fixed.normalize_bits(0xFFFF8000, False, 32), 0xFFFF8000) + + +if __name__ == "__main__": + unittest.main()