Decode numeric damage components with Codec.INT semantics

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-06 05:03:08 +01:00
1 parent 76b8b4eb83
commit 1e1c6a900f
3 files changed
+358 -1

No files matched your search

+59 -1
View File
@@ -939,7 +939,9 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option<Box<StackData
let Some(c) = components else {
return (0, None);
};
let damage = c.get("minecraft:damage").and_then(int).unwrap_or(0).max(0) as u32;
// NbtOps.getNumberValue supplies a boxed number; Codec.INT truncates its value.
let damage =
horizon_world::datafix::dynamic::as_int(c.get("minecraft:damage"), 0).max(0) as u32;
let potion = match c.get("minecraft:potion_contents") {
Some(Tag::String(s)) => Some(Arc::from(s.to_string_lossy().as_str())),
Some(t) => t
@@ -1141,6 +1143,62 @@ pub fn command_components(text: &str) -> Result<Tag, String> {
mod tests {
use super::*;
#[test]
fn numeric_damage_decodes_and_saves_as_a_modelled_int() {
let keep = Tag::compound([("keep", Tag::Int(87))]);
for (raw, expected) in [
(None, 0),
(Some(Tag::Byte(1)), 1),
(Some(Tag::Short(1)), 1),
(Some(Tag::Int(1)), 1),
(Some(Tag::Long(4_294_967_297)), 1),
(Some(Tag::Float(1.5_f32.to_bits())), 1),
(Some(Tag::Double(2.75_f64.to_bits())), 2),
(Some(Tag::Float((-0.5_f32).to_bits())), 0),
(
Some(Tag::Float(2_147_483_648_f32.to_bits())),
i32::MAX as u32,
),
(Some(Tag::Double(1.0e20_f64.to_bits())), i32::MAX as u32),
] {
let mut entries = vec![("minecraft:custom_data", keep.clone())];
if let Some(raw) = &raw {
entries.push(("minecraft:damage", raw.clone()));
}
let (damage, extra) = decode_components(Some(&Tag::compound(entries)));
let loaded = ItemStack {
damage,
extra,
..ItemStack::new("minecraft:diamond_pickaxe", 1)
};
assert_eq!(loaded.damage, expected, "{raw:?}");
assert!(loaded.wire_patch().is_none());
assert!(loaded.data().unwrap().other("minecraft:damage").is_none());
let saved = crate::item_nbt::item_to_nbt(&loaded);
assert_eq!(
saved
.get("components")
.and_then(|c| c.get("minecraft:damage")),
(expected > 0).then_some(&Tag::Int(expected as i32)),
"{raw:?}"
);
assert_eq!(
saved
.get("components")
.unwrap()
.get("minecraft:custom_data"),
Some(&keep)
);
// No retained source or wire patch: only modelled damage is saved.
let mut fresh = ItemStack::new("minecraft:diamond_pickaxe", 1);
fresh.damage = expected;
fresh
.data_mut()
.set_other("minecraft:custom_data", Some(keep.clone()));
assert_eq!(crate::item_nbt::item_to_nbt(&fresh), saved);
}
}
#[test]
fn map_decoration_types_round_trip_with_default_namespaces() {
let parse = |text| horizon_world::nbt::parse_snbt(text).unwrap().0;
+150
View File
@@ -0,0 +1,150 @@
# Numeric damage component decoding
H087 covers only the `minecraft:damage` component's numeric conversion at the
item model boundary. Command validity, other components, generic NBT helpers,
wear, default elision and simulation are unchanged.
## Authority and scope
Pinned Java 26.3 `DataComponents.DAMAGE` uses `ExtraCodecs.NON_NEGATIVE_INT`.
`Codec.INT.read` calls `DynamicOps.getNumberValue` and `Number.intValue`;
`NbtOps.getNumberValue` exposes the boxed number through `Tag.asNumber`.
This truncates Float/Double toward zero and narrows Long to its low 32 bits.
It is distinct from NumericTag's floor conversions. Encoding uses `createInt`.
Horizon command validation already performs that conversion. The item model
instead used an integral-only helper for damage. The existing
`horizon_world::datafix::dynamic::as_int` supplies the boxed-number semantics;
only the damage decoder should use it here. The integral helper and every other
component decoder remain unchanged.
## Existing actual Game route
`tools/test_numeric_damage_component_parity.py` reuses the existing
`test_command_exec_parity.py` integrated Game and official Java oracle. It uses
one loaded, supported chest and the actual connected owner. There is no new
observer or datapack. Every case writes through the actual item command, then
reads the saved chest stack, typed component, item/count/slot and unrelated
custom data. Fresh result/success sentinels preserve actual callbacks, including
rejected input and missing-field behavior.
Twelve independent slots cover plain/default, integer1, Float1.5, Double2.75,
Float-0.5, Long4294967297, positive Float/Double overflow, and four rejected
negative/narrowed-negative inputs. Rejected writes start with an independently
admitted stone2/custom-data stack and must retain it. Java independently measures
the parse failure's absent callback, rather than assuming a callback of zero.
The positive overflow cases save Int2147483647. The negative-half default saves
no damage patch. Full saved stacks retain exact tags and styles.
Before capture the 329-record canonical program was declared in
`.local/h087/declared-canonical.jsonl`, SHA256
`f15bf4ed8400a3b37b2b958369896ef14022dcdd4e6e2ee935eb0403517f2607`.
The complete332 program remains executable with `--full-diagnostic`, SHA256
`de28da09f9f02a36449ae440c849c956aaba606dea3f8aa7b7d3a245eb53e9e9`.
Exactly `clock-start`, `parent-block`, `clock-end` are predeclared raw diagnostics:
absolute startup clocks and complete parent-block presentation. Every saved
stack, damage value/type, component presence, callback/store, setup guard and
actual relative-clock delta remains strict. The canonical program retains those
three raw command replies in the execution logs. No record masking or numeric
tolerance is used.
The world is frozen and no observation tick is added. Actual start/end clocks
remain raw; the separately checked relative delta is zero. Positive admission
covers owner UUID/type/Health/mode/Pos, forced loaded chunk, stone support, exact
chest block state and coordinates, initially empty inventory and each slot's
initial absence. This does not establish persistence/reopen, packets or GUI.
The initial Java draft used an invalid typed-match path and assumed an absent
callback for a missing data field. Its complete script/output remain in
`.local/h087/java-draft` and `rejected-typed-path-driver.py` as a rejected test
prerequisite, not a native defect. The corrected Java-only validation is also
retained before the declared paired program.
## Reproduction
```sh
python3 tools/test_numeric_damage_component_parity.py --reference-only --work-dir /tmp/h087-java
python3 tools/test_numeric_damage_component_parity.py --binary /path/to/game --reference /tmp/h087-java/vanilla.jsonl --work-dir /tmp/h087-game
python3 tools/test_numeric_damage_component_parity.py --full-diagnostic --binary /path/to/game --work-dir /tmp/h087-full
```
Fresh scratch directories keep all command scripts, references, native raw
records, full logs, exact mismatch tables and world data. Official bytecode and
other nonredistributable artifacts stay local. Stage times are retained where
recorded; unobserved times remain null.
## Measured result
Both actual Game binaries use frozen main
`76b8b4eb8336e397555b326281a0bcc27b8e2ed1`; the fixed binary adds only the
local damage conversion. No pending prototype, selector or command fixes are
imported. The final fetch advanced to `a1710b696233b7cb3a1c1d3482aeeb8e270ad6e3`,
which only reuses the mob Seen snapshot buffer. The decoder, validator, item
command and boxed-number helper are unchanged; this zero-tick chest fixture
has no mob-snapshot input. This is source review, not a combined-latest runtime
claim.
| Program | Untouched | Fixed |
| --- | ---: | ---: |
| Strict329, identical program/reference bytes | 308/329 | 329/329 |
| Complete332 diagnostic, identical full comparison reference | 308/332 | 330/332 |
The strict reference SHA256 is
`d8ba27021581bcd7bd6dd4e6242e6fcdeb16d9dd2f277ca70bf824b63630cd90`.
Every raw record includes feedback text/styles and callbacks; no comparison
mask or tolerance is applied. All admission controls pass. There are zero actor
observation ticks and an exact zero relative world-clock delta.
| Accepted input | Java saved damage | Old Game | Fixed Game |
| --- | --- | --- | --- |
| Float1.5 | Int1 | absent | Int1 |
| Double2.75 | Int2 | absent | Int2 |
| Float2147483648 | Int2147483647 | absent | Int2147483647 |
| Double1e20 | Int2147483647 | absent | Int2147483647 |
Each of those four inputs accounts for five old differences: full stack, damage
read, stored result, stored success and typed-presence match. The final full
inventory accounts for the21st. Plain/integer1, negative-half default0, narrowed
Long1 and all four rejected-input/sentinel-stack controls are unchanged. The
full diagnostic adds the parent tag containing those state differences and two
raw startup-clock differences. The fixed parent tag now matches too.
Every remaining fixed full-diagnostic residual is listed here. The styles are
identical (unset colour, all flags false); only the literal number differs:
| Label | Java comparison-reference feedback; callback | Fixed feedback; callback |
| --- | --- | --- |
| clock-start | `The game time is 281 tick(s)`; `[[true,281]]` | `The game time is 0 tick(s)`; `[[true,0]]` |
| clock-end | `The game time is 281 tick(s)`; `[[true,281]]` | `The game time is 0 tick(s)`; `[[true,0]]` |
The independent fresh Java runs retain their actual absolute start/end values:
canonical reference313/313, old canonical321/321, fixed canonical281/281;
full reference281/281, old full rerun301/301, fixed full309/309. Native is0/0 in
those captures. These values are neither replaced nor subtracted in captured
records. Relative delta0 is a separate strict invariant, not raw clock equality.
There is no332/332 claim or clock repair.
The resource-free decoder/save unit fails unchanged code at Float1.5 (0 versus1)
and passes fixed code with ten input/default/boundary controls. It checks typed
saved Ints, unrelated custom data, absence of any retained damage/wire source,
and a separately constructed model with no raw input. No invalid-input acceptance
or save-default policy is changed.
Local evidence: `.local/h087/{java,old-final,fixed,old-full-final,fixed-full}`,
`unit-{old,fixed}.log`, immutable executables, bytecode, declaration files and
`manifest.json` hashes/timestamps. An earlier full-diagnostic checker incorrectly
treated its predeclared absolute clocks as admission after writing the complete
raw/report; that rejected wrapper/capture is retained. Correcting the diagnostic
assertion changed neither program nor comparisons, and the final old/fixed
captures use the same corrected checker. No failed setup is counted as a defect.
## Local checks
The final production patch passes rustfmt, Clippy with warnings denied,
70 horizon-items tests,26 content-root tests, doctests and the port-ledger check
(no new gaps). Python696 reports four existing macOS tooling failures and one
skip: the installer signature fixture lacks its ARM archive; three optional
release-archive cases hit Bash3.2 unset arrays. A narrow current installer run
confirms that independent prerequisite. No tooling repair is included.
Required checks ran once on the final Rust/harness; later changes only add these
evidence notes. Final public-tree/whitespace checks are recorded separately.
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Damage's Codec.INT conversion through actual item commands and saved chest stacks.
Complete records are strict. Parent block tags and absolute startup clocks are
predeclared diagnostics; all saved stacks and the zero relative clock stay strict.
"""
import hashlib
import json
from pathlib import Path
import sys
import test_command_exec_parity as driver
# Number.intValue: float/double truncate, long narrows; validation stays upstream.
CASES = [
('plain', None, 0), ('integer', '1', 1), ('float', '1.5f', 1),
('double', '2.75d', 2), ('negative_half', '-0.5f', 0),
('long_wrap', '4294967297L', 1), ('float_max', '2147483648.0f', 2147483647),
('double_max', '1.0e20d', 2147483647),
('negative_float', '-1.5f', None), ('negative_double', '-1.5d', None),
('long_negative', '2147483648L', None), ('negative_overflow', '-1.0e20d', None),
]
def program(full=False):
ops = []
def cmd(text, label, result=None, admission=True, diagnostic=False):
op = {'op': 'cmd', 'cmd': text, 'label': label, 'admission': admission}
if result is not None:
op['expect_result'] = result
if diagnostic and not full:
op.update(record=False, trace=True)
ops.append(op)
def read(path, label, result=None, admission=False):
for holder in ['result', 'success']:
cmd(f'/scoreboard players set {holder} h087 -99', label + '-reset-' + holder, -99)
cmd(f'/execute store result score result h087 store success score success h087 '
f'run data get block 4 200 4 {path}', label + '-read', result, admission)
cmd('/scoreboard players get result h087', label + '-result', admission=admission)
cmd('/scoreboard players get success h087', label + '-success', admission=admission)
cmd('/tick freeze', 'freeze')
cmd('/gamerule spawn_mobs false', 'no-spawn')
cmd('/gamerule advance_time false', 'no-daylight')
cmd('/forceload add 0 0', 'force', 1)
ops.append({'op': 'settle', 'required_chunks': [[0, 0]]})
cmd('/fill 0 199 0 15 199 15 stone', 'floor', 256)
cmd('/setblock 4 200 4 chest[facing=north,type=single,waterlogged=false]', 'chest-place', 1)
ops.append({'op': 'settle', 'required_chunks': [[0, 0]]})
for text, label in [('loaded 4 200 4', 'chest-loaded'), ('block 4 199 4 stone', 'support'),
('block 4 200 4 chest[facing=north,type=single,waterlogged=false]', 'chest-state')]:
cmd('/execute if ' + text, label, 1)
cmd('/tp @s 8.5 200 8.5 0 0', 'owner-position', 1)
cmd('/gamemode creative @s', 'owner-mode')
cmd('/execute if entity @s[type=player,name=Player]', 'owner-type', 1)
for key, expected in [('UUID', 4), ('Health', 20), ('playerGameType', 1), ('Pos', 3)]:
cmd('/data get entity @s ' + key, 'owner-' + key, expected)
for key, expected in [('id', 15), ('x', 4), ('y', 200), ('z', 4), ('Items', 0)]:
cmd('/data get block 4 200 4 ' + key, 'initial-' + key, expected)
cmd('/scoreboard objectives add h087 dummy', 'objective')
cmd('/execute store result score start h087 run time query gametime', 'clock-start', diagnostic=True)
for slot, (case, source, expected) in enumerate(CASES):
path = f'Items[{{Slot:{slot}b}}]'
cmd(f'/execute unless data block 4 200 4 {path}', case + '-empty', 1)
if expected is None:
cmd(f'/item replace block 4 200 4 container.{slot} with stone[custom_data={{keep:87}}] 2', case + '-sentinel', 1)
for holder in ['result', 'success']:
cmd(f'/scoreboard players set {holder} h087 -99', case + '-write-reset-' + holder, -99)
parts = ['custom_data={keep:87}']
if source is not None:
parts.append('damage=' + source)
spec = 'diamond_pickaxe[' + ','.join(parts) + ']'
cmd(f'/execute store result score result h087 store success score success h087 '
f'run item replace block 4 200 4 container.{slot} with {spec}', case + '-write',
1 if expected is not None else None, expected is not None)
cmd('/scoreboard players get result h087', case + '-write-result', admission=expected is not None)
cmd('/scoreboard players get success h087', case + '-write-success', admission=expected is not None)
for key, count in [('id', len('minecraft:stone') if expected is None else len('minecraft:diamond_pickaxe')),
('count', 2 if expected is None else 1), ('Slot', slot)]:
cmd(f'/data get block 4 200 4 {path}.{key}', case + '-' + key, count)
read(path, case + '-stack')
read(path + '.components."minecraft:damage"', case + '-damage')
read(path + '.components."minecraft:custom_data".keep', case + '-custom', 87, True)
cmd(f'/execute if data block 4 200 4 Items[{{Slot:{slot}b,components:{{"minecraft:damage":{expected}}}}}]'
if expected else f'/execute unless data block 4 200 4 {path}.components."minecraft:damage"',
case + '-typed-damage', 1, admission=False)
cmd('/data get block 4 200 4 Items', 'final-stacks', len(CASES), admission=False)
cmd('/data get block 4 200 4', 'parent-block', diagnostic=True)
cmd('/execute store result score end h087 run time query gametime', 'clock-end', diagnostic=True)
cmd('/scoreboard players operation end h087 -= start h087', 'clock-delta-operation', 0)
cmd('/scoreboard players get end h087', 'clock-delta', 0)
return ops
def main():
full = '--full-diagnostic' in sys.argv
if full:
sys.argv.remove('--full-diagnostic')
reference = None
if '--reference' in sys.argv:
i = sys.argv.index('--reference')
reference = Path(sys.argv[i + 1]).resolve()
del sys.argv[i:i + 2]
if '--work-dir' not in sys.argv:
sys.argv += ['--work-dir', str(driver.lab.LOCAL / 'numeric-damage-component-parity')]
build = Path(sys.argv[sys.argv.index('--work-dir') + 1]).resolve()
reference_bytes = reference.read_bytes() if reference else None
assert reference is None or reference != build / 'vanilla.jsonl'
ops = program(full)
driver.corpus_module.groups = lambda: [('numeric damage component', ops)]
sys.argv.append('--allow-gaps')
driver.main()
recorded = [op for op in ops if op['op'] == 'cmd' and op.get('record', True)]
java = [json.loads(s) for s in (build / 'vanilla.jsonl').read_text().splitlines()]
assert len(recorded) == len(java)
rows = {op['label']: row for op, row in zip(recorded, java)}
for case, _, expected in CASES:
if expected is None:
assert rows[case + '-write']['results'] == [], rows[case + '-write']
else:
callbacks = [[True, expected]] if expected else [[False, 0]]
assert rows[case + '-damage-read']['results'] == callbacks, rows[case + '-damage-read']
if '--reference-only' in sys.argv:
return
comparison = reference_bytes or (build / 'vanilla.jsonl').read_bytes()
if reference_bytes and not full:
assert reference_bytes == (build / 'vanilla.jsonl').read_bytes(), 'fresh reference changed'
(build / 'comparison-reference.jsonl').write_bytes(comparison)
java = [json.loads(s) for s in comparison.decode().splitlines()]
native = [json.loads(s) for s in (build / 'rust.jsonl').read_text().splitlines()]
assert len(recorded) == len(java) == len(native)
differences = [{'label': op['label'], 'expected': a, 'actual': b}
for op, a, b in zip(recorded, java, native) if a != b]
report = {'records': len(native), 'passed': len(native) - len(differences), 'differences': differences,
'comparison': 'complete raw records, no masks/tolerances',
'reference_sha256': hashlib.sha256(comparison).hexdigest()}
(build / 'strict-report.json').write_text(json.dumps(report, indent=2) + '\n')
for op, a, b in zip(recorded, java, native):
if op['admission'] and op['label'] not in {'clock-start', 'parent-block', 'clock-end'}:
assert a == b, ('admission', op, a, b)
print(f'Strict raw records: {report["passed"]}/{report["records"]}')
if differences:
raise SystemExit(1)
if __name__ == '__main__':
main()