Skip to main content

steel_core/chunk/light/
propagation.rs

1use steel_registry::{blocks::block_state_ext::BlockStateExt, vanilla_blocks};
2use steel_utils::{BlockPos, BlockStateId, ChunkPos, Direction, SectionPos};
3
4use super::{
5    CachedLightBlock, LIGHT_BLOCKED, LightAxisDirection, LightCacheLayout, LightDirectionSet,
6    LightLayer, LightLayerEdit, LightQueueFlags, LightSectionEmptinessChange,
7    LightSectionReadCache, LightWorkset, MAX_LIGHT_LEVEL, PackedLightPropagationQueues,
8    PackedLightQueueEntry, get_light_block_into, get_light_opacity, light_occlusion_shape,
9};
10
11/// Error returned when a block-light propagation context is built from mismatched caches.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum BlockLightPropagationContextError {
14    /// Block-light propagation requires a block light edit cache.
15    WrongLayer {
16        /// Layer supplied by the edit cache.
17        layer: LightLayer,
18    },
19    /// Section and light caches were built from different cache layouts.
20    LayoutMismatch {
21        /// Layout used by the section cache.
22        section_layout: Box<LightCacheLayout>,
23        /// Layout used by the light cache.
24        light_layout: Box<LightCacheLayout>,
25    },
26    /// The workset does not contain its center chunk.
27    MissingCenterChunk {
28        /// Missing center chunk position.
29        chunk_pos: ChunkPos,
30    },
31}
32
33impl BlockLightPropagationContextError {
34    fn layout_mismatch(section_layout: LightCacheLayout, light_layout: LightCacheLayout) -> Self {
35        Self::LayoutMismatch {
36            section_layout: Box::new(section_layout),
37            light_layout: Box::new(light_layout),
38        }
39    }
40}
41
42/// Sections whose visible block-light data changed during a scoped update.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct BlockLightUpdateResult {
45    /// Light sections that should be reported to the world/chunk update layer.
46    pub updated_sections: Vec<SectionPos>,
47}
48
49/// Whether chunk block-light generation must validate edge consistency.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum BlockLightChunkEdgeChecks {
52    /// Seed sources and validate this chunk's horizontal edges against neighbors.
53    Required,
54    /// Trust existing neighboring light and pull initialized edge levels inward.
55    Skipped,
56}
57
58/// Runs ScalableLux-style block-light propagation for changed blocks in a scoped workset.
59///
60/// This is the block-light equivalent of `ScalableLux` `propagateBlockChanges`
61/// plus publishing edited sections. It assumes the caller already created a
62/// cache window around the affected chunk and will deliver returned section
63/// updates to the world/chunk notification layer.
64pub fn propagate_block_light_changes(
65    workset: &LightWorkset,
66    positions: impl IntoIterator<Item = BlockPos>,
67) -> Result<BlockLightUpdateResult, BlockLightPropagationContextError> {
68    propagate_block_light_changes_with_empty_sections(workset, positions, [])
69}
70
71/// Runs block-light propagation after applying real section emptiness transitions.
72pub fn propagate_block_light_changes_with_empty_sections(
73    workset: &LightWorkset,
74    positions: impl IntoIterator<Item = BlockPos>,
75    empty_sections: impl IntoIterator<Item = LightSectionEmptinessChange>,
76) -> Result<BlockLightUpdateResult, BlockLightPropagationContextError> {
77    let empty_sections = empty_sections.into_iter().collect::<Vec<_>>();
78
79    workset.with_chunk_read_cache(|chunk_cache| {
80        let layout = chunk_cache.layout();
81        // ScalableLux drops queued dynamic changes once the center chunk leaves the light cache.
82        let Some(center_slot) = layout.cached_chunk(layout.center_chunk()) else {
83            return Ok(BlockLightUpdateResult {
84                updated_sections: Vec::new(),
85            });
86        };
87        if chunk_cache.chunk(center_slot).is_none() {
88            return Ok(BlockLightUpdateResult {
89                updated_sections: Vec::new(),
90            });
91        }
92
93        chunk_cache.with_section_read_cache(|section_cache| {
94            chunk_cache.with_light_edit(LightLayer::Block, |mut light_edit| {
95                let mut queues = PackedLightPropagationQueues::new();
96
97                {
98                    apply_block_empty_section_changes(
99                        section_cache,
100                        &mut light_edit,
101                        &empty_sections,
102                    );
103                    let mut context = BlockLightPropagationContext::new(
104                        section_cache,
105                        &mut light_edit,
106                        &mut queues,
107                    )?;
108                    for position in positions {
109                        context.check_block(position);
110                    }
111                    context.perform_light_decrease();
112                }
113
114                let mut updated_sections = Vec::new();
115                light_edit.commit(None, |section_pos| updated_sections.push(section_pos));
116                Ok(BlockLightUpdateResult { updated_sections })
117            })
118        })
119    })
120}
121
122fn apply_block_empty_section_changes(
123    sections: &LightSectionReadCache<'_>,
124    light: &mut LightLayerEdit<'_>,
125    empty_sections: &[LightSectionEmptinessChange],
126) -> usize {
127    let mut changed_chunks = Vec::new();
128    for change in empty_sections {
129        light.set_section_empty(change.section_pos, change.empty);
130        let chunk_pos = ChunkPos::new(change.section_pos.x(), change.section_pos.z());
131        if !changed_chunks.contains(&chunk_pos) {
132            changed_chunks.push(chunk_pos);
133        }
134    }
135
136    let mut initialized = 0;
137    for chunk_pos in changed_chunks {
138        initialized += sync_block_empty_light_sections(sections, light, chunk_pos);
139    }
140    initialized
141}
142
143/// Seeds and propagates block light for the center chunk of a scoped workset.
144///
145/// This matches `ScalableLux` `BlockStarLightEngine.lightChunk`: source blocks in
146/// the center chunk are seeded, then the caller chooses between validating edge
147/// consistency or pulling already-initialized neighbor levels inward.
148pub fn propagate_block_light_chunk(
149    workset: &LightWorkset,
150    edge_checks: BlockLightChunkEdgeChecks,
151) -> Result<BlockLightUpdateResult, BlockLightPropagationContextError> {
152    workset.with_chunk_read_cache(|chunk_cache| {
153        let layout = chunk_cache.layout();
154        let Some(center_slot) = layout.cached_chunk(layout.center_chunk()) else {
155            return Err(BlockLightPropagationContextError::MissingCenterChunk {
156                chunk_pos: layout.center_chunk(),
157            });
158        };
159        let Some(center_chunk) = chunk_cache.chunk(center_slot) else {
160            return Err(BlockLightPropagationContextError::MissingCenterChunk {
161                chunk_pos: layout.center_chunk(),
162            });
163        };
164        let sources = center_chunk.block_light_sources();
165
166        chunk_cache.with_section_read_cache(|section_cache| {
167            chunk_cache.with_light_edit(LightLayer::Block, |mut light_edit| {
168                let mut queues = PackedLightPropagationQueues::new();
169
170                {
171                    light_edit.reset_chunk_sections_to_missing(layout.center_chunk());
172                    sync_block_empty_light_sections(
173                        section_cache,
174                        &mut light_edit,
175                        layout.center_chunk(),
176                    );
177                    let mut context = BlockLightPropagationContext::new(
178                        section_cache,
179                        &mut light_edit,
180                        &mut queues,
181                    )?;
182                    context.seed_block_light_sources(sources);
183                    match edge_checks {
184                        BlockLightChunkEdgeChecks::Required => {
185                            context.perform_light_increase();
186                            context.check_chunk_edges(layout.center_chunk());
187                        }
188                        BlockLightChunkEdgeChecks::Skipped => {
189                            context.propagate_neighbor_levels(layout.center_chunk());
190                            context.perform_light_increase();
191                        }
192                    }
193                }
194
195                let mut updated_sections = Vec::new();
196                light_edit.commit(None, |section_pos| updated_sections.push(section_pos));
197                Ok(BlockLightUpdateResult { updated_sections })
198            })
199        })
200    })
201}
202
203/// Force-synchronizes block-light sections for an already-lit loaded chunk.
204///
205/// This matches the block layer of `ScalableLux` `forceLoadInChunk`: existing
206/// light data is kept, empty-section state is synchronized, and dirty visible
207/// sections are published before the later edge-check pass.
208pub fn force_load_block_light_chunk(
209    workset: &LightWorkset,
210) -> Result<BlockLightUpdateResult, BlockLightPropagationContextError> {
211    workset.with_chunk_read_cache(|chunk_cache| {
212        let layout = ensure_center_chunk(chunk_cache)?;
213
214        chunk_cache.with_section_read_cache(|section_cache| {
215            chunk_cache.with_light_edit(LightLayer::Block, |mut light_edit| {
216                sync_block_empty_light_sections(
217                    section_cache,
218                    &mut light_edit,
219                    layout.center_chunk(),
220                );
221
222                let mut updated_sections = Vec::new();
223                light_edit.commit(None, |section_pos| updated_sections.push(section_pos));
224                Ok(BlockLightUpdateResult { updated_sections })
225            })
226        })
227    })
228}
229
230/// Validates already-loaded block-light chunk edges without resetting sections.
231///
232/// This matches `ScalableLux` `checkBlockEdges`: the force-load pass has already
233/// synchronized empty-section state, so this pass only checks horizontal
234/// consistency against loaded neighbors and publishes its own dirty sections.
235pub fn check_block_light_chunk_edges(
236    workset: &LightWorkset,
237) -> Result<BlockLightUpdateResult, BlockLightPropagationContextError> {
238    workset.with_chunk_read_cache(|chunk_cache| {
239        let layout = ensure_center_chunk(chunk_cache)?;
240
241        chunk_cache.with_section_read_cache(|section_cache| {
242            chunk_cache.with_light_edit(LightLayer::Block, |mut light_edit| {
243                let mut queues = PackedLightPropagationQueues::new();
244
245                {
246                    let mut context = BlockLightPropagationContext::new(
247                        section_cache,
248                        &mut light_edit,
249                        &mut queues,
250                    )?;
251                    context.check_chunk_edges(layout.center_chunk());
252                }
253
254                let mut updated_sections = Vec::new();
255                light_edit.commit(None, |section_pos| updated_sections.push(section_pos));
256                Ok(BlockLightUpdateResult { updated_sections })
257            })
258        })
259    })
260}
261
262/// Loads already-persisted block light and validates chunk edges without resetting sections.
263///
264/// This is the complete block-layer `lit == true` path: force-load
265/// empty-section state first, then run the edge-check pass.
266pub fn load_block_light_chunk(
267    workset: &LightWorkset,
268) -> Result<BlockLightUpdateResult, BlockLightPropagationContextError> {
269    let mut updated_sections = force_load_block_light_chunk(workset)?.updated_sections;
270    updated_sections.extend(check_block_light_chunk_edges(workset)?.updated_sections);
271    Ok(BlockLightUpdateResult { updated_sections })
272}
273
274fn ensure_center_chunk(
275    chunk_cache: &super::LightChunkReadCache<'_>,
276) -> Result<LightCacheLayout, BlockLightPropagationContextError> {
277    let layout = chunk_cache.layout();
278    let Some(center_slot) = layout.cached_chunk(layout.center_chunk()) else {
279        return Err(BlockLightPropagationContextError::MissingCenterChunk {
280            chunk_pos: layout.center_chunk(),
281        });
282    };
283    if chunk_cache.chunk(center_slot).is_none() {
284        return Err(BlockLightPropagationContextError::MissingCenterChunk {
285            chunk_pos: layout.center_chunk(),
286        });
287    }
288
289    Ok(layout)
290}
291
292fn sync_block_empty_light_sections(
293    sections: &LightSectionReadCache<'_>,
294    light: &mut LightLayerEdit<'_>,
295    chunk_pos: ChunkPos,
296) -> usize {
297    let layout = sections.layout();
298    let mut initialized = 0;
299
300    for section_y in
301        (layout.range().min_chunk_section_y()..layout.range().max_chunk_section_y_exclusive()).rev()
302    {
303        let section_pos = SectionPos::new(chunk_pos.0.x, section_y, chunk_pos.0.y);
304        if !section_is_non_empty(sections, light, section_pos) {
305            continue;
306        }
307
308        for offset_z in -1..=1 {
309            for offset_x in -1..=1 {
310                for offset_y in (-1..=1).rev() {
311                    let target = SectionPos::new(
312                        chunk_pos.0.x + offset_x,
313                        section_y + offset_y,
314                        chunk_pos.0.y + offset_z,
315                    );
316                    if light.set_section_non_missing(target) {
317                        initialized += 1;
318                    }
319                }
320            }
321        }
322    }
323
324    for offset_z in -1..=1 {
325        for offset_x in -1..=1 {
326            let target_chunk = ChunkPos::new(chunk_pos.0.x + offset_x, chunk_pos.0.y + offset_z);
327
328            for section_y in
329                (layout.range().min_section_y()..layout.range().max_section_y_exclusive()).rev()
330            {
331                let section_pos = SectionPos::new(target_chunk.0.x, section_y, target_chunk.0.y);
332                match section_neighborhood_all_empty_if_known(sections, target_chunk, section_y) {
333                    Some(true) => {
334                        light.set_section_internal(section_pos);
335                    }
336                    Some(false) => {
337                        if light.set_section_non_missing(section_pos) {
338                            initialized += 1;
339                        }
340                    }
341                    None => {
342                        if !section_neighborhood_all_empty(sections, light, target_chunk, section_y)
343                            && light.set_section_non_missing(section_pos)
344                        {
345                            initialized += 1;
346                        }
347                    }
348                }
349            }
350        }
351    }
352
353    initialized
354}
355
356fn section_neighborhood_all_empty(
357    sections: &LightSectionReadCache<'_>,
358    light: &LightLayerEdit<'_>,
359    chunk_pos: ChunkPos,
360    section_y: i32,
361) -> bool {
362    for offset_y in -1..=1 {
363        let neighbor_y = section_y + offset_y;
364        if neighbor_y < sections.layout().range().min_chunk_section_y()
365            || neighbor_y >= sections.layout().range().max_chunk_section_y_exclusive()
366        {
367            continue;
368        }
369
370        for offset_z in -1..=1 {
371            for offset_x in -1..=1 {
372                let section_pos = SectionPos::new(
373                    chunk_pos.0.x + offset_x,
374                    neighbor_y,
375                    chunk_pos.0.y + offset_z,
376                );
377                if section_is_non_empty(sections, light, section_pos) {
378                    return false;
379                }
380            }
381        }
382    }
383
384    true
385}
386
387fn section_neighborhood_all_empty_if_known(
388    sections: &LightSectionReadCache<'_>,
389    chunk_pos: ChunkPos,
390    section_y: i32,
391) -> Option<bool> {
392    for offset_y in -1..=1 {
393        let neighbor_y = section_y + offset_y;
394        if neighbor_y < sections.layout().range().min_chunk_section_y()
395            || neighbor_y >= sections.layout().range().max_chunk_section_y_exclusive()
396        {
397            continue;
398        }
399
400        for offset_z in -1..=1 {
401            for offset_x in -1..=1 {
402                let section_pos = SectionPos::new(
403                    chunk_pos.0.x + offset_x,
404                    neighbor_y,
405                    chunk_pos.0.y + offset_z,
406                );
407                let empty = sections.section_empty(section_pos)?;
408                if !empty {
409                    return Some(false);
410                }
411            }
412        }
413    }
414
415    Some(true)
416}
417
418fn section_is_non_empty(
419    sections: &LightSectionReadCache<'_>,
420    light: &LightLayerEdit<'_>,
421    section_pos: SectionPos,
422) -> bool {
423    if let Some(empty) = sections.section_empty(section_pos) {
424        return !empty;
425    }
426
427    if let Some(empty) = light.section_empty(section_pos) {
428        return !empty;
429    }
430
431    sections.has_non_empty_section(section_pos)
432}
433
434/// ScalableLux-style block-light propagation over scoped Steel light caches.
435///
436/// This keeps the queue algorithm close to `ScalableLux` while avoiding long-lived
437/// references into chunks: the caller owns the scoped section and light caches,
438/// and this context only borrows them for one propagation pass.
439pub struct BlockLightPropagationContext<'a, 'sections, 'light> {
440    layout: LightCacheLayout,
441    sections: &'a LightSectionReadCache<'sections>,
442    light: &'a mut LightLayerEdit<'light>,
443    queues: &'a mut PackedLightPropagationQueues,
444}
445
446impl<'a, 'sections, 'light> BlockLightPropagationContext<'a, 'sections, 'light> {
447    /// Creates a block-light propagation context from matching scoped caches.
448    pub fn new(
449        sections: &'a LightSectionReadCache<'sections>,
450        light: &'a mut LightLayerEdit<'light>,
451        queues: &'a mut PackedLightPropagationQueues,
452    ) -> Result<Self, BlockLightPropagationContextError> {
453        if light.layer() != LightLayer::Block {
454            return Err(BlockLightPropagationContextError::WrongLayer {
455                layer: light.layer(),
456            });
457        }
458
459        if sections.layout() != light.layout() {
460            return Err(BlockLightPropagationContextError::layout_mismatch(
461                sections.layout(),
462                light.layout(),
463            ));
464        }
465
466        Ok(Self {
467            layout: light.layout(),
468            sections,
469            light,
470            queues,
471        })
472    }
473
474    /// Handles one block-light source/opacity change, matching `ScalableLux` `checkBlock`.
475    ///
476    /// Returns false when the changed block is outside this cache window.
477    pub fn check_block(&mut self, block_pos: BlockPos) -> bool {
478        let Some(cached_block) = self.layout.cached_block(block_pos) else {
479            return false;
480        };
481
482        let current_level = self.light.get(cached_block);
483        let block_state = self.sections.get_block_state(cached_block);
484        let emitted_level = block_state.get_light_emission() & MAX_LIGHT_LEVEL;
485
486        self.light.set(cached_block, emitted_level);
487        if emitted_level != 0 {
488            self.enqueue_increase(
489                block_pos,
490                emitted_level,
491                LightDirectionSet::all(),
492                Self::shape_flags(block_state),
493            );
494        }
495
496        self.enqueue_decrease(
497            block_pos,
498            current_level,
499            LightDirectionSet::all(),
500            LightQueueFlags::EMPTY,
501        );
502        true
503    }
504
505    /// Seeds block-light sources in `ScalableLux` local-index order.
506    pub fn seed_block_light_sources(&mut self, positions: impl IntoIterator<Item = BlockPos>) {
507        for position in positions {
508            self.seed_block_light_source(position);
509        }
510    }
511
512    /// Pulls horizontal neighbor levels into this chunk's increase queue.
513    pub fn propagate_neighbor_levels(&mut self, chunk_pos: ChunkPos) {
514        for section_y in (self.layout.range().min_section_y()
515            ..self.layout.range().max_section_y_exclusive())
516            .rev()
517        {
518            let section_pos = SectionPos::new(chunk_pos.0.x, section_y, chunk_pos.0.y);
519            if !self.light.has_non_missing_section(section_pos) {
520                continue;
521            }
522
523            for direction in LightAxisDirection::HORIZONTAL {
524                self.propagate_neighbor_level_section(chunk_pos, section_y, direction);
525            }
526        }
527    }
528
529    /// Validates this chunk's horizontal edges against cached neighbor edges.
530    ///
531    /// This mirrors `ScalableLux` `checkChunkEdges`: edge values whose calculated
532    /// level differs from the stored value are delayed, converted into regular
533    /// block checks, then resolved through the decrease queue.
534    pub fn check_chunk_edges(&mut self, chunk_pos: ChunkPos) {
535        for section_y in (self.layout.range().min_section_y()
536            ..self.layout.range().max_section_y_exclusive())
537            .rev()
538        {
539            self.check_chunk_edge(chunk_pos, section_y);
540        }
541
542        self.perform_light_decrease();
543    }
544
545    /// Calculates the block-light value that should exist at `block_pos`.
546    ///
547    /// Returns `None` when the position is outside this cache window.
548    #[must_use]
549    pub fn calculate_light_value(&self, block_pos: BlockPos, expect: u8) -> Option<u8> {
550        let cached_block = self.layout.cached_block(block_pos)?;
551        let center_state = self.sections.get_block_state(cached_block);
552        let mut level = center_state.get_light_emission() & MAX_LIGHT_LEVEL;
553
554        if level >= MAX_LIGHT_LEVEL - 1 || level > expect {
555            return Some(level);
556        }
557
558        let opacity = get_light_opacity(center_state);
559        if opacity >= MAX_LIGHT_LEVEL {
560            return Some(level);
561        }
562
563        for axis_direction in LightAxisDirection::ALL {
564            let neighbor_pos = Self::offset(block_pos, axis_direction);
565            let Some(neighbor_block) = self.layout.cached_block(neighbor_pos) else {
566                continue;
567            };
568            let neighbor_level = self.light.get(neighbor_block);
569            if neighbor_level.saturating_sub(1) <= level {
570                continue;
571            }
572
573            let neighbor_state = self.sections.get_block_state(neighbor_block);
574            let direction_from_neighbor = axis_direction.opposite().direction();
575            if get_light_block_into(
576                neighbor_state,
577                center_state,
578                direction_from_neighbor,
579                opacity,
580            ) == LIGHT_BLOCKED
581            {
582                continue;
583            }
584
585            level = level.max(neighbor_level.saturating_sub(opacity));
586            if level > expect {
587                return Some(level);
588            }
589        }
590
591        Some(level)
592    }
593
594    /// Performs queued `ScalableLux` block-light decreases, then re-propagates increases.
595    pub fn perform_light_decrease(&mut self) {
596        while let Some(entry) = self.queues.dequeue_decrease() {
597            let Some(source_block) = self.cached_block_from_entry(entry) else {
598                continue;
599            };
600            let source_state = if entry.has_sided_transparent_blocks() {
601                Some(self.sections.get_block_state(source_block))
602            } else {
603                None
604            };
605
606            for axis_direction in entry.directions().directions() {
607                let neighbor_pos = Self::offset(source_block.block_pos, axis_direction);
608                let Some(neighbor_block) = self.layout.cached_block(neighbor_pos) else {
609                    continue;
610                };
611                if !self.light.has_non_missing(neighbor_block) {
612                    continue;
613                }
614                let current_level = self.light.get(neighbor_block);
615                if current_level == 0 {
616                    continue;
617                }
618
619                let neighbor_state = self.sections.get_block_state(neighbor_block);
620                let Some((target_level, flags)) = Self::target_level(
621                    entry.level(),
622                    source_state,
623                    neighbor_state,
624                    axis_direction.direction(),
625                    true,
626                ) else {
627                    continue;
628                };
629
630                if current_level > target_level {
631                    self.enqueue_increase(
632                        neighbor_pos,
633                        current_level,
634                        LightDirectionSet::all(),
635                        flags.with(LightQueueFlags::RECHECK_LEVEL),
636                    );
637                    continue;
638                }
639
640                let emitted_light = neighbor_state.get_light_emission() & MAX_LIGHT_LEVEL;
641                if emitted_light != 0 {
642                    self.enqueue_increase(
643                        neighbor_pos,
644                        emitted_light,
645                        LightDirectionSet::all(),
646                        flags.with(LightQueueFlags::WRITE_LEVEL),
647                    );
648                }
649
650                self.light.set(neighbor_block, 0);
651                if target_level > 0 {
652                    self.enqueue_decrease(
653                        neighbor_pos,
654                        target_level,
655                        LightDirectionSet::all_except_opposite(axis_direction),
656                        flags,
657                    );
658                }
659            }
660        }
661
662        self.perform_light_increase();
663    }
664
665    /// Performs queued `ScalableLux` block-light increases.
666    pub fn perform_light_increase(&mut self) {
667        while let Some(entry) = self.queues.dequeue_increase() {
668            let Some(source_block) = self.cached_block_from_entry(entry) else {
669                continue;
670            };
671            if entry.should_recheck_level() {
672                if self.light.get(source_block) != entry.level() {
673                    continue;
674                }
675            } else if entry.should_write_level() {
676                self.light.set(source_block, entry.level());
677            }
678
679            let source_state = if entry.has_sided_transparent_blocks() {
680                Some(self.sections.get_block_state(source_block))
681            } else {
682                None
683            };
684
685            for axis_direction in entry.directions().directions() {
686                let neighbor_pos = Self::offset(source_block.block_pos, axis_direction);
687                let Some(neighbor_block) = self.layout.cached_block(neighbor_pos) else {
688                    continue;
689                };
690                if !self.light.has_non_missing(neighbor_block) {
691                    continue;
692                }
693                let current_level = self.light.get(neighbor_block);
694                if current_level >= entry.level().saturating_sub(1) {
695                    continue;
696                }
697
698                let neighbor_state = self.sections.get_block_state(neighbor_block);
699                let Some((target_level, flags)) = Self::target_level(
700                    entry.level(),
701                    source_state,
702                    neighbor_state,
703                    axis_direction.direction(),
704                    false,
705                ) else {
706                    continue;
707                };
708                if target_level <= current_level {
709                    continue;
710                }
711
712                self.light.set(neighbor_block, target_level);
713                if target_level > 1 {
714                    self.enqueue_increase(
715                        neighbor_pos,
716                        target_level,
717                        LightDirectionSet::all_except_opposite(axis_direction),
718                        flags,
719                    );
720                }
721            }
722        }
723    }
724
725    fn cached_block_from_entry(&self, entry: PackedLightQueueEntry) -> Option<CachedLightBlock> {
726        self.layout.cached_block_from_packed(entry.block_pos())
727    }
728
729    fn enqueue_decrease(
730        &mut self,
731        block_pos: BlockPos,
732        level: u8,
733        directions: LightDirectionSet,
734        flags: LightQueueFlags,
735    ) {
736        let Some(packed_pos) = self.layout.encode_block_pos(block_pos) else {
737            return;
738        };
739        self.queues
740            .enqueue_decrease(PackedLightQueueEntry::from_parts(
741                packed_pos, level, directions, flags,
742            ));
743    }
744
745    fn check_chunk_edge(&mut self, chunk_pos: ChunkPos, section_y: i32) {
746        let current_section_pos = SectionPos::new(chunk_pos.0.x, section_y, chunk_pos.0.y);
747        if !self.light.has_cached_section(current_section_pos) {
748            return;
749        }
750
751        for direction in LightAxisDirection::HORIZONTAL {
752            let (neighbor_offset_x, _, neighbor_offset_z) = direction.offset();
753            let neighbor_chunk_pos = ChunkPos::new(
754                chunk_pos.0.x + neighbor_offset_x,
755                chunk_pos.0.y + neighbor_offset_z,
756            );
757            let neighbor_section_pos =
758                SectionPos::new(neighbor_chunk_pos.0.x, section_y, neighbor_chunk_pos.0.y);
759            if !self.light.has_cached_section(neighbor_section_pos) {
760                continue;
761            }
762            if !self.light.has_light_data_section(current_section_pos)
763                && !self.light.has_light_data_section(neighbor_section_pos)
764            {
765                continue;
766            }
767
768            self.check_chunk_edge_direction(chunk_pos, neighbor_chunk_pos, section_y, direction);
769        }
770    }
771
772    fn check_chunk_edge_direction(
773        &mut self,
774        chunk_pos: ChunkPos,
775        neighbor_chunk_pos: ChunkPos,
776        section_y: i32,
777        direction: LightAxisDirection,
778    ) {
779        let (neighbor_offset_x, _, neighbor_offset_z) = direction.offset();
780        let (increment_x, increment_z, start_x, start_z) =
781            Self::current_edge_scan(chunk_pos, direction);
782        let mut center_delayed_checks = [0usize; 16 * 16];
783        let mut neighbor_delayed_checks = [0usize; 16 * 16];
784        let mut center_delayed_check_count = 0;
785        let mut neighbor_delayed_check_count = 0;
786
787        let min_y = section_y << 4;
788        let max_y = min_y | 15;
789        for y in min_y..=max_y {
790            let mut x = start_x;
791            let mut z = start_z;
792            for _ in 0..16 {
793                let current_pos = BlockPos::new(x, y, z);
794                let neighbor_pos = BlockPos::new(x + neighbor_offset_x, y, z + neighbor_offset_z);
795                let Some(current_block) = self.layout.cached_block(current_pos) else {
796                    x += increment_x;
797                    z += increment_z;
798                    continue;
799                };
800                let Some(neighbor_block) = self.layout.cached_block(neighbor_pos) else {
801                    x += increment_x;
802                    z += increment_z;
803                    continue;
804                };
805
806                let current_level = self.light.get(current_block);
807                if self
808                    .calculate_light_value(current_pos, current_level)
809                    .is_some_and(|calculated| calculated != current_level)
810                {
811                    center_delayed_checks[center_delayed_check_count] = current_block.local_index;
812                    center_delayed_check_count += 1;
813                }
814
815                let neighbor_level = self.light.get(neighbor_block);
816                if self
817                    .calculate_light_value(neighbor_pos, neighbor_level)
818                    .is_some_and(|calculated| calculated != neighbor_level)
819                {
820                    neighbor_delayed_checks[neighbor_delayed_check_count] =
821                        neighbor_block.local_index;
822                    neighbor_delayed_check_count += 1;
823                }
824
825                x += increment_x;
826                z += increment_z;
827            }
828        }
829
830        let current_chunk_offset_x = chunk_pos.0.x << 4;
831        let current_chunk_offset_z = chunk_pos.0.y << 4;
832        let neighbor_chunk_offset_x = neighbor_chunk_pos.0.x << 4;
833        let neighbor_chunk_offset_z = neighbor_chunk_pos.0.y << 4;
834        let chunk_offset_y = section_y << 4;
835        let delayed_check_count = center_delayed_check_count.max(neighbor_delayed_check_count);
836        for delayed_check_index in 0..delayed_check_count {
837            if delayed_check_index < center_delayed_check_count {
838                let local_index = center_delayed_checks[delayed_check_index];
839                self.check_block(Self::block_pos_from_local_index(
840                    current_chunk_offset_x,
841                    chunk_offset_y,
842                    current_chunk_offset_z,
843                    local_index,
844                ));
845            }
846            if delayed_check_index < neighbor_delayed_check_count {
847                let local_index = neighbor_delayed_checks[delayed_check_index];
848                self.check_block(Self::block_pos_from_local_index(
849                    neighbor_chunk_offset_x,
850                    chunk_offset_y,
851                    neighbor_chunk_offset_z,
852                    local_index,
853                ));
854            }
855        }
856    }
857
858    fn seed_block_light_source(&mut self, block_pos: BlockPos) -> bool {
859        let Some(cached_block) = self.layout.cached_block(block_pos) else {
860            return false;
861        };
862
863        let block_state = self.sections.get_block_state(cached_block);
864        let emitted_level = block_state.get_light_emission() & MAX_LIGHT_LEVEL;
865        if emitted_level <= self.light.get(cached_block) {
866            return false;
867        }
868
869        self.enqueue_increase(
870            block_pos,
871            emitted_level,
872            LightDirectionSet::all(),
873            Self::shape_flags(block_state),
874        );
875        self.light.set(cached_block, emitted_level);
876        true
877    }
878
879    fn propagate_neighbor_level_section(
880        &mut self,
881        chunk_pos: ChunkPos,
882        section_y: i32,
883        direction: LightAxisDirection,
884    ) {
885        let (neighbor_offset_x, _, neighbor_offset_z) = direction.offset();
886        let neighbor_section_pos = SectionPos::new(
887            chunk_pos.0.x + neighbor_offset_x,
888            section_y,
889            chunk_pos.0.y + neighbor_offset_z,
890        );
891        if !self.light.has_light_data_section(neighbor_section_pos) {
892            return;
893        }
894
895        let (increment_x, increment_z, start_x, start_z) =
896            Self::neighbor_edge_scan(chunk_pos, direction);
897        let directions = LightDirectionSet::only(direction.opposite());
898        let flags = LightQueueFlags::EMPTY.with(LightQueueFlags::HAS_SIDED_TRANSPARENT_BLOCKS);
899
900        let min_y = section_y << 4;
901        let max_y = min_y | 15;
902        for y in min_y..=max_y {
903            let mut x = start_x;
904            let mut z = start_z;
905            for _ in 0..16 {
906                let source_pos = BlockPos::new(x, y, z);
907                let Some(source_block) = self.layout.cached_block(source_pos) else {
908                    x += increment_x;
909                    z += increment_z;
910                    continue;
911                };
912                let level = self.light.get(source_block);
913                if level > 1 {
914                    self.enqueue_increase(source_pos, level, directions, flags);
915                }
916                x += increment_x;
917                z += increment_z;
918            }
919        }
920    }
921
922    const fn current_edge_scan(
923        chunk_pos: ChunkPos,
924        direction: LightAxisDirection,
925    ) -> (i32, i32, i32, i32) {
926        let (offset_x, _, offset_z) = direction.offset();
927        if offset_x != 0 {
928            let start_x = if offset_x < 0 {
929                chunk_pos.0.x << 4
930            } else {
931                (chunk_pos.0.x << 4) | 15
932            };
933            return (0, 1, start_x, chunk_pos.0.y << 4);
934        }
935
936        let start_z = if offset_z < 0 {
937            chunk_pos.0.y << 4
938        } else {
939            (chunk_pos.0.y << 4) | 15
940        };
941        (1, 0, chunk_pos.0.x << 4, start_z)
942    }
943
944    const fn neighbor_edge_scan(
945        chunk_pos: ChunkPos,
946        direction: LightAxisDirection,
947    ) -> (i32, i32, i32, i32) {
948        let (offset_x, _, offset_z) = direction.offset();
949        if offset_x != 0 {
950            let start_x = if offset_x < 0 {
951                (chunk_pos.0.x << 4) - 1
952            } else {
953                (chunk_pos.0.x << 4) + 16
954            };
955            return (0, 1, start_x, chunk_pos.0.y << 4);
956        }
957
958        let start_z = if offset_z < 0 {
959            (chunk_pos.0.y << 4) - 1
960        } else {
961            (chunk_pos.0.y << 4) + 16
962        };
963        (1, 0, chunk_pos.0.x << 4, start_z)
964    }
965
966    const fn block_pos_from_local_index(
967        chunk_offset_x: i32,
968        chunk_offset_y: i32,
969        chunk_offset_z: i32,
970        local_index: usize,
971    ) -> BlockPos {
972        BlockPos::new(
973            chunk_offset_x | (local_index & 15) as i32,
974            chunk_offset_y | (local_index >> 8) as i32,
975            chunk_offset_z | ((local_index >> 4) & 15) as i32,
976        )
977    }
978
979    fn enqueue_increase(
980        &mut self,
981        block_pos: BlockPos,
982        level: u8,
983        directions: LightDirectionSet,
984        flags: LightQueueFlags,
985    ) {
986        let Some(packed_pos) = self.layout.encode_block_pos(block_pos) else {
987            return;
988        };
989        self.queues
990            .enqueue_increase(PackedLightQueueEntry::from_parts(
991                packed_pos, level, directions, flags,
992            ));
993    }
994
995    fn target_level(
996        propagated_level: u8,
997        source_state: Option<BlockStateId>,
998        target_state: BlockStateId,
999        direction: Direction,
1000        saturating: bool,
1001    ) -> Option<(u8, LightQueueFlags)> {
1002        let source_state = match source_state {
1003            Some(source_state) => source_state,
1004            None => Self::air(),
1005        };
1006        let opacity = get_light_block_into(
1007            source_state,
1008            target_state,
1009            direction,
1010            get_light_opacity(target_state),
1011        );
1012        if opacity == LIGHT_BLOCKED {
1013            return None;
1014        }
1015
1016        let target_level = if saturating {
1017            propagated_level.saturating_sub(opacity)
1018        } else if opacity >= propagated_level {
1019            return None;
1020        } else {
1021            propagated_level - opacity
1022        };
1023
1024        Some((target_level, Self::shape_flags(target_state)))
1025    }
1026
1027    fn shape_flags(block_state: BlockStateId) -> LightQueueFlags {
1028        if light_occlusion_shape(block_state).is_empty() {
1029            LightQueueFlags::EMPTY
1030        } else {
1031            LightQueueFlags::EMPTY.with(LightQueueFlags::HAS_SIDED_TRANSPARENT_BLOCKS)
1032        }
1033    }
1034
1035    const fn offset(block_pos: BlockPos, direction: LightAxisDirection) -> BlockPos {
1036        let (dx, dy, dz) = direction.offset();
1037        block_pos.offset(dx, dy, dz)
1038    }
1039
1040    fn air() -> BlockStateId {
1041        vanilla_blocks::AIR.default_state()
1042    }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use std::sync::{Arc, Weak};
1048
1049    use steel_registry::{
1050        blocks::properties::{BlockStateProperties, SlabType},
1051        init_vanilla_registry, vanilla_blocks,
1052    };
1053    use steel_utils::{ChunkPos, types::UpdateFlags};
1054
1055    use super::*;
1056    use crate::behavior::init_behaviors;
1057    use crate::chunk::{
1058        Chunk,
1059        chunk_holder::ChunkHolder,
1060        chunk_ticket_manager::ChunkTicketLevel,
1061        light::{LightCacheSetupRadius, LightSection, LightSectionData, LightSectionRange},
1062        section::{ChunkSection, Sections},
1063        status::ChunkStatus,
1064    };
1065
1066    fn init_tests() {
1067        init_vanilla_registry();
1068        init_behaviors();
1069    }
1070
1071    fn range() -> LightSectionRange {
1072        let Ok(range) = LightSectionRange::from_world_height(0, 16) else {
1073            panic!("test height should create a valid light range");
1074        };
1075        range
1076    }
1077
1078    fn holder_with_section(pos: ChunkPos, section: ChunkSection) -> Arc<ChunkHolder> {
1079        let sections = Sections::from_owned(vec![section].into_boxed_slice());
1080        let proto = Chunk::new(sections, pos, 0, 16, Weak::new());
1081        let holder = Arc::new(ChunkHolder::new(
1082            pos,
1083            ChunkTicketLevel::FULL_CHUNK,
1084            Some(ChunkTicketLevel::FULL_CHUNK),
1085            0,
1086            16,
1087        ));
1088        holder.insert_chunk(proto, ChunkStatus::Light);
1089        holder
1090    }
1091
1092    fn initialize_holder_light(holder: &ChunkHolder) {
1093        let Some(chunk) = holder.try_chunk(ChunkStatus::Empty) else {
1094            panic!("test chunk should be available");
1095        };
1096        chunk.initialize_light_sources();
1097    }
1098
1099    fn set_block_section_non_missing(holder: &ChunkHolder, section_y: i32) {
1100        set_block_light_section(
1101            holder,
1102            section_y,
1103            LightSection::visible(LightSectionData::homogeneous(0)),
1104        );
1105    }
1106
1107    fn set_visible_block_light(
1108        holder: &ChunkHolder,
1109        section_y: i32,
1110        x: usize,
1111        y: usize,
1112        z: usize,
1113        level: u8,
1114    ) {
1115        let mut data = LightSectionData::homogeneous(0);
1116        data.set(x, y, z, level);
1117        set_block_light_section(holder, section_y, LightSection::visible(data));
1118    }
1119
1120    fn set_block_light_section(holder: &ChunkHolder, section_y: i32, section: LightSection) {
1121        let Some(chunk) = holder.try_chunk(ChunkStatus::Empty) else {
1122            panic!("test chunk should be available");
1123        };
1124        let mut light = chunk.light_mut();
1125        let Some(target) = light.block.section_mut(section_y) else {
1126            panic!("test section should be inside light range");
1127        };
1128        *target = section;
1129    }
1130
1131    fn block_light_at(holder: &ChunkHolder, pos: BlockPos) -> u8 {
1132        let Some(chunk) = holder.try_chunk(ChunkStatus::Empty) else {
1133            panic!("test chunk should be available");
1134        };
1135        chunk.light().get_light_value(LightLayer::Block, pos)
1136    }
1137
1138    #[test]
1139    fn context_requires_block_layer() {
1140        init_tests();
1141        let center = ChunkPos::new(0, 0);
1142        let holder = holder_with_section(center, ChunkSection::new_empty());
1143        set_block_section_non_missing(&holder, 0);
1144        let layout = LightCacheLayout::new(center, range());
1145        let Ok(workset) = LightWorkset::setup(
1146            layout,
1147            LightCacheSetupRadius::Inner,
1148            true,
1149            |pos| (pos == center).then(|| Arc::clone(&holder)),
1150            |_| true,
1151        ) else {
1152            panic!("relaxed setup should accept missing neighbors");
1153        };
1154
1155        workset.with_chunk_read_cache(|chunk_cache| {
1156            chunk_cache.with_section_read_cache(|section_cache| {
1157                chunk_cache.with_light_edit(LightLayer::Sky, |mut light_edit| {
1158                    let mut queues = PackedLightPropagationQueues::new();
1159                    let result = BlockLightPropagationContext::new(
1160                        section_cache,
1161                        &mut light_edit,
1162                        &mut queues,
1163                    );
1164
1165                    assert_eq!(
1166                        result.err(),
1167                        Some(BlockLightPropagationContextError::WrongLayer {
1168                            layer: LightLayer::Sky,
1169                        })
1170                    );
1171                });
1172            });
1173        });
1174    }
1175
1176    #[test]
1177    fn block_light_runner_publishes_visible_updates() {
1178        init_tests();
1179        let center = ChunkPos::new(0, 0);
1180        let source_pos = BlockPos::new(1, 1, 1);
1181        let mut section = ChunkSection::new_empty();
1182        section.set_block_state(1, 1, 1, vanilla_blocks::LIGHT.default_state());
1183        let holder = holder_with_section(center, section);
1184        set_block_section_non_missing(&holder, 0);
1185        let layout = LightCacheLayout::new(center, range());
1186        let Ok(workset) = LightWorkset::setup(
1187            layout,
1188            LightCacheSetupRadius::Inner,
1189            true,
1190            |pos| (pos == center).then(|| Arc::clone(&holder)),
1191            |_| true,
1192        ) else {
1193            panic!("relaxed setup should accept missing neighbors");
1194        };
1195
1196        let Ok(result) = propagate_block_light_changes(&workset, [source_pos]) else {
1197            panic!("matching block caches should run block light updates");
1198        };
1199
1200        assert!(result.updated_sections.contains(&SectionPos::new(0, 0, 0)));
1201        assert_eq!(block_light_at(&holder, source_pos), 15);
1202        assert_eq!(block_light_at(&holder, BlockPos::new(2, 1, 1)), 14);
1203    }
1204
1205    #[test]
1206    fn block_light_changes_apply_empty_section_transitions() {
1207        init_tests();
1208        let center = ChunkPos::new(0, 0);
1209        let removed_pos = BlockPos::new(1, 1, 1);
1210        let mut holders = Vec::new();
1211        let mut center_holder = None;
1212        for z in -2..=2 {
1213            for x in -2..=2 {
1214                let pos = ChunkPos::new(x, z);
1215                let mut section = ChunkSection::new_empty();
1216                if pos == center {
1217                    section.set_block_state(1, 1, 1, vanilla_blocks::STONE.default_state());
1218                }
1219                let holder = holder_with_section(pos, section);
1220                initialize_holder_light(&holder);
1221                if pos == center {
1222                    center_holder = Some(Arc::clone(&holder));
1223                }
1224                holders.push((pos, holder));
1225            }
1226        }
1227        let Some(center_holder) = center_holder else {
1228            panic!("center holder should be created");
1229        };
1230        set_visible_block_light(&center_holder, 0, 1, 1, 1, 9);
1231
1232        let Some(chunk) = center_holder.try_chunk(ChunkStatus::Empty) else {
1233            panic!("center chunk should be available");
1234        };
1235        assert_eq!(
1236            chunk.set_block_state_for_generation(
1237                ChunkStatus::Light,
1238                removed_pos,
1239                vanilla_blocks::AIR.default_state(),
1240                UpdateFlags::UPDATE_NONE,
1241            ),
1242            Some(vanilla_blocks::STONE.default_state())
1243        );
1244
1245        let layout = LightCacheLayout::new(center, range());
1246        let Ok(workset) = LightWorkset::setup(
1247            layout,
1248            LightCacheSetupRadius::Full,
1249            true,
1250            |pos| {
1251                holders
1252                    .iter()
1253                    .find(|(holder_pos, _)| *holder_pos == pos)
1254                    .map(|(_, holder)| Arc::clone(holder))
1255            },
1256            |_| true,
1257        ) else {
1258            panic!("relaxed setup should accept cached test chunks");
1259        };
1260
1261        let Ok(result) = propagate_block_light_changes_with_empty_sections(
1262            &workset,
1263            [removed_pos],
1264            [LightSectionEmptinessChange {
1265                section_pos: SectionPos::new(0, 0, 0),
1266                empty: true,
1267            }],
1268        ) else {
1269            panic!("matching block caches should run block light updates");
1270        };
1271
1272        assert!(result.updated_sections.contains(&SectionPos::new(0, 0, 0)));
1273        let Some(chunk) = center_holder.try_chunk(ChunkStatus::Empty) else {
1274            panic!("center chunk should be available");
1275        };
1276        let light = chunk.light();
1277        assert_eq!(light.block.section_empty(0), Some(true));
1278        assert_eq!(light.get_light_value(LightLayer::Block, removed_pos), 0);
1279        assert!(matches!(
1280            light.block.section(0),
1281            Some(LightSection::Missing | LightSection::Internal(_))
1282        ));
1283    }
1284
1285    #[test]
1286    fn block_light_chunk_seeds_center_sources() {
1287        init_tests();
1288        let center = ChunkPos::new(0, 0);
1289        let source_pos = BlockPos::new(1, 1, 1);
1290        let mut section = ChunkSection::new_empty();
1291        section.set_block_state(1, 1, 1, vanilla_blocks::LIGHT.default_state());
1292        let holder = holder_with_section(center, section);
1293        let layout = LightCacheLayout::new(center, range());
1294        let Ok(workset) = LightWorkset::setup(
1295            layout,
1296            LightCacheSetupRadius::Inner,
1297            true,
1298            |pos| (pos == center).then(|| Arc::clone(&holder)),
1299            |_| true,
1300        ) else {
1301            panic!("relaxed setup should accept missing neighbors");
1302        };
1303
1304        let Ok(result) = propagate_block_light_chunk(&workset, BlockLightChunkEdgeChecks::Skipped)
1305        else {
1306            panic!("matching block caches should run block chunk lighting");
1307        };
1308
1309        assert!(result.updated_sections.contains(&SectionPos::new(0, 0, 0)));
1310        assert_eq!(block_light_at(&holder, source_pos), 15);
1311        assert_eq!(block_light_at(&holder, BlockPos::new(2, 1, 1)), 14);
1312    }
1313
1314    #[test]
1315    fn block_light_chunk_pulls_neighbor_edge_levels() {
1316        init_tests();
1317        let center = ChunkPos::new(0, 0);
1318        let east_chunk = ChunkPos::new(1, 0);
1319        let center_holder = holder_with_section(center, ChunkSection::new_empty());
1320        let mut east_section = ChunkSection::new_empty();
1321        east_section.set_block_state(0, 1, 1, vanilla_blocks::LIGHT.default_state());
1322        let east_holder = holder_with_section(east_chunk, east_section);
1323        set_visible_block_light(&east_holder, 0, 0, 1, 1, 15);
1324        let layout = LightCacheLayout::new(center, range());
1325        let Ok(workset) = LightWorkset::setup(
1326            layout,
1327            LightCacheSetupRadius::Inner,
1328            true,
1329            |pos| {
1330                if pos == center {
1331                    Some(Arc::clone(&center_holder))
1332                } else if pos == east_chunk {
1333                    Some(Arc::clone(&east_holder))
1334                } else {
1335                    None
1336                }
1337            },
1338            |_| true,
1339        ) else {
1340            panic!("relaxed setup should accept missing neighbors");
1341        };
1342
1343        let Ok(result) = propagate_block_light_chunk(&workset, BlockLightChunkEdgeChecks::Skipped)
1344        else {
1345            panic!("matching block caches should run block chunk lighting");
1346        };
1347
1348        assert!(result.updated_sections.contains(&SectionPos::new(0, 0, 0)));
1349        assert_eq!(block_light_at(&center_holder, BlockPos::new(15, 1, 1)), 14);
1350        assert_eq!(block_light_at(&center_holder, BlockPos::new(14, 1, 1)), 13);
1351    }
1352
1353    #[test]
1354    fn block_light_chunk_requires_center_chunk() {
1355        init_tests();
1356        let center = ChunkPos::new(0, 0);
1357        let layout = LightCacheLayout::new(center, range());
1358        let Ok(workset) = LightWorkset::setup(
1359            layout,
1360            LightCacheSetupRadius::Inner,
1361            true,
1362            |_| None,
1363            |_| true,
1364        ) else {
1365            panic!("relaxed setup should accept missing chunks");
1366        };
1367
1368        assert_eq!(
1369            propagate_block_light_chunk(&workset, BlockLightChunkEdgeChecks::Skipped).err(),
1370            Some(BlockLightPropagationContextError::MissingCenterChunk { chunk_pos: center })
1371        );
1372    }
1373
1374    #[test]
1375    fn block_light_changes_skip_missing_center_chunk() {
1376        init_tests();
1377        let center = ChunkPos::new(0, 0);
1378        let layout = LightCacheLayout::new(center, range());
1379        let Ok(workset) = LightWorkset::setup(
1380            layout,
1381            LightCacheSetupRadius::Full,
1382            true,
1383            |_| None,
1384            |_| true,
1385        ) else {
1386            panic!("relaxed setup should accept missing chunks");
1387        };
1388
1389        let Ok(result) = propagate_block_light_changes_with_empty_sections(
1390            &workset,
1391            [BlockPos::new(1, 1, 1)],
1392            [LightSectionEmptinessChange {
1393                section_pos: SectionPos::new(0, 0, 0),
1394                empty: true,
1395            }],
1396        ) else {
1397            panic!("dynamic block changes should skip a missing center chunk");
1398        };
1399
1400        assert!(result.updated_sections.is_empty());
1401    }
1402
1403    #[test]
1404    fn block_light_calculation_respects_occluding_faces() {
1405        init_tests();
1406        let center = ChunkPos::new(0, 0);
1407        let mut section = ChunkSection::new_empty();
1408        let bottom_slab = vanilla_blocks::STONE_SLAB
1409            .default_state()
1410            .set_value(&BlockStateProperties::SLAB_TYPE, SlabType::Bottom);
1411        section.set_block_state(1, 1, 1, bottom_slab);
1412        let holder = holder_with_section(center, section);
1413        set_block_section_non_missing(&holder, 0);
1414        let layout = LightCacheLayout::new(center, range());
1415        let Ok(workset) = LightWorkset::setup(
1416            layout,
1417            LightCacheSetupRadius::Inner,
1418            true,
1419            |pos| (pos == center).then(|| Arc::clone(&holder)),
1420            |_| true,
1421        ) else {
1422            panic!("relaxed setup should accept missing neighbors");
1423        };
1424
1425        workset.with_chunk_read_cache(|chunk_cache| {
1426            chunk_cache.with_section_read_cache(|section_cache| {
1427                chunk_cache.with_light_edit(LightLayer::Block, |mut light_edit| {
1428                    let mut queues = PackedLightPropagationQueues::new();
1429                    let Ok(context) = BlockLightPropagationContext::new(
1430                        section_cache,
1431                        &mut light_edit,
1432                        &mut queues,
1433                    ) else {
1434                        panic!("matching block caches should build a propagation context");
1435                    };
1436                    let Some(below) = layout.cached_block(BlockPos::new(1, 0, 1)) else {
1437                        panic!("below neighbor should be cached");
1438                    };
1439                    assert!(context.light.set(below, 15));
1440
1441                    assert_eq!(
1442                        context.calculate_light_value(BlockPos::new(1, 1, 1), 0),
1443                        Some(0)
1444                    );
1445                });
1446            });
1447        });
1448    }
1449}