Correct empty tag selector predicates

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-05 21:31:34 +01:00
1 parent f00c4c6392
commit 85c277b5e2
5 files changed
+364 -1

No files matched your search

+5 -1
View File
@@ -325,7 +325,8 @@ impl Game<'_> {
let tags = self.tags_of(t);
for (tag, negated) in &s.tags {
let has = if tag.is_empty() {
!tags.is_empty()
// EntitySelectorOptions' empty-name predicate (lambda$bootStrap$43).
tags.is_empty()
} else {
tags.contains(tag)
};
@@ -1122,3 +1123,6 @@ mod team_tests {
);
}
}
#[cfg(test)]
mod empty_tag_tests;
@@ -0,0 +1,104 @@
use super::*;
use horizon_core::geometry::Vec3;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn empty_tag_selectors_follow_actual_tag_edits() {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::motion::PlayerMotion;
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let scratch = std::env::temp_dir().join(format!(
"horizon-empty-tags-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&scratch).unwrap();
game.world_root = scratch.clone();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(10.5, 64.0, 0.5)).unwrap());
game.build_registries();
game.set_allow_commands(true);
for (n, x, tags) in [(1, 4.5, ""), (2, 8.5, ",Tags:[\"audit\"]")] {
assert!(game.run_command(&format!(
"summon pig {x} 64 4.5 {{UUID:[I;267,0,0,{n}],NoAI:1b,NoGravity:1b{tags}}}"
)));
}
let a = game.target_with_uuid([267, 0, 0, 1]).unwrap();
let b = game.target_with_uuid([267, 0, 0, 2]).unwrap();
assert_ne!(a, b);
let select = |g: &mut Game<'_>, tag: &str| {
let text = format!("@e[type=minecraft:pig,tag={tag}]");
let selector = horizon_client::command::selector::parse(
&mut horizon_client::command::Reader::new(&text),
&g.ui.registries,
true,
)
.unwrap();
g.select(&selector).into_iter().collect::<HashSet<_>>()
};
for (phase, tagged_a) in [false, true, false].into_iter().enumerate() {
if phase != 0 {
for (n, tagged) in [(1, tagged_a), (2, !tagged_a)] {
assert!(game.run_command(&format!(
"tag 0000010b-0000-0000-0000-{n:012} {} audit",
if tagged { "add" } else { "remove" }
)));
}
}
assert_eq!(game.target_with_uuid([267, 0, 0, 1]), Some(a));
assert_eq!(game.target_with_uuid([267, 0, 0, 2]), Some(b));
assert_eq!(
game.tags_of(a),
if tagged_a {
vec!["audit".to_owned()]
} else {
vec![]
}
);
assert_eq!(
game.tags_of(b),
if tagged_a {
vec![]
} else {
vec!["audit".to_owned()]
}
);
let (empty, tagged) = if tagged_a { (b, a) } else { (a, b) };
// Named-tag admissions pass before the old empty-tag assertion fails.
assert_eq!(select(&mut game, "audit"), HashSet::from([tagged]));
assert_eq!(select(&mut game, "!audit"), HashSet::from([empty]));
assert_eq!(select(&mut game, ""), HashSet::from([empty]));
assert_eq!(select(&mut game, "!"), HashSet::from([tagged]));
}
drop(game);
std::fs::remove_dir_all(scratch).unwrap();
}
+125
View File
@@ -0,0 +1,125 @@
# Empty tag selector truth value (H267)
`tag=` selects entities with no tags; `tag=!` selects entities with at least one tag.
The fix changes only the empty-name branch from `!tags.is_empty()` to
`tags.is_empty()` before the existing negation comparison. Named tags, normalization,
the tag cap, entity ordering and other selector predicates are unchanged.
Pinned Java26.3 `EntitySelectorOptions.lambda$bootStrap$43` calls
`Entity.entityTags().isEmpty()` for the empty name (bytecode10–20), then applies
negation. The port ledger and actual bytecode were reviewed. Current/closed issue/PR
searches found no exact competing fix. Merged #6468 preserves tags on more entity kinds;
#6935 changes candidate traversal. Neither fixes this branch.
## Actual Game/Java fixture
Frozen base `f00c4c6392ef444130692da4db99f0a7268dd20b`. No pending #10130 (H264)
or #10219 (H266) production is imported. Two actual NoAI/NoGravity pigs have fixed UUIDs
`0000010b-0000-0000-0000-000000000001` and
`0000010b-0000-0000-0000-000000000002`, Health10 and initial positions
[4.5,100,4.5] and [8.5,100,4.5]. Identity never depends on a tag or entity order.
Initially A has no tags and B has only audit. Actual `/tag UUID add/remove audit` commands
swap their tag states and restore them on those same actors.
Each phase observes actual UUID/type/Health/Pos/NoAI/NoGravity, live `/tag UUID list`,
loaded chunk/floor and the real owner body/mode. For each actor, tag=, tag=!, tag=audit,
and tag=!audit are tested with both if/unless scoreboard actions. Every call resets
result/success/branch independently to-99, then reads all three. True actions and false
no-callback behavior are independently validated by fresh Java. There are zero observation
world ticks, witnessed by exact relative-clock deltas. No new observer or generic
infrastructure is added: this uses the existing actual Game/Java command harness.
The strict **421-record** program was declared before its fresh Java and old/fixed runs.
All recorded JSON is compared in full, including message styles and callbacks, without
normalization, tolerance or value masks. Six saved Tags getters, six full body reads and
four absolute clock reads still execute and are traced; they are additionally recorded
in the executable **437-record** full diagnostic. These 16 declared diagnostic labels are:
clock-start; initial/swapped/restored-clock-raw; initial/swapped/restored-a/b-Tags;
initial/swapped/restored-a/b-body. No full437 equality is claimed.
An earlier **421-record** draft used saved `Tags` getters as live-state admissions.
Its old capture was rejected as a prerequisite: after removing the last tag, Java omits
Tags while native returns Tags:[]. It is not a qualified H267 count. The revised program
uses independent real `/tag list` receipts, retaining every saved Tags read in the full
diagnostic. The original421 script/reference/native capture and original431 Java diagnostic, plus
runnable Python modules, remain unchanged under `.local/h267/original421` and the
original capture directories. No fixed-pass claim is made for that rejected program.
No serializer or tag-normalization repair is included.
Replay:
```sh
python3 tools/test_selector_empty_tag_parity.py --binary /path/to/game --work-dir .local/h267/replay
python3 tools/test_selector_empty_tag_parity.py --full-diagnostic --binary /path/to/game --work-dir .local/h267/full-replay
HORIZON_TEST_RESOURCES=/absolute/resources/26.3 cargo test -p horizon-client --lib empty_tag_selectors_follow_actual_tag_edits -- --ignored --nocapture
```
Resource-backed units require isolated fresh test homes/cwd, as in AGENTS.md. Raw commands,
all reference/native JSON, complete expected/actual differences, logs, bytecode, declarations,
hashes and immutable executables remain locally under `.local/h267/`. No Mojang files or
prepared resources are committed.
## Declared hashes
| Artifact | SHA-256 |
|---|---|
| canonical program | `7aa4443f5a5ec8f805aadb6cd3f675f665f102aef7fd199a7269fc8f94fc00f3` |
| Fresh canonical Java records | `e29c68a719df77c7302e388f22b80cabfdbb0de475fda9b3cbd5d8a064e4b449` |
| full program | `5a4f4418f6da23a3ec3ea756e247c777a51939c9fa34b3a3179650be0763ce42` |
| Fresh full Java records | `72ae7a5daed2f426079efb518f519bc673bc3e46f9323f378f37ff4c5ba4712a` |
## Paired results and residuals
| Program | Untouched Game | Fixed Game |
|---|---:|---:|
| Strict complete records | 325/421 | **421/421** |
| Full diagnostic | 329/437 | **425/437** |
Both old/fixed programs are byte-identical for each mode. Their strict fresh Java records
are byte-identical to the frozen strict reference. All actual identity/live-tag/named-tag
and zero-clock admissions pass. The 96 old target differences are 24 reversed empty-form
actions plus their three stored observations each, not 96 independent defects.
The real Game unit fails old at tag= selecting B instead of A after UUID/tag/named-tag
admissions pass. It uses actual summon/tag commands and Game::select, and compares sets
to avoid relying on entity order.
All 12 full-diagnostic residuals persist unchanged by this fix. The table gives exact
field/text differences and callbacks; it is a readable report, not the comparator.
The comparator retains complete raw JSON, including all styled text fragments. Complete
expected/actual records for all old/fixed differences remain in strict-report.json.
| Label | Expected Java | Actual native | Reason outside H267 |
|---|---|---|---|
| `clock-start` | `The game time is 321 tick(s)`; callback `[true,321]` | `The game time is 0 tick(s)`; callback `[true,0]` | Absolute startup clocks; strict relative deltas are0 |
| `initial-clock-raw` | `The game time is 321 tick(s)`; callback `[true,321]` | `The game time is 0 tick(s)`; callback `[true,0]` | Absolute startup clocks; strict relative deltas are0 |
| `initial-a-body` | `attributes: []`; callback `[true,1]` | `attributes: [{id:"minecraft:movement_speed",base:0.25d}]`; callback `[true,1]` | Extra default movement-speed attribute serialization |
| `initial-b-body` | `attributes: []`; callback `[true,1]` | `attributes: [{id:"minecraft:movement_speed",base:0.25d}]`; callback `[true,1]` | Extra default movement-speed attribute serialization |
| `swapped-clock-raw` | `The game time is 321 tick(s)`; callback `[true,321]` | `The game time is 0 tick(s)`; callback `[true,0]` | Absolute startup clocks; strict relative deltas are0 |
| `swapped-a-body` | `attributes: []`; callback `[true,1]` | `attributes: [{id:"minecraft:movement_speed",base:0.25d}]`; callback `[true,1]` | Extra default movement-speed attribute serialization |
| `swapped-b-Tags` | `Found no elements matching Tags`; callback `[false,0]` | `Pig has the following entity data: []`; callback `[true,0]` | Saved empty-Tags representation after actual last-tag removal |
| `swapped-b-body` | `attributes: []`; callback `[true,1]` | `attributes: [{id:"minecraft:movement_speed",base:0.25d}]`; also `Tags:[]`; callback `[true,1]` | Extra default movement-speed attribute serialization and saved empty Tags |
| `restored-clock-raw` | `The game time is 321 tick(s)`; callback `[true,321]` | `The game time is 0 tick(s)`; callback `[true,0]` | Absolute startup clocks; strict relative deltas are0 |
| `restored-a-Tags` | `Found no elements matching Tags`; callback `[false,0]` | `Pig has the following entity data: []`; callback `[true,0]` | Saved empty-Tags representation after actual last-tag removal |
| `restored-a-body` | `attributes: []`; callback `[true,1]` | `attributes: [{id:"minecraft:movement_speed",base:0.25d}]`; also `Tags:[]`; callback `[true,1]` | Extra default movement-speed attribute serialization and saved empty Tags |
| `restored-b-body` | `attributes: []`; callback `[true,1]` | `attributes: [{id:"minecraft:movement_speed",base:0.25d}]`; callback `[true,1]` | Extra default movement-speed attribute serialization |
These whole-body differences are actual extra serialized fields, not merely insertion
order. No attribute/default codec, saved Tags or clock repair is included. Exact full
bodies/styles remain raw. No full437-green or all-entity-class runtime claim is made.
## Local readiness and source limits
The unchanged real Game regression passes fixed (one resource-backed test), after the
intended old failure. Required `pr_ready.py --quick` ran once on the final gameplay/test
patch: formatting, workspace and headless clippy, 1,015 Rust tests, doc tests and public
tree pass. Python ran686 tests with four existing macOS packaging failures and one skip:
the signature mock lacks the ARM64 archive, and optional-archive masks0/1/2 encounter
unset Bash3.2 arrays. Those files are unchanged. The focused actual Game/Java programs
were run explicitly; `--quick` does not claim the broad parity suite. Evidence-only docs
were finalized after that check, then public-tree and whitespace checks were repeated.
A final actual main pull reached `3d33b0299d5c80769300d8b33dafb389ac9e7656`.
Only launcher/cosmetics files changed after the frozen f00 base. Gameplay/selector/driver
sources are unchanged, so no repeated same-base proof or broader latest-main runtime
claim is made. H264/#10130 and H266/#10219 still need their independent routing/criterion
branches and all three regression modules preserved when combined.
+77
View File
@@ -0,0 +1,77 @@
"""Fixed-UUID pigs; actual tag edits, no entity-order or observation-tick dependency."""
ACTORS = [('a', '0000010b-0000-0000-0000-000000000001', 1, 4.5),
('b', '0000010b-0000-0000-0000-000000000002', 2, 8.5)]
def empty_tags(full=False):
ops = []
def cmd(text, label, result=None, callback=None, admission=True, diagnostic=False):
op = {'op': 'cmd', 'cmd': text, 'label': label, 'admission': admission}
if result is not None:
op['expect_result'] = result
if callback is not None:
op['expect_callback'] = callback
if diagnostic and not full:
op.update(record=False, trace=True)
ops.append(op)
cmd('/tick freeze', 'freeze')
for rule, value in [('spawn_mobs', 'false'), ('advance_time', 'false'), ('random_tick_speed', '0')]:
cmd(f'/gamerule {rule} {value}', 'rule-' + rule)
cmd('/forceload add 0 0', 'force', 1)
ops.append({'op': 'settle', 'required_chunks': [[0, 0]]})
cmd('/fill 0 99 0 15 99 15 stone', 'floor', 256)
cmd('/tp @s 4.5 100 12.5 0 0', 'owner-position', 1)
cmd('/gamemode creative @s', 'owner-mode')
cmd('/scoreboard objectives add h267 dummy', 'objective')
cmd('/scoreboard objectives add h267_clock dummy', 'clock-objective')
for actor, uuid, n, x in ACTORS:
tags = ',Tags:["audit"]' if actor == 'b' else ''
cmd(f'/summon pig {x} 100 4.5 {{UUID:[I;267,0,0,{n}],NoAI:1b,NoGravity:1b{tags}}}',
actor + '-summon', 1)
cmd('/execute store result score start h267_clock run time query gametime',
'clock-start', diagnostic=True)
for phase, tagged_a in [('initial', False), ('swapped', True), ('restored', False)]:
if phase != 'initial':
for actor, uuid, _, _ in ACTORS:
tagged = tagged_a if actor == 'a' else not tagged_a
cmd(f'/tag {uuid} {"add" if tagged else "remove"} audit',
phase + '-' + actor + '-edit', 1)
cmd('/execute if entity @s[type=player,name=Player]', phase + '-owner', 1)
for field, result in [('UUID', 4), ('Health', 20), ('Pos', 3), ('playerGameType', 1)]:
cmd(f'/data get entity @s {field}', phase + '-owner-' + field, result)
cmd('/execute if loaded 4 100 4', phase + '-loaded', 1)
cmd('/execute if block 4 99 4 stone', phase + '-floor', 1)
cmd('/execute store result score now h267_clock run time query gametime',
phase + '-clock-raw', diagnostic=True)
cmd('/scoreboard players operation now h267_clock -= start h267_clock', phase + '-clock-delta-op', 0)
cmd('/scoreboard players get now h267_clock', phase + '-clock-delta', 0)
for actor, uuid, n, x in ACTORS:
key = phase + '-' + actor
tagged = tagged_a if actor == 'a' else not tagged_a
cmd(f'/execute as {uuid} if entity @s[type=pig]', key + '-type', 1)
for field, result in [('UUID', 4), ('Health', 10), ('Pos', 3), ('NoAI', 1), ('NoGravity', 1)]:
cmd(f'/data get entity {uuid} {field}', key + '-' + field, result)
cmd(f'/tag {uuid} list', key + '-live-tags', 1 if tagged else 0)
cmd(f'/data get entity {uuid} Tags', key + '-Tags',
result=1 if tagged else None, callback=None if tagged else [False, 0], diagnostic=True)
# Keep the full body as raw diagnostics; typed scalar guards above are canonical.
cmd(f'/data get entity {uuid}', key + '-body', diagnostic=True)
for name, value, matches in [('empty', '', not tagged), ('nonempty', '!', tagged),
('audit', 'audit', tagged), ('not_audit', '!audit', not tagged)]:
for sign in ['if', 'unless']:
label = key + '-' + name + '-' + sign
take = matches if sign == 'if' else not matches
for holder in ['result', 'success', 'branch']:
cmd(f'/scoreboard players set {holder} h267 -99', label + '-reset-' + holder, -99)
cmd(f'/execute as {uuid} store result score result h267 store success score success h267 '
f'{sign} entity @s[tag={value}] run scoreboard players set branch h267 1',
label + '-action', admission=name not in ('empty', 'nonempty'))
ops[-1]['expect_results'] = [[True, 1]] if take else []
for holder in ['result', 'success', 'branch']:
cmd(f'/scoreboard players get {holder} h267', label + '-' + holder,
1 if take else -99, admission=name not in ('empty', 'nonempty'))
return ops
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Strict existing actual Game/Java command-route proof for empty tag selectors."""
import hashlib
import json
from pathlib import Path
import sys
import test_command_exec_parity as driver
from selector_empty_tag_corpus import empty_tags
if __name__ == '__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 / 'selector-empty-tag-parity')]
build = Path(sys.argv[sys.argv.index('--work-dir') + 1]).resolve()
ops = empty_tags(full)
driver.corpus_module.groups = lambda: [('fixed UUID empty tag selectors', 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(java) == len(recorded)
for op, row in zip(recorded, java):
if 'expect_results' in op:
assert row['results'] == op['expect_results'], ('Java action/callback admission', op, row)
if '--reference-only' not in sys.argv:
source = reference or build / 'vanilla.jsonl'
if reference and not full:
assert source.read_bytes() == (build / 'vanilla.jsonl').read_bytes(), 'fresh Java bytes changed'
expected = [json.loads(s) for s in source.read_text().splitlines()]
native = [json.loads(s) for s in (build / 'rust.jsonl').read_text().splitlines()]
assert len(expected) == len(native) == len(recorded)
for op, a, b in zip(recorded, expected, native):
if op['admission'] and not full:
assert a == b, ('actual actor/state/control admission', op, a, b)
differences = [{'label': op['label'], 'expected': a, 'actual': b}
for op, a, b in zip(recorded, expected, native) if a != b]
report = {'records': len(native), 'passed': len(native) - len(differences),
'comparison': 'complete raw records, no masks/tolerances',
'reference_sha256': hashlib.sha256(source.read_bytes()).hexdigest(),
'fresh_java_sha256': hashlib.sha256((build / 'vanilla.jsonl').read_bytes()).hexdigest(),
'differences': differences}
(build / 'strict-report.json').write_text(json.dumps(report, indent=2) + '\n')
print(f'Strict raw records: {report["passed"]}/{report["records"]}')
if differences:
raise SystemExit(1)