Skip to main content

steel_core/block_entity/entities/
jukebox.rs

1//! Vanilla jukebox block-entity storage and song playback.
2
3use std::io::Cursor;
4use std::mem;
5use std::sync::{Arc, Weak};
6
7use glam::DVec3;
8use simdnbt::borrow::{
9    BaseNbtCompound as BorrowedNbtCompound, NbtCompound as NbtCompoundView,
10    NbtTag as BorrowedNbtTag, read_compound as read_borrowed_compound,
11};
12use simdnbt::owned::NbtCompound;
13use steel_registry::blocks::block_state_ext::BlockStateExt as _;
14use steel_registry::blocks::properties::BlockStateProperties;
15use steel_registry::data_components::vanilla_components::JUKEBOX_PLAYABLE;
16use steel_registry::item_stack::ItemStack;
17use steel_registry::jukebox_song::JukeboxSongValue;
18use steel_registry::particle_type::ParticleData;
19use steel_registry::{
20    RegistryEntry, level_events, vanilla_block_entity_types, vanilla_game_events,
21    vanilla_particle_types,
22};
23use steel_utils::nbt::{merge_nbt_compounds, nbt_compounds_equal};
24use steel_utils::types::UpdateFlags;
25use steel_utils::{BlockPos, BlockStateId, DowncastType, DowncastTypeKey, locks::SyncMutex};
26
27use crate::block_entity::{BlockEntity, BlockEntityBase};
28use crate::world::World;
29use crate::world::game_event::GameEventContext;
30
31const RECORD_ITEM_TAG: &str = "RecordItem";
32const TICKS_SINCE_SONG_STARTED_TAG: &str = "ticks_since_song_started";
33const TICKS_PER_SECOND: f32 = 20.0;
34const PLAY_EVENT_INTERVAL_TICKS: i64 = 20;
35const SONG_END_PADDING_TICKS: i32 = 20;
36const UNKNOWN_SONG_REGISTRY_ID: i32 = -1;
37const BLOCK_CENTER_OFFSET: f64 = 0.5;
38const ITEM_EJECTION_Y_OFFSET: f64 = 1.01;
39const ITEM_EJECTION_MAX_HORIZONTAL_OFFSET: f32 = 0.35;
40const NOTE_PARTICLE_Y_OFFSET: f32 = 1.2;
41const NOTE_PARTICLE_COLOR_VARIANTS: u8 = 4;
42const NOTE_PARTICLE_COLOR_DIVISOR: f32 = 24.0;
43
44struct JukeboxPlayback {
45    ticks_since_song_started: i64,
46}
47
48struct JukeboxState {
49    item: ItemStack,
50    playback: Option<JukeboxPlayback>,
51}
52
53/// Vanilla `JukeboxBlockEntity`.
54pub struct JukeboxBlockEntity {
55    base: BlockEntityBase,
56    state: SyncMutex<JukeboxState>,
57}
58
59// SAFETY: This key is owned by Steel and uniquely identifies `JukeboxBlockEntity`.
60unsafe impl DowncastType for JukeboxBlockEntity {
61    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/jukebox");
62}
63
64impl JukeboxBlockEntity {
65    /// Creates an empty jukebox block entity.
66    #[must_use]
67    pub fn new(level: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
68        Self {
69            base: BlockEntityBase::new(&vanilla_block_entity_types::JUKEBOX, level, pos, state),
70            state: SyncMutex::new(JukeboxState {
71                item: ItemStack::empty(),
72                playback: None,
73            }),
74        }
75    }
76
77    fn song_value(item: &ItemStack) -> Option<&JukeboxSongValue> {
78        Some(item.get(JUKEBOX_PLAYABLE)?.song().value())
79    }
80
81    fn song_event_data(item: &ItemStack) -> i32 {
82        let Some(playable) = item.get(JUKEBOX_PLAYABLE) else {
83            return UNKNOWN_SONG_REGISTRY_ID;
84        };
85        let song = playable.song();
86        song.as_reference()
87            .and_then(RegistryEntry::try_id)
88            .and_then(|id| i32::try_from(id).ok())
89            .unwrap_or(UNKNOWN_SONG_REGISTRY_ID)
90    }
91
92    fn song_has_finished(song: &JukeboxSongValue, ticks_elapsed: i64) -> bool {
93        // Vanilla multiplies as `float`, applies `Mth.ceil(float)`, and performs
94        // the padding addition as a wrapping Java `int` before widening.
95        let length_in_ticks = (song.length_in_seconds * TICKS_PER_SECOND).ceil() as i32;
96        ticks_elapsed >= i64::from(length_in_ticks.wrapping_add(SONG_END_PADDING_TICKS))
97    }
98
99    fn value_input_long(tag: BorrowedNbtTag<'_, '_>) -> Option<i64> {
100        tag.byte()
101            .map(i64::from)
102            .or_else(|| tag.short().map(i64::from))
103            .or_else(|| tag.int().map(i64::from))
104            .or_else(|| tag.long())
105            .or_else(|| tag.float().map(|value| value as i64))
106            // Vanilla's DoubleTag.longValue floors before narrowing.
107            .or_else(|| tag.double().map(|value| value.floor() as i64))
108    }
109
110    fn on_song_changed(&self, world: &Arc<World>) {
111        world.update_neighbors_at(self.get_block_pos(), self.get_block_state().get_block());
112        self.set_changed();
113    }
114
115    fn emit_stop_pair(&self, world: &Arc<World>) {
116        let pos = self.get_block_pos();
117        world.game_event(
118            &vanilla_game_events::JUKEBOX_STOP_PLAY,
119            pos,
120            &GameEventContext::new(None, Some(self.get_block_state())),
121        );
122        world.level_event(level_events::SOUND_STOP_JUKEBOX_SONG, pos, 0, None);
123    }
124
125    fn stop_playback(&self, world: Option<&Arc<World>>) {
126        let stopped = self.state.lock().playback.take().is_some();
127        if !stopped {
128            return;
129        }
130        let Some(world) = world else {
131            return;
132        };
133        self.emit_stop_pair(world);
134        self.on_song_changed(world);
135    }
136
137    fn notify_item_changed(&self, world: &Arc<World>, has_record: bool) {
138        let pos = self.get_block_pos();
139        let cached_state = self.get_block_state();
140        if world.get_block_state(pos) != cached_state {
141            return;
142        }
143
144        world.set_block(
145            pos,
146            cached_state.set_value(&BlockStateProperties::HAS_RECORD, has_record),
147            UpdateFlags::UPDATE_CLIENTS,
148        );
149        world.game_event(
150            &vanilla_game_events::BLOCK_CHANGE,
151            pos,
152            &GameEventContext::new(None, Some(self.get_block_state())),
153        );
154    }
155
156    /// Replaces the stored item and starts or stops its jukebox song.
157    pub fn set_the_item(&self, item: ItemStack) {
158        let has_record = !item.is_empty();
159        let has_song = Self::song_value(&item).is_some();
160        let song_event_data = Self::song_event_data(&item);
161        let world = self.get_level();
162        self.state.lock().item = item;
163        let Some(world) = world else {
164            return;
165        };
166
167        self.notify_item_changed(&world, has_record);
168        if has_record && has_song {
169            self.state.lock().playback = Some(JukeboxPlayback {
170                ticks_since_song_started: 0,
171            });
172            world.level_event(
173                level_events::SOUND_PLAY_JUKEBOX_SONG,
174                self.get_block_pos(),
175                song_event_data,
176                None,
177            );
178            self.on_song_changed(&world);
179        } else {
180            self.stop_playback(Some(&world));
181        }
182    }
183
184    /// Ejects the stored item with Vanilla's position, velocity, and pickup delay.
185    pub fn pop_out_the_item(&self) {
186        let Some(world) = self.get_level() else {
187            return;
188        };
189        let item = {
190            let mut state = self.state.lock();
191            if state.item.is_empty() {
192                return;
193            }
194            mem::replace(&mut state.item, ItemStack::empty())
195        };
196
197        self.notify_item_changed(&world, false);
198        self.stop_playback(Some(&world));
199
200        let pos = self.get_block_pos();
201        let random_x = rand::random_range(
202            -ITEM_EJECTION_MAX_HORIZONTAL_OFFSET..ITEM_EJECTION_MAX_HORIZONTAL_OFFSET,
203        );
204        let random_z = rand::random_range(
205            -ITEM_EJECTION_MAX_HORIZONTAL_OFFSET..ITEM_EJECTION_MAX_HORIZONTAL_OFFSET,
206        );
207        let item_pos = DVec3::new(
208            f64::from(pos.x()) + BLOCK_CENTER_OFFSET + f64::from(random_x),
209            f64::from(pos.y()) + ITEM_EJECTION_Y_OFFSET,
210            f64::from(pos.z()) + BLOCK_CENTER_OFFSET + f64::from(random_z),
211        );
212        if let Some(entity) = world.spawn_item(item_pos, item) {
213            entity.set_default_pickup_delay();
214        }
215        // Vanilla notifies a second time after attempting to add the entity.
216        self.on_song_changed(&world);
217    }
218
219    /// Returns whether a song is currently active.
220    #[must_use]
221    pub fn is_record_playing(&self) -> bool {
222        self.state.lock().playback.is_some()
223    }
224
225    /// Returns the stored song's extracted comparator output.
226    #[must_use]
227    pub fn analog_output_signal(&self) -> i32 {
228        let state = self.state.lock();
229        Self::song_value(&state.item).map_or(0, |song| song.comparator_output)
230    }
231
232    /// Merges an owned `BLOCK_ENTITY_DATA` payload into this jukebox.
233    ///
234    /// The placing block validates the payload's declared block-entity type
235    /// before calling this method and releases the source inventory lock first.
236    pub fn apply_item_block_entity_data(&self, payload: NbtCompound) -> bool {
237        let before = self.save_custom_only();
238        let mut merged = self.save_custom_only();
239        merge_nbt_compounds(&mut merged, &payload);
240        if nbt_compounds_equal(&before, &merged) {
241            return false;
242        }
243
244        let mut bytes = Vec::new();
245        merged.write(&mut bytes);
246        let Ok(borrowed) = read_borrowed_compound(&mut Cursor::new(bytes.as_slice())) else {
247            log::warn!(
248                "failed to reborrow item block-entity data for jukebox at {:?}",
249                self.get_block_pos()
250            );
251            return false;
252        };
253        self.load_additional(&borrowed);
254        self.set_changed();
255        true
256    }
257}
258
259enum TickAction {
260    Stop,
261    EmitPlayingEvent,
262    None,
263}
264
265impl BlockEntity for JukeboxBlockEntity {
266    fn base(&self) -> &BlockEntityBase {
267        &self.base
268    }
269
270    fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
271        let nbt: NbtCompoundView<'_, '_> = nbt.into();
272        let new_item = nbt
273            .compound(RECORD_ITEM_TAG)
274            .and_then(|item| ItemStack::from_borrowed_compound(&item))
275            .unwrap_or_else(ItemStack::empty);
276        let saved_ticks = nbt
277            .get(TICKS_SINCE_SONG_STARTED_TAG)
278            .and_then(Self::value_input_long);
279
280        let should_stop = {
281            let state = self.state.lock();
282            !state.item.is_empty()
283                && !ItemStack::is_same_item_same_components(&new_item, &state.item)
284        };
285        if should_stop {
286            let world = self.get_level();
287            self.stop_playback(world.as_ref());
288        }
289
290        let should_resume = saved_ticks.is_some_and(|ticks| {
291            Self::song_value(&new_item).is_some_and(|song| !Self::song_has_finished(song, ticks))
292        });
293        let mut state = self.state.lock();
294        state.item = new_item;
295        if should_resume {
296            state.playback = saved_ticks.map(|ticks| JukeboxPlayback {
297                ticks_since_song_started: ticks,
298            });
299        }
300    }
301
302    fn save_additional(&self, nbt: &mut NbtCompound) {
303        let state = self.state.lock();
304        if !state.item.is_empty() {
305            nbt.insert(RECORD_ITEM_TAG, state.item.to_nbt_tag_ref());
306        }
307        if let Some(playback) = &state.playback {
308            nbt.insert(
309                TICKS_SINCE_SONG_STARTED_TAG,
310                playback.ticks_since_song_started,
311            );
312        }
313    }
314
315    fn tick(&self, world: &Arc<World>) {
316        let action = {
317            let mut state = self.state.lock();
318            let Some(ticks) = state
319                .playback
320                .as_ref()
321                .map(|playback| playback.ticks_since_song_started)
322            else {
323                return;
324            };
325
326            if Self::song_value(&state.item).is_none_or(|song| Self::song_has_finished(song, ticks))
327            {
328                state.playback = None;
329                TickAction::Stop
330            } else if ticks % PLAY_EVENT_INTERVAL_TICKS == 0 {
331                TickAction::EmitPlayingEvent
332            } else {
333                TickAction::None
334            }
335        };
336
337        match action {
338            TickAction::Stop => {
339                self.emit_stop_pair(world);
340                self.on_song_changed(world);
341                return;
342            }
343            TickAction::EmitPlayingEvent => {
344                let pos = self.get_block_pos();
345                world.game_event(
346                    &vanilla_game_events::JUKEBOX_PLAY,
347                    pos,
348                    &GameEventContext::new(None, Some(self.get_block_state())),
349                );
350                let random_color = f64::from(
351                    f32::from(rand::random_range(0..NOTE_PARTICLE_COLOR_VARIANTS))
352                        / NOTE_PARTICLE_COLOR_DIVISOR,
353                );
354                world.send_particles(
355                    ParticleData::simple(&vanilla_particle_types::NOTE),
356                    DVec3::new(
357                        f64::from(pos.x()) + BLOCK_CENTER_OFFSET,
358                        f64::from(pos.y()) + f64::from(NOTE_PARTICLE_Y_OFFSET),
359                        f64::from(pos.z()) + BLOCK_CENTER_OFFSET,
360                    ),
361                    0,
362                    DVec3::new(random_color, 0.0, 0.0),
363                    1.0,
364                );
365            }
366            TickAction::None => {}
367        }
368
369        if let Some(playback) = &mut self.state.lock().playback {
370            playback.ticks_since_song_started = playback.ticks_since_song_started.wrapping_add(1);
371        }
372    }
373
374    fn pre_remove_side_effects(&self, _pos: BlockPos, _state: BlockStateId) {
375        self.pop_out_the_item();
376    }
377
378    fn on_set_removed(&self) {
379        if let Some(world) = self.get_level() {
380            // Vanilla emits this pair on every removal callback, even when
381            // normal ejection already stopped and cleared the song.
382            self.emit_stop_pair(&world);
383        }
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use simdnbt::borrow::read_compound;
390    use steel_registry::data_components::components::JukeboxPlayable;
391    use steel_registry::jukebox_song::JukeboxSongValue;
392    use steel_registry::sound_event::SoundEventHolder;
393    use steel_registry::{
394        init_vanilla_registry, vanilla_blocks, vanilla_items, vanilla_jukebox_songs,
395    };
396    use steel_utils::Identifier;
397    use text_components::TextComponent;
398
399    use super::*;
400
401    const SAVED_PLAYBACK_TICKS: i64 = 37;
402
403    fn jukebox() -> JukeboxBlockEntity {
404        init_vanilla_registry();
405        JukeboxBlockEntity::new(
406            Weak::new(),
407            BlockPos::new(3, 70, -4),
408            vanilla_blocks::JUKEBOX.default_state(),
409        )
410    }
411
412    fn record_payload(item: &ItemStack, ticks: Option<i64>) -> NbtCompound {
413        let mut nbt = NbtCompound::new();
414        nbt.insert(RECORD_ITEM_TAG, item.to_nbt_tag_ref());
415        if let Some(ticks) = ticks {
416            nbt.insert(TICKS_SINCE_SONG_STARTED_TAG, ticks);
417        }
418        nbt
419    }
420
421    fn load_owned(jukebox: &JukeboxBlockEntity, nbt: &NbtCompound) {
422        let mut bytes = Vec::new();
423        nbt.write(&mut bytes);
424        let borrowed = read_compound(&mut Cursor::new(bytes.as_slice()))
425            .expect("test jukebox NBT should reborrow");
426        jukebox.load_additional(&borrowed);
427    }
428
429    #[test]
430    fn saved_song_resumes_before_but_not_at_its_vanilla_finish_tick() {
431        let record = ItemStack::new(&vanilla_items::MUSIC_DISC_CAT);
432        let song = JukeboxBlockEntity::song_value(&record)
433            .expect("vanilla music disc should carry a jukebox song");
434        let length_in_ticks = (song.length_in_seconds * TICKS_PER_SECOND).ceil() as i32;
435        let finish_tick = i64::from(length_in_ticks.wrapping_add(SONG_END_PADDING_TICKS));
436
437        let before_finish = jukebox();
438        load_owned(
439            &before_finish,
440            &record_payload(&record, Some(finish_tick - 1)),
441        );
442        assert!(before_finish.is_record_playing());
443        let mut saved = NbtCompound::new();
444        before_finish.save_additional(&mut saved);
445        assert_eq!(
446            saved.long(TICKS_SINCE_SONG_STARTED_TAG),
447            Some(finish_tick - 1)
448        );
449
450        let at_finish = jukebox();
451        load_owned(&at_finish, &record_payload(&record, Some(finish_tick)));
452        assert!(!at_finish.is_record_playing());
453        assert_eq!(at_finish.analog_output_signal(), song.comparator_output);
454    }
455
456    #[test]
457    fn item_block_entity_data_merges_with_existing_record_before_loading() {
458        let record = ItemStack::new(&vanilla_items::MUSIC_DISC_PIGSTEP);
459        let jukebox = jukebox();
460        load_owned(&jukebox, &record_payload(&record, None));
461        assert!(!jukebox.is_record_playing());
462
463        let mut ticks_only = NbtCompound::new();
464        ticks_only.insert(TICKS_SINCE_SONG_STARTED_TAG, SAVED_PLAYBACK_TICKS);
465        assert!(jukebox.apply_item_block_entity_data(ticks_only));
466        assert!(jukebox.is_record_playing());
467        assert_eq!(
468            jukebox.analog_output_signal(),
469            JukeboxBlockEntity::song_value(&record)
470                .expect("vanilla music disc should carry a jukebox song")
471                .comparator_output
472        );
473
474        let mut saved = NbtCompound::new();
475        jukebox.save_additional(&mut saved);
476        assert!(saved.compound(RECORD_ITEM_TAG).is_some());
477        assert_eq!(
478            saved.long(TICKS_SINCE_SONG_STARTED_TAG),
479            Some(SAVED_PLAYBACK_TICKS)
480        );
481
482        let mut unchanged = NbtCompound::new();
483        unchanged.insert(TICKS_SINCE_SONG_STARTED_TAG, SAVED_PLAYBACK_TICKS);
484        assert!(!jukebox.apply_item_block_entity_data(unchanged));
485    }
486
487    #[test]
488    fn direct_song_uses_vanilla_unknown_registry_level_event_data() {
489        init_vanilla_registry();
490        let mut direct = ItemStack::new(&vanilla_items::STONE);
491        direct.set(
492            JUKEBOX_PLAYABLE,
493            JukeboxPlayable::direct(JukeboxSongValue {
494                sound_event: SoundEventHolder::Direct {
495                    sound_id: Identifier::vanilla_static("jukebox_test_direct"),
496                    fixed_range: None,
497                },
498                description: TextComponent::plain("Direct test song"),
499                length_in_seconds: 1.0,
500                comparator_output: 1,
501            }),
502        );
503
504        assert!(JukeboxBlockEntity::song_value(&direct).is_some());
505        assert_eq!(
506            JukeboxBlockEntity::song_event_data(&direct),
507            UNKNOWN_SONG_REGISTRY_ID
508        );
509
510        let reference = ItemStack::new(&vanilla_items::MUSIC_DISC_CAT);
511        assert_eq!(
512            JukeboxBlockEntity::song_event_data(&reference),
513            vanilla_jukebox_songs::CAT
514                .try_id()
515                .and_then(|id| i32::try_from(id).ok())
516                .unwrap_or(UNKNOWN_SONG_REGISTRY_ID)
517        );
518    }
519
520    #[test]
521    fn value_input_long_uses_each_numeric_tag_long_value() {
522        let mut nbt = NbtCompound::new();
523        nbt.insert("double", -0.5_f64);
524        nbt.insert("float", -0.5_f32);
525        let mut bytes = Vec::new();
526        nbt.write(&mut bytes);
527        let borrowed = read_compound(&mut Cursor::new(bytes.as_slice()))
528            .expect("numeric test NBT should reborrow");
529        let view: NbtCompoundView<'_, '_> = (&borrowed).into();
530
531        assert_eq!(
532            view.get("double")
533                .and_then(JukeboxBlockEntity::value_input_long),
534            Some(-1)
535        );
536        assert_eq!(
537            view.get("float")
538                .and_then(JukeboxBlockEntity::value_input_long),
539            Some(0)
540        );
541    }
542}