mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Reject unknown inline slot-range names before execution
Co-Authored-By: Codex <[email protected]>
This commit is contained in:
1 parent
41f822b659
commit
5b44880bfb
4 files changed
+212
-2
No files matched your search
@@ -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, ®, "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, ®, "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, ®),
|
||||
Err(either(
|
||||
message.clone(),
|
||||
either("Not a string".into(), either(not_list(&raw), message))
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
@@ -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()
|
||||
Reference in new issue
Block a user