Reject unknown inline slot-range names before execution

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-06 16:26:05 +01:00
1 parent 41f822b659
commit 5b44880bfb
4 files changed
+212 -2

No files matched your search

+46 -1
View File
@@ -1,7 +1,7 @@
//! ResourceOrIdArgument and the data-fixer-upper decoding of inline values
//! commands accept (loot predicates, item modifiers, slot sources, context
//! providers, dialogs), reproducing the codec error messages vanilla reports.
use super::args::{Registries, is_id_char, parse_identifier};
use super::args::{Registries, is_id_char, parse_identifier, slot_ranges};
use super::{CmdResult, CommandError, Reader};
use horizon_world::nbt::{Compound, JavaString, Tag};
@@ -191,6 +191,15 @@ pub fn decode(registry: &str, t: &Tag, reg: &Registries) -> Result<(), String> {
) {
return Err("No items in component".into());
}
} else if id == "minecraft:slot_range" {
// RangeSlotSource.MAP_CODEC uses the global SlotRanges.CODEC,
// independently of the eventual target inventory's size.
require(map, t, &["slots"])?;
let value = get(map, "slots").expect("required slots");
let name = as_string(value).ok_or_else(|| "Not a string".to_owned())?;
if !slot_ranges().iter().any(|(known, _)| known == &name) {
return Err(format!("Unknown element name:{name}"));
}
}
Ok(())
})
@@ -320,3 +329,39 @@ mod contents_component_tests {
}
}
}
#[cfg(test)]
mod slot_range_tests {
use super::*;
#[test]
fn inline_range_names_use_the_global_slot_registry() {
let reg = Registries::default();
for name in ["container.0", "container.53", "container.*", "hotbar.8"] {
let input = format!(r#"{{type:"minecraft:slot_range",slots:"{name}"}}"#);
let mut reader = Reader::new(&input);
assert!(
resource_or_id(&mut reader, &reg, "minecraft:slot_source").is_ok(),
"{name}"
);
assert_eq!(reader.cursor, input.len());
}
for name in ["container.54", "container.99"] {
let input = format!(r#"{{type:"minecraft:slot_range",slots:"{name}"}}"#);
let mut reader = Reader::new(&input);
assert!(
resource_or_id(&mut reader, &reg, "minecraft:slot_source").is_err(),
"{name} crossed argument parsing"
);
let (raw, _) = horizon_world::nbt::parse_snbt(&input).unwrap();
let message = format!("Unknown element name:{name}");
assert_eq!(
decode("minecraft:slot_source", &raw, &reg),
Err(either(
message.clone(),
either("Not a string".into(), either(not_list(&raw), message))
))
);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
# Inline slot-range name admission (H279)
Frozen source `41f822b659789780f58d32c63ddfb6887ea1b8bf`. Own actual Game/fresh pinned Java26.3, `tools/test_slot_range_codec_parity.py`. All98 declared complete records compare exact feedback/styles/callbacks and fresh result/success stores without masks/tolerances. Eight predeclared raw traces retain full chest bodies and exact absolute clocks separately.
The loaded single chest has27 slots. Cases cover inline container.0 with a populated and an empty target, invalid99/54, wildcard, and globally valid53 (beyond this chest's size). Initial/post full typed inventories and a neighbor control chest remain strict, alongside block/BE identity, coordinates, support/load, actual write results and relative-clock0. The original exact invalid99 command runs before the stored variants.
Untouched Game: **91/98**; fixed Game: **98/98**, same program and byte-identical fresh Java reference; all admissions pass. Seven residuals below concern parse-versus-execution feedback/callbacks/stores. Invalid inputs leave target and neighbor unchanged in both builds; Java rejects without callback and retains -99 sentinels, while old Game reaches execution and stores0. Valid53 must remain accepted, reaching execution with callback(false,0) and stores0. Valid0 replaces one slot; wildcard replaces27.
## Exact untouched residuals
| Label | Java expected | Untouched Game actual | Callback expected / actual |
|---|---|---|---|
| original-inline99 | `Failed to parse structure: Failed to parse either. First: Unknown element name:container.99; Second: Failed to parse either. First: Not a string; Second: Failed to parse either. First: Not a list: {slots:"container.99",type:"minecraft:slot_range"}; Second: Unknown element name:container.99 | ...ainer.99"} with diamond<--[HERE]` | `The target does not have specified slots` | `[]` / `[[False, 0]]` |
| invalid99-fill | `Failed to parse structure: Failed to parse either. First: Unknown element name:container.99; Second: Failed to parse either. First: Not a string; Second: Failed to parse either. First: Not a list: {slots:"container.99",type:"minecraft:slot_range"}; Second: Unknown element name:container.99 | ...ainer.99"} with diamond<--[HERE]` | `The target does not have specified slots` | `[]` / `[[False, 0]]` |
| invalid99-result | `result has -99 [h279]` | `result has 0 [h279]` | `[[True, -99]]` / `[[True, 0]]` |
| invalid99-success | `success has -99 [h279]` | `success has 0 [h279]` | `[[True, -99]]` / `[[True, 0]]` |
| invalid54-fill | `Failed to parse structure: Failed to parse either. First: Unknown element name:container.54; Second: Failed to parse either. First: Not a string; Second: Failed to parse either. First: Not a list: {slots:"container.54",type:"minecraft:slot_range"}; Second: Unknown element name:container.54 | ...ainer.54"} with diamond<--[HERE]` | `The target does not have specified slots` | `[]` / `[[False, 0]]` |
| invalid54-result | `result has -99 [h279]` | `result has 0 [h279]` | `[[True, -99]]` / `[[True, 0]]` |
| invalid54-success | `success has -99 [h279]` | `success has 0 [h279]` | `[[True, -99]]` / `[[True, 0]]` |
## Source and scope
Pinned `RangeSlotSource.MAP_CODEC` reads slots with `SlotRanges.CODEC`, using global names. The inline command codec now reuses `args::slot_ranges`; it has no inventory-size context. `container.53` remains globally valid;54/99 are not names. The resource-free real `resource_or_id` argument regression admits0/53/wildcard/hotbar8, then fails old because54 crosses parsing. Its exact decoder error is independently pinned by the Java pair.
Only inline slot_range name admission changes. There is no target-size check, evaluator, global registry/parser rewrite, contextual source/provider policy or Player/Guest change. Other slot-source families and nested schema validation are not expanded or claimed. Existing contents validation stays intact.
Merged PR9117's original valid51/full52 programs and optional `--slot-codec-diagnostic` remain byte-unchanged in `command_exec_corpus.py`/`test_command_exec_parity.py`. Those counts are preserved historical evidence, not this own current denominator. No H247 production is imported.
## Reproduction and evidence
```sh
python3 tools/test_slot_range_codec_parity.py --reference-only --work-dir /tmp/slot-java/run
python3 tools/test_slot_range_codec_parity.py --binary /absolute/path/to/horizon-client --reference /tmp/slot-java/run/vanilla.jsonl --work-dir /tmp/slot-game/run
```
Output directories must be fresh. Complete scripts, declarations, reference/output JSONL, raw logs, immutable binaries and hashes remain under `.local/h279/`. No failed input is counted as a valid admission.
- Program SHA256: `d23f51bc5a1e7f507d199b1d428172bb4a0bf8e8b81f2fc6416e83f24546249c`
- Fresh Java reference SHA256: `1ef944bc6b57e3a7130d5e31f8c9660e81778d0cb8294a27d8c9a0858e155db3`
- Untouched Game SHA256: `e45a7e8023e21869d10c1b010f3c3617bb092f62e3b941a94bbd986619d1e131`
## Full raw diagnostics
6/8 separately declared full-body/absolute-clock traces match. Exact unmatched text follows. Complete styles/callbacks stay retained; no absolute-clock normalization or equality claim.
```text
/execute store result score start h279 run time query gametime
JAVA: The game time is 293 tick(s)
GAME: The game time is 0 tick(s)
/execute store result score end h279 run time query gametime
JAVA: The game time is 293 tick(s)
GAME: The game time is 0 tick(s)
```
## Local validation
The same resource-free argument-codec regression fails old (`container.54 crossed argument parsing`) and passes in the final required suite. `pr_ready --quick --verbose --base 41f822b659789780f58d32c63ddfb6887ea1b8bf` ran once: **44 command tests passed**, along with rustfmt, clippy (including server features), doc tests and public-tree checks. Overall exit1 is the same three pre-existing macOS optional-release-archive Python subtests (masks0/1 `musl[@]`, mask2 `arm64[@]` unbound arrays);708 ran, one skipped. The381 broad affected harnesses were listed for CI by `--quick`; the focused actual Game pair was run locally. Ledger map-check:158 rows pass; ledger check retains one uncited generic multi-codec dispatcher warning for decode, whose range arm cites the actual MAP_CODEC. No full multi-codec closure is claimed.
+1 -1
View File
@@ -145,7 +145,7 @@ TrimPatterns.register skip:data trim patterns are read from data
CompositeSlotSource.createCodec skip:java
CompositeSlotSource.group port crates/horizon-client/src/game/commands/items.rs::Game::slot_candidates inline groups preserve ordered repeated slots; gap: minor: registered holder/tag sources and contents component adapters remain unsupported
CountingModifier.accept port crates/horizon-client/src/game/commands/items.rs::Game::cmd_item_modify counts each slot set, limited to the max stack size
RangeSlotSource.provide port crates/horizon-client/src/game/commands/items.rs::Game::slots_named inline slot_range uses the addressed accessor; gap: minor: explicit context source overrides remain unsupported
RangeSlotSource.provide port crates/horizon-client/src/game/commands/items.rs::Game::slots_named inline slot_range uses the addressed accessor; gap: minor: explicit context source overrides remain unsupported; inline codec admission checks the global slot_ranges names, not target size
SlotCollection.concat inline crates/horizon-client/src/game/commands/items.rs::Game::slot_candidates ordered inline term expansion
SlotCollection.of port crates/horizon-client/src/game/commands/items.rs::Game::slot_get
SlotCollection.FlatMapped.modifySlots inline crates/horizon-client/src/game/commands/items.rs::Game::cmd_item_modify per-target slot loop
Can't render this file because it contains an unexpected character in line 1 and column 181.
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Inline slot-range name admission through actual item fill."""
import hashlib
import json
from pathlib import Path
import sys
import tempfile
import test_command_exec_parity as driver
CASES = [('first-populated','container.0',False),('first-empty','container.0',True),('invalid99','container.99',False),('invalid54','container.54',False),('wildcard','container.*',False),('global-last','container.53',False)]
POPULATED = '[{Slot:0b,id:"minecraft:stone",count:3},{Slot:5b,id:"minecraft:apple",count:2}]'
EMPTY_ZERO = '[{Slot:5b,id:"minecraft:apple",count:2}]'
CONTROL = '[{Slot:0b,id:"minecraft:stick",count:3}]'
def program():
ops = []
def cmd(text, label, result=None, admission=True, diagnostic=False):
op = dict(op='cmd', cmd=text, label=label, admission=admission)
if result is not None: op['expect_result'] = result
if diagnostic: op.update(record=False, trace=True)
ops.append(op)
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(dict(op='settle', required_chunks=[[0, 0]]))
cmd('/fill 0 199 0 15 199 15 stone', 'floor', 256)
cmd('/tp @s 14.5 200 14.5 0 0', 'player-position', 1)
cmd('/gamemode creative @s', 'player-mode')
for condition,label in [('loaded 4 200 4','loaded-target'),('loaded 6 200 4','loaded-control'),('block 4 199 4 stone','target-support'),('block 6 199 4 stone','control-support'),('entity @s[type=player,name=Player,nbt={Health:20f,playerGameType:1,Pos:[14.5d,200d,14.5d]}]','player')]:
cmd('/execute if '+condition,label,1)
cmd('/setblock 4 200 4 chest{Items:'+POPULATED+'}', 'place-target', 1)
cmd('/setblock 6 200 4 chest{Items:'+CONTROL+'}', 'place-control', 1)
for x,label in [(4,'target'),(6,'control')]:
cmd(f'/execute if block {x} 200 4 chest[type=single]', label+'-block',1)
cmd(f'/execute if data block {x} 200 4 {{id:"minecraft:chest",x:{x},y:200,z:4}}', label+'-entity',1)
cmd('/scoreboard objectives add h279 dummy', 'objective')
cmd('/execute store result score start h279 run time query gametime', 'clock-start', diagnostic=True)
cmd('/data get block 4 200 4 Items', 'diagnostic-initial')
cmd('/item fill block 4 200 4 {type:"minecraft:slot_range",slots:"container.99"} with diamond', 'original-inline99', admission=False)
cmd('/data get block 4 200 4 Items', 'original-inline99-state')
cmd('/execute if data block 4 200 4 {Items:'+POPULATED+'}', 'original-inline99-unchanged',1)
for label,name,empty in CASES:
initial=EMPTY_ZERO if empty else POPULATED
cmd('/data modify block 4 200 4 Items set value '+initial, label+'-reset')
cmd('/data get block 4 200 4 Items', label+'-initial')
cmd('/execute if data block 4 200 4 {Items:'+initial+'}', label+'-initial-types',1)
if empty:cmd('/execute unless data block 4 200 4 Items[{Slot:0b}]', label+'-empty-zero',1)
cmd('/data get block 6 200 4 Items', label+'-control-before')
cmd('/scoreboard players set result h279 -99', label+'-reset-result',-99)
cmd('/scoreboard players set success h279 -99', label+'-reset-success',-99)
source='{type:"minecraft:slot_range",slots:"'+name+'"}'
cmd('/execute store result score result h279 store success score success h279 run item fill block 4 200 4 '+source+' with diamond', label+'-fill',admission=False)
for key in ['result','success']:
cmd('/scoreboard players get '+key+' h279',label+'-'+key,admission=False)
cmd('/data get block 4 200 4 Items', label+'-after')
cmd('/data get block 6 200 4 Items', label+'-control-after')
if name in ['container.99','container.54','container.53']:
cmd('/execute if data block 4 200 4 {Items:'+initial+'}', label+'-unchanged',1)
else:
cmd('/execute if data block 4 200 4 {Items:[{Slot:0b,id:"minecraft:diamond",count:1}]}', label+'-diamond',1)
cmd('/data get block 4 200 4', label+'-body',diagnostic=True)
cmd('/execute store result score end h279 run time query gametime','clock-end',diagnostic=True)
cmd('/scoreboard players operation end h279 -= start h279','clock-delta-operation',0)
cmd('/scoreboard players get end h279','clock-delta',0)
return ops
def main():
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:
parent = Path(tempfile.mkdtemp(prefix='slot-range-', dir=driver.lab.LOCAL))
sys.argv += ['--work-dir', str(parent / 'run')]
build = Path(sys.argv[sys.argv.index('--work-dir')+1]).resolve()
assert not build.exists(), 'preserve attempts; choose a fresh directory'
build.mkdir(parents=True)
ops = program(); recorded = [op for op in ops if op['op']=='cmd' and op.get('record', True)]
(build/'declaration.json').write_text(json.dumps(dict(
script_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), records=len(recorded),
cases=CASES, labels=[op['label'] for op in recorded],
raw_diagnostics=[op['label'] for op in ops if op.get('trace')],
comparison='complete records, no masks/tolerances; bodies and absolute clocks are separate raw traces'), indent=2)+'\n')
expected_bytes = reference.read_bytes() if reference else None
driver.corpus_module.groups = lambda: [('Inline slot ranges', ops)]
sys.argv.append('--allow-gaps'); driver.main()
java_bytes = (build/'vanilla.jsonl').read_bytes()
if expected_bytes is not None:
assert java_bytes==expected_bytes and reference.read_bytes()==expected_bytes, 'fresh Java reference changed'
java = [json.loads(s) for s in java_bytes.splitlines()]
if '--reference-only' in sys.argv: return
native = [json.loads(s) for s in (build/'rust.jsonl').read_bytes().splitlines()]
assert len(recorded)==len(java)==len(native)
residuals = [dict(label=op['label'], expected=a, actual=b) for op,a,b in zip(recorded,java,native) if a!=b]
report = dict(records=len(recorded), passed=len(recorded)-len(residuals), residuals=residuals,
reference_sha256=hashlib.sha256(java_bytes).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']: assert a==b, ('admission',op,a,b)
print(f'Strict records: {report["passed"]}/{report["records"]}')
if residuals: raise SystemExit(1)
if __name__ == '__main__':
main()