mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Decode numeric damage components with Codec.INT semantics
Co-Authored-By: Codex <[email protected]>
This commit is contained in:
1 parent
76b8b4eb83
commit
1e1c6a900f
3 files changed
+358
-1
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user