Merge pull request #10266 from TheMrZZ/fix/ominous-warning-sound-source

Use neutral source for ominous item warning
This commit is contained in:
Kacper authored and GitHub committed 2026-11-06 09:17:10 +01:00
commit 57512281ba
4 files changed
+422 -1

No files matched your search

@@ -114,7 +114,8 @@ impl Game<'_> {
let at = block(s.position);
let center = at.map(|v| f64::from(v) + 0.5);
if s.tick_count == s.spawn_item_after - ABOUT_TO_SPAWN {
self.audio.play(
self.audio.play_on(
6, // SoundSource.NEUTRAL, as OminousItemSpawner.tickServer selects.
"minecraft:block.trial_spawner.about_to_spawn_item",
Some(center),
1.0,
@@ -237,3 +238,6 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod warning_tests;
@@ -0,0 +1,112 @@
use horizon_core::geometry::Vec3;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn warning_dispatch_uses_neutral_at_tick_four() {
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-ominous-sound-{}-{}",
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(4.5, 100.0, 4.5)).unwrap());
const WARNING: &str = "minecraft:block.trial_spawner.about_to_spawn_item";
let center = [6.5, 102.5, 4.5];
game.audio.capture = Some(Vec::new());
game.audio
.play("minecraft:block.stone.break", Some(center), 1.0, 1.0);
game.audio.play_on(
6,
"minecraft:block.note_block.pling",
Some(center),
1.0,
1.0,
);
assert_eq!(
game.audio
.capture
.as_ref()
.unwrap()
.iter()
.map(|s| s.source)
.collect::<Vec<_>>(),
[4, 6]
);
game.audio.capture.as_mut().unwrap().clear();
let (tag, _) = horizon_world::nbt::parse_snbt(r#"{id:"minecraft:ominous_item_spawner",UUID:[I;128,0,0,1],Pos:[6.25d,102.75d,4.875d],item:{id:"minecraft:diamond",count:1},spawn_item_after_ticks:40L}"#).unwrap();
game.load_ominous_item_spawner(tag);
assert_eq!(game.ominous_spawners.len(), 1);
let s = &game.ominous_spawners[0];
assert_eq!(s.uuid, [128, 0, 0, 1]);
assert_eq!(s.position, [6.25, 102.75, 4.875]);
assert_eq!(&*s.item.item, "minecraft:diamond");
assert_eq!(s.item.count, 1);
assert_eq!(s.spawn_item_after, 40);
assert_eq!(s.tick_count, 0);
for tick in 1..=5 {
game.tick_ominous_item_spawners();
assert_eq!(game.ominous_spawners[0].tick_count, tick);
let warning: Vec<_> = game
.audio
.capture
.as_ref()
.unwrap()
.iter()
.filter(|s| s.event == WARNING)
.collect();
assert_eq!(warning.len(), if tick >= 4 { 1 } else { 0 });
if tick == 4 {
let sound = warning[0];
assert_eq!(sound.position, center);
assert_eq!(sound.volume.to_bits(), 1.0f32.to_bits());
assert_eq!(sound.pitch.to_bits(), 1.0f32.to_bits());
assert_eq!(
sound.source, 6,
"OminousItemSpawner.tickServer explicitly selects NEUTRAL"
);
}
}
game.audio
.play("minecraft:block.stone.break", Some(center), 1.0, 1.0);
assert_eq!(
game.audio.capture.as_ref().unwrap().last().unwrap().source,
4,
"the explicit warning source must not change later event-family inference"
);
drop(game);
std::fs::remove_dir_all(scratch).unwrap();
}
+111
View File
@@ -0,0 +1,111 @@
# Ominous item spawner warning source (H128)
Official pinned Java26.3 OminousItemSpawner.tickServer chooses SoundSource.NEUTRAL
explicitly for block.trial_spawner.about_to_spawn_item. At a loaded delay40, its
comparison tickCount == spawnItemAfterTicks -36 warns at tick4. The BlockPos overload
uses the block center and volume/pitch1. Own bytecode and port ledger were reviewed.
Native used Audio::play, whose settings::sound_source infers a category from the event
family; this particular block.trial_spawner name resolves to BLOCKS4. Audio::play does
not globally default to blocks. Only this warning call changes to play_on(6,...).
Global inference, other audio, levelEvent3021, H065 extra local sounds, particles,
items, delay/expiry and rendering remain unchanged.
## Frozen actual Game/Java route
Base5849fa33b306beae3f218d03694f4e17497d59c4. The existing dedicated-server and
LocatorBarClient helpers run a fresh pinned Java server and actual headless Game server
against copies of a Java-created flat world. A small opt-in sounds decoder uses the
existing native packet codec and records typed ClientboundSoundPacket fields plus the
complete packet payload. It adds no packet framework and is disabled by default.
Actual nearby Alice is identified by her offline UUID, type, Health20, mode1 and
Pos[4.5,100,4.5]. The actual loaded spawner has UUID[I;128,0,0,1], type,
Pos[6.25,102.75,4.875], item diamond1 and spawn_item_after_ticks40L. Positive actor,
player, loaded chunk, stone floor and air controls run at every observation boundary.
An independent /playsound neutral note-block control proves the observation route and
neutral source value before the actual spawner warning; it does not manufacture a warning.
The declared program advances actual world deltas0/1/3/4/4/5, with frozen-world command
receipts before steps and after each observation. Both absolute game clocks and exact
relative deltas are retained. There is no warning at0/1/3, exactly one at4, none during
the immediate same-tick repeat or at5. It is not a wall-wait or expiry/drop trial.
| Field | Fresh Java | Untouched Game |
|---|---|---|
| Event | minecraft:block.trial_spawner.about_to_spawn_item | same |
| Center | [6.5,102.5,4.5] | same |
| Volume/pitch | 1.0/1.0; bits1065353216 each | same |
| Warning boundary | actual world tick4 | same |
| Category | NEUTRAL6 | **BLOCKS4** |
The strict contract names stage, event, source ordinal/name, position, volume/pitch and
both float-bit values. These fields are compared exactly, without tolerance or repaired
labels. The expected two records are the neutral control and the actual warning; empty
windows and duplicate rejection are enforced too. All sound seeds and payload bytes,
other sound records, full chats and entity bodies remain raw diagnostics. No random-seed,
full-packet-byte, broad delivery/packet-family or audible playback parity is claimed.
Available wall/monotonic stage receipt timestamps and server logs remain unchanged.
The actual sound emission timestamp is unknown and recorded as null, not invented from
a receipt time. Absolute startup clocks are diagnostic, not normalized into equality.
The first Java draft observed the correct sound but rejected successful block/load guards
because its checker incorrectly expected the entity count reply for every guard. That
capture/program/log is retained under .local/h128/draft1 and java-draft1 as a rejected
prerequisite, not a qualified native baseline. Exact per-kind guards and an explicit
pre-step receipt barrier were declared and freshly Java-validated before the real pair.
The reachable Game regression loads a real spawner through its production NBT route,
checks identity/item/delay, and calls the actual warning-dispatch tick from0 through5.
It verifies no early warning, block center and exact float bits, one warning at4 and no
duplicate at5. Independent inferred-block and explicit-neutral controls precede the old
intended4-versus6 assertion; a later block sound checks source-override restoration.
This unit is a Game dispatch test; the dedicated-server pair supplies the real world ticks.
Replay with prepared resources and isolated test homes/cwd as required by AGENTS.md:
```sh
python3 tools/test_ominous_warning_sound_parity.py --binary /path/to/game --reference-only --work-dir .local/h128/reference
python3 tools/test_ominous_warning_sound_parity.py --binary /path/to/game --reference .local/h128/reference/reference.json --work-dir .local/h128/replay
HORIZON_TEST_RESOURCES=/absolute/resources/26.3 cargo test -p horizon-client --lib warning_dispatch_uses_neutral_at_tick_four -- --ignored --nocapture
```
Complete raw sounds/payloads, all transcripts, actor/body guards, stage timestamps, clocks,
worlds, bytecode, declarations, logs and immutable binaries stay under .local/h128.
No Mojang files or prepared resources are committed.
## Declared hashes
| Artifact | SHA-256 |
|---|---|
| Program before paired runs | `d539ab028d1b6f5fbf65ecbdf93cfd85ac33a0cc9b9fd33b29436d77c7c0b5c4` |
| Fresh strict Java sound reference | `cc293f8619552459f0a7f1b1fc27a4c7769bf281ab25d7321ff997f9d9508d25` |
## Paired result
Untouched Game passes the independent sound control but the warning category is
BLOCKS4. Fixed Game matches both declared sound records exactly: controlNEUTRAL6 and
warningNEUTRAL6, including timing, center and exact volume/pitch bits. Both paired
programs and independently regenerated strict Java reference files are byte-identical.
All actor/player/geometry/clock admissions pass. Raw seeds/payloads and timestamp
diagnostics remain complete and unnormalized; they are not asserted byte-equal.
## Local readiness and later source review
The resource-backed Game regression executes one test and passes fixed, after its
intended old category4-versus6 failure. Required pr_ready --quick ran once on the final
production/test patch: formatting, workspace/headless clippy,1015 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
hit unset Bash3.2 arrays. These files are unchanged; no packaging repair is included.
Focused Java/native network captures and the ignored Game regression were explicitly
run. --quick does not claim the broad parity suite. Only evidence docs were finalized
after checks, followed by public-tree and whitespace verification.
Actual main pulls reached b0a738abc and then
3c7bb44aa12407aff43fe8c9adab51a9b9ebc61c. The warning producer, audio API/inference,
sound packet builder/codec and existing typed-client baseline are unchanged. Other
cosmetics/launcher and profiling changes were reviewed; LAN changes wrap existing calls
with timing and add wire byte counters, without changing this sound's category or order.
The measured pair remains frozen5849, not a combined latest-main runtime claim.
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Real dedicated Game/Java spawner warning, through the existing typed network client."""
import argparse
import hashlib
import json
from pathlib import Path
import re
import time
import test_locator_bar_parity as net
from test_dedicated_server_parity import Server, game_time
PLAYER = 'Alice'
SPAWNER = '00000080-0000-0000-0000-000000000001'
WARNING = 'minecraft:block.trial_spawner.about_to_spawn_item'
CONTROL = 'minecraft:block.note_block.pling'
STAGES = [('at0', 0), ('at1', 1), ('at3', 3), ('at4', 4), ('repeat4', 4), ('at5', 5)]
def words(name):
raw = bytearray(hashlib.md5(('OfflinePlayer:' + name).encode()).digest())
raw[6] = (raw[6] & 15) | 48
raw[8] = (raw[8] & 63) | 128
return [int.from_bytes(raw[i:i+4], 'big', signed=True) for i in range(0, 16, 4)]
def program():
player_uuid = ','.join(map(str, words(PLAYER)))
guards = [
f'execute if entity @s[type=player,name={PLAYER},nbt={{UUID:[I;{player_uuid}],Health:20f,Pos:[4.5d,100d,4.5d],playerGameType:1}}]',
'execute if loaded 6 102 4', 'execute if block 4 99 4 stone',
'execute if block 6 102 4 air',
f'execute if entity @e[type=ominous_item_spawner,nbt={{UUID:[I;128,0,0,1],Pos:[6.25d,102.75d,4.875d],item:{{id:"minecraft:diamond",count:1}},spawn_item_after_ticks:40L}}]',
]
return {'player': PLAYER, 'stages': STAGES, 'guards': guards,
'guard_translations': ['commands.execute.conditional.pass_count' if i in (0,4) else 'commands.execute.conditional.pass' for i in range(len(guards))],
'step_barrier': 'client receipt before every requested world-step batch',
'summon': 'summon ominous_item_spawner 6.25 102.75 4.875 {UUID:[I;128,0,0,1],item:{id:"minecraft:diamond",count:1},spawn_item_after_ticks:40L}',
'sound_contract': ['stage', 'event', 'source', 'source_name', 'position', 'volume', 'pitch', 'volume_bits', 'pitch_bits'],
'raw_diagnostics': 'all sound seeds/payloads, all chat, absolute clocks and available stage timestamps; emission time unknown'}
def dump(client, label):
client.tell('dump ' + label)
while (line := client.process.stdout.readline()) != 'dumped ' + label + '\n':
if not line:
raise AssertionError('network client exited before dump')
client.log.write(line)
def wait_new(server, start, expression, timeout=60):
until = time.monotonic() + timeout
while time.monotonic() < until:
if any(re.search(expression, s) for s in server.lines[start:]):
return
if server.process.poll() is not None:
break
time.sleep(.02)
raise AssertionError(f'no new {expression}: {server.lines[start:][-8:]}')
def generate(command, build):
directory = build / 'generate'
net.server_dir(directory)
server = Server(command, directory, 'generate')
try:
server.wait_for(r'Done \(')
for cmd, ack in [('gamerule spawn_mobs false', 'spawn_mobs'),
('gamerule advance_time false', 'advance_time'),
('gamerule respawn_radius 0', 'respawn_radius'),
('forceload add 0 0 15 15', 'force loaded')]:
start = len(server.lines); server.console(cmd); wait_new(server, start, ack)
# Readiness only, before actors or the timed program.
until = time.monotonic() + 60
while True:
start = len(server.lines); server.console('execute if loaded 4 100 4'); game_time(server)
if any('Test passed' in s for s in server.lines[start:]):
break
assert time.monotonic() < until, 'generation chunk did not become ready'
time.sleep(.05)
for cmd, ack in [('fill 0 99 0 15 99 15 stone', '256'),
('setworldspawn 4 100 4', 'world spawn'), ('save-all flush', 'Saved the game')]:
start = len(server.lines); server.console(cmd); wait_new(server, start, ack)
finally:
server.stop()
return directory / 'world'
def run(label, command, client_command, build, world, script):
directory = build / label
port = net.server_dir(directory, world)
server = Server(command, directory, label)
raw = build / (label + '-wire.txt')
log = (build / (label + '-client.log')).open('w')
client = None
timeline = []
def mark(stage, **data):
timeline.append({'stage': stage, 'wall_time': time.time(), 'monotonic': time.monotonic(), **data})
try:
server.wait_for(r'Done \(')
client = net.Client(client_command, port, raw, log)
for line in ['sounds on', 'chat on', 'events on', 'join ' + PLAYER, 'fly Alice on']:
client.tell(line)
server.wait_for('Alice joined the game', timeout=120)
client.sync([PLAYER])
def cmd(text, stage):
client.tell('step ' + stage)
client.tell('cmd Alice ' + text)
client.sync([PLAYER])
mark(stage, command=text)
cmd('tick freeze', 'freeze')
cmd('tp @s 4.5 100 4.5 0 0', 'position')
cmd('playsound ' + CONTROL + ' neutral Alice 4.5 100 4.5 1 1', 'sound-control')
cmd(script['summon'], 'summon')
start_clock = game_time(server)
previous = 0
for stage, at in STAGES:
client.tell('step ' + stage)
client.sync([PLAYER])
if at != previous:
server.console(f'tick step {at - previous}')
until = time.monotonic() + 30
while game_time(server) < start_clock + at:
assert time.monotonic() < until, 'world steps did not finish'
time.sleep(.01)
actual_clock = game_time(server)
assert actual_clock - start_clock == at, (stage, actual_clock, start_clock)
client.sync([PLAYER])
mark(stage, clock=actual_clock, relative_clock=actual_clock-start_clock, sound_emission_time=None)
for i, guard in enumerate(script['guards']):
cmd(guard, stage + '-guard' + str(i))
cmd('data get entity ' + SPAWNER, stage + '-body')
cmd('data get entity Alice', stage + '-player-body')
dump(client, stage)
previous = at
finally:
(build / (label + '-stages.json')).write_text(json.dumps(timeline, indent=2) + '\n')
try:
if client:
try:
dump(client, 'failure-or-final')
client.end()
finally:
client.kill()
finally:
server.stop()
log.close()
records = []
chats = {}
for line in raw.read_text().splitlines():
who, rest = line.split(' step=', 1)
stage, kind, data = rest.split(' ', 2)
assert who == PLAYER
if kind == 'sound-wire':
value = json.loads(data)
records.append({'stage': stage, **value})
elif kind == 'chat':
chats.setdefault(stage, []).append(json.loads(data))
for stage, _ in STAGES:
for i in range(len(script['guards'])):
rows = chats[stage + '-guard' + str(i)]
key = script['guard_translations'][i]
assert any(r.get('translate') == key and (key.endswith('.pass') or r.get('with') == [1]) for r in rows), (stage, i, rows)
assert not any(' error ' in s for s in raw.read_text().splitlines()), 'client error in raw transcript'
(build / (label + '-sounds.json')).write_text(json.dumps(records, indent=2) + '\n')
fields = script['sound_contract']
selected = [{k: r[k] for k in fields} for r in records if r['event'] in (WARNING, CONTROL)]
expected = [dict(stage='sound-control', event=CONTROL, source=6, source_name='NEUTRAL', position=[4.5,100.0,4.5], volume=1.0, pitch=1.0, volume_bits=1065353216, pitch_bits=1065353216),
dict(stage='at4', event=WARNING, source=6, source_name='NEUTRAL', position=[6.5,102.5,4.5], volume=1.0, pitch=1.0, volume_bits=1065353216, pitch_bits=1065353216)]
# Warning must actually exist with every non-category field correct, even in the old binary.
assert len(selected) == 2
assert selected[0] == expected[0], ("independent neutral sound control", selected[0])
for a, e in zip(selected, expected):
assert {k:v for k,v in a.items() if k not in ('source','source_name')} == {k:v for k,v in e.items() if k not in ('source','source_name')}, (a,e)
return selected
def main():
p=argparse.ArgumentParser();p.add_argument('--binary',type=Path,required=True);p.add_argument('--work-dir',type=Path,required=True);p.add_argument('--reference-only',action='store_true');p.add_argument('--reference',type=Path);a=p.parse_args()
build=a.work_dir.resolve();build.mkdir(parents=True,exist_ok=True)
script=program();(build/'program.json').write_text(json.dumps(script,indent=2)+'\n')
name=str(build.relative_to(net.lab.LOCAL));_,_,java,native,client=net.sides(name,a.binary.resolve())
world=generate(java,build)
reference=run('java',java,client,build,world,script)
(build/'reference.json').write_text(json.dumps(reference,indent=2)+'\n')
assert all(r['source']==6 and r['source_name']=='NEUTRAL' for r in reference)
if a.reference_only:return
if a.reference:assert a.reference.read_bytes()==(build/'reference.json').read_bytes(),'fresh Java reference changed'
actual=run('native',native,client,build,world,script)
report={'expected':reference,'actual':actual,'pass':reference==actual,'scope':script['sound_contract']}
(build/'report.json').write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report,indent=2))
if reference!=actual:raise SystemExit(1)
if __name__=='__main__':main()