Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ jobs:
with:
distribution: temurin
java-version: '21'
- name: Test release safeguards (including private/disabled builds)
- name: Install image acceptance test dependencies
run: python3 -m pip install -r scripts/requirements-test.txt
- name: Test release safeguards and spotlight image analyzer
run: python3 -m unittest discover -s scripts -p '*_test.py'
- name: Build and compile smoke tests
run: ./gradlew --no-daemon build smokeClasses
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,27 @@ Server settings live in `config/bestflashlight-server.toml`. A world's `serverco
| `energyPerTick` | 0.025 | FE per emitting tick; 0 disables consumption |
| `worksUnderwater` | true | Allow submerged emitters |
| `beamRange` | 12.0 | 1–32 blocks |
| `coneAngleDegrees` | 35.0 | 1–90 degrees, full cone angle |
| `coneAngleDegrees` | 50.0 | 1–90 degrees, full cone angle |
| `beamBrightness` | 15 | 1–15, maximum light level |
| `beamSoftness` | 0.35 | 0–1, radial fraction occupied by the fading edge |

Default charge lasts about 5 hours 33 minutes at 20 TPS. The beam follows the view direction and clips at obstacles. Near walls, emitter placement falls back safely.

Without LambDynamicLights, temporary server light blocks illuminate the world. Their circular placement and intensity follow the cone, but Minecraft block light remains discrete and spreads beyond its sources. Source and flowing water are preserved; overlapping beams combine by maximum brightness and clear when no longer needed.

With a compatible, enabled LambDynamicLights, surfaces receive a soft circular light profile bounded by the cone. LDL still samples Minecraft's block grid, so narrow spots retain visible block-shaped shading; this is not a per-pixel shader spotlight. There is no glowing volumetric fog effect. The server stops its temporary lights only after all clients in the dimension acknowledge readiness. Mixed clients, disabled LDL, and incompatible APIs keep server fallback, preventing double illumination. Tested real LDL fixture: 4.8.11 for NeoForge 1.21.1.
With **LambDynamicLights 4.8.11 + Iris 1.8.14-beta.1 + Complementary Reimagined r5.9.3**, the local first-person flashlight uses a per-pixel surface spotlight. A perpendicular wall receives a smooth circle; tilted surfaces receive the corresponding ellipse. The beam follows the camera up and down, and its diameter on a surface grows with distance. Existing range, cone angle, brightness and softness settings control this light. Both hands and the headlamp use the same source priority and battery rules.

The optional adapter augments the loaded Complementary programs in memory; it does not modify the shader-pack ZIP. It activates only after the supported programs and uniforms are ready, replacing the local player's LDL cone while preserving other LDL sources. The supported pack is named `ComplementaryReimagined_r5.9.3.zip` (or the equivalent unpacked directory). Unsupported lighting structures retain LDL lighting and report an incompatibility. Other shader packs and versions are not covered by this adapter.

Without that shader combination, LDL supplies the existing block-grid approximation. Third-person views and other players also retain that path. There is no glowing volumetric fog effect. The server stops its temporary lights only after all clients in the dimension acknowledge readiness. Mixed clients, disabled LDL, and incompatible APIs keep server fallback; the shader spotlight stays inactive in those sessions.

## Build and test

Use Java 21 and `./gradlew build smokeClasses runGameTestServer`. Offline asset/recipe checks: `python3 -m unittest discover -s scripts -p '*_test.py'`.
Use Java 21 and `./gradlew build smokeClasses runGameTestServer`. Install test-only Python dependencies with `python3 -m pip install -r scripts/requirements-test.txt`, then run offline checks with `python3 -m unittest discover -s scripts -p '*_test.py'`.

Real shader acceptance: `python3 scripts/test_shader_beam.py --minecraft-dir <existing-instance/minecraft>`. It copies cached LDL/Iris/Sodium JARs, Complementary r5.9.3 and its options into ignored `run-shader-smoke/`, explicitly enables shaders there, and creates a disposable world. The play instance is read-only. No mods or shader packs are downloaded. Add `--baseline` to exercise the previous LDL rendering with the adapter disabled, or `--cases wall-35-4.0,ceiling` for a focused run.

The fixture verifies an active Iris shader pipeline, captures off/on/level-15 reference frames, and checks circular contours at 25/50/75% intensity (maximum two-pixel radial error), alignment, brightness and distance scaling. It also exercises ceiling/floor aiming, both hands, headlamp, empty charge, shader reload/toggle, camera handoff, obstacles and a fresh-world transition. Square boundaries and isolated bright blocks fail the target shader mode. Results and screenshots remain under `run-shader-smoke/fixed/` or `baseline/`; `--analyze-only` repeats image analysis. A failed shader launch is a test failure, even if Gradle itself exits successfully.

The real client matrix uses `./gradlew runCompatibilityClient -PcompatCurios=absent|empty|head -PcompatLdl=false|true`. For LDL runs, also pass `-PbeamSmokeModDir=<directory-with-one-lambdynamiclights-jar>`. Run each value separately, or use `python3 scripts/test_compatibility.py --ldl-dir <directory>`.

Expand Down
8 changes: 8 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ neoForge {
systemProperty 'bestflashlight.beamSmoke.stages', providers.gradleProperty('beamSmokeStages').getOrElse('')
programArgument '--username'; programArgument 'BeamSmoke'
}
shaderBeamSmoke {
client(); sourceSet = sourceSets.smoke; loadedMods = [mods.bestflashlightSmoke]
gameDirectory = file('run-shader-smoke')
systemProperty 'bestflashlight.shaderSmoke', 'true'
systemProperty 'bestflashlight.shaderBeam.disabled', providers.gradleProperty('shaderBeamBaseline').getOrElse('false')
systemProperty 'bestflashlight.shaderSmoke.cases', providers.gradleProperty('shaderBeamCases').getOrElse('')
programArgument '--username'; programArgument 'ShaderBeamSmoke'
}
clientReloadSmoke { client(); sourceSet = sourceSets.smoke; loadedMods = [mods.bestflashlightSmoke]; gameDirectory = file('run-client-smoke'); systemProperty 'bestflashlight.smoke', 'true'; systemProperty 'bestflashlight.smoke.reload', 'true'; programArgument '--username'; programArgument 'FlashlightSmoke' }
multiplayerServer { server(); sourceSet = sourceSets.smoke; loadedMods = [mods.bestflashlightSmoke]; gameDirectory = file('run-multiplayer-server'); systemProperty 'bestflashlight.multiplayer.server', 'true'; programArgument '--nogui' }
multiplayerA { client(); sourceSet = sourceSets.smoke; loadedMods = [mods.bestflashlightSmoke]; gameDirectory = file('run-client-a'); systemProperty 'bestflashlight.multiplayer.client', 'true'; programArgument '--username'; programArgument 'FlashlightA' }
Expand Down
2 changes: 2 additions & 0 deletions scripts/requirements-test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
numpy==2.4.4
Pillow==12.2.0
33 changes: 33 additions & 0 deletions scripts/shader_beam_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import unittest
import numpy as np
from test_shader_beam import measure_spot


class SpotAcceptanceTest(unittest.TestCase):
def image(self, square=False, shift=0):
y, x = np.mgrid[:256, :256]
x, y = x - 127.5 - shift, y - 127.5
r = np.maximum(abs(x), abs(y)) if square else np.hypot(x, y)
intensity = np.clip(1 - (r / 60) ** 2, 0, 1)
return np.repeat(intensity[:, :, None], 3, axis=2)

def test_round_spot_passes(self):
result = measure_spot(np.zeros((256, 256, 3)), self.image(), np.ones((256, 256, 3)))
self.assertLess(result['max_radial_error_px'], 2)
self.assertLess(result['center_error_px'], 1)

def test_square_spot_fails_even_when_its_width_equals_its_height(self):
result = measure_spot(np.zeros((256, 256, 3)), self.image(square=True), np.ones((256, 256, 3)))
self.assertGreater(result['max_radial_error_px'], 2)

def test_off_center_circle_is_rejected(self):
result = measure_spot(np.zeros((256, 256, 3)), self.image(shift=8), np.ones((256, 256, 3)))
self.assertGreater(result['center_error_px'], 2)

def test_dark_frame_does_not_pass_as_a_circle(self):
with self.assertRaises(ValueError):
measure_spot(np.zeros((256, 256, 3)), np.zeros((256, 256, 3)), np.ones((256, 256, 3)))


if __name__ == '__main__':
unittest.main()
163 changes: 163 additions & 0 deletions scripts/test_shader_beam.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Offline real-Iris beam acceptance. Copies cached inputs into an isolated run directory."""
import argparse
import csv
import hashlib
import json
import math
from pathlib import Path
import shutil
import subprocess
import sys

import numpy as np
from PIL import Image

ROOT = Path(__file__).resolve().parents[1]
RUN = ROOT / 'run-shader-smoke'
PACK = 'ComplementaryReimagined_r5.9.3.zip'


def measure_spot(off, on, reference):
weights = np.array([.2126, .7152, .0722])
delta = np.maximum(0, (on - off) @ weights)
ref = np.maximum(0, (reference - off) @ weights)
h, w = delta.shape
cy, cx = (h - 1) / 2, (w - 1) / 2
y, x = np.mgrid[:h, :w]
center = np.hypot(x - cx, y - cy) <= 3
peak = float(np.median(delta[center]))
if peak < .02:
raise ValueError('No visible light at the crosshair')
# Divide by the same material's level-15 reference to remove texture albedo.
profile = delta / np.maximum(ref, .02)
relative_peak = float(np.median(profile[center]))
contours = []
for fraction in (.25, .5, .75):
mask = profile >= relative_peak * fraction
inside = mask.copy()
inside[1:-1, 1:-1] &= mask[:-2, 1:-1] & mask[2:, 1:-1] & mask[1:-1, :-2] & mask[1:-1, 2:]
ys, xs = np.where(mask & ~inside)
if len(xs) < 16 or mask[0].any() or mask[-1].any() or mask[:, 0].any() or mask[:, -1].any():
raise ValueError('Missing, clipped or unbounded spotlight contour')
mx, my = (xs.min() + xs.max()) / 2, (ys.min() + ys.max()) / 2
radii = np.hypot(xs - mx, ys - my)
radius = float(np.median(radii))
contours.append({'fraction': fraction, 'radius_px': radius,
'radial_error_px': float(np.max(np.abs(radii - radius))),
'center_error_px': float(np.hypot(mx - cx, my - cy))})
return {'contours': contours, 'max_radial_error_px': max(c['radial_error_px'] for c in contours),
'center_error_px': max(c['center_error_px'] for c in contours),
'center_relative_brightness': relative_peak,
'saturated_fraction': float(np.mean(np.max(on[center], axis=1) >= .995))}


def analyze(output):
rows = list(csv.DictReader((output / 'cases.csv').open()))
cases = {row['case']: row for row in rows}
results = {}
failures = []
for name, row in cases.items():
try:
frames = [np.asarray(Image.open(output / 'screenshots' / f'{name}-{phase}.png').convert('RGB'), dtype=float) / 255
for phase in ('off', 'on', 'reference')]
if name == 'discharged':
difference = float(np.max(np.abs(frames[1] - frames[0])))
if difference > .03: failures.append(f'{name}: empty lamp changes rendered light')
results[name] = {'max_difference': difference}
continue
if name in ('third-person', 'toggle-shaders'):
# The fixture asserts actual LDL registration; its block-grid shape is not a shader-circle test.
results[name] = {'lifecycle_verified': True}
continue
if name == 'obstacles':
h, w, _ = frames[1].shape
center = float(np.mean(frames[1][h//2-3:h//2+3, w//2-3:w//2+3]))
# The opaque black block covers the aiming ray. A screen overlay would wash it white.
if center > .5: failures.append(f'{name}: opaque dark material washed out by light overlay')
results[name] = {'occluder_luminance': center}
continue
metrics = measure_spot(*frames)
focal = frames[0].shape[0] / (2 * math.tan(math.radians(35)))
h, w, _ = frames[0].shape
y, x = np.mgrid[:h, :w]
outside = np.hypot(x - (w-1)/2, y - (h-1)/2) > focal * math.tan(math.radians(float(row['angle']) / 2)) + 3
spill = float(np.quantile(np.maximum(0, frames[1] - frames[0])[outside], .999))
metrics['outside_spill'] = spill
if spill > .02: failures.append(f'{name}: light outside the cone')
metrics['diameter_world_50'] = 2 * metrics['contours'][1]['radius_px'] * float(row['distance']) / focal
results[name] = metrics
if metrics['max_radial_error_px'] > 2: failures.append(f'{name}: contour error > 2 px')
if metrics['center_error_px'] > 2: failures.append(f'{name}: centre error > 2 px')
if metrics['center_relative_brightness'] < .9: failures.append(f'{name}: brightness < 90% of level-15 reference')
if metrics['saturated_fraction'] > .01: failures.append(f'{name}: clipped centre texture')
except (ValueError, FileNotFoundError) as error:
failures.append(f'{name}: {error}')
for angle in (15, 35, 50):
group = [(float(cases[n]['distance']), m['diameter_world_50']) for n, m in results.items() if n.startswith(f'wall-{angle}-')]
if len(group) > 1:
group.sort()
slopes = [diameter / distance for distance, diameter in group]
if max(slopes) / min(slopes) > 1.05: failures.append(f'angle {angle}: diameter does not scale with distance')
if any(a[1] >= b[1] for a, b in zip(group, group[1:])): failures.append(f'angle {angle}: diameter plateau')
if not cases: failures.append('No captured test cases')
report = {'passed': not failures, 'cases': results, 'failures': failures}
(output / 'metrics.json').write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps({'passed': report['passed'], 'cases': len(results), 'failures': failures}, indent=2))
return not failures


def prepare(source):
if source.resolve() == RUN.resolve(): raise ValueError('Source must be the read-only play instance')
for name in ('mods', 'config', 'shaderpacks'): (RUN / name).mkdir(parents=True, exist_ok=True)
inputs = []
for pattern in ('iris-neoforge-*.jar', 'sodium-neoforge-*.jar', 'lambdynamiclights-*.jar'):
candidates = list((source / 'mods').glob(pattern))
if len(candidates) != 1: raise ValueError(f'Expected one cached {pattern}, found {len(candidates)}')
for old in (RUN / 'mods').glob(pattern): old.unlink()
path = candidates[0]
shutil.copy2(path, RUN / 'mods' / path.name)
inputs.append(path)
shader = source / 'shaderpacks' / PACK
shutil.copy2(shader, RUN / 'shaderpacks' / PACK)
inputs.append(shader)
options = source / 'shaderpacks' / (PACK + '.txt')
if options.exists(): shutil.copy2(options, RUN / 'shaderpacks' / options.name); inputs.append(options)
for config in ('iris.properties', 'lambdynlights.toml'):
shutil.copy2(source / 'config' / config, RUN / 'config' / config)
inputs.append(source / 'config' / config)
# The play instance may be toggled while tests run. Test the requested combination explicitly.
iris = RUN / 'config' / 'iris.properties'
lines = [line for line in iris.read_text().splitlines() if not line.startswith(('enableShaders=', 'shaderPack='))]
iris.write_text('\n'.join(lines + ['enableShaders=true', f'shaderPack={PACK}']) + '\n')
(RUN / 'inputs.json').write_text(json.dumps({str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in inputs}, indent=2))
muted = [f'soundCategory_{x}:0' for x in ('master','music','record','weather','block','hostile','neutral','player','ambient','voice')]
(RUN / 'options.txt').write_text('\n'.join(['onboardAccessibility:false', 'pauseOnLostFocus:false', 'renderDistance:4',
'simulationDistance:5', 'maxFps:60', 'overrideWidth:960', 'overrideHeight:640', 'gamma:0.5', 'fov:0.0', 'ao:true'] + muted) + '\n')
for world in (RUN / 'saves').glob('Shader-beam-*'): shutil.rmtree(world)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--minecraft-dir', type=Path)
parser.add_argument('--baseline', action='store_true')
parser.add_argument('--cases', default='')
parser.add_argument('--analyze-only', action='store_true')
args = parser.parse_args()
output = RUN / ('baseline' if args.baseline else 'fixed')
if not args.analyze_only:
if not args.minecraft_dir: parser.error('--minecraft-dir is required for cached inputs')
prepare(args.minecraft_dir)
if output.exists(): shutil.rmtree(output)
with (RUN / 'launch.log').open('w') as log:
result = subprocess.run(['./gradlew', '--offline', 'runShaderBeamSmoke',
f'-PshaderBeamBaseline={str(args.baseline).lower()}', f'-PshaderBeamCases={args.cases}'],
cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, timeout=960)
if result.returncode or not (output / 'result.txt').exists() or (output / 'result.txt').read_text() != 'CAPTURE_COMPLETE':
print(f'Render fixture failed; inspect {RUN / "launch.log"}', file=sys.stderr)
return 2
return 0 if analyze(output) else 1


if __name__ == '__main__':
sys.exit(main())
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public final class FlashlightConfig {
BEAM_RANGE = b.comment("Дальность луча в блоках. Мягкое рассеяние света Minecraft выходит за геометрию луча.")
.worldRestart().defineInRange("beamRange", 12.0, 1.0, 32.0);
CONE_ANGLE_DEGREES = b.comment("Полный угол конуса в градусах (не половина). Меньше угол — уже луч.")
.worldRestart().defineInRange("coneAngleDegrees", 35.0, 1.0, 90.0);
.worldRestart().defineInRange("coneAngleDegrees", 50.0, 1.0, 90.0);
BEAM_BRIGHTNESS = b.comment("Максимальная яркость луча (уровень света 1–15).")
.worldRestart().defineInRange("beamBrightness", 15, 1, 15);
BEAM_SOFTNESS = b.comment("Мягкость края луча: 0 — чёткий круг, 1 — плавное затухание от центра.")
Expand Down
Loading
Loading