acdream/tools/cdb/decode_retail_hex.py
Erik 194ed3ef21 feat(cdb): A6.P1 — decode_retail_hex.py hex→float decoder
Python tool that decodes the retail.log hex-bits float fields produced
by a6-probe.cdb v4 into IEEE 754 single-precision values. Required
because cdb's .printf %f doesn't reliably format floats from dwo()
reads — v4 works around this by emitting 32-bit hex, this script
reinterprets via struct.unpack('<f', struct.pack('<I', value)).

Verified against scen1 retail.log:
  BP6 threshold_h=0x3F2A0751 → threshold=0.6642 (= FloorZ exactly)
  BP5 hit#1 Nz_h=0x3F800000 → Nz=1.0 (ground normal)
  9,517 float fields decoded across 9,331 lines.

Output written next to input as .decoded.log. Format matches
acdream-side [push-back] probe (4-decimal floats), so A6.P2
analysis can compare line-for-line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:03:03 +02:00

98 lines
3.2 KiB
Python

#!/usr/bin/env python3
"""decode_retail_hex.py — A6.P1 retail-log hex→float decoder.
cdb's `.printf %f` doesn't reliably format floats from `dwo()` reads
(see history of a6-probe.cdb v2→v3→v4). The v4 probe prints all float
fields as 32-bit hex bits (`_h=0xHHHHHHHH`), and this script decodes
them via IEEE 754 single-precision reinterpretation.
Usage:
py tools/cdb/decode_retail_hex.py <retail.log path>
Output:
Writes a sibling file `<input>.decoded.log` with all `_h=0x...`
fields replaced by `=<float-value>`.
Example:
[BP6] check_walkable hit#1 threshold_h=0x3F2A0751
[BP6] check_walkable hit#1 threshold=0.66417414
"""
import re
import struct
import sys
from pathlib import Path
HEX_FIELD_RE = re.compile(r'(\w+)_h=0x([0-9A-Fa-f]{8})')
def decode_hex_float(hex_str: str) -> float:
"""Decode 8 hex chars as IEEE 754 single-precision little-endian float.
Note: cdb prints the dword in big-endian byte order (most significant
byte first), but IEEE 754 single is little-endian in memory. So we
decode the 4 bytes as if read directly from memory: take the hex
string, convert to bytes, reverse byte order (since x86 is LE), then
unpack as little-endian float.
Actually simpler: cdb's %X prints the dword value, which is already
interpreted as a uint32. To get the float, we re-pack the uint32 as
bytes (in any consistent order) and unpack as float with matching
order. Using struct.pack/unpack with the same byte order ensures
round-trip correctness.
"""
n = int(hex_str, 16)
# Pack as little-endian uint32, unpack as little-endian float.
# The byte order cancels out — what matters is that pack + unpack
# agree, which they do via the same '<' specifier.
return struct.unpack('<f', struct.pack('<I', n))[0]
def decode_line(line: str) -> str:
"""Replace all `name_h=0xHHHHHHHH` occurrences with `name=<float>`."""
def repl(m):
name = m.group(1)
hex_str = m.group(2)
try:
value = decode_hex_float(hex_str)
# Match the acdream-side formatting: 4 decimal places.
return f'{name}={value:.4f}'
except (ValueError, struct.error):
# Keep original on decode failure.
return m.group(0)
return HEX_FIELD_RE.sub(repl, line)
def main():
if len(sys.argv) != 2:
print('Usage: py tools/cdb/decode_retail_hex.py <retail.log>',
file=sys.stderr)
sys.exit(1)
in_path = Path(sys.argv[1])
if not in_path.exists():
print(f'Error: {in_path} not found', file=sys.stderr)
sys.exit(1)
out_path = in_path.with_suffix('.decoded.log')
lines_decoded = 0
fields_decoded = 0
with in_path.open('r', encoding='ascii', errors='replace') as fin, \
out_path.open('w', encoding='utf-8') as fout:
for line in fin:
decoded = decode_line(line)
if decoded != line:
lines_decoded += 1
fields_decoded += len(HEX_FIELD_RE.findall(line))
fout.write(decoded)
print(f'Decoded {lines_decoded} lines, {fields_decoded} float fields')
print(f'Output: {out_path}')
if __name__ == '__main__':
main()