initial commit
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 24 Oct 2026 10:40:16 +0000 (13:40 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 24 Oct 2026 10:40:16 +0000 (13:40 +0300)
134 files changed:
.gitignore [new file with mode: 0644]
AGENTS.org [new file with mode: 0644]
Documentation/index.org [new file with mode: 0644]
Model/BvhSnapshot.tla [new file with mode: 0644]
Model/BvhSnapshot_buggy.cfg [new file with mode: 0644]
Model/BvhSnapshot_fix.cfg [new file with mode: 0644]
Model/GiScheduler.cfg [new file with mode: 0644]
Model/GiScheduler.tla [new file with mode: 0644]
Model/GiScheduler2.cfg [new file with mode: 0644]
Model/GiScheduler2.tla [new file with mode: 0644]
Model/GiScheduler3.cfg [new file with mode: 0644]
Model/GiScheduler3.tla [new file with mode: 0644]
Model/GiScheduler4.cfg [new file with mode: 0644]
Model/GiScheduler4.tla [new file with mode: 0644]
Model/GiScheduler5.tla [new file with mode: 0644]
Model/GiScheduler5_p1.cfg [new file with mode: 0644]
Model/GiScheduler5_p1_gate.cfg [new file with mode: 0644]
Model/GiScheduler5_p2.cfg [new file with mode: 0644]
Model/GiScheduler5_p2_gate.cfg [new file with mode: 0644]
Model/MeshPublish.tla [new file with mode: 0644]
Model/MeshPublish_buggy.cfg [new file with mode: 0644]
Model/MeshPublish_fix.cfg [new file with mode: 0644]
Model/SlotLifecycle.tla [new file with mode: 0644]
Model/SlotLifecycle_buggy.cfg [new file with mode: 0644]
Model/SlotLifecycle_fix.cfg [new file with mode: 0644]
oracle/GiOracle.java [new file with mode: 0644]
oracle/OracleTemple.java [new file with mode: 0644]
oracle/compare.sh [new file with mode: 0755]
oracle/gi-compare.sh [new file with mode: 0755]
pom.xml [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/BvhBenchMain.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/CornellDemoMain.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/Fo4ExamMain.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/GuiDemoMain.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/LightmapDemoMain.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/Main.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/ProceduralTerrainSource.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/RasterBenchMain.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/RenderMain.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/StereoDemoMain.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/StreamingDemoMain.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/StreamingFlightMain.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/SyntheticCitySource.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/TexturedDemoMain.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/app/AwtShell.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/app/BugReport.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/app/DeveloperToolsWindow.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/app/PersistentLog.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/app/SpaceMouseManager.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/app/SpaceNavigatorHid.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/BvhStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/CameraStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/ChunkStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/DebugStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FramePipeline.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FrameStream.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FrameView.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/GiStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/GuiStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/ImageStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/InputStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Kernel.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/LightStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/LightmapStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/LineStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/MeshStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Parallel.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Pass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/ScreenStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/SdfFontStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/StreamedPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Substrate.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/kernel/TextureStore.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/shapes/Shapes.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/CameraPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/ChunkFrustumCullPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/CullPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/DebugStatsPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/FogPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/LinePass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/RasterPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/SpaceMousePass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/StereoMergePass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/TransformPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/WorldStreamer.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/EntryWork.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiScheduler.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/Raycast.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/DiagnosticsHudPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/GuiPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/GuiRenderPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfFont.java [new file with mode: 0644]
src/main/resources/META-INF/services/eu.svjatoslav.aukio.e3d2.kernel.Pass [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/DiscoverOrderTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/RasterPipelineTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/app/AwtShellTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/app/BugReportTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/app/DeveloperToolsWindowTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/app/DragLookLoopTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/app/SpaceNavigatorHidTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/kernel/BvhRaceStressTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/kernel/BvhStoreTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/kernel/KernelOrderingTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/kernel/KernelSortTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/kernel/LightmapStoreVersionTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/kernel/MeshPublishStressTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/kernel/MeshStoreBlocksTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/shapes/ShapesTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/CameraPhysicsTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/ClipTextureTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/FogPassTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/LinePassTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/RasterDebugTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/RasterParallelTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/RasterTranslucencyTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/SpaceMousePassTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/StereoTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/StreamRaceStressTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiBounceTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiLitSideTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiPassTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiSchedulerTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiStressTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/LightmapBounceTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/LightmapTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfGuiTest.java [new file with mode: 0644]
states/26-10-11-19-54-26.124/MeshPublish-0.st [new file with mode: 0644]
states/26-10-11-19-54-26.124/MeshPublish-1.st [new file with mode: 0644]
states/26-10-11-19-54-26.124/MeshPublish-2.st [new file with mode: 0644]
states/26-10-11-19-54-26.124/MeshPublish-3.st [new file with mode: 0644]
states/26-10-11-19-54-26.124/MeshPublish.st [new file with mode: 0644]
states/26-10-11-19-54-26.124/MeshPublish_0.fp [new file with mode: 0644]
states/26-10-11-19-54-26.124/MeshPublish_1.fp [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..460f2ab
--- /dev/null
@@ -0,0 +1,7 @@
+target/
+Model/tla2tools.jar
+*.class
+Model/*.old
+Model/states/
+Model/*_TTrace_*.tla
+Model/*_TTrace_*
diff --git a/AGENTS.org b/AGENTS.org
new file mode 100644 (file)
index 0000000..ce7eb86
--- /dev/null
@@ -0,0 +1,37 @@
+#+TITLE: AGENTS - aukio-3d-2
+
+* What this is
+
+Kernel/data (substrate + isolated systems) architecture, the
+sanctioned REWRITE of Aukio 3D. Read Documentation/index.org FIRST —
+the "rewrite plan" section (goal, slice order, rules, current status)
+is the anchor for every session.
+
+* Hard rules for agents
+
+- The interaction rule: passes (systems/) never reference each other;
+  they read/write substrate stores only. Violations = architecture bugs.
+- The kernel (kernel/) is deliberately tiny. Do not grow it without the
+  user explicitly asking.
+- The GI scheduler is spec-first: no scheduler Java code without the
+  corresponding TLC-green model in Model/.
+- NEVER git commit. The user commits himself after verifying.
+- Java 21 (host default javac may be newer; pom pins release 21).
+
+* Commands
+
+#+BEGIN_SRC sh
+# build + smoke run
+mvn -q compile
+mvn -q exec:java -Dexec.mainClass=eu.svjatoslav.aukio.e3d2.Main 2>/dev/null || \
+  javac --release 21 -d /tmp/poc-classes $(find src -name '*.java') && \
+  java -cp /tmp/poc-classes eu.svjatoslav.aukio.e3d2.Main
+
+# TLA+: translate PlusCal, then model-check
+cd Model
+java -cp tla2tools.jar pcal.trans GiScheduler.tla
+java -jar tla2tools.jar -config GiScheduler.cfg GiScheduler.tla
+#+END_SRC
+
+tla2tools.jar is gitignored; fetch from
+https://github.com/tlaplus/tlaplus/releases (asset tla2tools.jar).
diff --git a/Documentation/index.org b/Documentation/index.org
new file mode 100644 (file)
index 0000000..6fdcc01
--- /dev/null
@@ -0,0 +1,2435 @@
+:PROPERTIES:
+:CUSTOM_ID: aukio-3d-2
+:END:
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: aukio-3d-2 - kernel/data architecture proof of concept
+#+LANGUAGE: en
+#+OPTIONS: H:20 num:20 author:nil
+
+* The project
+:PROPERTIES:
+:CUSTOM_ID: rewrite-plan
+:END:
+
+This file is the project's memory: the rewrite plan and its state
+first, then the engine AS IT IS NOW, then the verification gates, then the
+dated progress log. Everything above =Progress log= describes the present
+engine; everything inside that log records how it got there, with the
+numbers true on the day the entry was written. Sections are ordered so each
+one assumes only what came before it.
+
+*Goal:* transfer Aukio 3D's v1.x functionality into this v 2.x repo,
+feature by feature, until it does everything Aukio 3D v1.x does.  This
+is V2 is major redesign of V1 functionality. No API compatibility
+required; the architecture's API wins.
+
+*Why:* the goal is to scale ~infinitely in complexity by making direct
+interaction between features impossible — every feature talks only to
+substrate data, so new complexity cannot reach old complexity (the law
+in =Why a rewrite=). The rewrite is how that law becomes the engine,
+not just an experiment.
+
+*Slice order* (status in brackets):
+
+1. Raster foundations: near-plane clipping, textures +
+   perspective-correct UVs. [done 2026-10-11]
+2. Scale machinery: SoA geometry blocks, BVH for rays, tile-parallel
+   raster, occlusion culling. [done 2026-10-11; occlusion DEFERRED —
+   see Progress log → Slice 2]
+3. Input + GUI: camera physics, InputManager, GuiComponent/TextCanvas,
+   SDF text. [done 2026-10-11 — InputStore covers InputManager; Label
+   covers TextCanvas (redesign)]
+4. Platform: ViewPanel/ViewFrame windowing, render-on-demand, XR
+   glasses + SpaceNavigator, diagnostics/BugReport, config.
+   [shell + render-on-demand + HUD done 2026-10-11; dev tools (F12 FPS
+   + unlock, camera pose panel) done 2026-10-12/13; zero-copy present
+   path done 2026-10-12; resize-to-black fix 2026-10-13; bug report +
+   PersistentLog 2026-10-14; SpaceMouse 6DOF done 2026-10-13; XR
+   glasses DEFERRED, config DROPPED — see Progress log → Slice 4]
+5. Stereo rendering. Niche (CSG, octree) deferred. [stereo done
+   2026-10-11; XR head-tracking stays with the device deferral]
+6. Consumers: aukio workspace app, FO4 environment — the final exam
+   (2.5M-triangle streaming world + GI, audits quiet).  [synthetic
+   exam done 2026-10-11 — see Progress log → Slice 6]
+
+*Rules per slice:* features land as NEW files (the metric); a forced
+kernel edit is an explicit reviewed event; every new concurrent core
+gets the TLA+ treatment (invariants first; streaming's spec landed as
+=Model/SlotLifecycle.tla=); each slice starts by asking port / redesign /
+DROP (dropping shrinks the rename date and is a legitimate outcome); doc
+section updated at slice end.
+
+*Rename criteria:* all slices done OR consciously dropped, consumers
+running, FO4 exam green.
+
+*Current verified state (2026-10-21):* 132/132 tests green under
+=xvfb-run -a=; flat temple oracle 688/307200 px (0.224%, meanAbs 0.19);
+GI oracle 37541 px (12.220%, meanAbs 11.62), both engines converged,
+drift in named classes only. Numbers quoted inside the progress log
+below are the state at the time each entry was written — this line is
+the current one.
+
+** Repository layout
+:PROPERTIES:
+:CUSTOM_ID: layout
+:END:
+
+Maven project, Java 21 (=--release 21=), package
+=eu.svjatoslav.aukio.e3d2=.
+
+| path          | contents                                                                                                                                                                             |
+|---------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| =kernel/=     | the fixed pipeline (=Kernel=, =Pass=, =Parallel=) and every substrate store: MeshStore, ScreenStore, ImageStore, CameraStore, LightStore, TextureStore, BvhStore, ChunkStore, InputStore, GuiStore, SdfFontStore, GiStore, LightmapStore, LineStore |
+| =systems/=    | one pass per file: Transform, Cull, Raster, Fog, Line, ChunkFrustumCull, WorldStreamer, Camera, SpaceMouse, StereoMerge                                                                 |
+| =systems/gi/= | GiPass (lifecycle + radiometry), GiScheduler, Raycast, EntryWork                                                                                                                        |
+| =systems/gui/= | GuiPass, GuiRenderPass, SdfFont, DiagnosticsHudPass                                                                                                                                    |
+| =shapes/=     | the geometry gallery: cube, sphere, cylinder, cone, arrow, pyramid, grid                                                                                                                |
+| =app/=        | AwtShell, DeveloperToolsWindow, BugReport, PersistentLog, SpaceNavigatorHid, SpaceMouseManager                                                                                          |
+| =oracle/=     | compare.sh, gi-compare.sh, OracleTemple, GiOracle                                                                                                                                       |
+| =Model/=      | the TLA+ specs and their =_p1=/_p2=/_buggy= configs                                                                                                                                     |
+| =Documentation/= | this file                                                                                                                                                                            |
+
+Interactive app assemblies belong to the sibling repo =aukio-3d-demos-2=
+(AwtShellMain, HouseDemo, RGBCubeDemo, CoordinateSystemDemo,
+ShapesGalleryDemo); the engine repo keeps the shell and its headless test.
+
+* Why a rewrite
+:PROPERTIES:
+:CUSTOM_ID: why-this-repo
+:END:
+
+Aukio 3D hit the complexity ceiling every accreting codebase hits: each
+new feature touched N existing files, so cost grew quadratically and
+both the human and the agent started producing bugs faster than fixes.
+The industry answer (Unix pipes, the web, relational databases, ECS in
+game engines) is always the same shape: force all interaction through
+one small, fixed, universal medium so new complexity cannot reach old
+complexity directly.
+
+This repo is where that shape becomes the engine: it is the sanctioned
+rewrite of Aukio 3D (see =The project= above). The old repo stays
+alive as the reference implementation and oracle until the rename.
+
+The law: *a system scales only if each new unit of complexity costs
+O(1) reasoning.* Everything in this repo is in service of that law.
+
+* The contract
+:PROPERTIES:
+:CUSTOM_ID: the-contract
+:END:
+
+Three kinds of code, with hard boundaries:
+
+| Kind      | Lives in   | Rule                                                                                       |
+|-----------+------------+--------------------------------------------------------------------------------------------|
+| Kernel    | =kernel/=  | Fixed pipeline. Small enough to hold in one head. Frozen.                                  |
+| Substrate | =kernel/=  | Shared SoA data tables, governed by the protocols in =Concurrency=. Never a private cache. |
+| Systems   | =systems/= | Passes over the substrate. One feature = one new file.                                     |
+
+The interaction rule: *a pass may read and write substrate stores but
+must never reference another pass.* A violation is an architecture
+bug, treated like a compile error. A feature that requires editing an
+existing file outside the substrate is an architecture violation —
+except inside the kernel, where edits are deliberate and rare.
+
+The metric: files touched per feature. It must stay ~1 forever.
+VALIDATED 2026-10-10 with FogPass (exponential distance fog as a
+post-process over the substrate image): landed as ONE new Java file
+(=systems/FogPass.java=) + one registration line in
+=META-INF/services/...kernel.Pass= — zero existing Java files edited.
+Enabling mechanism (built once, deliberately): camera is substrate
+data (=CameraStore=), passes self-register via ServiceLoader,
+=Kernel.discover()= runs them in registration order. The registration
+line is registration, not a code edit — same as a menu entry.
+
+** Plugin ordering
+:PROPERTIES:
+:CUSTOM_ID: plugin-ordering
+:END:
+
+=Pass.order()= is the ordering contract: kernel passes sit below 100,
+feature passes from 100, app-level passes from 200. =Kernel.discover()=
+stable-sorts by it, and =Kernel.add()= is a sorted insert with the same
+guarantee, so a feature JAR is truly drop-in and registration sequence
+never changes run order. =DiscoverOrderTest= and =KernelSortTest= pin it.
+The live order table is in =Rendering= → =Pass order=.
+
+** World conventions
+:PROPERTIES:
+:CUSTOM_ID: conventions
+:END:
+
+Three facts that are cheap to read here and expensive to rediscover:
+
+- *Axes.* The world is +Y up; the camera looks along +Z and +x projects to
+  screen-RIGHT — a mirror of the OpenGL convention
+  (=TransformPass= computes =sy = cy − (y/z)·scale=).
+- *Cull keeps the back.* =CullPass= keeps the triangle whose winding
+  normal points AWAY from the camera (measured 2026-10-14 with a two-quad
+  colour probe, not read off prose). So "front = counter-clockwise from
+  the camera" is INVERTED here: solids are wound with normals pointing
+  INTO the solid, interior surfaces (a room's walls) with normals pointing
+  away from the interior viewer. Inside-out geometry is invisible under
+  flat albedo and blatant under GI — pin it geometrically
+  (=ShapesTest.assertWindingIntoSolid=), never with a render-from-N-
+  directions test.
+- *Thin geometry is two-sided.* A surface without volume (floor, wall) is
+  authored with BOTH windings: the cull keeps exactly one per view (no
+  z-fight), and GI gathers into whichever side received light. A
+  single-wound floor makes the bounce gather downward into the void.
+
+** Vocabulary
+:PROPERTIES:
+:CUSTOM_ID: vocabulary
+:END:
+
+The terms this file uses without further explanation:
+
+| term             | meaning                                                                                               |
+|------------------+-------------------------------------------------------------------------------------------------------|
+| kernel           | the fixed pipeline and the pass/store interfaces; frozen on purpose                                   |
+| substrate        | the shared SoA tables passes read and write; protocol-governed (see =Concurrency=)                    |
+| store            | one SoA table — MeshStore, GiStore, LightmapStore, …                                                  |
+| pass             | a feature: =apply(Substrate)=, reads/writes stores, never another pass                                |
+| snapshot         | an immutable object published through ONE volatile reference (RCU)                                    |
+| pin              | an immutable shallow copy of the mesh tables, held by a published tree so it outlives eviction        |
+| epoch            | a generation tag; writes carrying a stale epoch are discarded, never applied                          |
+| frame barrier    | the frame point where structural world changes (publish, evict) may apply — no reader holds the world |
+| eligibility      | which entries a generation may work on, re-filtered at each barrier from the live camera              |
+| convergence vote | the scheduler's own test that a bake is done (mean delta below threshold)                             |
+| catch-up         | post-convergence work for late entrants; never starts a generation                                    |
+| named drift      | an oracle difference listed with a cause; an unlisted difference is a defect                          |
+
+* Concurrency
+:PROPERTIES:
+:CUSTOM_ID: concurrency
+:END:
+
+Every concurrent core in this engine is built from four publication
+protocols and one workflow. The protocols are not suggestions: each one
+was arrived at by reproducing a real corruption first, and each has a
+stress test and (for the scheduler and the two stores) a TLA+ model.
+
+** Snapshot publication: one reference, captured once
+:PROPERTIES:
+:CUSTOM_ID: rcu
+:END:
+
+Multi-field state read by workers while a rebuild may replace it is
+published as ONE volatile reference to an IMMUTABLE snapshot object.
+Readers capture the reference ONCE at entry, into a local, and read every
+field from that local. Re-reading the reference per field is NOT the fix —
+it mixes two versions inside one query.
+
+Pinning is the same mechanism applied to inputs: a published tree holds a
+shallow-cloned copy of the mesh tables, so it keeps answering the
+pre-evict world after the live mesh has dropped those blocks.
+
+** Commit-flag ordering
+:PROPERTIES:
+:CUSTOM_ID: commit-order
+:END:
+
+Data writes first, then the count, then the version LAST (volatile =
+release ordering). A derived index is tagged with the COUNT captured ONCE
+at build start — tag and content from one counter, one read — so a tree
+can never claim more triangles than it contains.
+
+Lesson: a tag and the content it describes must come from ONE source
+of truth read ONCE; two counters read at two times is a race by
+construction.
+
+** The frame barrier
+:PROPERTIES:
+:CUSTOM_ID: frame-barrier
+:END:
+
+Structural world changes apply only at reader quiescence — the point in
+the frame where no query, render or bake holds the world. That covers
+BOTH directions of change: a chunk finishing its load tears the world
+exactly as an eviction would, so the counterexample needs zero evictions.
+Cross-version stale readers are served by pinning, not by waiting.
+
+** Static partition parallelism
+:PROPERTIES:
+:CUSTOM_ID: parallel
+:END:
+
+=kernel/Parallel.forRange= over ONE shared daemon pool is the engine's
+only parallelism primitive. The contract: bodies write DISJOINT ranges and
+nothing else, so output is byte-identical to the serial loop. Tiles are
+128 px — submit/get overhead dominates below roughly the core count — and
+per-range partial bins merge in range order, so per-pixel triangle order
+matches the serial loop exactly.
+
+** TLA+ first
+:PROPERTIES:
+:CUSTOM_ID: tla-first
+:END:
+
+A new concurrent core is specified in TLA+ and checked with TLC before the
+Java exists. The GI scheduler is the case in point: it is where Aukio 3D's
+interleaving bugs came from. The milestone history is in the log; the rules
+below are what the Java obeys.
+
+TLC red is treated like a broken test. Workflow: model change → TLC
+green → prose updated → Java implements the prose.
+
+=mvn verify -Ptla-check= re-runs every green spec at gate constants.
+
+** The GI scheduler's normative rules
+:PROPERTIES:
+:CUSTOM_ID: spec-first
+:END:
+
+TLC compared two movement policies on one model (=Model/GiScheduler5.tla=,
+=Policy= constant, configs =GiScheduler5_p1.cfg= / =_p2.cfg=). The
+survivor table:
+
+| Policy            | Invariants | ConvergesAtRest | Scale checked                                                                                                                          |
+|-------------------+------------+-----------------+----------------------------------------------------------------------------------------------------------------------------------------|
+| 1 never abort     | all hold   | holds           | N=3 full (62.4M states) + N=2 (8 s)                                                                                                    |
+| 2 bounded abandon | all hold   | holds           | N=2 full green (33 s); N=3 safety green (347M states, 3 min at -workers 20), N=3 liveness not re-run (killed, verified at N=2 instead) |
+
+DECISION (2026-10-10, user): *policy 1 adopted* — never abort +
+barrier re-filter, with ring membership as the only skip mechanism.
+Policy 2 stays modeled but unimplemented. Normative rules for the
+Java scheduler:
+
+1. An in-flight generation always finishes; entries are never
+   abandoned mid-generation.
+2. Eligibility is consulted ONLY at barriers; the barrier re-filters
+   AND re-sorts the work queue from the live camera.
+3. A late near-field entrant waits for the next barrier or for
+   post-convergence catch-up; near-field latency under continuous
+   movement is NOT bounded (documented cost, accepted).
+4. Convergence certifies a RECORDED eligibility set
+   (=eligibleAtConv=), not the live one.
+5. Post-convergence the barrier is a no-op; late work is catch-up's.
+6. Catch-up never starts a generation and never touches =gen=,
+   =cursor=, =inFlight=, or the barrier; its queue check-and-grab is
+   one atomic operation (poll).
+7. Writes are epoch-tagged and stale completions discarded; the
+   barrier fires for ANY worker once =inFlight= drains; a rebuild
+   never touches =inFlight=.
+8. No convergence and no disturbance after halt/teardown.
+
+Policy 2's extra design rules, forced by TLC: (a) the barrier must
+run for EVERY worker reaching it, including one that dropped its
+entry — gating it on "did work" deadlocks when all entries are
+dropped; (b) a generation containing drops certifies nothing —
+convergence requires =droppedSet = {}=; (c) post-convergence the
+barrier is a no-op (a disturbance landing between drain and barrier
+otherwise starts a whole new generation for the late entrant AND
+queues it for catch-up — double service, =ConvFrozen= violation);
+(d) no convergence and no disturbance after a cap-halt (both
+require =~halted=). Rules (c)+(d) also apply to policy 1.
+Policy 3 collapses into policy 1 at this abstraction (the model has
+generations, not per-entry stages). Full-scale runs use
+=-workers 20= (host has 24 cores; TLC defaults to 1).
+
+* Rendering
+:PROPERTIES:
+:CUSTOM_ID: rendering
+:END:
+
+The frame is a fixed sequence of passes over the substrate. Nothing here
+knows about anything else — a pass reads stores, writes stores, and the
+order is the table below.
+
+** Pass order
+:PROPERTIES:
+:CUSTOM_ID: pass-order
+:END:
+
+Measured from code (2026-10-21):
+
+| order | pass               | job                                                        |
+|-------+--------------------+------------------------------------------------------------|
+|     5 | WorldStreamer      | camera-driven chunk load/evict at the frame barrier         |
+|     6 | GuiPass            | hit-test, hover, click publish                             |
+|     7 | SpaceMousePass     | 6DOF cap drive                                             |
+|     8 | CameraPass         | mouse look, keyboard fly, movement physics                  |
+|     9 | ChunkFrustumCull   | per-chunk cone test, hides whole chunk ranges               |
+|    10 | TransformPass      | near-plane clip, project, perspective UV                    |
+|    20 | CullPass           | keeps the winding normal pointing away from the camera      |
+|    30 | RasterPass         | tiles, opaque then translucent, textures, lightmaps         |
+|    40 | LinePass           | world-space lines                                          |
+|    40 | StereoMergePass    | anaglyph / side-by-side merge                              |
+|    90 | FogPass            | exponential distance fog, post-process                      |
+|   249 | DiagnosticsHudPass | fps / tris / chunks / camera readout                        |
+|   250 | GuiRenderPass      | panels, labels, SDF text                                    |
+
+=LinePass= and =StereoMergePass= share order 40; the tie is broken by
+registration order. They do not normally meet in one kernel (the stereo
+rig runs the mono pipeline per eye and merges afterwards), but a new pass
+must not be given 40.
+
+** Transform and clip
+:PROPERTIES:
+:CUSTOM_ID: transform
+:END:
+
+=TransformPass= clips triangles straddling =z=near= with
+Sutherland–Hodgman against that one plane, interpolating UV at the
+intersection; only fully-behind triangles are culled. It projects with the
++Y-up convention above and carries =u·w, v·w, w= so the raster can divide
+per pixel.
+
+Regression caught by the Cornell demo: the pitch rotation in
+=TransformPass= had an inverted sign — invisible at pitch 0 (every
+scene to date), black frame at pitch ≠ 0. Pinned by
+=pitchedCameraCentersAimPoint=.
+
+** Raster
+:PROPERTIES:
+:CUSTOM_ID: raster
+:END:
+
+=RasterPass= has two phases: bin triangles into 128 px tiles (ascending
+triangle order preserved), then one task per non-empty tile over the
+shared pool. Pixels and depth entries are written only by their owning
+tile. The opaque pass uses an early-out depth test.
+
+** Textures, lightmaps, translucency
+:PROPERTIES:
+:CUSTOM_ID: shading
+:END:
+
+- *Textures.* Per-vertex UV in =MeshStore=, nearest-sampled: the raster
+  interpolates =u·w, v·w, w= barycentrically, divides per pixel, samples
+  NEAREST, and multiplies the vertex colour as a tint. Texture colour
+  must therefore be authored at full brightness where the tint is meant
+  to show.
+- *Lightmaps.* Where a triangle has baked light, the raster samples
+  =LightmapStore.sampleBilinear= in the triangle's own orthogonal frame
+  (the frame is built from the longest edge so raster and bake agree
+  exactly on barycentrics). Nearest sampling turns bake noise into a
+  checkerboard; that is why bilinear is the rule, not a taste.
+- *Translucency.* Alpha 1–254 means "second pass": painted after all
+  opaque triangles, alpha-blended, depth-TESTED against the opaque
+  z-buffer, never depth-written; alpha 0 is skipped. Translucents are not
+  sorted against each other — bin order decides. Translucent SOLIDS are
+  single-wound: the cull already keeps one winding per view, and two
+  windings double-blend (71% becomes ~92%).
+
+** Fog and lines
+:PROPERTIES:
+:CUSTOM_ID: fog-lines
+:END:
+
+=FogPass= (order 90) is exponential distance fog as a post-process over
+the substrate image — the proof that a feature can land as one file.
+=LinePass= (order 40) draws world-space lines from =LineStore=: radius =
+=imageWidth·0.2·width/z= per endpoint; below 1 px it degrades to a 1 px
+DDA whose alpha fades with distance (far lines fade, never shimmer), above
+it a capsule with linear falloff. Lines depth-TEST against the frame and
+never write depth; the near clip is z = 0.1 and must stay equal to
+=TransformPass='s.
+
+** Stereo
+:PROPERTIES:
+:CUSTOM_ID: stereo
+:END:
+
+The stereo RIG is app-layer orchestration, not a pass that knows about
+passes: run the mono pipeline twice per frame, once per eye, into
+=imageLeft= / =imageRight=, then =StereoMergePass= (order 40) writes
+ANAGLYPH (left→red, right→green+blue) or SIDE_BY_SIDE. Eye poses are
+parallel cameras offset along view-RIGHT by ±IOD/2 — parallel, not toe-in,
+because toe-in converges at one distance and strains elsewhere. Aukio 3D's
+IPD was a raw world-X offset, not rotated by yaw; the PoC's is correct at
+any yaw, and that is a named fix, not a divergence.
+
+* The world
+:PROPERTIES:
+:CUSTOM_ID: world
+:END:
+
+Geometry lives in =MeshStore= as SoA blocks; chunks own contiguous
+block-aligned ranges; derived indexes (the BVH) rebuild from the version
+counter. The whole subsystem exists so that a 2.5M-triangle streaming
+world costs O(1) reasoning per chunk.
+
+** Mesh storage
+:PROPERTIES:
+:CUSTOM_ID: mesh-storage
+:END:
+
+=MeshStore= stores triangles in fixed-capacity blocks of **1024**
+triangles (=BLOCK_SHIFT = 10=), behind an UNCHANGED public API — growth
+allocates one block instead of copying the whole store (at FO4 scale that
+copy is hundreds of MB). Blocks are the unit of streaming eviction, which
+is why they are small enough for a chunk to own whole ones. The volatile
+commit protocol covers block allocation and outer-array growth: both
+precede the version release. =MeshStoreBlocksTest= probes block
+boundaries and BVH indexing across blocks.
+
+** Chunks and streaming
+:PROPERTIES:
+:CUSTOM_ID: streaming
+:END:
+
+=WorldStreamer= (order 5, first) runs the chunk lifecycle from the
+camera: load = pad to a block boundary → generate → publish; evict = drop
+the blocks and unregister the chunk. A chunk owns contiguous,
+block-aligned mesh ranges, and every structural change applies at the
+frame barrier (see =Concurrency=). =ProceduralTerrainSource= is the
+reference source: deterministic, infinite, 32×32 quads, 2048 triangles, 2 blocks
+per chunk, lossless across eviction and reload.
+=ChunkFrustumCullPass= (order 9) hides whole chunk ranges behind a
+conservative cone test; dead-block triangles write count 0 in
+=TransformPass= so nothing ghosts on screen.
+
+** The ray index
+:PROPERTIES:
+:CUSTOM_ID: bvh
+:END:
+
+=BvhStore= is a median-split BVH over =MeshStore= (split on the longest
+axis, leaf 4, allocation-free traversal), rebuilt lazily by
+=MeshStore.version()= via idempotent =ensure()=. It is SUBSTRATE data —
+shared derived state (GI gather today, picking and occlusion tomorrow) —
+not a feature-private cache; a private cache is how hidden coupling
+starts. Its published tree PINS the mesh tables at build and indexes only
+live blocks, so a published tree keeps serving the pre-evict world.
+=Raycast= is a thin facade; the Möller–Trumbore math exists once, here.
+
+** Known limits
+:PROPERTIES:
+:CUSTOM_ID: world-limits
+:END:
+
+- No GI bake DURING streaming: a bake holds mesh reads across frames, so
+  bake-vs-evict needs its own pass-ordering rule (bake only at rest, or
+  bake reads through pins). Open.
+- Occlusion culling is deferred, not forgotten — see =Decisions=.
+
+* Global illumination
+:PROPERTIES:
+:CUSTOM_ID: gi
+:END:
+
+GI is a background system, not a per-frame pass: =GiPass= owns the
+scheduler's lifecycle and supplies the radiometry, =GiScheduler= drives
+workers and generations, =Raycast= is the thin facade over =BvhStore=. It
+is deliberately NOT in the ServiceLoader registry — the registry runs
+every entry every frame, and a bake is dozens of frames.
+
+** How a bake runs
+:PROPERTIES:
+:CUSTOM_ID: gi-bake
+:END:
+
+Work is a queue of entries divided into generations. Each generation runs
+to completion; at the barrier the queue is re-filtered and re-sorted from
+the LIVE camera, and the barrier also flips the display buffers. Writes
+are epoch-tagged, so a result computed against a replaced epoch is
+discarded, never applied. =GiStore= holds RGB =direct= / =cur= / =prev=
+per triangle; =prev= is simultaneously the frozen gather field and the
+display field. Convergence = structural (eligibility stable) AND a
+radiometry vote (=meanDelta < 2=), delivered through the scheduler's
+=convergenceVote= hook. Late entrants are served by catch-up: a local
+bake that publishes centroid and texels via =catchUpListener= and never
+starts a generation.
+
+** Direct light
+:PROPERTIES:
+:CUSTOM_ID: gi-direct
+:END:
+
+Per channel: =shade_c = AMBIENT + (light_c − AMBIENT)·dot·att=,
+max-pooled over the lights that reach the centroid (Lambert plus a shadow
+ray per light, two-sided normals). =att = 1/(1 + 0.0001·d²)= is the
+legacy =LightingManager= curve, so demos pass legacy colour×intensity
+verbatim and lamps read as pools instead of a uniform tint. White light
+reproduces the former grayscale model exactly. Intensity is folded into
+the light's colour channels (they may exceed 255; =pack()= clamps), and
+=AMBIENT = 40=.
+
+** Bounce
+:PROPERTIES:
+:CUSTOM_ID: gi-bounce
+:END:
+
+8 deterministic Fibonacci hemisphere rays per entry. At a hit: albedo ×
+the hit triangle's =prev= shade; on a miss: a dim sky term
+(=18/24/34 × cos=). =GAIN = 0.65=. Rays gather into the GEOMETRICALLY lit
+hemisphere — a per-triangle lit-side vote taken from light directions at
+the centroid. The vote is never gated on visibility: shadow belongs to
+energy, not orientation. (Gating it on "unshadowed" left a fully-shadowed
+floor triangle with no vote, gathering from the void below the floor, and
+it baked nearly black while its neighbours were lifted — that was a real
+bugreport.)
+
+** Lightmaps
+:PROPERTIES:
+:CUSTOM_ID: gi-texels
+:END:
+
+Per-texel direct lighting in an orthogonal frame per triangle (longest
+edge = u-axis; the corner barycentrics are taken per ORIGINAL vertex so
+the raster's barycentric uv matches the bake's frame). Frames are
+allocated single-threaded up front — concurrent lazy allocation would race
+texel offsets — and the store is epoch-local, swapped atomically on
+rebuild, so a stale worker writes an orphan rather than corrupting the
+live bake. Texels outside the triangle are NEIGHBOUR-FILLED at bake time
+and DILATED for display: a centroid fill paints the triangle's average
+into its edge band, and the raster reads that band as dark dots marching
+along the hypotenuse. Stage 4 gathers 4 rays per texel and reads ONLY the
+frozen =prev= field; the freeze is one buffer swap owned by the barrier
+listener.
+
+An un-baked surface displays at FULL irradiance, exactly like v1's
+=Lightmap.INITIAL_IRRADIANCE= (255): the light field starts bright, so the
+display composite is the triangle's albedo and the first frames show an
+unlit model instead of a black screen while generation 0 bakes.
+=-De3d2.initialIrradiance= (0..255) dims that placeholder; 0 restores the
+old zero field, which was black. The placeholder is display-only — the
+bake overwrites it at the first flip, and a converged render is identical
+(measured: HouseOracle =house-poc.png= md5-identical at 255 and at 0).
+
+Both stores carry a monotonic =version()= that bumps on =flip()= and
+=publishEntry()=. It exists because GI progress is FRAME INPUT: see the
+render-on-demand rule in the shell section.
+
+** Quality pass (2026-10-14)
+:PROPERTIES:
+:CUSTOM_ID: gi-quality
+:END:
+
+Driven by the HouseDemo port, which needs coloured lamps and readable
+light pools: per-channel direct light, the distance falloff above, the
+geometric lit-side vote, and bilinear lightmap sampling with dilation
+fill. Every pre-existing pin held, because white light reduces to the old
+grayscale model.
+
+*Failed experiment, pinned:* per-generation Fibonacci seed rotation
+(=gen·7919=) breaks the convergence vote — per-generation sampling noise
+keeps =meanDelta= above the threshold and =isConverged= never fires.
+Cross-generation averaging was rejected too: it dilutes late-bounce
+Neumann energy. Denoising stays display-side.
+
+** Known limits
+:PROPERTIES:
+:CUSTOM_ID: gi-limits
+:END:
+
+- *Light-signature invalidation.* A rebuild harvests all shades; there is
+  no per-light signature yet, so changing one light repaints the bake.
+- *No bake during streaming* — see =The world= → =Known limits=.
+
+* Interface
+:PROPERTIES:
+:CUSTOM_ID: interface
+:END:
+
+Input, camera and GUI are all substrate data. Adapters write stores; passes
+read them. No pass ever touches AWT.
+
+** Events are data
+:PROPERTIES:
+:CUSTOM_ID: input
+:END:
+
+=InputStore= holds key level state, accumulating mouse deltas and the
+wheel, and the current mouse position/buttons. Adapters write it — the AWT
+shell, the SpaceMouse HID reader, tests and scripted demos. Deltas are
+consumed exactly once per frame (by whoever reads them), so a pass that
+reads input cannot starve another.
+
+** Camera
+:PROPERTIES:
+:CUSTOM_ID: camera
+:END:
+
+=CameraPass= (order 8) turns that data into a pose. Mouse look is
+DRAG-ONLY — hover tracks position for GUI hit-testing and never turns the
+camera; deltas accumulate only while a button is held. Sensitivity is the
+legacy =/50= rad/px: drag right = turn right, drag UP = look up,
+=pitch −= dy=, pitch clamped to ±1.5.
+
+Movement uses the legacy Camera constants converted to units/second:
+acceleration 2000 u/s² growing as (1 + speed/200), damping 5/s, top speed
+600 u/s. Frame dt comes from an injectable clock: =apply()= uses nanotime,
+tests drive =tick(data, dt)= directly, so the physics is deterministic and
+unit-tested (=CameraPhysicsTest= pins forward-follows-yaw, turn-then-move,
+damping, the cap, the drag-up sign, the clamp, arrows mirroring WASD, the
+Dvorak cluster and the vertical keys).
+
+** Keys
+:PROPERTIES:
+:CUSTOM_ID: keys
+:END:
+
+Both layouts work, because a key's layout identity is not visible to
+AWT/X11:
+
+- QWERTY: WASD fly, arrows mirror it (legacy
+  =WorldNavigationUserInputTracker=: UP=forward, DOWN=back, LEFT/RIGHT=
+  strafe), =Q= down, =R= up.
+- Dvorak (physical WASD positions): =,= forward, =A= left, =O= back,
+  =E= right; vertical at the physical Q/E positions: ='= down, =.= up.
+- Mouse wheel = vertical thrust (legacy =handleMouseScrolling=): one notch
+  kicks 100 u/s scaled by the same speed curve.
+
+E therefore cannot also mean "fly up" on QWERTY — hence the Q/R split.
+
+** GUI components
+:PROPERTIES:
+:CUSTOM_ID: gui
+:END:
+
+=GuiStore= holds panels, labels and buttons as plain records; =GuiPass=
+(order 6) runs hit-test, hover and click on release-inside, and publishes
+clicks as ID STRINGS that the app drains — passes never hold callbacks.
+=GuiRenderPass= (order 250) draws after the world, so the UI is never
+occluded by geometry. Multiline labels are several labels; the old
+TextCanvas terminal grid returns only if a real consumer needs it.
+
+** SDF text
+:PROPERTIES:
+:CUSTOM_ID: sdf-text
+:END:
+
+=SdfFontStore= holds a signed-distance-field atlas of 95 ASCII glyphs in
+64 px cells, built ONCE by =systems.gui.SdfFont= (AWT is tooling, never a
+pass): a chamfer transform turns coverage into a distance field, so text
+is crisp at any scale, sampled bilinearly with coverage =
+0.5 + signedScreenDistance. Two rules that bit: draw each glyph's INK box
+plus 2 px for the AA ramp (full-cell quads overlap at advance width and
+double-blend into blobs), and advance the pen even for out-of-range
+characters.
+
+* Platform
+:PROPERTIES:
+:CUSTOM_ID: platform
+:END:
+
+Everything that touches the operating system — a window, the display, a
+USB device, a log file — lives here behind one adapter seam each. Under
+it, the engine sees only stores.
+
+** The shell and render-on-demand
+:PROPERTIES:
+:CUSTOM_ID: shell
+:END:
+
+=app/AwtShell= is a JFrame whose listeners are the ONLY AWT entry point:
+key and mouse events become =InputStore= data. The kernel runs on a loop
+thread. The window opens =MAXIMIZED_BOTH= (=setExtendedState= before the
+first show, the legacy =ViewFrame(title)= rule — every v1 demo used the
+maximizing constructor), and the maximized geometry arrives as an ordinary
+resize, so the back buffer and =projectionScale= grow with it.
+=-De3d2.maximized=false= keeps a fixed-size window for scripted runs.
+Rendering is ON DEMAND — the loop renders only when the frame
+SIGNATURE changes (world version, camera pose, GUI hover/pressed/click
+state), so a static world costs an 8 ms poll, not a frame. Two hard
+rules, both learned the hard way:
+
+- The signature must never READ input. Consuming mouse deltas in the
+  signature steals them from =CameraPass=.
+- The signature must include GI PROGRESS (=GiStore.version()= and
+  =LightmapStore.version()=), because the raster samples the light field.
+  Without it a scene whose first frame lands before generation 0 flips
+  stays black on screen — no camera move, no repaint — which is exactly
+  how the House demo opened before the fix.
+- Resize events only invalidate the signature. Back-buffer reallocation
+  and the projection-scale update happen on the LOOP thread, never on the
+  EDT, or the display shows a half-rendered frame.
+
+** The present path
+:PROPERTIES:
+:CUSTOM_ID: present
+:END:
+
+Zero-copy: the presented =BufferedImage= wraps the =ImageStore='s own
+=pixels= array (=Raster.createPackedRaster=, 24-bit =DirectColorModel=),
+so a frame is never copied for display. A dedicated present thread
+page-flips via =BufferStrategy(3)= on a heavyweight =Canvas= (a JPanel
+cannot have one); excess frames drop through a one-slot mailbox, so the
+screen always shows the LATEST complete frame. The FPS counter counts
+BLITTED frames — the number the user sees is the screen rate, not the
+production rate. Benchmark mode (=targetFPS <= 0=) renders every tick even
+when the scene is static, so the measured rate is throughput.
+
+A present-path heartbeat re-presents the current front after 250 ms of
+idle: a bare BufferStrategy present can be erased by a later X background
+erase (map/resize) and would otherwise stay black forever. Heartbeat
+re-presents do not count into the FPS meter, so the honest =idle= reading
+survives. =-De3d2.present.trace=true= logs the resize/render/publish/
+present steps.
+
+** Developer tools (F12)
+:PROPERTIES:
+:CUSTOM_ID: devtools
+:END:
+
+=app/DeveloperToolsWindow=, toggled by F12 (a shell key — it never enters
+=InputStore=), carries the Frame Rate row (target, measured FPS over a
+500 ms window of published frames, Unlock FPS with legacy semantics:
+unlock remembers the locked rate and re-lock restores it) and the Camera
+panel: live pose readout plus Copy/Paste, in the legacy six-field format
+MINUS roll (the PoC camera has no roll — SpaceMouse roll is measured, not
+applied): five strict decimals =x, y, z, yaw, pitch=. Paste preserves
+=scale=, rejects everything else silently (strict-number regex; NaN and
+hex floats never parse), and rejects six-field LEGACY poses on purpose:
+legacy is −Y-up, and a verbatim paste would be a silent wrong teleport.
+The remaining legacy panels (culling stats, GI rows, log viewer, thread
+timeline) port on demand.
+
+** Bug reports and logging
+:PROPERTIES:
+:CUSTOM_ID: bugreport
+:END:
+
+=app/PersistentLog= tees stdout and stderr into
+=~/.cache/aukio/logs/e3d2.log= with 4 MB rotation to =.1=, installed by the
+=AwtShell= constructor so every windowed app is logged with zero wiring.
+=app/BugReport.create= writes a timestamped report directory, copies
+=e3d2.log= (+ =.1=) into it and names the log path in =info.txt=; the
+result dialog has Copy Path and Open Folder (=Desktop.Action.OPEN=,
+disabled with a tooltip where unsupported).
+
+** SpaceMouse 6DOF
+:PROPERTIES:
+:CUSTOM_ID: spacemouse
+:END:
+
+=app/SpaceNavigatorHid= speaks the device over JNA hidraw (both models'
+report layouts, poll-before-read), =app/SpaceMouseManager= does the 2 s
+hot-plug scan and zeroes the channel on disconnect — a stale non-zero
+level would drift the camera forever. =SpaceMousePass= (order 7) writes
+LEVEL state into =InputStore=: deflection → velocity directly (no
+integrator; release = instant stop), quadratic response with deadband 1,
+translation along the FULL view direction (pitch included) and vertical
+along world-up; twist/tilt add yaw/pitch at 3.0 / 1.5 deg per frame at
+60 fps; roll is measured, never applied. Constants: translation factor
+5.0, sensitivity 5.0 (=-De3d2.spacemouse.sensitivity=), top speed 600 u/s.
+The left cap button is a brake: it zeroes keyboard/wheel velocity on the
+rising edge, detected in =CameraPass= where the velocity lives.
+
+Sign rule, binding for every axis: port the CONVENTION (gesture → world
+direction), never the sign constant. The legacy engine was −Y-up and had
++yaw = LEFT; this one is +Y-up with +yaw = RIGHT, and the device's
+pull-up reads NEGATIVE tz. Vertical is therefore =−shaped(tz)=.
+
+The pass is AUTO-WIRED: constructing an =AwtShell= adds it when missing
+(=kernel.has(...)=, never a duplicate), so every windowed app flies with
+the 3D mouse with zero setup; =-De3d2.spacemouse=false= disables. XR
+glasses stay deferred — see =Decisions=.
+
+* Verification
+:PROPERTIES:
+:CUSTOM_ID: verification
+:END:
+
+Nothing is done until it is green here, and no number is quoted that was
+not just measured.
+
+** How to run
+:PROPERTIES:
+:CUSTOM_ID: how-to-run
+:END:
+
+#+BEGIN_SRC sh
+# full suite — ALWAYS under Xvfb: AwtShellTest opens real windows
+JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 xvfb-run -a mvn test
+
+# TLA+ regression gate: every green spec at gate constants (seconds each)
+mvn verify -Ptla-check
+
+# oracles (pure render, no X needed)
+sh oracle/compare.sh        # flat temple vs Aukio 3D
+sh oracle/gi-compare.sh     # GI blocker scene, both engines baked
+
+# benches: RasterBenchMain grid frames [noraster] [w h]
+mvn -q exec:java -Dexec.mainClass=eu.svjatoslav.aukio.e3d2.RasterBenchMain -Dexec.args="32 200"
+# BvhBenchMain grid
+mvn -q exec:java -Dexec.mainClass=eu.svjatoslav.aukio.e3d2.BvhBenchMain -Dexec.args="45"
+# Fo4ExamMain radius frames [noraster] [chunkcull] [nearTess]
+mvn -q exec:java -Dexec.mainClass=eu.svjatoslav.aukio.e3d2.Fo4ExamMain -Dexec.args="5 60"
+#+END_SRC
+
+** The test suite
+:PROPERTIES:
+:CUSTOM_ID: tests
+:END:
+
+132 tests in 32 classes (2026-10-21). The suite is the architecture's
+alarm system: differential tests against test-owned brute-force
+references (BVH), stress tests for every publication protocol, race
+stress for streaming, and behavior pins for every camera axis, input
+mapping and oracle number. =AwtShellTest= drives the shell and the F12
+window with synthetic events, which is why the suite runs under Xvfb.
+
+** The TLA+ gate
+:PROPERTIES:
+:CUSTOM_ID: tla-gate
+:END:
+
+=mvn verify -Ptla-check= re-runs the green specs at gate constants:
+=BvhSnapshot= (fix), =MeshPublish= (fix), =GiScheduler5= policies 1 and 2
+at N=2. The =*_buggy.cfg= models are excluded BY DESIGN — they exist as
+non-vacuity probes (a model that cannot fail proves nothing) and are run
+manually. Full-scale N=3 scheduler runs stay manual.
+
+** Oracle policy: explainable drift
+:PROPERTIES:
+:CUSTOM_ID: oracle-policy
+:END:
+
+Renders are compared against Aukio 3D, but bit-exactness is NOT the
+goal. The old implementation has known bugs; where this implementation
+is better, drift toward betterness is good drift.
+
+The discipline replacing bit-exactness: *every visual difference must
+be named and argued as an improvement.* An unexplained difference is a
+defect, even if it looks fine. Differences land in an explicit list
+(the oracle-gate section below), each with cause and justification.
+
+** Oracle gate: temple vs Aukio 3D
+:PROPERTIES:
+:CUSTOM_ID: oracle-gate
+:END:
+
+Two harnesses: =oracle/compare.sh= (flat shading) and
+=oracle/gi-compare.sh= (full GI, =GiOracle.java=). Current numbers,
+re-run 2026-10-21: flat 688/307200 px differ (0.224%, meanAbs 0.19);
+GI (blocker scene, both engines' bakes converge) 37541 px (12.220%,
+meanAbs 11.62). The flat delta is geometric (the fill-rule list below);
+the GI scene is a structural match — shadow position, shape and layout
+identical — and its delta is TONAL.
+
+Two flat baselines existed while demos-2 still carried a harness: the
+temple here (688 px) and MinimalExample (158 px, 0.051%). The demos-only
+repo boundary deleted that harness, so the temple numbers are the live
+ones — a 158 px figure elsewhere in this file is history, not a gate.
+
+The GI delta's causes, with status. Items 1–2 are the original
+2026-10-11 findings, kept with their original reasoning; both were
+addressed on 2026-10-14.
+
+1. *Falloff model.* Original: Aukio attenuates 1/(1+0.0001·d²), the PoC
+   had none, so floor brightness diverged with distance from the light
+   (the dominant term). The PoC now uses the same curve (2026-10-14).
+2. *Texel filtering.* Original: Aukio smooths lightmap composites, the
+   PoC sampled nearest, leaving a red ring hugging the shadow edge. The
+   PoC now samples bilinear with dilation fill (2026-10-14).
+3. *Bounce/sky gains.* STILL DIFFERS: Aukio BOUNCE_GAIN × skyScale vs
+   PoC GAIN 0.65 and fixed sky fill (18/24/34); shadow interiors lifted
+   by different amounts.
+4. *Ambient handling.* STILL DIFFERS: Aukio ambient feeds shading AND
+   GI stage 1; PoC AMBIENT=40 flat.
+5. *Lit-side bounce vote* (new 2026-10-14): the PoC gathers into the
+   geometrically lit hemisphere; Aukio gathers along the raw winding
+   normal. A named fix, not a regression — see the 2026-10-14 GI entry
+   in the progress log.
+
+Residual attribution after the 2026-10-14 re-baseline, in that entry's
+own words: lit-side voting, falloff and bounce gains.
+
+Geometry-level drift stays at the flat-oracle list (fill rule, depth
+precision, near plane, backfaces, conventions, alpha).
+
+*GI bake headless gotcha:* Aukio's GI snapshot collects only what the
+warm-up render QUEUED. A warm-up from a throwaway pose that misses the
+scene collects 0 triangles and the bake never converges — warm up from
+the final viewpoint.
+
+New drift found in a diff is a defect unless it lands on this list with
+a justification — that is the oracle policy in practice.
+
+** Benches
+:PROPERTIES:
+:CUSTOM_ID: benches
+:END:
+
+Measure BEFORE and after, never infer a speedup from reasoning. The three
+mains and their arguments are in =How to run=; their numbers live in the
+log entry that introduced each change, because a bench without its code
+version is folklore.
+
+** The fullscreen gate
+:PROPERTIES:
+:CUSTOM_ID: fullscreen-gate
+:END:
+
+Any display-path work (shell, blit, present, windowing) adds a gate at
+2560×1440 in benchmark mode under Xvfb. Never infer display performance
+from a windowed run: at 640×480 a full-frame copy costs 1.5 ms, at 1440p
+it costs 17.6 ms — the same code, 12× apart. When FPS looks low, split
+render from display with a headless raster-only probe first.
+
+** The FO4 exam
+:PROPERTIES:
+:CUSTOM_ID: fo4-exam
+:END:
+
+=SyntheticCitySource= + =Fo4ExamMain= reproduce the FO4 workload SHAPE
+(dense tessellated city, LOD-aware, 121 chunks at radius 5). Its verdict
+ladder — uniform → chunk cull → LOD → honest near density — is in the
+log, and it is the answer to "what breaks first at scale": the transform
+wall, then the raster wall, which LOD answers, not engine cleverness.
+
+* Decisions and what remains
+:PROPERTIES:
+:CUSTOM_ID: decisions
+:END:
+
+** Decided
+:PROPERTIES:
+:CUSTOM_ID: decided
+:END:
+
+- *GI scheduler policy 1* — never abort an in-flight generation, barrier
+  re-filter and re-sort, ring membership as the only skip. Policy 2
+  (bounded abandon) stays modeled but unimplemented. Rules in
+  =Concurrency=.
+- *Occlusion culling: deferred.* The correct design (temporal Hi-Z with
+  reprojection) pays off only on dense scenes, and there was no dense
+  scene to measure against — building it then would have been
+  unverifiable optimization. Revisit when the FO4 exam provides the
+  workload.
+- *Stereo format:* anaglyph plus side-by-side. Process lesson recorded:
+  the output format was chosen BEFORE reading Aukio's design (side-by-side
+  halves written straight into one buffer). The rewrite rule is
+  check-first — read the oracle's WHOLE design, including its performance
+  machinery, before choosing an approach.
+
+** Deferrals
+:PROPERTIES:
+:CUSTOM_ID: deferrals
+:END:
+
+**XR glasses: still DEFERRED.** No hardware attached to verify
+against — shipping an unverified device driver is exactly what this
+repo exists to prevent. The adapter seam is the architecture and it
+exists: device events -> InputStore. Implement when the device is
+present.
+
+**Config file: DROPPED.** System properties (=-De3d2.*=) cover the
+real toggles; a config format returns when a consumer needs it.
+
+** Out of scope
+:PROPERTIES:
+:CUSTOM_ID: non-goals
+:END:
+
+This list was the PoC's original scoping (2026-10-10) and has been
+overtaken by the rewrite plan: SDF text, stereo, GUI and input handling
+all shipped in slices 3 and 5. What is genuinely out of scope now:
+
+- *Aukio-3D API compatibility* — the new architecture's API wins.
+- *XR device driver* — deferred until hardware is present to verify
+  against; the adapter seam exists (device events → InputStore).
+- *Config file format* — dropped; =-De3d2.*= system properties cover the
+  real toggles.
+
+The point that survives: the PoC earns its keep by proving the
+interaction claim on the *hard* parts (concurrent GI over a uniform
+substrate), not by re-implementing product features for their own sake.
+
+** What remains
+:PROPERTIES:
+:CUSTOM_ID: road
+:END:
+
+TODO in slice 6: the consumers themselves — FO4 loader port
+(BA2/ESM -> MeshStore, the big one) and the aukio workspace port.
+
+- GI bake during streaming: needs its own pass-ordering rule (bake only at
+  rest, or bake through pins).
+- Occlusion culling, once the FO4 workload exists to verify it against.
+- Remaining legacy dev-tools panels: culling stats, GI rows, log viewer,
+  thread timeline.
+- A config format, if a consumer ever needs one beyond =-De3d2.*=.
+
+* Progress log
+:PROPERTIES:
+:CUSTOM_ID: slices
+:END:
+
+Every dated entry, in the order it happened, kept as written: numbers
+inside an entry are point-in-time. Current numbers live in =The project=.
+
+*Done so far (2026-10-11):* substrate/kernel contract; TLA+ M1–M5
+scheduler spec (policy 1 adopted); Java scheduler with runtime audits +
+stress; 3-pass pipeline + fog (feature metric proven); GI stages 1–4
+(direct → bounce → lightmaps → texel bounce) with neighbor-fill;
+oracles: flat 0.224% named drift, GI structural match + named tonal
+drift; Pass.order() plugin ordering (drop-in JARs, no central file);
+slice 1: near-plane clipping + textures with perspective-correct UVs.
+2026-10-14: GI quality pass — colored direct light with distance
+falloff (legacy curve), lit-side bounce hemisphere, bilinear lightmap
+sampling + dilation fill; HouseDemo ported to aukio-3d-demos-2 with a
+dual-engine GI oracle.
+Details in the sections above.
+
+** Slice 1: raster foundations (clipping + textures)
+:PROPERTIES:
+:CUSTOM_ID: slice-1
+:END:
+
+Two changes that alter the raster core, landed while it is small:
+
+1. *Near-plane clipping* (=TransformPass=). Triangles straddling
+   =z=near= are clipped (Sutherland–Hodgman against one plane), with UV
+   interpolated at the intersection; only fully-behind triangles are
+   culled. Retires the v1 simplification (whole-triangle straddler
+   culling) that used to eat floors near the camera.
+2. *Textures* (=TextureStore= in the substrate; per-vertex UV in
+   =MeshStore=; =RasterPass= samples perspective-correct:
+   interpolate =u·w, v·w, w= barycentrically, divide per pixel,
+   nearest texel, color acts as tint).
+
+Verified by =ClipTextureTest= (straddler clips to a quad loop and
+survives culling; fully-behind culled; UV quadrants land in the right
+regions; textured straddler renders), =TexturedDemoMain= (visual:
+perspective compression correct, no seams), and both oracles unchanged
+within named drift (flat identical 688px; GI 11.1%, slightly better
+than the 11.3% baseline).
+
+Plugin ordering (=Pass.order()=, stable sort in =Kernel.discover()=)
+shipped in the same window: a feature JAR is now truly drop-in.
+=DiscoverOrderTest= + =KernelSortTest= pin it.
+
+** Slice 2: scale machinery
+:PROPERTIES:
+:CUSTOM_ID: slice-2
+:END:
+
+**BVH ray index (done 2026-10-11).** =BvhStore= (kernel): SoA bounding
+volume hierarchy over =MeshStore=, median split on longest axis, leaf 4,
+allocation-free traversal. Rebuilt lazily by =MeshStore.version()= via
+=ensure()= (idempotent, O(1) steady state); =GiPass= ensures once per
+bake. =Raycast= is now a thin facade — the Möller–Trumbore math has
+exactly one copy (in =BvhStore=).
+
+Placement decision: the index is SUBSTRATE data (shared derived state —
+GI gather today, picking/occlusion tomorrow), not a feature-private
+cache; a private cache is how hidden coupling starts.
+
+*Pitfall pinned for future maintainers:* in =buildNode=, never write
+=left[node] = buildNode(...)= directly — the child recursion can grow
+(reallocate) the node arrays, and Java captures the array reference
+BEFORE the RHS runs, silently storing into the stale array (JLS
+15.26). Lost writes manifested as child slots left 0 → cycles →
+traversal stack overflow. Assign to locals first, then store. Found by
+the differential test, not by reading.
+
+Verified: =BvhStoreTest= — 2000-ray differential vs a test-owned
+brute-force reference (nearest identity, occlusion identity, uv
+identity), empty/single-tri edge cases, version-checked rebuild.
+Bench (=BvhBenchMain=, grid terrain, full GI bake to convergence):
+3600 tris 2579ms → 163ms (15.8x); 16384 tris 377ms (brute force
+infeasible). Oracles unchanged: flat identical 688px; GI 11.126%
+(named-drift class).
+
+Open at the close of this entry: SoA geometry blocks and tile-parallel
+raster (both landed later the same day, below), occlusion culling
+(DEFERRED, below). Slice 2's remaining sized work became streaming.
+
+**Snapshot consistency under rebuild (done 2026-10-11).** Queries run
+unsynchronized from GI worker threads while a world rebuild may call
+=ensure()= — the first BVH version swapped array CONTENTS in place, so
+a concurrent traversal could mix two tree versions. Reproduced by
+=BvhRaceStressTest= before the fix: 35k queries → 22k wrong answers +
+all 4 readers crashed. Specified in =Model/BvhSnapshot.tla= (tree
+abstracted to F version-tagged fields): =Fix=0= (per-field swap, the
+buggy design) violates MixedFree — TLC counterexample in 196 states;
+=Fix=1= (RCU: ONE volatile reference to an immutable snapshot, captured
+once at query entry) is green — 128 distinct states, depth 11. TLC
+also caught a modeling error in the first Fix=1 draft (re-reading the
+reference per field is not the fix — the capture must be a SINGLE
+read). Java: =BvhStore= now builds an immutable =Tree= off to the side
+and publishes it via one volatile reference; queries capture the
+reference once. After the fix the same stress test: 69M queries, 6.4k
+rebuilds, 0 mismatches, 0 crashes. Bake unchanged (142ms vs 163ms).
+
+**Mesh append/publish protocol (done 2026-10-11).** =MeshStore.version=
+is the commit counter derived indexes rebuild from. Getting the
+publication order wrong = an index permanently MISSING triangles with
+no crash. Three designs existed in one day; =Model/MeshPublish.tla=
+documents and decides:
+
+*A* (version++ before count++): builder tags tree with a version whose
+last triangle it may not contain — permanent miss.
+*B* (appender fixed, but =build= read count at start and tagged with
+version at end): appends finishing mid-build over-tag the tree — same
+miss, subtler window. TLC counterexample in 32 states;
+=MeshPublishStressTest= (appender and ensure on SEPARATE threads —
+=BvhRaceStressTest= cannot see this race) caught it live: 768 misses
+in 2000 triangles.
+*C* (final): tree tagged with the COUNT captured once at build start —
+tag and content from one counter, one read; the tag can never claim
+more than the tree contains. TLC green. MeshStore additionally keeps
+commit-last volatile version for JMM release ordering of triangle data.
+
+Lesson: a tag and the content it describes must come from ONE source
+of truth read ONCE; two counters read at two times is a race by
+construction.
+
+**Regression gate.** =mvn verify -Ptla-check= re-runs every green spec
+at gate constants (seconds each): BvhSnapshot fix, MeshPublish fix,
+GiScheduler5 P1+P2 at N=2. Bug-expectation cfgs (=*_buggy.cfg=) are
+excluded by design — they exist as non-vacuity probes, run manually.
+Full-scale N=3 scheduler runs stay manual.
+
+**Parallel pipeline (done 2026-10-11).** =kernel/Parallel= — ONE shared
+daemon pool + static-partition =forRange= — is the engine's only
+parallelism primitive. The contract: bodies write disjoint ranges, so
+output is byte-identical to the serial loop. Applied to:
+
+- =RasterPass=: phase 1 bins triangles into 128px tiles (ascending
+  order preserved), phase 2 runs one task per non-empty tile. Pixels
+  and depth entries are written only by their owning tile; per-pixel
+  triangle order matches the serial loop exactly.
+- =TransformPass= / =CullPass=: contiguous triangle-range partition;
+  per-tri writes (screen slots, culled flag) are disjoint by index.
+
+Verified: flat oracle BYTE-IDENTICAL to the pre-parallel baseline
+(688px, 0.224%); GI oracle 11.136% (named-drift class);
+=RasterParallelTest= run-to-run determinism; 36/36 tests.
+
+Numbers (=RasterBenchMain=, exec:java, 24-core): 8192 tris 640x480:
+3.57 -> 1.70 ms/frame. 32768 tris 1280x960: 5.35 -> 4.30 ms/frame;
+transform+cull 3.20 -> 2.20. Lessons: (1) tile size 64 -> 128 px —
+submit/get overhead dominates below ~core-count tasks; (2) per-tile
+triangle rescans dominated until binning was added; (3) the remaining
+floor is single-threaded binning plus memory bandwidth (transform
+moves ~10MB/frame), not cores — Amdahl, as always.
+
+**SoA geometry blocks (done 2026-10-11).** =MeshStore= storage is now
+fixed-capacity 8192-triangle blocks (the TriangleMeshBlock idea; later
+shrank to 1024 so a chunk owns whole blocks — see =Streaming v1=) behind
+an UNCHANGED public API — zero caller edits (the metric on an internal
+layout change). Growth allocates one block instead of copying a
+whole-store array (at FO4 scale that copy is hundreds of MB). Blocks
+are the future unit of streaming eviction. The volatile commit protocol
+is unchanged and documented to cover block allocation and outer-array
+growth (both precede the version release). Verified: 38/38 tests (new
+=MeshStoreBlocksTest= probes block boundaries and BVH indexing across
+blocks), flat oracle byte-identical (688px), GI oracle named-drift.
+Cost: +4.7% frame time, +15% bake time (the shift/mask indirection in
+=at()=) — accepted: bake is amortized per scene; optimize the hot
+accessor later if profiles demand.
+
+**Occlusion culling: DEFERRED (2026-10-11, user may veto).** Rationale:
+the correct design (temporal Hi-Z with reprojection) pays off only on
+dense scenes, and there is no dense-scene workload in the PoC to
+measure against yet — building it now would be unverifiable
+optimization. Revisit after streaming lands, when the FO4 exam provides
+the workload. Slice 2 is otherwise complete; the remaining slice-2
+sized work IS streaming (with its lifecycle spec).
+
+**Streaming lifecycle spec (done 2026-10-11).** =Model/SlotLifecycle.tla=
+— slots cycle chunks (load → live → evict → empty); readers capture a
+world version and read slots one by one; =ReaderConsistent= requires
+every completed read to equal the world map at the captured version.
+Verdicts: naive (immediate structural change) VIOLATED — and the
+counterexample needs zero evictions: a chunk FINISHING LOAD mid-read
+tears the world just as well. The protocol (Fix=1) is GREEN: every
+structural change (publish AND evict) applies only at reader
+quiescence — the frame barrier — plus shadow-pinning for cross-version
+stale readers (the BVH Tree mechanism). Java meaning: =WorldStreamer=
+decides load/evict sets during a frame; transitions apply at a point
+where no query/render/bake holds the world. Spec lessons pinned:
+TLC equality is type-strict (integer vs string sentinel = evaluation
+error); use integer sentinels; IF-THEN-ELSE where => can go out of
+domain. Implemented in the next entry (=Streaming v1=, below):
+ChunkStore + WorldStreamer + MeshStore slot reuse + procedural source +
+flying-camera demo + race stress.
+
+**Streaming v1 (done 2026-10-11).** The full arc, spec to silence:
+
+- =MeshStore=: blocks shrink to 1024 tris so chunks own whole blocks;
+  =evictBlock= (nulls storage, version bump LAST), =blockAlive=,
+  =padToBlockBoundary= (chunks start on fresh blocks; degenerate
+  far-away padding dies with the preceding chunk), =pinBlocks=
+  (shallow-cloned outer tables — RCU by GC).
+- =BvhStore.Tree= PINS the mesh tables at build; queries read pinned
+  data, so a published tree keeps serving the PRE-EVICT world after
+  the live mesh drops the blocks (the shadow rule made concrete).
+  Builds index only live blocks (count never shrinks; dead skipped).
+- =ChunkStore= (substrate): chunk registry; chunks own contiguous
+  block-aligned mesh ranges. =WorldStreamer= pass (order 5, first):
+  camera-driven load (pad → generate → publish) and evict (drop
+  blocks, unregister). =ProceduralTerrainSource=: deterministic
+  infinite terrain (32x32 quads = 2048 tris = 2 blocks per chunk) —
+  eviction and reload are lossless.
+- Frame barrier honored in =TransformPass= too: dead-block triangles
+  write count 0 (cull drops them; no stale-screen ghost rendering).
+
+Verified: =StreamRaceStressTest= — (1) pinned tree answers the
+pre-evict world with the blocks already dropped from the live mesh,
+(2) post-ensure tree reflects the eviction exactly, (3) chaos run:
+115.6M queries across 161 load/evict transitions, 0 crashes.
+=StreamingDemoMain= (flying camera, 300 frames): 4.76 ms/frame,
+live chunks pinned at the 49-chunk radius cap, live triangles bounded
+(100k) while appended indices grow (301k), terrain renders clean.
+40/40 tests.
+
+Limit, stated: no GI bake DURING streaming yet — bakes hold mesh
+reads across frames, so bake-vs-evict needs its own pass-ordering
+rule (bake only at rest, or bake reads via pins). Follow-up item.
+
+** Slice 3: input + GUI
+:PROPERTIES:
+:CUSTOM_ID: slice-3
+:END:
+
+**Input + camera physics (done 2026-10-11).** Events are DATA:
+=InputStore= (substrate) holds key state and accumulating mouse
+deltas, written by an adapter (slice 4's AWT shell) or by tests and
+scripts — no pass ever touches AWT. =CameraPass= (order 8) reads it:
+mouse look (yaw/pitch, clamped; legacy InputManager feel — drag
+right = turn right, drag UP = look up, =pitch -= dy=, sensitivity
+the legacy =/50= rad-per-pixel), fly movement: WASD, arrow keys
+(legacy =WorldNavigationUserInputTracker= mapping) and the Dvorak
+cluster at the physical WASD positions (=,= forward, =A= left, =O=
+back, =E= right — so QWERTY up moved E→R; Dvorak vertical at the
+physical Q/E is ='= down, =.= up; QWERTY =Q= down, =R= up). Mouse
+wheel = vertical thrust like legacy =handleMouseScrolling= (scroll
+up = fly up, one notch kicks 100 u/s scaled by the same speed curve;
+no GUI wheel consumers exist yet, so the wheel always reaches the
+camera).
+Physics constants are the legacy Camera's, converted to
+units/second: acceleration 2000 u/s² growing as (1 + speed/200)
+(legacy 0.1/ms × (1 + speed/10) curve), damping 5/s (legacy /1.005
+per ms friction), top speed 600 u/s (legacy SPEED_LIMIT 30 ×
+SPEED_MULTIPLIER 0.02 per ms). Frame dt from an
+injectable clock: apply() uses nanotime, tests drive =tick(data, dt)=
+directly — physics is deterministic and unit-tested
+(=CameraPhysicsTest=: forward follows yaw, turn-then-move matches
+tan(yaw), damping stops after release, speed caps, drag-up sign
+matches legacy, pitch clamps, arrows mirror WASD, Dvorak cluster,
+vertical keys).
+
+=StreamingFlightMain= exercises the whole input pipeline the way the
+windowing shell will drive it: scripted InputStore (W held, a 165°
+bank at frame 120) → camera flies out and returns over the streaming
+terrain at 3.68 ms/frame, checkpoints render clean.
+
+Open at the close of this entry, closed by the next: GuiStore +
+GuiPass (components/focus/hit-test as data), the TextCanvas collapse
+(into Label), SDF text pass.
+
+**GUI + SDF text (done 2026-10-11).** Components are DATA: =GuiStore=
+holds panels, labels, buttons as plain records; =GuiPass= (order 6)
+runs hit-test/hover/click-publish (clicks surface as id strings —
+passes never hold callbacks); =GuiRenderPass= (order 250, after fog)
+draws them. TextCanvas collapses into Label (redesign: multiline =
+multiple labels; the terminal-grid use case returns with an actual
+consumer). InputManager collapses into InputStore.
+
+SDF text: =SdfFontStore= (substrate) holds a signed-distance-field
+glyph atlas (95 ASCII glyphs, 64px cells, chamfer transform, built
+ONCE by =systems.gui.SdfFont= — AWT is tooling, never a pass);
+=GuiRenderPass.drawText= samples it bilinear, coverage =
+0.5 + signedScreenDistance — crisp at any scale. Two bugs the tests
+caught, both worth remembering: (1) full-cell quads overlap at
+advance width and double-blend into solid blobs — draw the per-glyph
+INK box (+2px for the AA ramp) at the pen instead; (2) out-of-range
+chars must still advance the pen.
+
+Verified: =SdfGuiTest= (atlas signed field, button hit-test/click
+publish incl. release-outside, AA ramp on upscale, coverage at 0.25x
+and 2.0x); =GuiDemoMain= renders panel + title + button over the
+streaming terrain, letters crisp. 50/50 tests.
+
+** Slice 4: platform
+:PROPERTIES:
+:CUSTOM_ID: slice-4
+:END:
+
+**Windowing shell + render-on-demand + diagnostics (done 2026-10-11).**
+=app/AwtShell=: a JFrame displaying the ImageStore, AWT listeners
+feeding InputStore (the ONLY AWT entry point — events become data),
+a loop thread running the kernel. Render-on-demand: the loop renders
+only when the frame SIGNATURE changes (world version, camera pose,
+GUI hover/pressed/click state) — a static world costs an 8ms poll,
+not a frame. Signature must never READ input (consuming mouse deltas
+in the signature steals them from CameraPass — caught before it
+shipped). Double buffering (fixed 2026-10-12, the resize-to-
+fullscreen flicker): the loop renders into a BACK ImageStore and
+publishes by reference swap under a lock only after runOnce
+completes; the EDT's blit copies the published frame under the same
+lock, so the screen never sees a mid-render buffer (cleared
+background without the scene). Resize events only invalidate the
+signature — the back-buffer reallocation + projection-scale update
+happen on the loop thread, never on the EDT.
+=DiagnosticsHudPass= (order 249): fps/tris/chunks/camera as
+a SDF label — the F3-style readout. The reference full-app assembly
+(streaming terrain + GUI + HUD, WASD/QE fly, working Quit via the
+click queue) lives in demos-2 as =AwtShellMain= — interactive demos
+belong to aukio-3d-demos-2; the engine repo keeps the shell and its
+headless test. Verified: =AwtShellTest= (synthetic key/mouse events
+reach InputStore, paint composites, blit shows only complete frames,
+signature tracks change and
+stays stable at rest); 54/54 tests; window smoke-run clean.
+
+**Developer tools window (done 2026-10-12).** F12 toggles
+=app/DeveloperToolsWindow=, the port of legacy =DeveloperToolsPanel=.
+First slice of features: the Frame Rate row — target FPS, measured
+FPS (500 ms sliding window of published frames, legacy
+=noteFrameBlitted=), and the Unlock FPS toggle with legacy semantics
+(unlock remembers the locked rate, re-lock restores it). The shell
+gained the legacy pacing pair: =maintainTargetFps= as a fixed-step
+nano deadline (resync when behind, never catch up) and benchmark mode
+(=targetFPS <= 0=): render EVERY tick even when the scene is static,
+so the measured rate is throughput, not demand. F12 is a shell key —
+it never enters =InputStore=. Two first-day defects, both fixed same
+day: (1) the measured-FPS window goes stale at rest (render-on-demand
+publishes nothing) and showed a decaying 0.2 fps — the label now reads
+=idle= when no frame published for 1s (=lastPublishNanos= on the
+shell); (2) the window packed while labels held placeholder spaces, so
+real timer text outgrew the row and FlowLayout wrapped the button into
+a clipped second line — labels now pack at WORST-CASE width
+(=unlimited= / =0000.0= placeholders). Camera panel (done 2026-10-13):
+live pose readout + Copy/Paste, the legacy six-field format MINUS
+roll (e3d2's camera has no roll — SpaceMouse roll is measured, not
+applied): five strict decimals =x, y, z, yaw, pitch=; paste preserves
+=scale= (a viewport property, not a pose), rejects anything else
+silently (strict-number regex, legacy rule — NaN/Infinity/hex-float
+never parse), and six-field LEGACY poses are rejected on purpose
+(legacy is -Y-up; verbatim paste would be a silent wrong teleport).
+Render-on-demand picks the teleport up via the signature. Remaining
+legacy panels (culling stats, GI rows, log viewer, thread timeline,
+bug report) port later, on demand. Verified: =DeveloperToolsWindowTest=
+(format, round-trip, strict-only, wrong-count rejection) +
+=AwtShellTest=
+(+4: cap default/settable, unlimited renders every tick, measured-FPS
+window + publish timestamp, F12 not input); Xvfb probes: F12 opens the
+window, idle shows =Target: 60 / idle= with the button fully visible,
+unlock shows =unlimited / 905 fps= on MinimalExample, re-lock holds
+60.01 fps with W held; published front buffer BYTE-IDENTICAL to the
+headless golden in both modes. 69/69 tests.
+
+**Bug report tool: complete port (done 2026-10-14).** The v2 dialog
+was a plain JOptionPane with the path in dead text; now the legacy
+result dialog: selectable monospace path field + *Copy Path* +
+*Open Folder* (=Desktop.Action.OPEN=, disabled with a tooltip when
+unsupported) + *Close*. Log collection landed with it: new
+=app/PersistentLog= (tee of stdout/stderr into
+=~/.cache/aukio/logs/e3d2.log=, 4 MB rotation to =.1= — the legacy
+PersistentLog pattern, distinct file name so both engines log side by
+side), installed from the =AwtShell= constructor so every shell app is
+logged with zero wiring; =BugReport.create= copies =e3d2.log= (+ =.1=)
+into the report and info.txt names the log path. Pinned by
+=BugReportTest.reportIncludesPersistentLog=.
+
+**Shapes inside-out winding: fixed (2026-10-14, user bugreport
+20261014-214114 "I see back side of the cube").** Root cause found by
+measurement, not convention prose: CullPass KEEPS the triangle whose
+winding normal points AWAY from the camera (Probe4: two quads, two
+windings — normal-toward is culled). The camera looks along +Z with +x
+projecting to screen-right: a mirror of the OpenGL convention, so the
+"front = CCW from camera" intuition inverts. The Shapes gallery was
+authored in MIXED conventions during the −Y→+Y port: cube side faces
+inward but its ±Y faces outward (the reported see-through sofa), and
+cylinder/cone fully outward (the earlier "hollow arrows" incident was
+the same defect). Flat albedo hides inside-out completely — only GI
+shading made it visible. Fixed face by face: all solids now wind
+normals INTO the solid; pyramid and sphere were already correct.
+=ShapesTest= gained the geometric pin =assertWindingIntoSolid=
+(per-triangle winding normal must point at the solid's interior
+reference point) — the six-direction render check cannot see this
+class. CullPass/Shapes javadocs now state the measured contract.
+
+**Present path rewrite (done 2026-10-12, fullscreen FPS).** The first
+shell blitted by =canvas.setRGB= + EDT =repaint()=: a 14.7 MB
+full-frame copy plus a second full traversal, serialized on the EDT.
+Measured at 2560x1440 on MinimalExample: pure render 2.02 ms (494 fps
+ceiling) but the display path 17.6 ms (57 fps ceiling) — the user saw
+~30 fps where Aukio v1 does >100. Root-cause lesson: every render
+bench in this repo measured =runOnce=; nothing ever rendered
+fullscreen, so all gates were green while the blit ate 90% of the
+frame. Now ported from legacy ViewPanel, exactly: (1) ZERO-COPY —
+the presented BufferedImage wraps the ImageStore's own =pixels= array
+(=Raster.createPackedRaster= over it, 24-bit DirectColorModel, the
+legacy "TYPE_INT_RGB is optimal for blitting" choice); (2) a dedicated
+present thread page-flips via =BufferStrategy(3)= on a heavyweight
+=Canvas= (JPanel can't have one), excess frames drop from a one-slot
+mailbox so the screen always shows the LATEST complete frame; (3) the
+FPS counter counts BLITTED frames (legacy =noteFrameBlitted=) — the
+number the user sees is the screen rate, not the production rate.
+Buffer protocol under =frameLock=: the loop's back buffer must not be
+the front, mid-present, or unclaimed in the mailbox; if the only
+candidate is mid-present the loop WAITS (present backpressure is the
+display's pacing). =paint()= fallback (expose/tests) draws front
+lock-free — front is never written while it is front. Verified:
+69/69 tests (blit-driven FPS window test); Xvfb 2560x1440 bench:
+150.7 fps blitted unlimited (vs ~30 before), 59.9 capped with W held;
+root-window capture mid-benchmark clean (no tearing/bands). Process
+fix recorded: fullscreen FPS on MinimalExample is now a verification
+number, not an afterthought.
+
+**Resize-to-black fix (done 2026-10-13).** User: resize the window
+and it goes black forever. Root cause found by isolation, not by
+guessing at BufferStrategy flags: a bare-JDK repro (Canvas +
+BufferStrategy(3), one show(), no engine code) stays BLACK on the
+X server even with =contentsLost()= false — a single present is
+erased by a later X background erase (map/resize, ignoreRepaint on),
+while continuous presents survive (fullscreen bench was clean at
+150 fps for this reason). The old setRGB/EDT path never hit this
+because AWT's own paint machinery re-painted on expose; the new
+zero-copy strategy path must self-heal. Fix: the present thread runs
+a HEARTBEAT — =presentSignal.tryAcquire(250ms)=, on timeout with an
+empty mailbox it re-presents the current front. Any erased/lost frame
+self-heals within 250 ms at the cost of 4 idle blits/s; heartbeat
+re-presents do NOT count into the FPS meter (idle detection and the
+honest idle reading preserved). Also added the missing outer
+=contentsLost()= retry loop to the draw idiom along the way.
+Diagnostic aid kept: =-De3d2.present.trace=true= logs resize/render/
+publish/present steps. Verified: deterministic in-probe resize
+640x480 -> 1280x900 on Xvfb — scene visible 2 s after resize
+(before fix: black); 69/69 tests; 2560x1440 bench unchanged
+(150.7 fps blitted, 60.1 capped).
+
+**Devices: SpaceMouse 6DOF (done 2026-10-13), XR glasses DEFERRED.**
+Ported from legacy =gui/spacemouse= against the real SpaceNavigator
+(USB 046d:c626 — the deferral was "no hardware"; hardware is present
+now). The adapter seam is exactly the AWT one: =app/SpaceNavigatorHid=
+(JNA hidraw transport, poll-before-read loop, both models' report
+layouts) writes cap axes into =InputStore= as LEVEL state (latest
+report wins; the device is silent at rest and reports zeros on
+release); =app/SpaceMouseManager= does the 2s hot-plug scan and zeroes
+the channel on disconnect (a stale non-zero level would drift the
+camera forever — legacy gated on =isRunning= per frame instead).
+=systems/SpaceMousePass= (order 7) is the only new pass: direct
+proportional drive (deflection -> velocity, no integrator, release =
+instant stop), forward/strafe along the FULL view direction (pitch
+included, legacy matrix columns), vertical world-up; twist/tilt add
+yaw/pitch deltas (3.0 / 1.5 deg per frame at full deflection, 60fps
+baseline); quadratic response + deadband 1; roll measured, not
+applied. Constants are the legacy CODE values —
+=TRANSLATION_SPEED_FACTOR= 5.0 (the doc page's 3.0 is stale, code was
+raised 2026-09-10), sensitivity 5.0 (=-De3d2.spacemouse.sensitivity=),
+top speed 600 u/s. Sign port trap pinned: legacy camera was +yaw =
+LEFT, PoC is +yaw = RIGHT, so the legacy yaw sign flips while pitch
+stays — per-axis tests pin every direction; the VERTICAL sign was
+shipped wrong on the first try (ported verbatim as +1) and caught by
+the user live: legacy aukio-3d is a -Y-up engine while PoC is +Y-up
+(=TransformPass= =sy = cy - (y/z)*scale=), and the probe showed the
+device's pull-up reads NEGATIVE tz (-419; press down +444 — the legacy
+javadoc's "pull up +" was inverted), so PoC vertical is
+=-shaped(tz)=. Lesson, now binding for every axis: port the
+CONVENTION (gesture -> world direction), never the sign constant. Left cap button = brake:
+zeroes the keyboard/wheel velocity on the rising edge, detected in
+=CameraPass= (the velocity lives there). Auto-starts with =AwtShell.open()=
+(=-De3d2.spacemouse=false= disables), and the pass is auto-wired the
+same way: constructing an =AwtShell= adds =SpaceMousePass= to the
+kernel when missing (=kernel.has(...)=, never a duplicate), so EVERY
+windowed app flies with the 3D mouse with zero setup — the legacy
+ViewPanel behavior (MinimalExampleMain needed nothing). To support
+that, =Kernel.add= became a sorted insert by =Pass.order()=: explicit
+kernels get the =discover()= ordering guarantee, and registration
+sequence never changes run order. Verified: =SpaceMousePassTest=
+15 (every axis sign, rates, quadratic/deadband, instant stop, brake
+edge, roll ignored, activity wake), =SpaceNavigatorHidTest= 8 (both
+wire protocols, merge semantics, battery), =AwtShellTest= +2
+(auto-wiring, no duplicate), =KernelSortTest= +2 (sorted insert, has).
+96/96 tests; flat oracle
+unchanged (158px, 0.051% — last run before the harness left:
+demos-2 is demos-only per the user's repo boundary, so
+MinimalExampleOracle/compare.sh/DragLookIT were deleted there
+2026-10-13; the temple/GI oracles in this repo's =oracle/= remain).
+
+TODO in slice 4: nothing else — slice complete modulo the deferral.
+
+** Slice 5: stereo
+:PROPERTIES:
+:CUSTOM_ID: slice-5
+:END:
+
+**Stereo rendering (done 2026-10-11).** The stereo RIG is app-layer
+orchestration (passes never reference passes): run the MONO pipeline
+twice per frame, once per eye, into per-eye ImageStores
+(=imageLeft/imageRight= in the substrate); the only new pass is
+=StereoMergePass= (ANAGLYPH: left→red, right→green+blue;
+SIDE_BY_SIDE). Eye model: parallel cameras offset along view-right by
+±IOD/2 (toe-in converges at one distance and strains; parallel is the
+standard). =StereoDemoMain.eyePose= computes the offset pose.
+
+Verified: =StereoTest= — parallax present in both markers and falling
+with depth (near shift > 2x far shift); anaglyph channel mapping
+exact per eye. (The first scene version put the far marker directly
+behind the near one — fully occluded in BOTH eyes, zero pixels:
+test-design lesson, check your markers are visible.) Demo anaglyph
+shows strong near fringing converging with distance. 56/56 tests.
+
+XR head tracking stays with the device deferral (slice 4): the
+device feeds eye poses through the same seam when hardware is
+present.
+
+**Oracle comparison (recorded 2026-10-11, after the user asked).**
+Aukio 3D's actual stereo (=StereoEye= + =ViewPanel.renderFrame=):
+side-by-side halves in ONE frame buffer, straight to the XR glasses —
+left eye into viewport [0, w/2), right into [w/2, w), one blit, no
+merge, no anaglyph. Its IPD is a raw =location.x += ipdOffset= —
+world-X, NOT rotated by yaw (fine for the forward-facing XR use;
+wrong at arbitrary yaw). The PoC deltas, deliberate: (1) eye pose
+offsets along view-right — correct at any yaw, a fix not a
+divergence; (2) per-eye buffers + merge pass (modes as data:
+ANAGLYPH exists for glasses-free verification, SIDE_BY_SIDE emits
+Aukio's exact output shape) at the cost of one full-frame copy —
+Aukio's single-buffer write-directly-into-halves is the recorded
+perf optimization (a raster viewport parameter later, not a
+redesign). Process note: the output format was chosen BEFORE
+checking the oracle's design — backwards; the rewrite rule is
+check-first.
+
+** Slice 6: consumers
+:PROPERTIES:
+:CUSTOM_ID: slice-6
+:END:
+
+**The synthetic FO4 exam (done 2026-10-11).** Before porting the real
+loader, the exam reproduces its workload SHAPE synthetically:
+=SyntheticCitySource= (deterministic dense city, tessellated
+buildings + terrain, LOD-aware: near chunks full tess, far coarse —
+the BTR/BTO answer in miniature) and =Fo4ExamMain= (stream radius 5 =
+121 chunks, fly, measure). Verdict table (960x540, 24-core):
+
+| configuration             | live tris | frame   | heap  |
+|---------------------------+-----------+---------+-------|
+| uniform tess, no culling  | 2.15M     | 112.3ms | 788MB |
+| + chunk frustum cull      | 2.15M     | 102.7ms | 788MB |
+| + LOD (far chunks coarse) | 491k      | 46.8ms  | 210MB |
+| + honest near density     | 131k      | 24.0ms  | 86MB  |
+
+The walls, in the order they appeared — the same walls Aukio's loader
+history shows: (1) the TRANSFORM wall: every live triangle
+transformed every frame — answered by =ChunkFrustumCullPass= (order 9,
+cone test per chunk AABB, =TransformPass= skips invisible chunk
+ranges, the composite-cull port); (2) the RASTER wall at uniform
+tessellation (~85ms of the frame): answered by LOD, not by engine
+cleverness — far content must be COARSE, exactly why FO4 ships
+decimated LOD meshes. (3) Parallel binning landed in the same window
+(per-range partial bins merged in range order — ascending triangle
+order preserved per tile, byte-identical output); cost-neutral at
+this scale but removes the documented single-threaded floor.
+Transform side after cull+LOD: 9.7ms; the remaining raster time is
+the honest cost of the full-detail near ring.
+
+Scale comparison for orientation: Aukio renders the REAL spawn
+(2.14M tris, 606k queued, textures, placement complexity) at ~1.3s;
+the PoC renders its synthetic 491k at 46.8ms. Not apples-to-apples —
+but the architecture demonstrably holds the workload class, audits
+quiet, 56/56 tests green, exam frame renders clean.
+
+** TLA+ milestones (M1–M5)
+:PROPERTIES:
+:CUSTOM_ID: tla-milestones
+:END:
+
+The GI scheduler — the subsystem that produced Aukio 3D's interleaving
+bug history — is specified in TLA+ and checked with TLC *before* it is
+implemented here. Model: =Model/GiScheduler.tla=, grown in milestones:
+
+1. M1 — core loop: workers + cursor + barrier + generations, no
+   camera. Invariants: TypeOK, barrier integrity, no double-count.
+   DONE (=GiScheduler.tla=, TLC green: 510 states).
+2. M2 — rings and camera: eligibility masks, camera action, barrier
+   re-filter/re-sort, convergence-at-rest liveness.
+   DONE (=GiScheduler2.tla=, TLC green: 18.1M states, safety +
+   liveness). Findings: (a) a frozen SUPERSET work set is safe but
+   wasteful; (b) intersect-only filtering starves late ring entrants —
+   caught by TLC as a =ConvergesAtRest= counterexample, which is the
+   formal justification for catch-up (M4). Modeling note: the
+   generation cap requires gating camera moves to early generations,
+   or the cap itself produces spurious liveness violations.
+3. M3 — snapshot epochs: rebuild single-flight, harvest/restore.
+   DONE (=GiScheduler3.tla=, TLC green: 31.4M states). Design results
+   TLC forced: (a) epoch-tagged writes discard stale completions, but
+   the barrier itself must NOT be epoch-gated — any worker may advance
+   it once =inFlight= drains, or a mid-generation rebuild deadlocks
+   the bake; (b) the rebuild must never touch =inFlight=. Non-vacuity:
+   adding =inFlight := 0= to the rebuild reproduces the 2026-09-27
+   production bug (counter goes negative) as a 5-state trace.
+   PlusCal pitfall: assignments inside one label are SEQUENTIAL (later
+   RHS reads earlier updates), not simultaneous.
+4. M4 — catch-up: post-convergence local bake for late entrants.
+   DONE (=GiScheduler4.tla=, TLC green: 51.0M states, safety +
+   liveness). Design results TLC forced: (a) convergence certifies a
+   RECORDED eligibility set (=eligibleAtConv=), not "whatever is
+   eligible now" — a post-convergence camera move would otherwise
+   falsify the certificate instantly; (b) workers may halt only once
+   the camera process is provably done (=camDone=) — a halt raced
+   against a disturbance stranded a queued entry forever (TLC
+   counterexample: halt then disturb, nobody left to drain); (c)
+   catch-up check-and-grab must be ONE atomic step (a separate decide
+   and grab deadlocks on the emptied queue — the Java queue poll has
+   to be the atomic op); (d) catch-up never touching =gen= is now
+   enforced by =ConvFrozen= — probe with =gen := gen + 1= in catch-up
+   violates it in 700k states.
+5. M5 — movement policy variants, chosen policy made normative.
+   DONE (=GiScheduler5.tla=, one model, =Policy= constant, configs
+   =GiScheduler5_p1.cfg= / =_p2.cfg=). Survivor table:
+
+Survivor table, the policy-1 decision and the normative rules:
+=Concurrency= → =The GI scheduler's normative rules=.
+
+** GI radiometry (stage 1) over the substrate
+:PROPERTIES:
+:CUSTOM_ID: gi-radiometry
+:END:
+
+The hypothesis test of this whole repo: can real GI live behind the
+substrate contract? Landed 2026-10-10:
+
+- *New tables* (substrate, additive): =LightStore= (point lights),
+  =GiStore= (per-triangle gray shade, null = GI off and raster is
+  bit-identical to pre-GI).
+- *New system files*: =systems/gi/GiPass.java= (owns the scheduler
+  lifecycle, supplies radiometry), =systems/gi/Raycast.java=
+  (Möller–Trumbore brute force), =systems/gi/EntryWork.java= (the
+  scheduler's work seam).
+- *Seam edits* (one-time, documented): GiScheduler's abstract work
+  became an =EntryWork= call, its epoch-guarded fold now writes the
+  computed shade — stale-epoch results are discarded, never written
+  (rule 7 made literal); RasterPass gained the single light-multiply
+  line (+shadeColor helper). No pass references another pass.
+- GiPass is NOT in the ServiceLoader registry on purpose: GI is a
+  background system driven over many frames, not a per-frame pass.
+
+Semantics: stage-1 centroid direct light only (Lambert + shadow ray per
+light, two-sided normals). Render: =RenderMain out.png gi= bakes to
+quiescence then renders (fog-free explicit pipeline for readability).
+Tests: GiPassTest pins occluded-darker-than-lit and shade stability.
+
+Limits at the time of this entry, with later status: per-texel lightmaps
+(stage 3) — landed 2026-10-11; bounces (stage 2+) — landed 2026-10-10/11;
+BVH acceleration — landed 2026-10-11 (slice 2); light-signature
+invalidation — still open (a rebuild harvests all shades).
+
+** GI: stages 1+2 (2026-10-10)
+:PROPERTIES:
+:CUSTOM_ID: gi-stages-1-2
+:END:
+
+Radiometry lives behind the contract as: =GiPass= (scheduler lifecycle +
+gen-aware =EntryWork=), =Raycast= (Möller–Trumbore + nearest-hit), and
+the RGB =GiStore= substrate table (direct / cur / prev; =prev= is the
+frozen gather field and the display field, flipped by the scheduler's
+=barrierListener=). Convergence = structural (eligibility stable) AND
+radiometry vote (=meanDelta < 2=) — the scheduler's
+=convergenceVote= hook. Bounce: 8 deterministic Fibonacci hemisphere
+rays per entry, albedo × prev at the hit, dim sky per miss.
+
+The thin-geometry convention: surfaces are authored TWO-SIDED (both
+windings). The cull keeps exactly one per view (no z-fight); GI gathers
+with the normal that points where the bounce goes. This bit hard once:
+bounce follows the raw winding normal, so a single-wound floor gathers
+downward — duplicate faces instead of "fixing" winding per system.
+
+Tests: 14/14 green, incl. =GiBounceTest= (red wall bleeds onto near
+floor, R−B margin > 4 vs control; bake bit-deterministic) and the
+updated =GiPassTest= (shadow floor = ambient, lifted further by bounce).
+Demos: =RenderMain ... gi= (temple), =CornellDemoMain= (bleed image).
+
+** GI: lightmaps (2026-10-11)
+:PROPERTIES:
+:CUSTOM_ID: gi-lightmaps
+:END:
+
+Per-texel direct lighting. =LightmapStore= (substrate): orthogonal
+bounding-rect frame per triangle (longest edge = u-axis; corners
+=(cornerU/V[k])= per ORIGINAL vertex so the raster's barycentric uv
+matches the bake's frame), CSR texels, RGB shades. Frames allocated
+single-threaded up front (concurrent lazy allocation would race texel
+offsets); the store is epoch-local and swapped atomically on rebuild —
+stale workers write the orphan, same discipline as the real snapshot.
+The scheduler is untouched (fold guards the centroid shade; texels live
+in the epoch store).
+
+The one rule that bit already: texels outside the triangle are
+NEIGHBOR-FILLED from computed texels (=fillInvalidFromNeighbors=),
+never centroid-filled. A centroid fill paints the triangle's average
+into its edge band — on a shadowed triangle that band is uniformly
+dark, and the raster reads it as a trail of dark dots marching along
+the hypotenuse (the artifact in the first lightmap demo render).
+Hypothesis confirmed by dumping baked texels as ASCII (pattern correct,
+edges dark) and re-rendering after the fill change (dots gone).
+
+Tests: =LightmapTest= (blocker casts a per-texel gradient,
+min < max − 60 across ONE triangle; texels bit-deterministic).
+Demo: =LightmapDemoMain= (plate shadow with real edges).
+
+** GI: stage 4 — bounce into lightmaps (2026-10-11)
+:PROPERTIES:
+:CUSTOM_ID: gi-stage-4
+:END:
+
+Per-texel gather. =LightmapStore= is double-buffered: =cur= (being
+accumulated) and =prev= (frozen at the barrier — gather AND display
+field), plus =direct= (gen-0 term bounce adds onto). Flip and
+convergence vote ride the existing scheduler hooks (barrier flips BOTH
+the centroid and the texel buffers; the vote is centroid delta AND
+texel delta). Gather rays (4 per texel, deterministic Fibonacci) sample
+the hit triangle's LIGHTMAP texel through its frame via hit-uv from
+=Raycast.nearest(..., uvOut)=, centroid prev as fallback.
+
+The structural rule this proves: gather reads ONLY the frozen prev
+field. In Aukio that was the =refine()=-races-=gatherTotal()= bug
+class; here the freeze is one buffer swap owned by the barrier
+listener, and a stale worker's writes land in the orphaned epoch store.
+Late entrants: catch-up bakes centroid + texels and publishes both
+(=publishEntry= per triangle) via the new =catchUpListener= hook — the
+third listener, same family as barrier/rebuild.
+
+Tests: =LightmapBounceTest= (red wall bleeds into near floor TEXELS,
+R−B margin > 3 vs far texels; texel bake bit-deterministic). Shadow
+interiors visibly lifted in the re-rendered lightmap demo.
+
+** GI: colored light, lit-side bounce, filtering, falloff (2026-10-14)
+:PROPERTIES:
+:CUSTOM_ID: gi-quality-stage5
+:END:
+
+Driven by the HouseDemo port (=aukio-3d-demos-2=), which needs colored
+lamps and readable light pools. Four engine changes, each following the
+big-engine pattern rather than a demo-local hack:
+
+1. *Colored direct light.* gen 0 is per channel:
+   =shade_c = AMBIENT + (light_c − AMBIENT)·dot·att=, max-pooled over
+   lights. White light reproduces the former grayscale model exactly, so
+   every pre-existing pin held. Intensity lives folded into the
+   =LightStore= color channels (may exceed 255; =pack()= clamps).
+2. *Distance falloff.* =att = 1/(1 + 0.0001·d²)= — the legacy
+   =LightingManager= curve, i.e. the standard point-light window.
+   Without it, folded intensity paints whole rooms in a uniform tint;
+   with it, lamps read as pools. Demos now pass legacy color×intensity
+   verbatim, no tuning constants.
+3. *Lit-side bounce hemisphere.* =CullPass= keeps faces whose winding
+   normal points AWAY from the viewer, so render-visible geometry's raw
+   normal gathers from behind the wall. Gen 0 now votes a per-triangle
+   =litSide= from light directions at the centroid, and bounce rays
+   gather into the hemisphere of =litNormal()= (the side that received
+   direct light). The vote is GEOMETRIC, not visibility-gated: the lit
+   side is where emitters ARE, not whether the centroid sees them.
+   (First version gated on unshadowed: a floor tri fully shadowed at its
+   centroid — sofa-shadowed, user bugreport 20261014-221955 "why is
+   this polygon dark" — got no vote, defaulted to the winding-normal
+   side, gathered bounce from the void below the floor, and baked
+   nearly black while its neighbors were lifted. Shadow belongs to
+   energy, not orientation.) Pinned by =GiLitSideTest= (red tint,
+   downward-wound floor lit from above, fully-shadowed centroid).
+4. *Display-side lightmap filtering.* =RasterPass= samples
+   =LightmapStore.sampleBilinear()= (standard lightmap filter) instead
+   of nearest — nearest rendered per-texel bake noise as a checkerboard.
+   Invalid (outside-triangle) texels are now filled by DILATION (copy
+   the nearest computed texel, iterated) instead of neighbor-averaging:
+   averaging darkened gradients and survived bilinear as dotted quad
+   seams.
+
+*Failed experiment, pinned:* per-generation Fibonacci seed rotation
+(=gen·7919= offset) breaks the convergence vote — per-generation
+sampling noise keeps =meanDelta= above threshold and =isConverged=
+never fires. Cross-generation averaging was rejected too (dilutes
+late-bounce Neumann energy). Denoising stays display-side only.
+
+Verified: =xvfb-run -a mvn test= 132/132 (incl. new =GiLitSideTest=,
+3 pins); GI oracle re-baselined 12.220% (meanAbs 11.62, both engines
+converged; drift = lit-side + falloff + bounce gains, all named
+classes); HouseDemo oracle in =aukio-3d-demos-2/oracle/=.
+
+** Perf: why v2 is slower than v1 at 4K (2026-10-21)
+:PROPERTIES:
+:CUSTOM_ID: perf-4k-raster
+:END:
+
+Trigger: =bugreport-20261021-210017= — HouseDemo, 3840x2091,
+=measured.fps = 31.1= unlocked, versus legacy Aukio 3D's HouseDemo
+"much higher" at the same resolution.
+
+*Measured (both engines, same Xvfb 4K display, blitted fps, unlimited
+cap, GI converged):* v2 29-30 fps; v1 70-75 fps. Frame decomposition at
+the bug pose (headless, 100-frame runs):
+
+| phase        | v2 e3d2 | v1 legacy |
+|--------------+---------+-----------|
+| clear        | 3.9-4.1 | 3.2       |
+| transform    | 0.3     | 1.2       |
+| sort + bin   | 0.2     | 1.0       |
+| raster/paint | 23.5    | 5.4       |
+| total        | 27.9    | 10.8 ms   |
+
+So the whole regression lives in =RasterPass=, not in the pipeline,
+present path or GI scheduling (GI is quiescent in both runs).
+
+*Measured causes, in order of weight:*
+
+1. *The lightmap fetch.* Replacing =sampleBilinear()= with a constant
+   drops the raster 23.5 → 6.2 ms at 4K: ~70% of the raster is the
+   4-tap bilinear sample of the per-triangle lightmap, done in doubles
+   with two clamps and two double→int conversions per fragment, on
+   ~9.9M fragments/frame. Legacy precomposites lightmap × albedo at
+   bake time and fetches ONE texel with mip selection.
+2. *Per-fragment attribute interpolation.* 2 edge functions × 1
+   division each + 4 perspective attributes recomputed from
+   =ScreenStore.at()= per fragment (six aliased array reads JIT cannot
+   hoist because the pixel/depth writes alias the same arrays) + 1/w.
+   Legacy walks spans: weights and attributes STEP by a constant per
+   pixel, one division per 16-pixel block.
+3. *Depth buffer width.* =double[]= depth is 66 MB at 4K versus
+   legacy's =float[]= 33 MB; the serial =ImageStore.clear()= alone is
+   ~4 ms/frame. (Not changed here — it moves the whole engine's depth
+   precision contract.)
+4. *Not* overdraw/early-z: measured 9.9M fragments over 8.03M pixels
+   (1.23x) and only ~6% depth-rejected, i.e. the interior view has
+   almost no hidden surfaces. Front-to-back ordering was measured and
+   buys nothing here; it stays a lever for dense scenes only.
+
+*Landed:* span-incremental =rasterizeSubTriangle= — row-start weights
+use the same closed form as before, then step in x (weights, depth,
+and the four attribute numerators). This removes the per-fragment edge
+divisions and the aliased attribute reads. Raster 23.5-24.6 →
+17.4-19.1 ms (min of runs, −20%), live 29.5 → 32.5 fps. Output changes
+only where a fragment sits within ~1e-15 of a coverage/depth tie.
+
+Verified: =xvfb-run -a mvn test= 132/132; flat temple oracle
+byte-identical (688 px, 0.224%, meanAbs 0.19); GI oracle 37515 px
+(12.212%, meanAbs 11.62) vs the 37541 committed baseline; HouseDemo
+oracle 68.380% → 67.889%, meanAbs 29.01 → 28.95.
+
+*The remaining lever* was the lightmap fetch (item 1) — landed the same
+day in the *Display path* section below, together with a float depth
+buffer and a render/present buffer pool.
+
+*Measured headroom for that change (span loop in place, same 4K pose):*
+one-tap sampling instead of the 4-tap bilinear — the ceiling a
+precomposited light×albedo texture can reach — takes the raster from
+17.7 to *9.4 ms*; the loop floor with the fetch removed AND the
+attribute interpolation dead-coded is 5.7 ms. Two anti-assumptions
+worth keeping: 8.8 fixed-point bilinear weights are *slower* than the
+double version in this loop (raster 17.7 → 23.4 ms — the cost is load
+latency plus register pressure, not FP math), and a global near-to-far
+triangle sort buys nothing (see item 4). Parallelising =ImageStore.clear=
+— 3.8 ms of the frame, serial today — is the one cheap independent win
+left on the platform side.
+
+** Display path: composite light×albedo, single tap (landed 2026-10-21)
+:PROPERTIES:
+:CUSTOM_ID: perf-display-composite
+:END:
+
+*Requirement (user): v2's FPS must not fall below Aukio 3D v1's.* The
+diagnosis above put the whole gap in the raster's lightmap fetch, so the
+display path was rebuilt as v1's:
+
+1. *Composite display texture.* =LightmapStore= gained =baseTexels= (the
+   triangle's albedo — color × texture — resampled ONCE per epoch in the
+   lightmap's own frame; the frame corners are always a permutation of
+   (0,0), (1,0), (apexU,1), so the barycentric weights are closed form
+   and no mapping table is needed) and =texelsComposite= (=baseTexels ×
+   texelsPrev=), refreshed inside the existing =flip()= /
+   =publishEntry()= hooks so the display still follows the bake. The
+   raster samples ONE texel for opaque lightmapped triangles — no
+   filtering, no base multiply, no texture multiply, no shade pass.
+   Translucent triangles keep the multiplicative path (a composite is
+   opaque by construction), and =texelsPrev= stays pure light because GI
+   gathers read it.
+2. *Float depth buffer.* =ImageStore.depth= is =float[]= (was =double[]=):
+   33 MB instead of 66 MB at 4K — depth traffic is what the raster is
+   actually bound by.
+3. *Render/present overlap.* =AwtShell= keeps a pool of =ImageStore=
+   buffers (3-4) instead of swapping between two, so the loop renders the
+   next frame while the present thread is still blitting. Two buffers
+   serialized them: live was render + present instead of max(render,
+   present).
+4. *Native blit format.* The presented buffer is an =ImageStore= built
+   from a =TYPE_INT_RGB= BufferedImage's own backing array, so the
+   page-flip needs no per-pixel format conversion (the previous 24-bit
+   packed raster view did).
+
+*Measured (3840×2091, HouseDemo, GI converged):*
+
+| phase | before | after |
+|-------+--------+-------|
+| raster | 17.7 ms | 6.5-8.5 ms |
+| clear | 3.9 ms | 3.1-3.3 ms |
+| frame (headless) | 22.2 ms | 10.0-12.0 ms |
+| v1 for reference | 10.8 ms | 10.2-10.3 ms |
+
+Live blitted FPS at 4K under Xvfb: 29-30 → *55-63* (v1: 71-75). Under
+Xvfb the 4K page-flip itself costs ~13.5 ms/frame, which is the ceiling
+that remains; the user's display has a GPU compositor, so the live number
+there is expected to be closer to the headless one.
+
+*Quality trade (accepted, and measured):* single-tap sampling brings back
+per-texel mottling and a faint dotted grid on quad-cell boundaries — at
+4K one lightmap texel is ~8 screen px. Proven to be the SAMPLING and not
+the composite: plain nearest light sampling with no composite shows the
+identical dots. v1 has the same character; the smooth look of 2026-10-14
+(bilinear) is what the user traded away for FPS parity.
+
+Verified: =xvfb-run -a mvn test= 132/132 (three tests updated for the
+float depth type); flat temple oracle byte-identical (688 px, 0.224%,
+meanAbs 0.19); GI oracle 36518 px (11.887%, meanAbs 11.62) against the
+37541 baseline; HouseDemo dual-engine oracle 68.688% (meanAbs 28.99)
+against the documented 68.6%.
+
+*Rejected alternatives, with numbers:* 8.8 fixed-point bilinear weights
+are SLOWER than the double version in this loop (17.7 → 23.4 ms);
+parallelising =ImageStore.clear= alone looks faster headless (3.1 → 2.4
+ms) but starves the 4K blit and DROPS live FPS (63 → 50), so the clear
+stays serial; a global near-to-far triangle sort buys nothing (only ~6%
+of fragments are depth-rejected).
+
+*Costs:* two extra int columns per lightmap texel (+40 MB at FO4 scale)
+and a re-composite pass per barrier during bakes.
+
+*** Frozen view bug: flashing between the real frame and a blank one (same day)
+:PROPERTIES:
+:CUSTOM_ID: perf-frozen-view
+:END:
+
+Symptom (user, live demo): "every second frame is static image and only
+every other frame shows the place I want to fly" — flashing.
+
+Diagnosis: under Xvfb with a STATIC camera, screen captures alternated
+between the correct frame and a uniform blank one, while the shell
+reported that every PRESENTED buffer was fully rendered (1395 presents,
+0 blank). So the corruption was in the display view, not the render.
+
+Cause: =viewOf()= cached a BufferedImage per ImageStore, and its
+fallback path COPIED the pixels (=setRGB=) instead of sharing the array.
+The resize path cleared the view map AFTER allocating the new buffer (so
+the new buffer lost the zero-copy view it had just registered), and the
+first blit then built a COPY of a buffer that was still blank — frozen
+forever, and the buffer pool rotated it back onto the screen every few
+frames. Two buffers hid it (the frozen one was reused less often and the
+old wrap() was zero-copy); the pool made it a steady flash.
+
+Fix: (1) =viewOf()= always returns a ZERO-COPY view (packed 32-bit
+raster over the store's own =int[]=), never a copy; (2) the resize path
+clears the maps BEFORE allocating the new store, so its registered view
+survives. Verified: 12/12 screen captures identical with a static camera
+at 1080p and at 4K (mean 90, std 38 — a real frame), and 6/6 captures
+distinct with a rotating camera (monotonic change, no repeats, no blank
+frames). 132/132 tests, flat oracle byte-identical, GI oracle 36529 px
+(11.891%).
+
+** Raster: span walk + per-tile clear (2026-10-21, checkpoint 232304)
+:PROPERTIES:
+:CUSTOM_ID: perf-span-walk
+:END:
+
+Trigger: user checkpoint (=bugreport-20261021-232304=, 3840x2091, a
+120 Hz panel set to 120, still ~60 fps at 4K while v1 was faster; a
+smaller window reached 800 fps, so it is render-bound, not refresh
+bound).
+
+Measured at that pose (4K, GI converged, 100-frame runs):
+
+| phase | before | after |
+|-------+--------+-------|
+| clear (serial, app loop) | 3.0 ms | 0 (folded into the raster) |
+| raster | 7.3 ms | 5.1-5.2 ms |
+| transform + cull | 0.4 | 0.31 |
+| *frame* | *10.7* | *5.4-5.5* |
+| v1 legacy, same bench | 8.8 (its clear is serial there too; ~6.2 live) | |
+
+Two changes:
+
+1. *Per-tile clear* (legacy =clearSegmentPixels=). The app loop no longer
+   clears the frame: each tile task fills its own pixel + depth band with
+   =image.background=, then rasters it. This removes a cold 3 ms pass over
+   99 MB and keeps the depth lines hot for the same thread. =ImageStore=
+   now RECORDS the colour from =clear(bg)= so harnesses that set their own
+   background keep working. RasterPass owns the frame clear — an app
+   without RasterPass must clear itself.
+2. *Span walk.* The triangle is convex, so the covered x range on a
+   scanline is ONE interval: solve the three barycentric inequalities per
+   ROW (3 divisions) instead of testing coverage per PIXEL (3 compares +
+   the l2 add + 4 accumulator steps). The loop then walks only covered
+   pixels — 7.3 → 5.1 ms, and it also stops paying for the bbox corners
+   the old per-pixel test rejected.
+
+Result: v2's render is now *faster than v1's* at this pose (5.4 vs ~6.2
+live-equivalent), where this morning it was 2x slower.
+
+*Named drift* (accepted): the flat temple oracle moves from 688 px to
+1743 px (0.567%, meanAbs 0.41). Row anchors now sit on the first covered
+pixel and coverage is solved in closed form, so depth ties resolve on a
+slightly different rounding path — ulp-level, visually identical. GI
+oracle 36416 px (11.854%), HouseDemo oracle 68.119% (meanAbs 28.95).
+
+*Still v1's structural advantage:* =ViewPanel= pipelines THREE frames
+(three frame contexts, transform on the render thread while the paint of
+the previous frame and the blit of the one before run concurrently, gates
++ mailbox). v2 renders a whole frame inline on the loop thread; only the
+blit overlaps. If live FPS still trails v1 on a fast display, that async
+frame pipeline — not any remaining per-pixel work — is the next thing to
+port.
+
+** Frame pipeline + pool utilisation (2026-10-21/22, measured, NOT enabled)
+:PROPERTIES:
+:CUSTOM_ID: perf-frame-pipeline
+:END:
+
+Goal from the user: unlocked rendering must keep the CPU pool above 90%
+busy; below that the pipeline is a failure regardless of FPS. Utilisation
+is now instrumented (=Parallel.busyNanos()= over =wall x poolSize=) and
+measured headless with =bench.UtilProbe= / =bench.PipeProbe= at the
+checkpoint pose, 3840x2091, GI converged, 100-frame runs, 24 cores.
+
+| variant | frame | utilisation |
+|---------+-------+-------------|
+| per-tile =Future=s, sequential (start of round) | 5.42-6.06 ms | 39-70% |
+| per-tile =Future=s, 2-slot pipeline | 5.33-5.71 ms | 73-78% |
+| ticketed raster, sequential (LANDED) | 5.33-5.61 ms | 74-75% |
+| ticketed raster, 2-slot pipeline | 6.6-7.0 ms | 74% |
+| *target* | | *>90%* |
+
+What landed:
+
+1. *=Parallel= instrumentation*: every pool task is wrapped and its busy
+   nanoseconds accumulated (a =LongAdder=), so utilisation is measured,
+   never eyeballed.
+2. *Ticketed raster* (=RasterPass=): one task per WORKER, each pulling the
+   next tile off an =AtomicInteger= until the frame is done, instead of one
+   =Future= per tile. At 4K that is 24 handoffs per frame instead of 1020.
+   Output is bit-identical (a tile is still rasterized by one thread in
+   fixed (Z, shapeId) order). Worth 62 -> 75% utilisation and ~0.1-0.2 ms.
+   At 64px tiles the per-tile =Future= version collapsed to 55% — that
+   measurement is what identified handoff cost as the idle source.
+
+What is built but NOT enabled (=Stage 10/20= + =FrameView= + =FramePipeline=):
+
+3. =FrameView= (kernel): per-frame render state — slot, =ScreenStore=,
+   target =ImageStore=, the lightmap store PINNED at frame start, and the
+   pose snapshot. =Pass= gained =stage()= (0 whole-frame, 10 geometry,
+   20 paint) and =apply(Substrate, FrameView)= defaulting to the 1-arg
+   form, so single-frame callers and all tests are untouched.
+4. =Substrate.screens[2]=: two transform slots, so two frames never share
+   screen-space output. ~224 B/triangle/slot (HouseDemo 309 KB, FO4-scale
+   ~560 MB — accepted for now).
+5. =FramePipeline=: two frames in flight, each running its stages on its
+   own thread over the shared pool, slots recycled when paint finishes,
+   frames published in submission order. Enabled only with
+   =-De3d2.pipeline=true=.
+
+Why it is not enabled: with per-tile =Future=s it was a wash (5.5 vs 5.4
+ms); with the ticketed raster it was WORSE (6.6-7.0 ms) because each stage
+now submits only =poolSize= tasks, so the queue has no slack to cover the
+stage joins. Live under Xvfb it was 15.8 fps against 45.6 sequential —
+image-acquisition starvation (front/mailbox/presenting aliasing in a
+4-buffer pool), not a small regression.
+
+Where the remaining idle is (measured, not guessed): a single raster phase
+is ~80% efficient on its own — the loss is stage joins (transform -> cull
+-> binning -> tiles, four tails per frame) plus each tail's last tile.
+Overlapping frames cannot hide joins that every frame has at the same
+time and the same place. The structural fix is a PERSISTENT work-queue
+scheduler: one queue of items (transform chunk, cull chunk, tile) whose
+dependencies clear counters, workers never exiting between stages, so
+nothing is submitted per frame at all. That is the next engineering step,
+not another parameter tweak.
+
+Oracles unchanged: flat temple 1743 px (0.567%), GI 36510 px (11.885%,
+bake variance), HouseDemo 68.562% (meanAbs 29.00, bake variance).
+
+** The 90% utilisation target, measured (2026-10-22)
+:PROPERTIES:
+:CUSTOM_ID: perf-utilisation-smt
+:END:
+
+The user's bar was "CPU pool above 90% busy while unlocked". Sizing the
+pool with -XX:ActiveProcessorCount answers it directly (4K, checkpoint
+pose, 100 frames, GI converged):
+
+| pool threads | frame | utilisation |
+|--------------+-------+-------------|
+| 12 | 6.26-6.44 ms | *95.7%* |
+| 16 | 5.88-5.92 ms | 89-90% |
+| 20 | 5.62-5.67 ms | 84% |
+| 24 (all logical) | 5.45-5.83 ms | 68-72% |
+
+=lscpu=: AMD Ryzen AI 9 HX 370, *12 cores x 2 SMT = 24 logical*. The pool
+is SATURATED at the physical core count (95.7%); the SMT siblings add only
+~13% throughput, so "utilisation of 24 threads" cannot exceed ~72% no
+matter what the scheduler does. The 90% bar is met on the correct
+denominator (physical cores) and unreachable on the naive one (logical).
+
+Two scheduling experiments followed from this, both measured, neither
+shipped as the default:
+
+1. *Tile order (LPT)*: handing out the heaviest bins first made the frame
+   12% SLOWER (5.45 -> 6.1 ms). Row-major tile order keeps pixel, depth
+   and lightmap accesses cache-local, which beats balancing the tail. The
+   tail is not where the time goes.
+2. *Frame stream* (=StreamedPass=, =FrameStream=): every pass contributes
+   waves of items, wave k+1 is submitted by the last worker of wave k —
+   no joins, no stage threads, two to four ScreenStore slots. Output is
+   identical (temple 1743 px, GI 36447 px, HouseDemo 68.400%), tests
+   132/132. Measured: headless 5.45-5.83 ms against 5.33-5.61 inline;
+   live 44 vs 48-49 fps. Frame slots 2/3/4 gave 5.83/5.42/5.65 ms — inside
+   run-to-run noise. Kept as =-De3d2.stream=true=.
+
+Conclusion: at this frame size the work is already spread across every
+physical core; the remaining cost is per-pixel work (lightmap sampling,
+span setup), not scheduling. Making the frame FASTER needs fewer
+instructions per pixel or a longer frame, not another overlap.
+
+** Why 24 threads only buy 83%: the CPU is 4 fast + 8 compact cores
+:PROPERTIES:
+:CUSTOM_ID: perf-hybrid-cores
+:END:
+
+=lscpu= says 12 cores x 2 SMT = 24 logical, but they are not equal:
+the Ryzen AI 9 HX 370 is 4 Zen 5 cores + 8 Zen 5c ("compact") cores.
+Measured per-thread throughput (interpreter loop, pinned with =taskset=,
+40M iterations, Mops/s):
+
+| cpu | Mops/s | | cpu | Mops/s |
+|-----+--------+--+-----+--------|
+| 0-3 (Zen 5) | 86-100 | | 12-15 (their SMT) | 95-99 |
+| 4-11 (Zen 5c) | 60-74 | | 16-23 (their SMT) | 63-82 |
+
+So 8 of 24 threads run ~35% faster than the other 16, and the pool's
+capacity is ~83% of 24 uniform threads. This is the missing 17% between
+the 68-72% utilisation measured at 24 threads and the 95.7% measured at
+12: utilisation of LOGICAL threads is capacity-blind and cannot be used
+as a target. Frame time is the honest measure.
+
+Two conclusions that came out of it, both measured:
+
+1. *Per-item overhead is paid on compact cores too.* Coarser tiles won:
+   at 4K, 192px tiles = 5.21-5.23 ms against 128px = 5.39-5.54 ms (2.5-3%).
+   At 1080p the same 192px left only 2.5 tiles per worker and LOST 20%
+   (2.27 vs 1.83 ms). The tile edge is therefore chosen per frame:
+   =sqrt(width*height / (9 * threads)) clamped to 64..256=, which gives
+   192 at 4K, 144 at 1440p, 64-96 at 1080p. =-De3d2.tile=N= overrides it.
+2. *Dynamic ticketing is the right shape.* Fast threads pull more tiles;
+   a fixed partition (the old =forRange=) cannot adapt, which is why the
+   ticket loop alone was worth 13 points of utilisation.
+
+Named drift: the temple oracle diff moves 1743 px (0.567%) -> 2813 px
+(0.916%), meanAbs 0.64. Cause is the same as before — span anchors sit on
+tile boundaries, and the tile size changed. Verified: forcing the old
+size back (-De3d2.tile=128) restores exactly 1743 px, so nothing else in
+the raster path moved.
+
+** Lightmap is already free (2026-10-22, measured, negative result)
+:PROPERTIES:
+:CUSTOM_ID: perf-lightmap-free
+:END:
+
+The plan was to cut the lightmap fetch (16-bit texels, mips, spatial
+addressing) because an early ablation — before the span walk — showed
+replacing =sampleBilinear= with a constant dropped the raster from 22.8 to
+6.2 ms. That was then; this is now. Ablations at 4K, checkpoint pose, 100
+frames (=-De3d2.nolightmap= / =-De3d2.notexture=, both verified to change
+the rendered pixels):
+
+| variant | frame |
+|---------+-------|
+| base | 5.30-5.36 ms |
+| lightmap off | 5.37 (one 6.37 outlier) |
+| albedo texture off | 5.29-5.38 ms |
+| both off | 5.65-5.67 ms |
+
+Removing the entire lightmap sample changes nothing measurable. The two
+cheap things worth doing were done:
+
+1. =LightmapStore.Sampler= — binds a triangle once (hoists width/height/
+   texelOffset) and caches the 4 texels while the sample stays in one
+   texel cell (~8 screen px at 4K). Arithmetic unchanged, bit-identical
+   output (temple oracle 2813 px before and after). A/B: 5.42 vs 5.50 ms
+   median, i.e. ~1.5% — real but small.
+2. Tile size scaled with resolution (see perf-hybrid-cores), worth 3%.
+
+So the per-fragment lightmap work is now hidden by memory-level
+parallelism: the frame is bound by the pixel pipeline as a whole (pixel +
+depth writes, span bookkeeping, the perspective divide), not by any one
+fetch. Further lightmap engineering (16-bit texels, mip chains) would buy
+nothing on this machine — the ablation says so before any code is written.
+
+If per-pixel work is attacked again, the measurable candidates are the
+per-fragment =1.0/w= divide and the 8 bytes/pixel of pixel+depth writes,
+not the lightmap.
+
+** Per-fragment divide: also free (2026-10-22, measured, reverted)
+:PROPERTIES:
+:CUSTOM_ID: perf-divide-free
+:END:
+
+Second candidate from the lightmap ablation: the perspective divide
+(=1.0 / w= per fragment, ~13 cycles latency on Zen 5) looked like the
+biggest single instruction left in the span loop. Replaced it with a
+Newton-refined incremental reciprocal (exact divide for the row's first
+pixel, then =invW *= 2 - w*invW=), which is 3 ops instead of a divsd and
+differs from the exact quotient only in the last ulp — far below 8-bit
+colour quantisation.
+
+Measured at 4K, 3 runs each: newton 5.02 / 5.14 (median) vs exact
+5.04 / 5.12 (median). No difference; =-De3d2.exactinv=true= was removed
+again together with the estimate, so the raster keeps the exact divide
+and its bit-identical output.
+
+Conclusion after two ablations: the 4K frame at this pose is limited by
+the pixel pipeline as a whole — writes, span bookkeeping, depth test — not
+by any single per-fragment operation. Both "obvious" costs (lightmap
+sample, perspective divide) are already amortised across 24 threads. What
+remains is doing FEWER pixels or FEWER bytes per pixel, and both are
+image-quality decisions, not engineering ones.
+
+** Bug: 1-px dark stripes at every tile edge (bugreport-20261022-085130)
+:PROPERTIES:
+:CUSTOM_ID: bug-tile-edge-stripe
+:END:
+
+Report: "vertical dark stripes all across the screen", HouseDemo 3840x2091
+at camera (-808.00, 63.33, 86.79, 0.18, -0.03), 36.9 fps. Column analysis
+of the screenshot: 19 darker columns, spacing exactly 192 px = the tile
+size, each at =tile*192 - 1= — the LAST column of every tile.
+
+Cause (span walk): =xa/xb= are EDGE coordinates (pixel p covers [p, p+1))
+but were initialised from =minX/maxX=, which are pixel INDICES:
+
+  xb = maxX;                      // 191, an index
+  rowMax = floor(xb - 0.5);       // 190 -- the tile's last column dropped
+
+So every tile cleared its last column and never rasterized it: one pixel of
+background at each vertical tile border. Fix: =double xb = maxX + 1.0;=.
+
+Evidence it is the real cause, not a cosmetic mask:
+
+- reproduced headless at the report's pose: 20 dark columns at 191, 383,
+  ... before, 0 after; same at =-De3d2.tile=128= (127, 255, ... -> none);
+- the temple oracle diff drops 2813 px (0.916%) -> *688 px (0.224%)*;
+- the diff is now IDENTICAL at tile sizes 128, 192 and 256, whereas before
+  it scaled with the tile count (1743 at 128, 2813 at 192).
+
+Correction to the two entries above that blamed span anchoring for the
+1743/2813 temple diff: that drift was this bug. v1 (legacy) never dropped
+the column, which is why more tiles meant more deviation.
+
+Tests 132/132, frame time unchanged (5.33-5.43 ms at 4K).
+
+** Fixed: lightmap border texels were never baked (dotted seams)
+:PROPERTIES:
+:CUSTOM_ID: bug-lightmap-border-unbaked
+:END:
+
+bugreport-20261022-085712 ("V1 does not have those dark edges on the
+tiles, why V2 has them?"): a dotted lattice of dark squares, one lightmap
+texel wide, along every triangle boundary. A per-column crack scan found
+no 1-px dips, so it was never a raster gap.
+
+Measurement (bench.LmProbe, camera 839.62, 165.41, -124.20, 3.15, -0.02):
+
+  DIRECT field   ring=25.8  inner=53.3   (+106.8% darker rim)
+  full field     ring=105.4 inner=118.8  (+12.7%)
+  luminance by distance from the grid edge: d0=104.8 d1=106.0 d2=107.3
+  d3=109.5 ... d9=129.1  (a wide ramp, not a one-texel rim)
+
+Dead ends, each measured and discarded: shadow-ray bias 1e-3 / 0.02 / 0.05
+/ 0.1 gives IDENTICAL numbers (a trace line proves the value was in use),
+so grazing self-hit is not the cause; a one-texel border bleed of
+texelsPrev did not change the lattice; the e3d2.notexture ablation had no
+effect (that flag is inert in this build).
+
+Cause: a triangle's texel grid covers the whole (u,v) square but the
+triangle only covers the region under the (1,0)-(apexU,1) edge, so the
+OUTER RING of every grid lies outside the surface. =bakeTexels= and
+=bounceTexels= skipped those texels (=if (!inside) continue=), leaving them
+with bounce-only light and NO direct light, and bilinear filtering at a
+triangle edge samples exactly that ring. =debugTexel= showed it directly:
+
+  texel 0,16  inside=false valid=true direct=0         light0 dot=0.887 occluded=false
+  texel 16,16 inside=true  valid=true direct=15256210  light0 dot=0.952 occluded=false
+
+Fix: =LightmapStore.clampToTriangle(tri,u,v,out)= — the closest point inside
+the triangle's UV triangle (three edge projections). Both bake stages now
+bake a border texel at its nearest interior point instead of skipping it
+(the standard lightmap border extension). Storage stays on the texel grid.
+
+After the fix: DIRECT rim +0.3% (92.6 vs 92.9), full field +0.7% (141.9 vs
+142.9), and the edge profile is flat (d0=141.9 ... d9=143.5). The lattice
+is gone from the render; the surface is also brighter overall because the
+ring now carries real light instead of bounce-only.
+
+Residual, not fixed: a faint BRIGHT hairline along some boundaries (the two
+triangles' border extensions do not agree exactly). Cross-triangle seam
+averaging would remove it; it is much less visible than the dark seam was.
+
+** Blit: overlapping render with present buys nothing (measured)
+:PROPERTIES:
+:CUSTOM_ID: perf-present-overlap-neutral
+:END:
+
+The bugreport-20261022-085712 log shows render=15.4 ms, blit=18.4 ms at
+4K, so the present path looked like the next bottleneck. v1's ViewPanel
+documents that show()+sync() costs ~16 ms at 4K and releases the frame
+before them, so the same overlap was implemented here: AwtShell released
+the published image right after drawImage and before show()/sync().
+
+Measured with bench.Live2 at 3840x2091 under Xvfb, 3 runs each:
+sequential 57.4 / 57.5 / 53.9 fps (median 57.4); with the early release
+58.6 / 57.5 / 56.6 (median 57.5). No gain, so the early release was
+removed — the frame mailbox already lets the renderer work during the
+present, so there is no serialisation to remove.
+
+What IS kept: presentDrawNanos() / presentShowNanos() accessors, so the
+draw vs show+sync split can be read on the real panel where the X11
+present is much more expensive (Xvfb's is not representative).
+
+Levers that remain for a real 4K panel, none of them engine work:
+the JDK's X11 pipelines (-Dsun.java2d.opengl / xrender), a compositor
+that is not copying every frame, and fewer bytes per frame (the window is
+3840x2091x4 = 33 MB; X11 software present at ~1.8 GB/s is 18 ms by
+arithmetic, which matches the report).
+
+** Window opens maximized, like v1
+:PROPERTIES:
+:CUSTOM_ID: shell-maximized
+:END:
+
+Every v1 demo constructed =ViewFrame(title)=, and that constructor calls
+=setExtendedState(MAXIMIZED_BOTH)= — so v1 demos start maximized, not
+fullscreen. =AwtShell= now does the same in its constructor (after
+=setSize= to the substrate image), which covers every v2 demo and app with
+no app-side wiring; =-De3d2.maximized=false= keeps a fixed-size window for
+scripted runs. No new resize path was needed: the maximized geometry
+arrives as an ordinary =componentResized=, and =loopTick= reallocates the
+back buffer and recomputes =camera.scale= (=width/3=) on the loop thread.
+
+Verified live under Xvfb 1600x900 with xfwm4 as the window manager (a bare
+Xvfb has none, and then the request is silently ignored — the window stays
+at its requested size): window 1600x876, render target 640x480 ->
+1600x876, and the demo frame fills it with the scene framed as at 640x480
+(red bbox 13.0% of width vs 12.7%). Engine suite 132/132.
+
+** Black House window until input (fixed)
+:PROPERTIES:
+:CUSTOM_ID: shell-gi-repaint
+:END:
+
+The House demo opened BLACK and only showed the scene once the user moved
+the camera. Two defects composed:
+
+1. =LightmapStore= starts its display field at zero, and =GiPass= runs
+   AFTER =RasterPass= (order 30), so frame 0 renders without lightmaps
+   (visible albedo) and frame 1 — the first frame that samples them —
+   is pure black. Measured headless: that pre-bake frame is mean 0.0,
+   0.0% non-black.
+2. The render-on-demand signature had no GI state, so nothing repainted
+   when the bake landed. Frame 2 existed only if the camera moved.
+
+Fixes: both stores now carry a monotonic =version()= that bumps on
+=flip()= / =publishEntry()= and the shell signature includes it, so every
+published generation repaints (live: 8 renders during a 6 s idle window,
+was 1). And an un-baked surface displays at full irradiance (v1
+=INITIAL_IRRADIANCE=, 255, =-De3d2.initialIrradiance=) instead of black —
+display-only, the bake overwrites it at the first flip.
+
+Verified: the same pre-bake frame measures mean 194.2 at the default and
+0.0 at =-De3d2.initialIrradiance=0=; the converged HouseOracle render is
+md5-identical either way (=house-poc.png=). Engine suite 137/137; flat
+temple oracle unchanged 688 px / 0.224% / meanAbs 0.19; GI oracle meanAbs
+11.62 (pixel share 11.78-11.82%, run-to-run noise). The HouseDemo now
+becomes visible without any input.
diff --git a/Model/BvhSnapshot.tla b/Model/BvhSnapshot.tla
new file mode 100644 (file)
index 0000000..d70fdc6
--- /dev/null
@@ -0,0 +1,82 @@
+---- MODULE BvhSnapshot ----
+\* BVH rebuild race: must a query see ONE consistent tree version?
+\*
+\* The tree is abstracted to F version-tagged fields (left/right/count
+\* arrays in the real code). The builder bumps the version by rewriting
+\* each field; a query reads each field. Fix = 0 models the current
+\* code (fields swapped one by one, no atomic publish) and MUST produce
+\* a mixed-version counterexample; Fix = 1 models the RCU holder (one
+\* volatile reference to an immutable snapshot, single atomic publish)
+\* and must be MIX-FREE. Both are checked — the Fix = 0 counterexample
+\* is the non-vacuity probe.
+
+EXTENDS Integers, Sequences, TLC
+
+CONSTANTS F,          \* number of tree fields
+          Fix,        \* 0 = per-field swap (buggy), 1 = atomic publish (RCU)
+          Queries     \* query process ids
+
+VARIABLES fields,     \* per-field version tag (Fix=0 live tree)
+          published,  \* atomically published version (Fix=1)
+          captured,   \* per-query snapshot tag, read ONCE at scan start
+          reads       \* per-query sequence of tags read so far
+
+vars == <<fields, published, captured, reads>>
+
+Init ==
+    /\ fields = [f \in 1..F |-> 0]
+    /\ published = 0
+    /\ captured = [q \in Queries |-> 0]
+    /\ reads = [q \in Queries |-> << >>]
+
+\* ---- builder: version v -> 1-v ----
+\* Fix=0: rewrite fields one by one (non-atomic).
+\* Fix=1: "build" happens off to the side; publish is ONE atomic step.
+BuilderStep(v) ==
+    IF Fix = 0
+    THEN \E f \in 1..F :
+             /\ fields[f] = v
+             /\ fields' = [fields EXCEPT ![f] = 1 - v]
+             /\ UNCHANGED <<published, captured, reads>>
+    ELSE /\ published = v
+         /\ published' = 1 - v
+         /\ UNCHANGED <<fields, captured, reads>>
+
+\* ---- query q: read all F fields, one per step ----
+\* Fix=1 captures the published reference ONCE (the first read is the
+\* single volatile read); the remaining reads hit the immutable snapshot.
+QueryRead(q) ==
+    /\ Len(reads[q]) < F
+    /\ LET first == Len(reads[q]) = 0
+           tag == IF Fix = 0
+                  THEN fields[Len(reads[q]) + 1]
+                  ELSE IF first THEN published ELSE captured[q]
+       IN /\ reads' = [reads EXCEPT ![q] = Append(@, tag)]
+          /\ IF Fix = 1 /\ first
+             THEN captured' = [captured EXCEPT ![q] = published]
+             ELSE UNCHANGED captured
+    /\ UNCHANGED <<fields, published>>
+
+QueryReset(q) ==
+    /\ Len(reads[q]) = F
+    /\ reads' = [reads EXCEPT ![q] = << >>]
+    /\ UNCHANGED <<fields, published, captured>>
+
+Next ==
+    \/ \E v \in {0, 1} : BuilderStep(v)
+    \/ \E q \in Queries : QueryRead(q)
+    \/ \E q \in Queries : QueryReset(q)
+
+\* SAFETY: no query ever COMPLETES a full scan having read mixed versions.
+MixedFree ==
+    \A q \in Queries :
+        Len(reads[q]) = F =>
+            \A i, j \in 1..F : reads[q][i] = reads[q][j]
+
+TypeOK ==
+    /\ \A f \in 1..F : fields[f] \in {0, 1}
+    /\ published \in {0, 1}
+    /\ \A q \in Queries : Len(reads[q]) <= F
+
+Spec == Init /\ [][Next]_vars
+=============================================================================
diff --git a/Model/BvhSnapshot_buggy.cfg b/Model/BvhSnapshot_buggy.cfg
new file mode 100644 (file)
index 0000000..1b896f9
--- /dev/null
@@ -0,0 +1,10 @@
+SPECIFICATION Spec
+
+CONSTANTS
+  F = 3
+  Fix = 0
+  Queries = {q1, q2}
+
+INVARIANTS
+  TypeOK
+  MixedFree
diff --git a/Model/BvhSnapshot_fix.cfg b/Model/BvhSnapshot_fix.cfg
new file mode 100644 (file)
index 0000000..ddc0114
--- /dev/null
@@ -0,0 +1,10 @@
+SPECIFICATION Spec
+
+CONSTANTS
+  F = 3
+  Fix = 1
+  Queries = {q1, q2}
+
+INVARIANTS
+  TypeOK
+  MixedFree
diff --git a/Model/GiScheduler.cfg b/Model/GiScheduler.cfg
new file mode 100644 (file)
index 0000000..3ef898c
--- /dev/null
@@ -0,0 +1,11 @@
+\* Add statements after this line.
+SPECIFICATION Spec
+
+CONSTANTS
+  Worker = {w1, w2}
+  N = 4
+  MaxGen = 3
+
+INVARIANTS
+  TypeOK
+  BarrierIntegrity
diff --git a/Model/GiScheduler.tla b/Model/GiScheduler.tla
new file mode 100644 (file)
index 0000000..79d9b26
--- /dev/null
@@ -0,0 +1,125 @@
+---- MODULE GiScheduler ----
+EXTENDS Integers, FiniteSets, TLC
+
+CONSTANTS Worker, N, MaxGen
+ASSUME N \in Nat /\ N > 0
+ASSUME MaxGen \in Nat /\ MaxGen > 0
+
+Entry == 1..N
+
+(* --algorithm GI
+
+variables
+  gen      = 0,                     \* current global generation
+  cursor   = 1,                     \* next queue slot to grab
+  inFlight = 0,                     \* entries being processed right now
+  done     = [en \in Entry |-> 0],   \* last finished generation per entry
+  folded   = [en \in Entry |-> {}],  \* generations already folded per entry
+  halted   = FALSE;                 \* set when MaxGen generations completed
+
+fair process W \in Worker
+  variable e = 0;
+begin
+w1: while (~halted) do
+      await (cursor <= N /\ ~halted) \/ halted;
+      if ~halted then
+        e := cursor;
+        cursor := cursor + 1;
+        inFlight := inFlight + 1;
+w2:     assert gen \notin folded[e];          \* no double-count
+        folded[e] := folded[e] \cup {gen};    \* fold this generation's contribution
+        done[e] := gen;
+w3:     inFlight := inFlight - 1;
+        if inFlight = 0 /\ cursor > N then    \* last worker out advances the barrier
+          gen := gen + 1;
+          if gen >= MaxGen
+          then halted := TRUE;
+          else cursor := 1;
+          end if;
+        end if;
+        e := 0;
+      end if;
+    end while;
+end process;
+
+end algorithm; *)
+\* BEGIN TRANSLATION (chksum(pcal) = "fd3f6f81" /\ chksum(tla) = "e62f9f31")
+VARIABLES pc, gen, cursor, inFlight, done, folded, halted, e
+
+vars == << pc, gen, cursor, inFlight, done, folded, halted, e >>
+
+ProcSet == (Worker)
+
+Init == (* Global variables *)
+        /\ gen = 0
+        /\ cursor = 1
+        /\ inFlight = 0
+        /\ done = [en \in Entry |-> 0]
+        /\ folded = [en \in Entry |-> {}]
+        /\ halted = FALSE
+        (* Process W *)
+        /\ e = [self \in Worker |-> 0]
+        /\ pc = [self \in ProcSet |-> "w1"]
+
+w1(self) == /\ pc[self] = "w1"
+            /\ IF (~halted)
+                  THEN /\ (cursor <= N /\ ~halted) \/ halted
+                       /\ IF ~halted
+                             THEN /\ e' = [e EXCEPT ![self] = cursor]
+                                  /\ cursor' = cursor + 1
+                                  /\ inFlight' = inFlight + 1
+                                  /\ pc' = [pc EXCEPT ![self] = "w2"]
+                             ELSE /\ pc' = [pc EXCEPT ![self] = "w1"]
+                                  /\ UNCHANGED << cursor, inFlight, e >>
+                  ELSE /\ pc' = [pc EXCEPT ![self] = "Done"]
+                       /\ UNCHANGED << cursor, inFlight, e >>
+            /\ UNCHANGED << gen, done, folded, halted >>
+
+w2(self) == /\ pc[self] = "w2"
+            /\ Assert(gen \notin folded[e[self]], 
+                      "Failure of assertion at line 29, column 9.")
+            /\ folded' = [folded EXCEPT ![e[self]] = folded[e[self]] \cup {gen}]
+            /\ done' = [done EXCEPT ![e[self]] = gen]
+            /\ pc' = [pc EXCEPT ![self] = "w3"]
+            /\ UNCHANGED << gen, cursor, inFlight, halted, e >>
+
+w3(self) == /\ pc[self] = "w3"
+            /\ inFlight' = inFlight - 1
+            /\ IF inFlight' = 0 /\ cursor > N
+                  THEN /\ gen' = gen + 1
+                       /\ IF gen' >= MaxGen
+                             THEN /\ halted' = TRUE
+                                  /\ UNCHANGED cursor
+                             ELSE /\ cursor' = 1
+                                  /\ UNCHANGED halted
+                  ELSE /\ TRUE
+                       /\ UNCHANGED << gen, cursor, halted >>
+            /\ e' = [e EXCEPT ![self] = 0]
+            /\ pc' = [pc EXCEPT ![self] = "w1"]
+            /\ UNCHANGED << done, folded >>
+
+W(self) == w1(self) \/ w2(self) \/ w3(self)
+
+(* Allow infinite stuttering to prevent deadlock on termination. *)
+Terminating == /\ \A self \in ProcSet: pc[self] = "Done"
+               /\ UNCHANGED vars
+
+Next == (\E self \in Worker: W(self))
+           \/ Terminating
+
+Spec == /\ Init /\ [][Next]_vars
+        /\ \A self \in Worker : WF_vars(W(self))
+
+Termination == <>(\A self \in ProcSet: pc[self] = "Done")
+
+\* END TRANSLATION 
+
+TypeOK ==
+  /\ gen \in 0..MaxGen
+  /\ cursor \in 1..(N + 1)
+  /\ inFlight \in 0..Cardinality(Worker)
+  /\ \A en \in Entry : done[en] \in 0..MaxGen
+
+BarrierIntegrity == \A en \in Entry : done[en] >= gen - 1
+
+====
diff --git a/Model/GiScheduler2.cfg b/Model/GiScheduler2.cfg
new file mode 100644 (file)
index 0000000..55e7967
--- /dev/null
@@ -0,0 +1,14 @@
+\* Add statements after this line.
+SPECIFICATION Spec
+
+CONSTANTS
+  Worker = {w1, w2}
+  N = 4
+  MaxGen = 6
+
+INVARIANTS
+  TypeOK
+  ConvergedSafe
+
+PROPERTY
+  ConvergesAtRest
diff --git a/Model/GiScheduler2.tla b/Model/GiScheduler2.tla
new file mode 100644 (file)
index 0000000..8dc094a
--- /dev/null
@@ -0,0 +1,211 @@
+---- MODULE GiScheduler2 ----
+EXTENDS Integers, FiniteSets, TLC
+
+CONSTANTS Worker, N, MaxGen
+ASSUME N \in Nat /\ N > 0
+ASSUME MaxGen \in Nat /\ MaxGen >= 5
+
+Entry == 1..N
+
+(* M2: rings + camera. Movement policy 1: never abort an in-flight
+   generation; the barrier re-filters the work set from the LIVE
+   eligibility mask; late entrants wait for the next generation.
+
+   --algorithm GI2
+
+variables
+  gen      = 0,
+  cursor   = 1,
+  inFlight = 0,
+  done     = [en \in Entry |-> 0],
+  folded   = [en \in Entry |-> {}],
+  eligible = Entry,                     \* ring membership (camera-driven),
+                                        \* a SET of entries
+  workSet  = Entry,                     \* this generation's filtered set
+  resting  = FALSE,                     \* camera has stopped moving
+  converged = FALSE,                    \* barrier completed at rest with
+                                        \* workSet covering all eligible
+  convergedGen = 0,
+  halted   = FALSE;
+
+fair process W \in Worker
+  variable e = 0;
+begin
+w1: while (~halted) do
+      await (cursor <= N /\ ~halted) \/ halted;
+      if ~halted then
+        e := cursor;
+        cursor := cursor + 1;
+        inFlight := inFlight + 1;
+w2:     if e \in workSet then
+          assert gen \notin folded[e];            \* no double-count
+          folded[e] := folded[e] \cup {gen};
+          done[e] := gen;
+        end if;
+w3:     inFlight := inFlight - 1;
+        if inFlight = 0 /\ cursor > N then        \* barrier
+          assert \A x \in workSet : done[x] = gen; \* barrier integrity
+          if resting /\ eligible \subseteq workSet then
+            converged := TRUE;                    \* every eligible entry was
+            convergedGen := gen;                  \* served this generation
+            halted := TRUE;
+          elsif gen + 1 >= MaxGen then
+            halted := TRUE;
+          else
+            gen := gen + 1;
+            workSet := eligible;                  \* re-filter, live mask
+            cursor := 1;
+          end if;
+        end if;
+        e := 0;
+      end if;
+    end while;
+end process;
+
+process Cam = 0                          \* the user, flying around
+begin
+c1: while (~halted /\ ~resting) do
+      if gen <= MaxGen - 4 then          \* moves allowed only early enough
+        either                           \* that convergence fits before
+          with f \in SUBSET Entry do     \* the generation cap (keeps the
+            eligible := f;               \* state space finite)
+          end with;
+        or
+          resting := TRUE;               \* camera stops: legal at any time
+        end either;
+      else
+        resting := TRUE;                 \* too late to move: rest
+      end if;
+    end while;
+end process;
+
+end algorithm; *)
+\* BEGIN TRANSLATION (chksum(pcal) = "2bfaa635" /\ chksum(tla) = "46b74136")
+VARIABLES pc, gen, cursor, inFlight, done, folded, eligible, workSet, resting, 
+          converged, convergedGen, halted, e
+
+vars == << pc, gen, cursor, inFlight, done, folded, eligible, workSet, 
+           resting, converged, convergedGen, halted, e >>
+
+ProcSet == (Worker) \cup {0}
+
+Init == (* Global variables *)
+        /\ gen = 0
+        /\ cursor = 1
+        /\ inFlight = 0
+        /\ done = [en \in Entry |-> 0]
+        /\ folded = [en \in Entry |-> {}]
+        /\ eligible = Entry
+        /\ workSet = Entry
+        /\ resting = FALSE
+        /\ converged = FALSE
+        /\ convergedGen = 0
+        /\ halted = FALSE
+        (* Process W *)
+        /\ e = [self \in Worker |-> 0]
+        /\ pc = [self \in ProcSet |-> CASE self \in Worker -> "w1"
+                                        [] self = 0 -> "c1"]
+
+w1(self) == /\ pc[self] = "w1"
+            /\ IF (~halted)
+                  THEN /\ (cursor <= N /\ ~halted) \/ halted
+                       /\ IF ~halted
+                             THEN /\ e' = [e EXCEPT ![self] = cursor]
+                                  /\ cursor' = cursor + 1
+                                  /\ inFlight' = inFlight + 1
+                                  /\ pc' = [pc EXCEPT ![self] = "w2"]
+                             ELSE /\ pc' = [pc EXCEPT ![self] = "w1"]
+                                  /\ UNCHANGED << cursor, inFlight, e >>
+                  ELSE /\ pc' = [pc EXCEPT ![self] = "Done"]
+                       /\ UNCHANGED << cursor, inFlight, e >>
+            /\ UNCHANGED << gen, done, folded, eligible, workSet, resting, 
+                            converged, convergedGen, halted >>
+
+w2(self) == /\ pc[self] = "w2"
+            /\ IF e[self] \in workSet
+                  THEN /\ Assert(gen \notin folded[e[self]], 
+                                 "Failure of assertion at line 41, column 11.")
+                       /\ folded' = [folded EXCEPT ![e[self]] = folded[e[self]] \cup {gen}]
+                       /\ done' = [done EXCEPT ![e[self]] = gen]
+                  ELSE /\ TRUE
+                       /\ UNCHANGED << done, folded >>
+            /\ pc' = [pc EXCEPT ![self] = "w3"]
+            /\ UNCHANGED << gen, cursor, inFlight, eligible, workSet, resting, 
+                            converged, convergedGen, halted, e >>
+
+w3(self) == /\ pc[self] = "w3"
+            /\ inFlight' = inFlight - 1
+            /\ IF inFlight' = 0 /\ cursor > N
+                  THEN /\ Assert(\A x \in workSet : done[x] = gen, 
+                                 "Failure of assertion at line 47, column 11.")
+                       /\ IF resting /\ eligible \subseteq workSet
+                             THEN /\ converged' = TRUE
+                                  /\ convergedGen' = gen
+                                  /\ halted' = TRUE
+                                  /\ UNCHANGED << gen, cursor, workSet >>
+                             ELSE /\ IF gen + 1 >= MaxGen
+                                        THEN /\ halted' = TRUE
+                                             /\ UNCHANGED << gen, cursor, 
+                                                             workSet >>
+                                        ELSE /\ gen' = gen + 1
+                                             /\ workSet' = eligible
+                                             /\ cursor' = 1
+                                             /\ UNCHANGED halted
+                                  /\ UNCHANGED << converged, convergedGen >>
+                  ELSE /\ TRUE
+                       /\ UNCHANGED << gen, cursor, workSet, converged, 
+                                       convergedGen, halted >>
+            /\ e' = [e EXCEPT ![self] = 0]
+            /\ pc' = [pc EXCEPT ![self] = "w1"]
+            /\ UNCHANGED << done, folded, eligible, resting >>
+
+W(self) == w1(self) \/ w2(self) \/ w3(self)
+
+c1 == /\ pc[0] = "c1"
+      /\ IF (~halted /\ ~resting)
+            THEN /\ IF gen <= MaxGen - 4
+                       THEN /\ \/ /\ \E f \in SUBSET Entry:
+                                       eligible' = f
+                                  /\ UNCHANGED resting
+                               \/ /\ resting' = TRUE
+                                  /\ UNCHANGED eligible
+                       ELSE /\ resting' = TRUE
+                            /\ UNCHANGED eligible
+                 /\ pc' = [pc EXCEPT ![0] = "c1"]
+            ELSE /\ pc' = [pc EXCEPT ![0] = "Done"]
+                 /\ UNCHANGED << eligible, resting >>
+      /\ UNCHANGED << gen, cursor, inFlight, done, folded, workSet, converged, 
+                      convergedGen, halted, e >>
+
+Cam == c1
+
+(* Allow infinite stuttering to prevent deadlock on termination. *)
+Terminating == /\ \A self \in ProcSet: pc[self] = "Done"
+               /\ UNCHANGED vars
+
+Next == Cam
+           \/ (\E self \in Worker: W(self))
+           \/ Terminating
+
+Spec == /\ Init /\ [][Next]_vars
+        /\ \A self \in Worker : WF_vars(W(self))
+
+Termination == <>(\A self \in ProcSet: pc[self] = "Done")
+
+\* END TRANSLATION 
+
+TypeOK ==
+  /\ gen \in 0..MaxGen
+  /\ cursor \in 1..(N + 1)
+  /\ inFlight \in 0..Cardinality(Worker)
+  /\ \A en \in Entry : done[en] \in 0..MaxGen
+  /\ convergedGen \in 0..MaxGen
+  /\ eligible \subseteq Entry
+  /\ workSet \subseteq Entry
+
+ConvergedSafe ==
+  converged => \A en \in eligible : done[en] >= convergedGen
+
+ConvergesAtRest == resting ~> converged
+
+====
diff --git a/Model/GiScheduler3.cfg b/Model/GiScheduler3.cfg
new file mode 100644 (file)
index 0000000..9a67689
--- /dev/null
@@ -0,0 +1,15 @@
+\* Add statements after this line.
+SPECIFICATION Spec
+
+CONSTANTS
+  Worker = {w1, w2}
+  N = 3
+  MaxGen = 5
+  MaxEpoch = 2
+
+INVARIANTS
+  TypeOK
+  ConvergedSafe
+
+PROPERTY
+  ConvergesAtRest
diff --git a/Model/GiScheduler3.tla b/Model/GiScheduler3.tla
new file mode 100644 (file)
index 0000000..16dcf59
--- /dev/null
@@ -0,0 +1,289 @@
+---- MODULE GiScheduler3 ----
+EXTENDS Integers, FiniteSets, TLC
+
+CONSTANTS Worker, N, MaxGen, MaxEpoch
+ASSUME N \in Nat /\ N > 0
+ASSUME MaxGen \in Nat /\ MaxGen >= 5
+ASSUME MaxEpoch \in Nat /\ MaxEpoch >= 1
+
+Entry == 1..N
+
+(* M3: snapshot epochs. The World process fires rebuilds (geometry/light
+   change): epoch advance, generation reset, queue re-filter, and
+   harvest/restore of a subset of entries (restored entries keep their
+   stage-equivalent state; the rest restart from nothing). In-flight
+   work from a replaced epoch is DISCARDED via epoch-tagged writes
+   (the snap /= snapshot guard in the Java code). The rebuild never
+   touches inFlight: workers that grabbed entries under the old epoch
+   still decrement it on the way out.
+
+   --algorithm GI3
+
+variables
+  epoch    = 0,
+  gen      = 0,
+  cursor   = 1,
+  inFlight = 0,
+  doneGen    = [en \in Entry |-> -1],  \* last folded generation, this epoch
+  doneEpoch  = [en \in Entry |-> 0],   \* epoch of that fold
+  folded     = [en \in Entry |-> {}],  \* generations folded, this epoch
+  eligible = Entry,
+  workSet  = Entry,
+  resting  = FALSE,
+  worldDone = FALSE,
+  converged = FALSE,
+  convergedGen = 0,
+  convergedEpoch = 0,
+  halted   = FALSE;
+
+fair process W \in Worker
+  variables e = 0, we = 0;             \* we = epoch this entry was grabbed in
+begin
+w1: while (~halted) do
+      await (cursor <= N /\ ~halted) \/ halted;
+      if ~halted then
+        e := cursor;
+        cursor := cursor + 1;
+        we := epoch;
+        inFlight := inFlight + 1;
+w2:     if we = epoch /\ e \in workSet /\ gen \notin folded[e] then
+          assert gen \notin folded[e];          \* no double-count
+          folded[e] := folded[e] \cup {gen};
+          doneGen[e] := gen;
+          doneEpoch[e] := epoch;
+        end if;
+w3:     inFlight := inFlight - 1;
+        if inFlight = 0 /\ cursor > N then   \* barrier — ANY worker may fire it
+          assert \A x \in workSet : doneGen[x] = gen /\ doneEpoch[x] = epoch;
+          if resting /\ worldDone /\ eligible \subseteq workSet then
+            converged := TRUE;
+            convergedGen := gen;
+            convergedEpoch := epoch;
+            halted := TRUE;
+          elsif gen + 1 >= MaxGen then
+            halted := TRUE;
+          else
+            gen := gen + 1;
+            workSet := eligible;              \* re-filter, live mask
+            cursor := 1;
+          end if;
+        end if;
+        e := 0;
+      end if;
+    end while;
+end process;
+
+process Cam = 0
+begin
+c1: while (~halted /\ ~resting) do
+      if gen <= MaxGen - 4 then
+        either
+          with f \in SUBSET Entry do
+            eligible := f;
+          end with;
+        or
+          resting := TRUE;
+        end either;
+      else
+        resting := TRUE;
+      end if;
+    end while;
+end process;
+
+process World = 1                      \* geometry/light changes
+begin
+d1: while (~halted /\ ~worldDone) do
+      if epoch < MaxEpoch then
+        either
+          with r \in SUBSET Entry do   \* rebuild: restore matching entries
+            folded := [en \in Entry |-> IF en \in r THEN {0} ELSE {}];
+            doneGen := [en \in Entry |-> IF en \in r THEN 0 ELSE -1];
+            doneEpoch := [en \in Entry |-> epoch + 1];
+            epoch := epoch + 1;        \* assignments are SEQUENTIAL within
+            gen := 0;                  \* a label: epoch bump comes AFTER
+            cursor := 1;               \* the RHS reads above
+            workSet := eligible;
+          end with;
+        or
+          worldDone := TRUE;
+        end either;
+      else
+        worldDone := TRUE;
+      end if;
+    end while;
+end process;
+
+end algorithm; *)
+\* BEGIN TRANSLATION (chksum(pcal) = "82a3670e" /\ chksum(tla) = "486249a3")
+VARIABLES pc, epoch, gen, cursor, inFlight, doneGen, doneEpoch, folded, 
+          eligible, workSet, resting, worldDone, converged, convergedGen, 
+          convergedEpoch, halted, e, we
+
+vars == << pc, epoch, gen, cursor, inFlight, doneGen, doneEpoch, folded, 
+           eligible, workSet, resting, worldDone, converged, convergedGen, 
+           convergedEpoch, halted, e, we >>
+
+ProcSet == (Worker) \cup {0} \cup {1}
+
+Init == (* Global variables *)
+        /\ epoch = 0
+        /\ gen = 0
+        /\ cursor = 1
+        /\ inFlight = 0
+        /\ doneGen = [en \in Entry |-> -1]
+        /\ doneEpoch = [en \in Entry |-> 0]
+        /\ folded = [en \in Entry |-> {}]
+        /\ eligible = Entry
+        /\ workSet = Entry
+        /\ resting = FALSE
+        /\ worldDone = FALSE
+        /\ converged = FALSE
+        /\ convergedGen = 0
+        /\ convergedEpoch = 0
+        /\ halted = FALSE
+        (* Process W *)
+        /\ e = [self \in Worker |-> 0]
+        /\ we = [self \in Worker |-> 0]
+        /\ pc = [self \in ProcSet |-> CASE self \in Worker -> "w1"
+                                        [] self = 0 -> "c1"
+                                        [] self = 1 -> "d1"]
+
+w1(self) == /\ pc[self] = "w1"
+            /\ IF (~halted)
+                  THEN /\ (cursor <= N /\ ~halted) \/ halted
+                       /\ IF ~halted
+                             THEN /\ e' = [e EXCEPT ![self] = cursor]
+                                  /\ cursor' = cursor + 1
+                                  /\ we' = [we EXCEPT ![self] = epoch]
+                                  /\ inFlight' = inFlight + 1
+                                  /\ pc' = [pc EXCEPT ![self] = "w2"]
+                             ELSE /\ pc' = [pc EXCEPT ![self] = "w1"]
+                                  /\ UNCHANGED << cursor, inFlight, e, we >>
+                  ELSE /\ pc' = [pc EXCEPT ![self] = "Done"]
+                       /\ UNCHANGED << cursor, inFlight, e, we >>
+            /\ UNCHANGED << epoch, gen, doneGen, doneEpoch, folded, eligible, 
+                            workSet, resting, worldDone, converged, 
+                            convergedGen, convergedEpoch, halted >>
+
+w2(self) == /\ pc[self] = "w2"
+            /\ IF we[self] = epoch /\ e[self] \in workSet /\ gen \notin folded[e[self]]
+                  THEN /\ Assert(gen \notin folded[e[self]], 
+                                 "Failure of assertion at line 50, column 11.")
+                       /\ folded' = [folded EXCEPT ![e[self]] = folded[e[self]] \cup {gen}]
+                       /\ doneGen' = [doneGen EXCEPT ![e[self]] = gen]
+                       /\ doneEpoch' = [doneEpoch EXCEPT ![e[self]] = epoch]
+                  ELSE /\ TRUE
+                       /\ UNCHANGED << doneGen, doneEpoch, folded >>
+            /\ pc' = [pc EXCEPT ![self] = "w3"]
+            /\ UNCHANGED << epoch, gen, cursor, inFlight, eligible, workSet, 
+                            resting, worldDone, converged, convergedGen, 
+                            convergedEpoch, halted, e, we >>
+
+w3(self) == /\ pc[self] = "w3"
+            /\ inFlight' = inFlight - 1
+            /\ IF inFlight' = 0 /\ cursor > N
+                  THEN /\ Assert(\A x \in workSet : doneGen[x] = gen /\ doneEpoch[x] = epoch, 
+                                 "Failure of assertion at line 57, column 11.")
+                       /\ IF resting /\ worldDone /\ eligible \subseteq workSet
+                             THEN /\ converged' = TRUE
+                                  /\ convergedGen' = gen
+                                  /\ convergedEpoch' = epoch
+                                  /\ halted' = TRUE
+                                  /\ UNCHANGED << gen, cursor, workSet >>
+                             ELSE /\ IF gen + 1 >= MaxGen
+                                        THEN /\ halted' = TRUE
+                                             /\ UNCHANGED << gen, cursor, 
+                                                             workSet >>
+                                        ELSE /\ gen' = gen + 1
+                                             /\ workSet' = eligible
+                                             /\ cursor' = 1
+                                             /\ UNCHANGED halted
+                                  /\ UNCHANGED << converged, convergedGen, 
+                                                  convergedEpoch >>
+                  ELSE /\ TRUE
+                       /\ UNCHANGED << gen, cursor, workSet, converged, 
+                                       convergedGen, convergedEpoch, halted >>
+            /\ e' = [e EXCEPT ![self] = 0]
+            /\ pc' = [pc EXCEPT ![self] = "w1"]
+            /\ UNCHANGED << epoch, doneGen, doneEpoch, folded, eligible, 
+                            resting, worldDone, we >>
+
+W(self) == w1(self) \/ w2(self) \/ w3(self)
+
+c1 == /\ pc[0] = "c1"
+      /\ IF (~halted /\ ~resting)
+            THEN /\ IF gen <= MaxGen - 4
+                       THEN /\ \/ /\ \E f \in SUBSET Entry:
+                                       eligible' = f
+                                  /\ UNCHANGED resting
+                               \/ /\ resting' = TRUE
+                                  /\ UNCHANGED eligible
+                       ELSE /\ resting' = TRUE
+                            /\ UNCHANGED eligible
+                 /\ pc' = [pc EXCEPT ![0] = "c1"]
+            ELSE /\ pc' = [pc EXCEPT ![0] = "Done"]
+                 /\ UNCHANGED << eligible, resting >>
+      /\ UNCHANGED << epoch, gen, cursor, inFlight, doneGen, doneEpoch, folded, 
+                      workSet, worldDone, converged, convergedGen, 
+                      convergedEpoch, halted, e, we >>
+
+Cam == c1
+
+d1 == /\ pc[1] = "d1"
+      /\ IF (~halted /\ ~worldDone)
+            THEN /\ IF epoch < MaxEpoch
+                       THEN /\ \/ /\ \E r \in SUBSET Entry:
+                                       /\ folded' = [en \in Entry |-> IF en \in r THEN {0} ELSE {}]
+                                       /\ doneGen' = [en \in Entry |-> IF en \in r THEN 0 ELSE -1]
+                                       /\ doneEpoch' = [en \in Entry |-> epoch + 1]
+                                       /\ epoch' = epoch + 1
+                                       /\ gen' = 0
+                                       /\ cursor' = 1
+                                       /\ workSet' = eligible
+                                  /\ UNCHANGED worldDone
+                               \/ /\ worldDone' = TRUE
+                                  /\ UNCHANGED <<epoch, gen, cursor, doneGen, doneEpoch, folded, workSet>>
+                       ELSE /\ worldDone' = TRUE
+                            /\ UNCHANGED << epoch, gen, cursor, doneGen, 
+                                            doneEpoch, folded, workSet >>
+                 /\ pc' = [pc EXCEPT ![1] = "d1"]
+            ELSE /\ pc' = [pc EXCEPT ![1] = "Done"]
+                 /\ UNCHANGED << epoch, gen, cursor, doneGen, doneEpoch, 
+                                 folded, workSet, worldDone >>
+      /\ UNCHANGED << inFlight, eligible, resting, converged, convergedGen, 
+                      convergedEpoch, halted, e, we >>
+
+World == d1
+
+(* Allow infinite stuttering to prevent deadlock on termination. *)
+Terminating == /\ \A self \in ProcSet: pc[self] = "Done"
+               /\ UNCHANGED vars
+
+Next == Cam \/ World
+           \/ (\E self \in Worker: W(self))
+           \/ Terminating
+
+Spec == /\ Init /\ [][Next]_vars
+        /\ \A self \in Worker : WF_vars(W(self))
+
+Termination == <>(\A self \in ProcSet: pc[self] = "Done")
+
+\* END TRANSLATION 
+
+TypeOK ==
+  /\ epoch \in 0..MaxEpoch
+  /\ gen \in 0..MaxGen
+  /\ cursor \in 1..(N + 1)
+  /\ inFlight \in 0..Cardinality(Worker)
+  /\ \A en \in Entry : doneGen[en] \in -1..MaxGen
+  /\ \A en \in Entry : doneEpoch[en] \in 0..MaxEpoch
+  /\ eligible \subseteq Entry
+  /\ workSet \subseteq Entry
+
+ConvergedSafe ==
+  converged => \A en \in eligible :
+                 doneGen[en] >= convergedGen /\ doneEpoch[en] = convergedEpoch
+
+ConvergesAtRest == (resting /\ worldDone) ~> converged
+
+====
diff --git a/Model/GiScheduler4.cfg b/Model/GiScheduler4.cfg
new file mode 100644 (file)
index 0000000..b888543
--- /dev/null
@@ -0,0 +1,16 @@
+\* Add statements after this line.
+SPECIFICATION Spec
+
+CONSTANTS
+  Worker = {w1, w2}
+  N = 3
+  MaxGen = 5
+  MaxEpoch = 2
+
+INVARIANTS
+  TypeOK
+  ConvergedSafe
+  ConvFrozen
+
+PROPERTY
+  ConvergesAtRest
diff --git a/Model/GiScheduler4.tla b/Model/GiScheduler4.tla
new file mode 100644 (file)
index 0000000..f373370
--- /dev/null
@@ -0,0 +1,398 @@
+---- MODULE GiScheduler4 ----
+EXTENDS Integers, FiniteSets, TLC
+
+CONSTANTS Worker, N, MaxGen, MaxEpoch
+ASSUME N \in Nat /\ N > 0
+ASSUME MaxGen \in Nat /\ MaxGen >= 5
+ASSUME MaxEpoch \in Nat /\ MaxEpoch >= 1
+
+Entry == 1..N
+
+(* M4: catch-up. After convergence the bake freezes (gen/epoch fixed);
+   a later camera move queues newly eligible entries into catchUpQueue,
+   and idle workers serve them with a LOCAL bake that never touches
+   gen, cursor, inFlight, or the barrier. A rebuild (World) re-opens
+   the bake and wipes catch-up state.
+
+   Served / AllServed are parameterized operators (no free variable
+   references): TLA+ defines-before-use is textual, and PlusCal emits
+   its VARIABLES declaration inside the translation — an unparameterized
+   operator can never sit on both sides of it. *)
+
+Served(en, de, dg, ep, cu) == (de[en] = ep /\ dg[en] >= 0) \/ cu[en]
+AllServed(el, de, dg, ep, cu) == \A en \in el : Served(en, de, dg, ep, cu)
+
+(* --algorithm GI4
+
+variables
+  epoch    = 0,
+  gen      = 0,
+  cursor   = 1,
+  inFlight = 0,
+  doneGen    = [en \in Entry |-> -1],
+  doneEpoch  = [en \in Entry |-> 0],
+  folded     = [en \in Entry |-> {}],
+  caughtUp   = [en \in Entry |-> FALSE],
+  catchUpQueue = {},
+  eligible = Entry,
+  workSet  = Entry,
+  resting  = FALSE,
+  disturbed = FALSE,                  \* post-convergence camera move happened
+  camDone  = FALSE,                   \* camera process ended: no more moves
+                                      \* ever — halt is legal only then
+  worldDone = FALSE,
+  converged = FALSE,
+  convergedGen = 0,
+  convergedEpoch = 0,
+  eligibleAtConv = Entry,             \* the set convergence certified
+  halted   = FALSE;
+
+fair process W \in Worker
+  variables e = 0, we = 0;
+begin
+w1: while (~halted) do
+      await cursor <= N
+         \/ catchUpQueue /= {}
+         \/ (converged /\ resting /\ worldDone /\ camDone /\ catchUpQueue = {}
+             /\ cursor > N /\ AllServed(eligible, doneEpoch, doneGen, epoch, caughtUp));
+      if converged /\ resting /\ worldDone /\ camDone /\ catchUpQueue = {}
+         /\ cursor > N /\ AllServed(eligible, doneEpoch, doneGen, epoch, caughtUp) then
+        halted := TRUE;               \* bake converged, catch-up drained: done
+      elsif cursor <= N then
+        e := cursor;
+        cursor := cursor + 1;
+        we := epoch;
+        inFlight := inFlight + 1;
+w2:     if we = epoch /\ e \in workSet /\ gen \notin folded[e] then
+          assert gen \notin folded[e];          \* no double-count
+          folded[e] := folded[e] \cup {gen};
+          doneGen[e] := gen;
+          doneEpoch[e] := epoch;
+        end if;
+w3:     inFlight := inFlight - 1;
+        if inFlight = 0 /\ cursor > N then      \* barrier — any worker fires it
+          assert \A x \in workSet : doneGen[x] = gen /\ doneEpoch[x] = epoch;
+          if resting /\ worldDone /\ eligible \subseteq workSet then
+            converged := TRUE;
+            convergedGen := gen;
+            convergedEpoch := epoch;
+            eligibleAtConv := eligible;
+          elsif gen + 1 >= MaxGen then
+            halted := TRUE;
+          else
+            gen := gen + 1;
+            workSet := eligible;
+            cursor := 1;
+          end if;
+        end if;
+        e := 0;
+      else
+        with x \in catchUpQueue do      \* catch-up: LOCAL bake. Check-and-grab
+          catchUpQueue := catchUpQueue \ {x};   \* is ONE atomic step (the real
+          caughtUp[x] := TRUE;                  \* queue poll is atomic); never
+        end with;                               \* touches gen/cursor/inFlight
+      end if;
+    end while;
+end process;
+
+process Cam = 0
+begin
+c1: while (~resting) do               \* phase A: pre-convergence
+      if halted then
+        resting := TRUE;
+      elsif gen <= MaxGen - 4 then
+        either
+          with f \in SUBSET Entry do
+            eligible := f;
+          end with;
+        or
+          resting := TRUE;
+        end either;
+      else
+        resting := TRUE;
+      end if;
+    end while;
+c2: if converged /\ ~disturbed then   \* phase B: one post-convergence move
+      either
+        with f \in SUBSET Entry do
+          eligible := f;
+          catchUpQueue := {x \in f : ~Served(x, doneEpoch, doneGen, epoch, caughtUp)};
+          disturbed := TRUE;
+        end with;
+      or
+        skip;                          \* user never flies again
+      end either;
+    end if;
+c3: camDone := TRUE;                   \* camera never moves again
+end process;
+
+process World = 1
+begin
+d1: while (~worldDone) do
+      if epoch < MaxEpoch then
+        either
+          with r \in SUBSET Entry do   \* rebuild: restore matching entries,
+            folded := [en \in Entry |-> IF en \in r THEN {0} ELSE {}];
+            doneGen := [en \in Entry |-> IF en \in r THEN 0 ELSE -1];
+            doneEpoch := [en \in Entry |-> epoch + 1];
+            caughtUp := [en \in Entry |-> FALSE];
+            catchUpQueue := {};
+            converged := FALSE;        \* re-open the bake
+            epoch := epoch + 1;
+            gen := 0;
+            cursor := 1;
+            workSet := eligible;
+          end with;
+        or
+          worldDone := TRUE;
+        end either;
+      else
+        worldDone := TRUE;
+      end if;
+    end while;
+end process;
+
+end algorithm; *)
+\* BEGIN TRANSLATION (chksum(pcal) = "183a8513" /\ chksum(tla) = "7ea80aee")
+VARIABLES pc, epoch, gen, cursor, inFlight, doneGen, doneEpoch, folded, 
+          caughtUp, catchUpQueue, eligible, workSet, resting, disturbed, 
+          camDone, worldDone, converged, convergedGen, convergedEpoch, 
+          eligibleAtConv, halted, e, we
+
+vars == << pc, epoch, gen, cursor, inFlight, doneGen, doneEpoch, folded, 
+           caughtUp, catchUpQueue, eligible, workSet, resting, disturbed, 
+           camDone, worldDone, converged, convergedGen, convergedEpoch, 
+           eligibleAtConv, halted, e, we >>
+
+ProcSet == (Worker) \cup {0} \cup {1}
+
+Init == (* Global variables *)
+        /\ epoch = 0
+        /\ gen = 0
+        /\ cursor = 1
+        /\ inFlight = 0
+        /\ doneGen = [en \in Entry |-> -1]
+        /\ doneEpoch = [en \in Entry |-> 0]
+        /\ folded = [en \in Entry |-> {}]
+        /\ caughtUp = [en \in Entry |-> FALSE]
+        /\ catchUpQueue = {}
+        /\ eligible = Entry
+        /\ workSet = Entry
+        /\ resting = FALSE
+        /\ disturbed = FALSE
+        /\ camDone = FALSE
+        /\ worldDone = FALSE
+        /\ converged = FALSE
+        /\ convergedGen = 0
+        /\ convergedEpoch = 0
+        /\ eligibleAtConv = Entry
+        /\ halted = FALSE
+        (* Process W *)
+        /\ e = [self \in Worker |-> 0]
+        /\ we = [self \in Worker |-> 0]
+        /\ pc = [self \in ProcSet |-> CASE self \in Worker -> "w1"
+                                        [] self = 0 -> "c1"
+                                        [] self = 1 -> "d1"]
+
+w1(self) == /\ pc[self] = "w1"
+            /\ IF (~halted)
+                  THEN /\    cursor <= N
+                          \/ catchUpQueue /= {}
+                          \/ (converged /\ resting /\ worldDone /\ camDone /\ catchUpQueue = {}
+                              /\ cursor > N /\ AllServed(eligible, doneEpoch, doneGen, epoch, caughtUp))
+                       /\ IF converged /\ resting /\ worldDone /\ camDone /\ catchUpQueue = {}
+                             /\ cursor > N /\ AllServed(eligible, doneEpoch, doneGen, epoch, caughtUp)
+                             THEN /\ halted' = TRUE
+                                  /\ pc' = [pc EXCEPT ![self] = "w1"]
+                                  /\ UNCHANGED << cursor, inFlight, caughtUp, 
+                                                  catchUpQueue, e, we >>
+                             ELSE /\ IF cursor <= N
+                                        THEN /\ e' = [e EXCEPT ![self] = cursor]
+                                             /\ cursor' = cursor + 1
+                                             /\ we' = [we EXCEPT ![self] = epoch]
+                                             /\ inFlight' = inFlight + 1
+                                             /\ pc' = [pc EXCEPT ![self] = "w2"]
+                                             /\ UNCHANGED << caughtUp, 
+                                                             catchUpQueue >>
+                                        ELSE /\ \E x \in catchUpQueue:
+                                                  /\ catchUpQueue' = catchUpQueue \ {x}
+                                                  /\ caughtUp' = [caughtUp EXCEPT ![x] = TRUE]
+                                             /\ pc' = [pc EXCEPT ![self] = "w1"]
+                                             /\ UNCHANGED << cursor, inFlight, 
+                                                             e, we >>
+                                  /\ UNCHANGED halted
+                  ELSE /\ pc' = [pc EXCEPT ![self] = "Done"]
+                       /\ UNCHANGED << cursor, inFlight, caughtUp, 
+                                       catchUpQueue, halted, e, we >>
+            /\ UNCHANGED << epoch, gen, doneGen, doneEpoch, folded, eligible, 
+                            workSet, resting, disturbed, camDone, worldDone, 
+                            converged, convergedGen, convergedEpoch, 
+                            eligibleAtConv >>
+
+w2(self) == /\ pc[self] = "w2"
+            /\ IF we[self] = epoch /\ e[self] \in workSet /\ gen \notin folded[e[self]]
+                  THEN /\ Assert(gen \notin folded[e[self]], 
+                                 "Failure of assertion at line 67, column 11.")
+                       /\ folded' = [folded EXCEPT ![e[self]] = folded[e[self]] \cup {gen}]
+                       /\ doneGen' = [doneGen EXCEPT ![e[self]] = gen]
+                       /\ doneEpoch' = [doneEpoch EXCEPT ![e[self]] = epoch]
+                  ELSE /\ TRUE
+                       /\ UNCHANGED << doneGen, doneEpoch, folded >>
+            /\ pc' = [pc EXCEPT ![self] = "w3"]
+            /\ UNCHANGED << epoch, gen, cursor, inFlight, caughtUp, 
+                            catchUpQueue, eligible, workSet, resting, 
+                            disturbed, camDone, worldDone, converged, 
+                            convergedGen, convergedEpoch, eligibleAtConv, 
+                            halted, e, we >>
+
+w3(self) == /\ pc[self] = "w3"
+            /\ inFlight' = inFlight - 1
+            /\ IF inFlight' = 0 /\ cursor > N
+                  THEN /\ Assert(\A x \in workSet : doneGen[x] = gen /\ doneEpoch[x] = epoch, 
+                                 "Failure of assertion at line 74, column 11.")
+                       /\ IF resting /\ worldDone /\ eligible \subseteq workSet
+                             THEN /\ converged' = TRUE
+                                  /\ convergedGen' = gen
+                                  /\ convergedEpoch' = epoch
+                                  /\ eligibleAtConv' = eligible
+                                  /\ UNCHANGED << gen, cursor, workSet, halted >>
+                             ELSE /\ IF gen + 1 >= MaxGen
+                                        THEN /\ halted' = TRUE
+                                             /\ UNCHANGED << gen, cursor, 
+                                                             workSet >>
+                                        ELSE /\ gen' = gen + 1
+                                             /\ workSet' = eligible
+                                             /\ cursor' = 1
+                                             /\ UNCHANGED halted
+                                  /\ UNCHANGED << converged, convergedGen, 
+                                                  convergedEpoch, 
+                                                  eligibleAtConv >>
+                  ELSE /\ TRUE
+                       /\ UNCHANGED << gen, cursor, workSet, converged, 
+                                       convergedGen, convergedEpoch, 
+                                       eligibleAtConv, halted >>
+            /\ e' = [e EXCEPT ![self] = 0]
+            /\ pc' = [pc EXCEPT ![self] = "w1"]
+            /\ UNCHANGED << epoch, doneGen, doneEpoch, folded, caughtUp, 
+                            catchUpQueue, eligible, resting, disturbed, 
+                            camDone, worldDone, we >>
+
+W(self) == w1(self) \/ w2(self) \/ w3(self)
+
+c1 == /\ pc[0] = "c1"
+      /\ IF (~resting)
+            THEN /\ IF halted
+                       THEN /\ resting' = TRUE
+                            /\ UNCHANGED eligible
+                       ELSE /\ IF gen <= MaxGen - 4
+                                  THEN /\ \/ /\ \E f \in SUBSET Entry:
+                                                  eligible' = f
+                                             /\ UNCHANGED resting
+                                          \/ /\ resting' = TRUE
+                                             /\ UNCHANGED eligible
+                                  ELSE /\ resting' = TRUE
+                                       /\ UNCHANGED eligible
+                 /\ pc' = [pc EXCEPT ![0] = "c1"]
+            ELSE /\ pc' = [pc EXCEPT ![0] = "c2"]
+                 /\ UNCHANGED << eligible, resting >>
+      /\ UNCHANGED << epoch, gen, cursor, inFlight, doneGen, doneEpoch, folded, 
+                      caughtUp, catchUpQueue, workSet, disturbed, camDone, 
+                      worldDone, converged, convergedGen, convergedEpoch, 
+                      eligibleAtConv, halted, e, we >>
+
+c2 == /\ pc[0] = "c2"
+      /\ IF converged /\ ~disturbed
+            THEN /\ \/ /\ \E f \in SUBSET Entry:
+                            /\ eligible' = f
+                            /\ catchUpQueue' = {x \in f : ~Served(x, doneEpoch, doneGen, epoch, caughtUp)}
+                            /\ disturbed' = TRUE
+                    \/ /\ TRUE
+                       /\ UNCHANGED <<catchUpQueue, eligible, disturbed>>
+            ELSE /\ TRUE
+                 /\ UNCHANGED << catchUpQueue, eligible, disturbed >>
+      /\ pc' = [pc EXCEPT ![0] = "c3"]
+      /\ UNCHANGED << epoch, gen, cursor, inFlight, doneGen, doneEpoch, folded, 
+                      caughtUp, workSet, resting, camDone, worldDone, 
+                      converged, convergedGen, convergedEpoch, eligibleAtConv, 
+                      halted, e, we >>
+
+c3 == /\ pc[0] = "c3"
+      /\ camDone' = TRUE
+      /\ pc' = [pc EXCEPT ![0] = "Done"]
+      /\ UNCHANGED << epoch, gen, cursor, inFlight, doneGen, doneEpoch, folded, 
+                      caughtUp, catchUpQueue, eligible, workSet, resting, 
+                      disturbed, worldDone, converged, convergedGen, 
+                      convergedEpoch, eligibleAtConv, halted, e, we >>
+
+Cam == c1 \/ c2 \/ c3
+
+d1 == /\ pc[1] = "d1"
+      /\ IF (~worldDone)
+            THEN /\ IF epoch < MaxEpoch
+                       THEN /\ \/ /\ \E r \in SUBSET Entry:
+                                       /\ folded' = [en \in Entry |-> IF en \in r THEN {0} ELSE {}]
+                                       /\ doneGen' = [en \in Entry |-> IF en \in r THEN 0 ELSE -1]
+                                       /\ doneEpoch' = [en \in Entry |-> epoch + 1]
+                                       /\ caughtUp' = [en \in Entry |-> FALSE]
+                                       /\ catchUpQueue' = {}
+                                       /\ converged' = FALSE
+                                       /\ epoch' = epoch + 1
+                                       /\ gen' = 0
+                                       /\ cursor' = 1
+                                       /\ workSet' = eligible
+                                  /\ UNCHANGED worldDone
+                               \/ /\ worldDone' = TRUE
+                                  /\ UNCHANGED <<epoch, gen, cursor, doneGen, doneEpoch, folded, caughtUp, catchUpQueue, workSet, converged>>
+                       ELSE /\ worldDone' = TRUE
+                            /\ UNCHANGED << epoch, gen, cursor, doneGen, 
+                                            doneEpoch, folded, caughtUp, 
+                                            catchUpQueue, workSet, converged >>
+                 /\ pc' = [pc EXCEPT ![1] = "d1"]
+            ELSE /\ pc' = [pc EXCEPT ![1] = "Done"]
+                 /\ UNCHANGED << epoch, gen, cursor, doneGen, doneEpoch, 
+                                 folded, caughtUp, catchUpQueue, workSet, 
+                                 worldDone, converged >>
+      /\ UNCHANGED << inFlight, eligible, resting, disturbed, camDone, 
+                      convergedGen, convergedEpoch, eligibleAtConv, halted, e, 
+                      we >>
+
+World == d1
+
+(* Allow infinite stuttering to prevent deadlock on termination. *)
+Terminating == /\ \A self \in ProcSet: pc[self] = "Done"
+               /\ UNCHANGED vars
+
+Next == Cam \/ World
+           \/ (\E self \in Worker: W(self))
+           \/ Terminating
+
+Spec == /\ Init /\ [][Next]_vars
+        /\ \A self \in Worker : WF_vars(W(self))
+
+Termination == <>(\A self \in ProcSet: pc[self] = "Done")
+
+\* END TRANSLATION
+
+TypeOK ==
+  /\ epoch \in 0..MaxEpoch
+  /\ gen \in 0..MaxGen
+  /\ cursor \in 1..(N + 1)
+  /\ inFlight \in 0..Cardinality(Worker)
+  /\ \A en \in Entry : doneGen[en] \in -1..MaxGen
+  /\ \A en \in Entry : doneEpoch[en] \in 0..MaxEpoch
+  /\ eligible \subseteq Entry
+  /\ workSet \subseteq Entry
+  /\ catchUpQueue \subseteq Entry
+  /\ catchUpQueue \subseteq eligible
+
+ConvergedSafe ==
+  converged => \A en \in eligibleAtConv :
+                 doneGen[en] >= convergedGen /\ doneEpoch[en] = convergedEpoch
+
+ConvFrozen ==
+  converged => (gen = convergedGen /\ epoch = convergedEpoch)
+
+ConvergesAtRest == (resting /\ worldDone /\ converged)
+                     ~> AllServed(eligible, doneEpoch, doneGen, epoch, caughtUp)
+
+====
diff --git a/Model/GiScheduler5.tla b/Model/GiScheduler5.tla
new file mode 100644 (file)
index 0000000..450fc82
--- /dev/null
@@ -0,0 +1,455 @@
+---- MODULE GiScheduler5 ----
+EXTENDS Integers, FiniteSets, TLC
+
+CONSTANTS Worker, N, MaxGen, MaxEpoch, Policy
+ASSUME N \in Nat /\ N > 0
+ASSUME MaxGen \in Nat /\ MaxGen >= 5
+ASSUME MaxEpoch \in Nat /\ MaxEpoch >= 1
+ASSUME Policy \in {1, 2}
+
+Entry == 1..N
+
+(* M5: movement policy variants on the M4 model.
+   Policy 1 (never abort): eligibility is only consulted at the barrier;
+     in-flight work always finishes. = M4 semantics.
+   Policy 2 (bounded abandon): at grab time a worker MAY drop an entry
+     that has fallen out of the live eligibility set mid-generation.
+     Dropped entries are recorded in droppedSet for this generation:
+       - the barrier assert exempts them (they were legally unserved),
+       - convergence requires droppedSet = {} (a generation with drops
+         certifies nothing; at rest there are no drops, so the next
+         generation converges),
+       - the no-double-count fold bookkeeping is untouched by drops.
+     What TLC must prove for policy 2: a dropped entry that becomes
+     eligible again is eventually served (covered by ConvergesAtRest's
+     AllServed), and drops cannot corrupt the fold (the asserts).
+   Policy 3 (ring shrink as skip) collapses into policy 1 at this
+   abstraction: the model has generations, not per-entry stages. A
+   stage-granular model is future work if stage-level policies matter.
+
+   Served / AllServed: parameterized operators, see M4 note. *)
+
+Served(en, de, dg, ep, cu) == (de[en] = ep /\ dg[en] >= 0) \/ cu[en]
+AllServed(el, de, dg, ep, cu) == \A en \in el : Served(en, de, dg, ep, cu)
+
+(* --algorithm GI5
+
+variables
+  epoch    = 0,
+  gen      = 0,
+  cursor   = 1,
+  inFlight = 0,
+  doneGen    = [en \in Entry |-> -1],
+  doneEpoch  = [en \in Entry |-> 0],
+  folded     = [en \in Entry |-> {}],
+  droppedSet = {},                    \* entries abandoned this generation (P2)
+  caughtUp   = [en \in Entry |-> FALSE],
+  catchUpQueue = {},
+  eligible = Entry,
+  workSet  = Entry,
+  resting  = FALSE,
+  disturbed = FALSE,
+  camDone  = FALSE,
+  worldDone = FALSE,
+  converged = FALSE,
+  convergedGen = 0,
+  convergedEpoch = 0,
+  eligibleAtConv = Entry,
+  halted   = FALSE;
+
+fair process W \in Worker
+  variables e = 0, we = 0, doWork = FALSE;
+begin
+w1: while (~halted) do
+      await cursor <= N
+         \/ catchUpQueue /= {}
+         \/ (converged /\ resting /\ worldDone /\ camDone /\ catchUpQueue = {}
+             /\ cursor > N /\ AllServed(eligible, doneEpoch, doneGen, epoch, caughtUp));
+      if converged /\ resting /\ worldDone /\ camDone /\ catchUpQueue = {}
+         /\ cursor > N /\ AllServed(eligible, doneEpoch, doneGen, epoch, caughtUp) then
+        halted := TRUE;
+      elsif cursor <= N then
+        e := cursor;
+        cursor := cursor + 1;
+        we := epoch;
+        if Policy = 2 /\ e \in workSet /\ e \notin eligible then
+          either                        \* bounded abandon: drop allowed...
+            droppedSet := droppedSet \cup {e};
+            doWork := FALSE;
+          or                            \* ...never required
+            inFlight := inFlight + 1;
+            doWork := TRUE;
+          end either;
+        else
+          inFlight := inFlight + 1;
+          doWork := TRUE;
+        end if;
+w2:     if doWork /\ we = epoch /\ e \in workSet /\ gen \notin folded[e] then
+          assert gen \notin folded[e];
+          folded[e] := folded[e] \cup {gen};
+          doneGen[e] := gen;
+          doneEpoch[e] := epoch;
+        end if;
+w3:     if doWork then
+          inFlight := inFlight - 1;
+        end if;
+        if inFlight = 0 /\ cursor > N then   \* barrier — also for droppers:
+          assert \A x \in workSet \ droppedSet : \* the LAST worker out fires it
+               doneGen[x] = gen /\ doneEpoch[x] = epoch;   \* even if it dropped
+          if ~halted /\ resting /\ worldDone /\ eligible \subseteq workSet
+             /\ droppedSet = {} then
+            converged := TRUE;
+            convergedGen := gen;
+            convergedEpoch := epoch;
+            eligibleAtConv := eligible;
+          elsif ~converged /\ gen + 1 >= MaxGen then
+            halted := TRUE;
+          elsif ~converged then
+            gen := gen + 1;
+            workSet := eligible;
+            droppedSet := {};
+            cursor := 1;
+          end if;
+        end if;
+        e := 0;
+      else
+        with x \in catchUpQueue do      \* catch-up: atomic check-and-grab
+          catchUpQueue := catchUpQueue \ {x};
+          caughtUp[x] := TRUE;
+        end with;
+      end if;
+    end while;
+end process;
+
+process Cam = 0
+begin
+c1: while (~resting) do
+      if halted then
+        resting := TRUE;
+      elsif gen <= MaxGen - 4 then
+        either
+          with f \in SUBSET Entry do
+            eligible := f;
+          end with;
+        or
+          resting := TRUE;
+        end either;
+      else
+        resting := TRUE;
+      end if;
+    end while;
+c2: if converged /\ ~disturbed /\ ~halted then
+      either
+        with f \in SUBSET Entry do
+          eligible := f;
+          catchUpQueue := {x \in f : ~Served(x, doneEpoch, doneGen, epoch, caughtUp)};
+          disturbed := TRUE;
+        end with;
+      or
+        skip;
+      end either;
+    end if;
+c3: camDone := TRUE;
+end process;
+
+process World = 1
+begin
+d1: while (~worldDone) do
+      if epoch < MaxEpoch then
+        either
+          with r \in SUBSET Entry do
+            folded := [en \in Entry |-> IF en \in r THEN {0} ELSE {}];
+            doneGen := [en \in Entry |-> IF en \in r THEN 0 ELSE -1];
+            doneEpoch := [en \in Entry |-> epoch + 1];
+            caughtUp := [en \in Entry |-> FALSE];
+            catchUpQueue := {};
+            droppedSet := {};
+            converged := FALSE;
+            epoch := epoch + 1;
+            gen := 0;
+            cursor := 1;
+            workSet := eligible;
+          end with;
+        or
+          worldDone := TRUE;
+        end either;
+      else
+        worldDone := TRUE;
+      end if;
+    end while;
+end process;
+
+end algorithm; *)
+\* BEGIN TRANSLATION (chksum(pcal) = "f20ae3b0" /\ chksum(tla) = "fcd21f06")
+VARIABLES pc, epoch, gen, cursor, inFlight, doneGen, doneEpoch, folded, 
+          droppedSet, caughtUp, catchUpQueue, eligible, workSet, resting, 
+          disturbed, camDone, worldDone, converged, convergedGen, 
+          convergedEpoch, eligibleAtConv, halted, e, we, doWork
+
+vars == << pc, epoch, gen, cursor, inFlight, doneGen, doneEpoch, folded, 
+           droppedSet, caughtUp, catchUpQueue, eligible, workSet, resting, 
+           disturbed, camDone, worldDone, converged, convergedGen, 
+           convergedEpoch, eligibleAtConv, halted, e, we, doWork >>
+
+ProcSet == (Worker) \cup {0} \cup {1}
+
+Init == (* Global variables *)
+        /\ epoch = 0
+        /\ gen = 0
+        /\ cursor = 1
+        /\ inFlight = 0
+        /\ doneGen = [en \in Entry |-> -1]
+        /\ doneEpoch = [en \in Entry |-> 0]
+        /\ folded = [en \in Entry |-> {}]
+        /\ droppedSet = {}
+        /\ caughtUp = [en \in Entry |-> FALSE]
+        /\ catchUpQueue = {}
+        /\ eligible = Entry
+        /\ workSet = Entry
+        /\ resting = FALSE
+        /\ disturbed = FALSE
+        /\ camDone = FALSE
+        /\ worldDone = FALSE
+        /\ converged = FALSE
+        /\ convergedGen = 0
+        /\ convergedEpoch = 0
+        /\ eligibleAtConv = Entry
+        /\ halted = FALSE
+        (* Process W *)
+        /\ e = [self \in Worker |-> 0]
+        /\ we = [self \in Worker |-> 0]
+        /\ doWork = [self \in Worker |-> FALSE]
+        /\ pc = [self \in ProcSet |-> CASE self \in Worker -> "w1"
+                                        [] self = 0 -> "c1"
+                                        [] self = 1 -> "d1"]
+
+w1(self) == /\ pc[self] = "w1"
+            /\ IF (~halted)
+                  THEN /\    cursor <= N
+                          \/ catchUpQueue /= {}
+                          \/ (converged /\ resting /\ worldDone /\ camDone /\ catchUpQueue = {}
+                              /\ cursor > N /\ AllServed(eligible, doneEpoch, doneGen, epoch, caughtUp))
+                       /\ IF converged /\ resting /\ worldDone /\ camDone /\ catchUpQueue = {}
+                             /\ cursor > N /\ AllServed(eligible, doneEpoch, doneGen, epoch, caughtUp)
+                             THEN /\ halted' = TRUE
+                                  /\ pc' = [pc EXCEPT ![self] = "w1"]
+                                  /\ UNCHANGED << cursor, inFlight, droppedSet, 
+                                                  caughtUp, catchUpQueue, e, 
+                                                  we, doWork >>
+                             ELSE /\ IF cursor <= N
+                                        THEN /\ e' = [e EXCEPT ![self] = cursor]
+                                             /\ cursor' = cursor + 1
+                                             /\ we' = [we EXCEPT ![self] = epoch]
+                                             /\ IF Policy = 2 /\ e'[self] \in workSet /\ e'[self] \notin eligible
+                                                   THEN /\ \/ /\ droppedSet' = (droppedSet \cup {e'[self]})
+                                                              /\ doWork' = [doWork EXCEPT ![self] = FALSE]
+                                                              /\ UNCHANGED inFlight
+                                                           \/ /\ inFlight' = inFlight + 1
+                                                              /\ doWork' = [doWork EXCEPT ![self] = TRUE]
+                                                              /\ UNCHANGED droppedSet
+                                                   ELSE /\ inFlight' = inFlight + 1
+                                                        /\ doWork' = [doWork EXCEPT ![self] = TRUE]
+                                                        /\ UNCHANGED droppedSet
+                                             /\ pc' = [pc EXCEPT ![self] = "w2"]
+                                             /\ UNCHANGED << caughtUp, 
+                                                             catchUpQueue >>
+                                        ELSE /\ \E x \in catchUpQueue:
+                                                  /\ catchUpQueue' = catchUpQueue \ {x}
+                                                  /\ caughtUp' = [caughtUp EXCEPT ![x] = TRUE]
+                                             /\ pc' = [pc EXCEPT ![self] = "w1"]
+                                             /\ UNCHANGED << cursor, inFlight, 
+                                                             droppedSet, e, we, 
+                                                             doWork >>
+                                  /\ UNCHANGED halted
+                  ELSE /\ pc' = [pc EXCEPT ![self] = "Done"]
+                       /\ UNCHANGED << cursor, inFlight, droppedSet, caughtUp, 
+                                       catchUpQueue, halted, e, we, doWork >>
+            /\ UNCHANGED << epoch, gen, doneGen, doneEpoch, folded, eligible, 
+                            workSet, resting, disturbed, camDone, worldDone, 
+                            converged, convergedGen, convergedEpoch, 
+                            eligibleAtConv >>
+
+w2(self) == /\ pc[self] = "w2"
+            /\ IF doWork[self] /\ we[self] = epoch /\ e[self] \in workSet /\ gen \notin folded[e[self]]
+                  THEN /\ Assert(gen \notin folded[e[self]], 
+                                 "Failure of assertion at line 88, column 11.")
+                       /\ folded' = [folded EXCEPT ![e[self]] = folded[e[self]] \cup {gen}]
+                       /\ doneGen' = [doneGen EXCEPT ![e[self]] = gen]
+                       /\ doneEpoch' = [doneEpoch EXCEPT ![e[self]] = epoch]
+                  ELSE /\ TRUE
+                       /\ UNCHANGED << doneGen, doneEpoch, folded >>
+            /\ pc' = [pc EXCEPT ![self] = "w3"]
+            /\ UNCHANGED << epoch, gen, cursor, inFlight, droppedSet, caughtUp, 
+                            catchUpQueue, eligible, workSet, resting, 
+                            disturbed, camDone, worldDone, converged, 
+                            convergedGen, convergedEpoch, eligibleAtConv, 
+                            halted, e, we, doWork >>
+
+w3(self) == /\ pc[self] = "w3"
+            /\ IF doWork[self]
+                  THEN /\ inFlight' = inFlight - 1
+                  ELSE /\ TRUE
+                       /\ UNCHANGED inFlight
+            /\ IF inFlight' = 0 /\ cursor > N
+                  THEN /\ Assert(  \A x \in workSet \ droppedSet :
+                                 doneGen[x] = gen /\ doneEpoch[x] = epoch, 
+                                 "Failure of assertion at line 97, column 11.")
+                       /\ IF ~halted /\ resting /\ worldDone /\ eligible \subseteq workSet
+                             /\ droppedSet = {}
+                             THEN /\ converged' = TRUE
+                                  /\ convergedGen' = gen
+                                  /\ convergedEpoch' = epoch
+                                  /\ eligibleAtConv' = eligible
+                                  /\ UNCHANGED << gen, cursor, droppedSet, 
+                                                  workSet, halted >>
+                             ELSE /\ IF ~converged /\ gen + 1 >= MaxGen
+                                        THEN /\ halted' = TRUE
+                                             /\ UNCHANGED << gen, cursor, 
+                                                             droppedSet, 
+                                                             workSet >>
+                                        ELSE /\ IF ~converged
+                                                   THEN /\ gen' = gen + 1
+                                                        /\ workSet' = eligible
+                                                        /\ droppedSet' = {}
+                                                        /\ cursor' = 1
+                                                   ELSE /\ TRUE
+                                                        /\ UNCHANGED << gen, 
+                                                                        cursor, 
+                                                                        droppedSet, 
+                                                                        workSet >>
+                                             /\ UNCHANGED halted
+                                  /\ UNCHANGED << converged, convergedGen, 
+                                                  convergedEpoch, 
+                                                  eligibleAtConv >>
+                  ELSE /\ TRUE
+                       /\ UNCHANGED << gen, cursor, droppedSet, workSet, 
+                                       converged, convergedGen, convergedEpoch, 
+                                       eligibleAtConv, halted >>
+            /\ e' = [e EXCEPT ![self] = 0]
+            /\ pc' = [pc EXCEPT ![self] = "w1"]
+            /\ UNCHANGED << epoch, doneGen, doneEpoch, folded, caughtUp, 
+                            catchUpQueue, eligible, resting, disturbed, 
+                            camDone, worldDone, we, doWork >>
+
+W(self) == w1(self) \/ w2(self) \/ w3(self)
+
+c1 == /\ pc[0] = "c1"
+      /\ IF (~resting)
+            THEN /\ IF halted
+                       THEN /\ resting' = TRUE
+                            /\ UNCHANGED eligible
+                       ELSE /\ IF gen <= MaxGen - 4
+                                  THEN /\ \/ /\ \E f \in SUBSET Entry:
+                                                  eligible' = f
+                                             /\ UNCHANGED resting
+                                          \/ /\ resting' = TRUE
+                                             /\ UNCHANGED eligible
+                                  ELSE /\ resting' = TRUE
+                                       /\ UNCHANGED eligible
+                 /\ pc' = [pc EXCEPT ![0] = "c1"]
+            ELSE /\ pc' = [pc EXCEPT ![0] = "c2"]
+                 /\ UNCHANGED << eligible, resting >>
+      /\ UNCHANGED << epoch, gen, cursor, inFlight, doneGen, doneEpoch, folded, 
+                      droppedSet, caughtUp, catchUpQueue, workSet, disturbed, 
+                      camDone, worldDone, converged, convergedGen, 
+                      convergedEpoch, eligibleAtConv, halted, e, we, doWork >>
+
+c2 == /\ pc[0] = "c2"
+      /\ IF converged /\ ~disturbed /\ ~halted
+            THEN /\ \/ /\ \E f \in SUBSET Entry:
+                            /\ eligible' = f
+                            /\ catchUpQueue' = {x \in f : ~Served(x, doneEpoch, doneGen, epoch, caughtUp)}
+                            /\ disturbed' = TRUE
+                    \/ /\ TRUE
+                       /\ UNCHANGED <<catchUpQueue, eligible, disturbed>>
+            ELSE /\ TRUE
+                 /\ UNCHANGED << catchUpQueue, eligible, disturbed >>
+      /\ pc' = [pc EXCEPT ![0] = "c3"]
+      /\ UNCHANGED << epoch, gen, cursor, inFlight, doneGen, doneEpoch, folded, 
+                      droppedSet, caughtUp, workSet, resting, camDone, 
+                      worldDone, converged, convergedGen, convergedEpoch, 
+                      eligibleAtConv, halted, e, we, doWork >>
+
+c3 == /\ pc[0] = "c3"
+      /\ camDone' = TRUE
+      /\ pc' = [pc EXCEPT ![0] = "Done"]
+      /\ UNCHANGED << epoch, gen, cursor, inFlight, doneGen, doneEpoch, folded, 
+                      droppedSet, caughtUp, catchUpQueue, eligible, workSet, 
+                      resting, disturbed, worldDone, converged, convergedGen, 
+                      convergedEpoch, eligibleAtConv, halted, e, we, doWork >>
+
+Cam == c1 \/ c2 \/ c3
+
+d1 == /\ pc[1] = "d1"
+      /\ IF (~worldDone)
+            THEN /\ IF epoch < MaxEpoch
+                       THEN /\ \/ /\ \E r \in SUBSET Entry:
+                                       /\ folded' = [en \in Entry |-> IF en \in r THEN {0} ELSE {}]
+                                       /\ doneGen' = [en \in Entry |-> IF en \in r THEN 0 ELSE -1]
+                                       /\ doneEpoch' = [en \in Entry |-> epoch + 1]
+                                       /\ caughtUp' = [en \in Entry |-> FALSE]
+                                       /\ catchUpQueue' = {}
+                                       /\ droppedSet' = {}
+                                       /\ converged' = FALSE
+                                       /\ epoch' = epoch + 1
+                                       /\ gen' = 0
+                                       /\ cursor' = 1
+                                       /\ workSet' = eligible
+                                  /\ UNCHANGED worldDone
+                               \/ /\ worldDone' = TRUE
+                                  /\ UNCHANGED <<epoch, gen, cursor, doneGen, doneEpoch, folded, droppedSet, caughtUp, catchUpQueue, workSet, converged>>
+                       ELSE /\ worldDone' = TRUE
+                            /\ UNCHANGED << epoch, gen, cursor, doneGen, 
+                                            doneEpoch, folded, droppedSet, 
+                                            caughtUp, catchUpQueue, workSet, 
+                                            converged >>
+                 /\ pc' = [pc EXCEPT ![1] = "d1"]
+            ELSE /\ pc' = [pc EXCEPT ![1] = "Done"]
+                 /\ UNCHANGED << epoch, gen, cursor, doneGen, doneEpoch, 
+                                 folded, droppedSet, caughtUp, catchUpQueue, 
+                                 workSet, worldDone, converged >>
+      /\ UNCHANGED << inFlight, eligible, resting, disturbed, camDone, 
+                      convergedGen, convergedEpoch, eligibleAtConv, halted, e, 
+                      we, doWork >>
+
+World == d1
+
+(* Allow infinite stuttering to prevent deadlock on termination. *)
+Terminating == /\ \A self \in ProcSet: pc[self] = "Done"
+               /\ UNCHANGED vars
+
+Next == Cam \/ World
+           \/ (\E self \in Worker: W(self))
+           \/ Terminating
+
+Spec == /\ Init /\ [][Next]_vars
+        /\ \A self \in Worker : WF_vars(W(self))
+
+Termination == <>(\A self \in ProcSet: pc[self] = "Done")
+
+\* END TRANSLATION 
+
+TypeOK ==
+  /\ epoch \in 0..MaxEpoch
+  /\ gen \in 0..MaxGen
+  /\ cursor \in 1..(N + 1)
+  /\ inFlight \in 0..Cardinality(Worker)
+  /\ \A en \in Entry : doneGen[en] \in -1..MaxGen
+  /\ \A en \in Entry : doneEpoch[en] \in 0..MaxEpoch
+  /\ eligible \subseteq Entry
+  /\ workSet \subseteq Entry
+  /\ catchUpQueue \subseteq Entry
+  /\ catchUpQueue \subseteq eligible
+  /\ droppedSet \subseteq workSet
+
+ConvergedSafe ==
+  converged => \A en \in eligibleAtConv :
+                 doneGen[en] >= convergedGen /\ doneEpoch[en] = convergedEpoch
+
+ConvFrozen ==
+  converged => (gen = convergedGen /\ epoch = convergedEpoch)
+
+ConvergesAtRest == (resting /\ worldDone /\ converged)
+                     ~> AllServed(eligible, doneEpoch, doneGen, epoch, caughtUp)
+
+====
diff --git a/Model/GiScheduler5_p1.cfg b/Model/GiScheduler5_p1.cfg
new file mode 100644 (file)
index 0000000..480b835
--- /dev/null
@@ -0,0 +1,16 @@
+SPECIFICATION Spec
+
+CONSTANTS
+  Worker = {w1, w2}
+  N = 3
+  MaxGen = 5
+  MaxEpoch = 2
+  Policy = 1
+
+INVARIANTS
+  TypeOK
+  ConvergedSafe
+  ConvFrozen
+
+PROPERTY
+  ConvergesAtRest
diff --git a/Model/GiScheduler5_p1_gate.cfg b/Model/GiScheduler5_p1_gate.cfg
new file mode 100644 (file)
index 0000000..01c36dd
--- /dev/null
@@ -0,0 +1,20 @@
+SPECIFICATION Spec
+
+\* Gate configuration: small constants for the -Ptla-check regression
+\* profile (seconds, not minutes). The full N=3 run lives in
+\* GiScheduler5_p1.cfg.
+
+CONSTANTS
+  Worker = {w1, w2}
+  N = 2
+  MaxGen = 5
+  MaxEpoch = 2
+  Policy = 1
+
+INVARIANTS
+  TypeOK
+  ConvergedSafe
+  ConvFrozen
+
+PROPERTY
+  ConvergesAtRest
diff --git a/Model/GiScheduler5_p2.cfg b/Model/GiScheduler5_p2.cfg
new file mode 100644 (file)
index 0000000..48ceaf6
--- /dev/null
@@ -0,0 +1,16 @@
+SPECIFICATION Spec
+
+CONSTANTS
+  Worker = {w1, w2}
+  N = 3
+  MaxGen = 5
+  MaxEpoch = 2
+  Policy = 2
+
+INVARIANTS
+  TypeOK
+  ConvergedSafe
+  ConvFrozen
+
+PROPERTY
+  ConvergesAtRest
diff --git a/Model/GiScheduler5_p2_gate.cfg b/Model/GiScheduler5_p2_gate.cfg
new file mode 100644 (file)
index 0000000..42d033a
--- /dev/null
@@ -0,0 +1,20 @@
+SPECIFICATION Spec
+
+\* Gate configuration: small constants for the -Ptla-check regression
+\* profile (seconds, not minutes). The full N=3 run lives in
+\* GiScheduler5_p2.cfg.
+
+CONSTANTS
+  Worker = {w1, w2}
+  N = 2
+  MaxGen = 5
+  MaxEpoch = 2
+  Policy = 2
+
+INVARIANTS
+  TypeOK
+  ConvergedSafe
+  ConvFrozen
+
+PROPERTY
+  ConvergesAtRest
diff --git a/Model/MeshPublish.tla b/Model/MeshPublish.tla
new file mode 100644 (file)
index 0000000..6e86e76
--- /dev/null
@@ -0,0 +1,101 @@
+---- MODULE MeshPublish ----
+\* Mesh append/publish protocol: can the BVH index permanently MISS a
+\* triangle?
+\*
+\* History (all three designs existed in the Java code on 2026-10-11):
+\*  A. version++ BEFORE count++ in addTriangle — a builder reading the
+\*     new version could build from the old count and tag the tree with
+\*     a version whose last triangle it did not contain: PERMANENT miss
+\*     (ensure() sees equal versions, never rebuilds).
+\*  B. Appender fixed to count-first / version-last (commit flag), but
+\*     BvhStore.build read count at START and tagged the tree with
+\*     version read at the END — appends finishing mid-build tagged the
+\*     tree ahead of its content. Same permanent miss, subtler window.
+\*     Caught by MeshPublishStressTest: 768 misses in 2000 triangles.
+\*  C. FINAL: the tree is tagged with the COUNT captured once at build
+\*     start — tag and content from ONE counter, ONE read. The tag can
+\*     never exceed the content. (MeshStore keeps commit-last volatile
+\*     version for JMM release ordering of triangle data.)
+\*
+\* This module models the appender (design B/C) against both builder
+\* tag designs: Fix = 0 is design B (tag = version read at publish),
+\* Fix = 1 is design C (tag = count captured at build start).
+\* Expected: Fix = 0 violates NeverUnderIndexed; Fix = 1 is green.
+
+EXTENDS Integers, Sequences, TLC
+
+CONSTANTS MaxV,     \* version cap (keeps the model finite)
+          Fix
+
+VARIABLES version,      \* commit counter (volatile in Java)
+          count,        \* triangles fully committed (data + count)
+          appPhase,     \* appender program counter
+          bldPhase,     \* builder program counter
+          bldC,         \* count captured at build start
+          published     \* sequence of <<tag, contentAtCapture>>
+
+vars == <<version, count, appPhase, bldPhase, bldC, published>>
+
+Init ==
+    /\ version = 0
+    /\ count = 0
+    /\ appPhase = "idle"
+    /\ bldPhase = "idle"
+    /\ bldC = 0
+    /\ published = << >>
+
+\* ---- appender: commit triangle (data + count), then version LAST ----
+AppStart ==
+    /\ appPhase = "idle"
+    /\ version < MaxV
+    /\ appPhase' = "mid"
+    /\ count' = count + 1
+    /\ UNCHANGED <<version, bldPhase, bldC, published>>
+
+AppFinish ==
+    /\ appPhase = "mid"
+    /\ appPhase' = "idle"
+    /\ version' = version + 1
+    /\ UNCHANGED <<count, bldPhase, bldC, published>>
+
+\* ---- builder: capture count at start; publish tag ----
+BldCapture ==
+    /\ bldPhase = "idle"
+    \* ensure() semantics: rebuild only when the tag falls behind the
+    \* COMMITTED count (in Java: tree tag = count-at-build, compared
+    \* against mesh.version which equals count after every commit)
+    /\ IF Len(published) = 0 THEN TRUE
+                             ELSE published[Len(published)][1] # count
+    /\ bldC' = count
+    /\ bldPhase' = "publish"
+    /\ UNCHANGED <<version, count, appPhase, published>>
+
+BldPublish ==
+    /\ bldPhase = "publish"
+    \* Fix=0 (design B): tag = version read NOW (end of build).
+    \* Fix=1 (design C): tag = count captured at build start.
+    /\ LET tag == IF Fix = 0 THEN version ELSE bldC
+       IN published' = Append(published, <<tag, bldC>>)
+    /\ bldPhase' = "idle"
+    /\ UNCHANGED <<version, count, appPhase, bldC>>
+
+Next ==
+    \/ AppStart \/ AppFinish
+    \/ BldCapture \/ BldPublish
+
+\* SAFETY: no published tree's tag claims more triangles than it
+\* contains (over-tag at the current version = permanent miss).
+NeverUnderIndexed ==
+    \A i \in 1..Len(published) :
+        published[i][2] >= published[i][1]
+
+TypeOK ==
+    /\ version \in 0..MaxV
+    /\ count \in 0..MaxV
+    /\ version <= count
+    /\ \A i \in 1..Len(published) :
+           /\ published[i][1] \in 0..MaxV
+           /\ published[i][2] \in 0..MaxV
+
+Spec == Init /\ [][Next]_vars
+=============================================================================
diff --git a/Model/MeshPublish_buggy.cfg b/Model/MeshPublish_buggy.cfg
new file mode 100644 (file)
index 0000000..9645e68
--- /dev/null
@@ -0,0 +1,9 @@
+SPECIFICATION Spec
+
+CONSTANTS
+  MaxV = 2
+  Fix = 0
+
+INVARIANTS
+  TypeOK
+  NeverUnderIndexed
diff --git a/Model/MeshPublish_fix.cfg b/Model/MeshPublish_fix.cfg
new file mode 100644 (file)
index 0000000..6467f51
--- /dev/null
@@ -0,0 +1,9 @@
+SPECIFICATION Spec
+
+CONSTANTS
+  MaxV = 2
+  Fix = 1
+
+INVARIANTS
+  TypeOK
+  NeverUnderIndexed
diff --git a/Model/SlotLifecycle.tla b/Model/SlotLifecycle.tla
new file mode 100644 (file)
index 0000000..6591669
--- /dev/null
@@ -0,0 +1,151 @@
+---- MODULE SlotLifecycle ----
+\* Streaming chunk-slot lifecycle: can a reader (query, BVH build or
+\* traversal) racing chunk load/evict see a TORN world — one slot from
+\* version V, another from V+1?
+\*
+\* World model: a fixed set of slots; chunks cycle through them
+\* (load -> live -> evict -> empty, slot reused). Every load/evict bumps
+\* a version and records the world's slot->chunk map at that version
+\* (hist). A reader captures a version, then reads slots one by one; it
+\* is CONSISTENT iff the chunk it sees in every slot equals that slot's
+\* chunk at the captured version.
+\*
+\* Fix = 0 (naive): eviction is immediate and unconditional — an
+\* in-flight reader sees the slot change under it. Expected: violation.
+\* Fix = 1 (the protocol): (a) EVERY structural change — load-finish
+\* AND evict — applies only when NO reader holds a captured version
+\* (the frame barrier: the world map is frozen while any reader holds
+\* a snapshot), and (b) an evicted slot leaves a SHADOW (its last
+\* chunk+data, version-tagged) — the cross-version pin that lets a
+\* stale structure (the BVH Tree pinning its mesh blocks) keep serving
+\* a consistent OLD world until rebuilt. Note the guard makes the
+\* shadow unreadable mid-read by construction; the shadow's job is
+\* cross-version staleness, not mid-read tearing.
+\*
+\* The spec must show: Fix = 0 violates ReaderConsistent (even with no
+\* evictions — a load finishing mid-read tears the world); Fix = 1 is
+\* green.
+
+EXTENDS Integers, Sequences, TLC
+
+CONSTANTS Slots,        \* slot ids
+          Chunks,       \* number of chunk ids (1..Chunks)
+          MaxV,         \* version cap
+          Readers,      \* reader process ids
+          Fix
+
+VARIABLES state,        \* slot -> "empty" | "loading" | "live"
+          chunk,        \* slot -> chunk id (1..Chunks) or NONE
+          shadow,       \* slot -> chunk id or NONE (evicted content)
+          shadowVer,    \* slot -> version at which the shadow went live
+          version,      \* world commit counter
+          hist,         \* sequence of slot->chunk maps, hist[v] is v's map
+          nextChunk,    \* round-robin chunk dispenser
+          rCap,         \* reader -> captured version, 0 = idle
+          rSeen         \* reader -> slot -> chunk id | NONE | UNREAD
+
+vars == <<state, chunk, shadow, shadowVer, version, hist,
+          nextChunk, rCap, rSeen>>
+
+NONE == -1
+UNREAD == 0
+
+\* slot->chunk map for a given slot-state function
+LiveMapOf(st) ==
+    [s2 \in Slots |-> IF st[s2] = "live" THEN chunk[s2] ELSE NONE]
+
+Init ==
+    /\ state = [s \in Slots |-> "empty"]
+    /\ chunk = [s \in Slots |-> NONE]
+    /\ shadow = [s \in Slots |-> NONE]
+    /\ shadowVer = [s \in Slots |-> 0]
+    /\ version = 0
+    /\ hist = << >>
+    /\ nextChunk = 1
+    /\ rCap = [r \in Readers |-> 0]
+    /\ rSeen = [r \in Readers |-> [s \in Slots |-> UNREAD]]
+
+\* ---- loader: empty slot -> loading -> live (version bump) ----
+LoadStart(s) ==
+    /\ state[s] = "empty"
+    /\ nextChunk <= Chunks
+    /\ state' = [state EXCEPT ![s] = "loading"]
+    /\ chunk' = [chunk EXCEPT ![s] = nextChunk]
+    /\ nextChunk' = nextChunk + 1
+    /\ UNCHANGED <<shadow, shadowVer, version, hist, rCap, rSeen>>
+
+LoadFinish(s) ==
+    /\ state[s] = "loading"
+    /\ version < MaxV
+    \* Fix=1 guard: structural changes only at reader quiescence
+    /\ Fix = 0 \/ \A r \in Readers : rCap[r] = 0
+    /\ state' = [state EXCEPT ![s] = "live"]
+    /\ version' = version + 1
+    /\ hist' = Append(hist, LiveMapOf([state EXCEPT ![s] = "live"]))
+    /\ UNCHANGED <<chunk, shadow, shadowVer, nextChunk, rCap, rSeen>>
+
+\* ---- eviction ----
+Evict(s) ==
+    /\ state[s] = "live"
+    /\ version < MaxV
+    \* Fix=1 guard: the frame barrier — no reader may hold a capture
+    /\ Fix = 0 \/ \A r \in Readers : rCap[r] = 0
+    /\ version' = version + 1
+    /\ state' = [state EXCEPT ![s] = "empty"]
+    /\ chunk' = [chunk EXCEPT ![s] = NONE]
+    \* Fix=1 leaves the shadow for pre-eviction readers
+    /\ IF Fix = 1
+       THEN /\ shadow' = [shadow EXCEPT ![s] = chunk[s]]
+            /\ shadowVer' = [shadowVer EXCEPT ![s] = version]
+       ELSE UNCHANGED <<shadow, shadowVer>>
+    /\ hist' = Append(hist, LiveMapOf([state EXCEPT ![s] = "empty"]))
+    /\ UNCHANGED <<nextChunk, rCap, rSeen>>
+
+\* ---- reader: capture, then read slots one by one, then release ----
+ReaderCapture(r) ==
+    /\ rCap[r] = 0
+    /\ version > 0
+    /\ rCap' = [rCap EXCEPT ![r] = version]
+    /\ rSeen' = [rSeen EXCEPT ![r] = [s \in Slots |-> UNREAD]]
+    /\ UNCHANGED <<state, chunk, shadow, shadowVer, version, hist, nextChunk>>
+
+ReaderRead(r, s) ==
+    /\ rCap[r] > 0
+    /\ rSeen[r][s] = UNREAD
+    /\ LET seen ==
+           IF state[s] = "live"
+           THEN chunk[s]
+           ELSE IF Fix = 1 /\ shadowVer[s] >= rCap[r]
+                THEN shadow[s]          \* pinned pre-eviction content
+                ELSE NONE
+       IN rSeen' = [rSeen EXCEPT ![r][s] = seen]
+    /\ UNCHANGED <<state, chunk, shadow, shadowVer, version, hist,
+                  nextChunk, rCap>>
+
+ReaderRelease(r) ==
+    /\ rCap[r] > 0
+    /\ \A s \in Slots : rSeen[r][s] # UNREAD
+    /\ rCap' = [rCap EXCEPT ![r] = 0]
+    /\ UNCHANGED <<state, chunk, shadow, shadowVer, version, hist,
+                  nextChunk, rSeen>>
+
+Next ==
+    \/ \E s \in Slots : LoadStart(s) \/ LoadFinish(s) \/ Evict(s)
+    \/ \E r \in Readers : ReaderCapture(r) \/ ReaderRelease(r)
+                          \/ \E s \in Slots : ReaderRead(r, s)
+
+\* SAFETY: every completed read matches the world at the capture version.
+\* (IF-THEN-ELSE, not =>: TLC evaluates eagerly and IF is the lazy form.)
+ReaderConsistent ==
+    \A r \in Readers :
+        IF rCap[r] > 0 /\ \A s \in Slots : rSeen[r][s] # UNREAD
+        THEN \A s \in Slots : rSeen[r][s] = hist[rCap[r]][s]
+        ELSE TRUE
+
+TypeOK ==
+    /\ version \in 0..MaxV
+    /\ Len(hist) = version
+    /\ \A s \in Slots : state[s] \in {"empty", "loading", "live"}
+
+Spec == Init /\ [][Next]_vars
+=============================================================================
diff --git a/Model/SlotLifecycle_buggy.cfg b/Model/SlotLifecycle_buggy.cfg
new file mode 100644 (file)
index 0000000..941df99
--- /dev/null
@@ -0,0 +1,12 @@
+SPECIFICATION Spec
+
+CONSTANTS
+  Slots = {s1, s2}
+  Chunks = 3
+  MaxV = 4
+  Readers = {r1, r2}
+  Fix = 0
+
+INVARIANTS
+  TypeOK
+  ReaderConsistent
diff --git a/Model/SlotLifecycle_fix.cfg b/Model/SlotLifecycle_fix.cfg
new file mode 100644 (file)
index 0000000..ce54bdf
--- /dev/null
@@ -0,0 +1,12 @@
+SPECIFICATION Spec
+
+CONSTANTS
+  Slots = {s1, s2}
+  Chunks = 3
+  MaxV = 4
+  Readers = {r1, r2}
+  Fix = 1
+
+INVARIANTS
+  TypeOK
+  ReaderConsistent
diff --git a/oracle/GiOracle.java b/oracle/GiOracle.java
new file mode 100644 (file)
index 0000000..ebb6503
--- /dev/null
@@ -0,0 +1,160 @@
+package eu.svjatoslav.aukio.e3d2.oracle;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.headless.Snapshot;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.RenderingContext;
+import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection;
+import eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination;
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightSource;
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape;
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import eu.svjatoslav.aukio.e3d2.systems.gi.GiPass;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * GI oracle: the blocker scene baked through BOTH engines' GI.
+ *
+ * Scene: floor quad, floating blocker plate, one sun at (2,20,6), ambient
+ * 40, sky fill (18,24,34), lightmap density 0.5 units/texel on both sides.
+ * Drift is named, not zero: falloff models, sky handling, fill rules,
+ * convergence criteria differ (Documentation/index.org, oracle policy).
+ */
+public final class GiOracle {
+
+    private static final int W = 640, H = 480;
+    private static final int SKY = 0xFF102030;
+    private static final int FLOOR = 0xFF808080;
+    private static final int PLATE = 0xFF404040;
+
+    public static void main(final String[] args) throws Exception {
+        final File outDir = new File(args.length > 0 ? args[0] : "target/gi-oracle");
+        outDir.mkdirs();
+
+        // ---------- PoC side ----------
+        final Substrate data = new Substrate();
+        data.triangles.addTriangle(-10, 0, -2, 0, 0, -2, 0, 0, 12, FLOOR);
+        data.triangles.addTriangle(0, 0, -2, 10, 0, -2, 10, 0, 12, FLOOR);
+        data.triangles.addTriangle(-10, 0, -2, 0, 0, 12, -10, 0, 12, FLOOR);
+        data.triangles.addTriangle(0, 0, -2, 10, 0, 12, 0, 0, 12, FLOOR);
+        data.triangles.addTriangle(-6, 5, 2, -1, 5, 2, -1, 5, 8, PLATE);
+        data.triangles.addTriangle(-6, 5, 2, -1, 5, 8, -6, 5, 8, PLATE);
+        data.triangles.addTriangle(-6, 5, 2, -1, 5, 8, -1, 5, 2, PLATE);
+        data.triangles.addTriangle(-6, 5, 2, -6, 5, 8, -1, 5, 8, PLATE);
+        data.lights.add(2, 20, 6);
+
+        final GiPass gi = new GiPass(4, 1e9);
+        gi.apply(data);
+        final long dl = System.currentTimeMillis() + 60_000;
+        while (!gi.isQuiescent() && System.currentTimeMillis() < dl) {
+            Thread.sleep(10);
+        }
+        System.out.println("poc converged=" + gi.isConverged());
+        gi.stop();
+
+        data.image = new ImageStore(W, H);
+        data.image.clear(SKY);
+        final double fx = -2 - 8, fy = 0 - 14, fz = 5 - (-6);
+        final double fl = Math.sqrt(fx * fx + fy * fy + fz * fz);
+        data.camera.set(8, 14, -6, Math.atan2(fx, fz), Math.asin(fy / fl), 300);
+        final Kernel kernel = new Kernel();
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        kernel.runOnce(data);
+        writePng(new File(outDir, "gi-poc.png"), data.image.pixels);
+
+        // ---------- Aukio 3D side (y negated; two-sided plates as-is) ----------
+        final ShapeCollection scene = new ShapeCollection();
+        final LightmappedCompositeShape world = new LightmappedCompositeShape();
+        world.setLightmappingEnabled(true);
+        world.setLightmapUnitsPerTexel(0.5);
+        final Color floor = Color.hex("808080");
+        final Color plate = Color.hex("404040");
+        // floor quad (two tris as two quads for stable UVs)
+        world.addShape(new SolidPolygon(new Point3D[] {
+                new Point3D(-10, 0, -2), new Point3D(10, 0, -2),
+                new Point3D(10, 0, 12), new Point3D(-10, 0, 12)}, floor));
+        // blocker plate
+        world.addShape(new SolidPolygon(new Point3D[] {
+                new Point3D(-6, -5, 2), new Point3D(-1, -5, 2),
+                new Point3D(-1, -5, 8), new Point3D(-6, -5, 8)}, plate));
+        scene.addShape(world);
+
+        final LightingManager lighting = new LightingManager();
+        lighting.setAmbientLight(new Color(40, 40, 40, 255));
+        lighting.setSkyColor(new Color(18, 24, 34, 255));
+        lighting.addLight(new LightSource(new Point3D(2, -20, 6),
+                new Color(255, 255, 255, 255), 1.0));
+
+        final double ax = 8, ay = -14, az = -6;
+        final double afx = -2 - ax, afy = 0 - ay, afz = 5 - az;
+        final double afl = Math.sqrt(afx * afx + afy * afy + afz * afz);
+        final double yaw = Math.atan2(-afx, afz);
+        final double pitch = -Math.asin(afy / afl);
+        final String pose = ax + ", " + ay + ", " + az + ", " + yaw + ", " + pitch + ", 0.0";
+
+        // warm-up render FROM THE FINAL VIEWPOINT: the GI snapshot collects
+        // only what the warm-up frame queued — a camera pointing elsewhere
+        // collects 0 triangles and the bake never converges.
+        Snapshot.renderInto(scene, Snapshot.cameraFromPose(pose),
+                new RenderingContext(W, H, 1), SKY);
+
+        final GlobalIllumination aukioGi =
+                new GlobalIllumination(scene, lighting, 4);
+        aukioGi.start();
+        final long dl2 = System.currentTimeMillis() + 300_000;
+        while (!aukioGi.isConverged() && System.currentTimeMillis() < dl2) {
+            Thread.sleep(250);
+        }
+        System.out.println("aukio converged=" + aukioGi.isConverged());
+        aukioGi.stop();
+
+        final RenderingContext ctx = new RenderingContext(W, H, 1);
+        Snapshot.renderInto(scene, Snapshot.cameraFromPose(pose), ctx, SKY);
+        for (int i = 0; i < ctx.pixels.length; i++) {
+            ctx.pixels[i] |= 0xFF000000;
+        }
+        writePng(new File(outDir, "gi-aukio.png"), ctx.pixels);
+
+        // ---------- diff ----------
+        final int[] a = data.image.pixels;
+        final int[] b = ctx.pixels;
+        final int[] heat = new int[W * H];
+        int over = 0;
+        long sumAbs = 0;
+        int maxDiff = 0;
+        for (int i = 0; i < W * H; i++) {
+            int d = 0;
+            for (int ch = 0; ch < 3; ch++) {
+                d = Math.max(d, Math.abs(((a[i] >> (ch * 8)) & 0xFF) - ((b[i] >> (ch * 8)) & 0xFF)));
+            }
+            sumAbs += d;
+            maxDiff = Math.max(maxDiff, d);
+            if (d > 16) {
+                over++;
+                heat[i] = 0xFF000000 | (Math.min(255, d * 4) << 16);
+            }
+        }
+        writePng(new File(outDir, "gi-diff.png"), heat);
+        System.out.printf("pixels>16: %d (%.3f%%), meanAbs=%.2f, max=%d%n",
+                over, 100.0 * over / (W * H), (double) sumAbs / (W * H), maxDiff);
+        System.out.println("out: " + outDir.getAbsolutePath());
+    }
+
+    private static void writePng(final File f, final int[] argb) throws Exception {
+        final BufferedImage img = new BufferedImage(W, H, BufferedImage.TYPE_INT_ARGB);
+        img.setRGB(0, 0, W, H, argb, 0, W);
+        ImageIO.write(img, "png", f);
+    }
+}
diff --git a/oracle/OracleTemple.java b/oracle/OracleTemple.java
new file mode 100644 (file)
index 0000000..72310f3
--- /dev/null
@@ -0,0 +1,110 @@
+package eu.svjatoslav.aukio.e3d2.oracle;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.headless.Snapshot;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.RenderingContext;
+import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d2.RenderMain;
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * Oracle gate: renders the temple scene through BOTH engines and diffs.
+ *
+ * Coordinate mapping PoC -> Aukio 3D: Aukio is -Y up, so (x, y, z) maps to
+ * (x, -y, z); camera pose likewise. Aukio projection scale is width/3, so
+ * the PoC side uses scale = WIDTH/3 too. Same background. Drift is NOT
+ * required to be zero — it must be NAMED (Documentation/index.org, oracle
+ * policy): different fill rules, depth precision, near-plane handling.
+ */
+public final class OracleTemple {
+
+    private static final int W = RenderMain.WIDTH, H = RenderMain.HEIGHT;
+    private static final int DIFF_TOLERANCE = 16;
+
+    public static void main(final String[] args) throws Exception {
+        final File outDir = new File(args.length > 0 ? args[0] : "target/oracle");
+        outDir.mkdirs();
+
+        // --- PoC render ---
+        final Substrate data = RenderMain.buildScene();
+        data.image = new ImageStore(W, H);
+        data.image.clear(RenderMain.SKY);
+        final Kernel kernel = new Kernel();
+        data.camera.set(RenderMain.CAMERA_X, RenderMain.CAMERA_Y,
+                RenderMain.CAMERA_Z, 0, 0, W / 3.0);
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        kernel.runOnce(data);
+        writePng(new File(outDir, "poc.png"), data.image.pixels);
+
+        // --- Aukio 3D render ---
+        final ShapeCollection scene = new ShapeCollection();
+        final int n = data.triangles.triangleCount();
+        for (int tri = 0; tri < n; tri++) {
+            final Point3D[] pts = new Point3D[3];
+            for (int v = 0; v < 3; v++) {
+                pts[v] = new Point3D(data.triangles.at(tri, v, 0),
+                        -data.triangles.at(tri, v, 1),   // PoC +Y up -> Aukio -Y up
+                        data.triangles.at(tri, v, 2));
+            }
+            scene.addShape(new SolidPolygon(pts,
+                    Color.hex(String.format("%06X", data.triangles.color(tri) & 0xFFFFFF))));
+        }
+        final RenderingContext ctx = new RenderingContext(W, H, 1);
+        Snapshot.renderInto(scene, Snapshot.cameraFromPose(
+                RenderMain.CAMERA_X + ", " + (-RenderMain.CAMERA_Y) + ", "
+                        + RenderMain.CAMERA_Z + ", 0.0, 0.0, 0.0"),
+                ctx, RenderMain.SKY);
+        // Aukio's pixel format drops alpha (00RRGGBB) — force opaque for
+        // comparison and display, or PNG viewers render it transparent.
+        for (int i = 0; i < ctx.pixels.length; i++) {
+            ctx.pixels[i] |= 0xFF000000;
+        }
+        writePng(new File(outDir, "aukio.png"), ctx.pixels);
+
+        // --- diff ---
+        final int[] a = data.image.pixels;
+        final int[] b = ctx.pixels;
+        final int[] heat = new int[W * H];
+        int over = 0;
+        long sumAbs = 0;
+        int maxDiff = 0;
+        for (int i = 0; i < W * H; i++) {
+            int d = 0;
+            for (int ch = 0; ch < 3; ch++) {
+                d = Math.max(d, Math.abs(((a[i] >> (ch * 8)) & 0xFF) - ((b[i] >> (ch * 8)) & 0xFF)));
+            }
+            sumAbs += d;
+            if (d > maxDiff) {
+                maxDiff = d;
+            }
+            if (d > DIFF_TOLERANCE) {
+                over++;
+                heat[i] = 0xFF000000 | (Math.min(255, d * 4) << 16);
+            }
+        }
+        writePng(new File(outDir, "diff.png"), heat);
+        System.out.printf("pixels>%d: %d (%.3f%%), meanAbs=%.2f, max=%d%n",
+                DIFF_TOLERANCE, over, 100.0 * over / (W * H),
+                (double) sumAbs / (W * H), maxDiff);
+        System.out.println("out: " + outDir.getAbsolutePath());
+    }
+
+    private static void writePng(final File f, final int[] argb) throws Exception {
+        final BufferedImage img = new BufferedImage(W, H, BufferedImage.TYPE_INT_ARGB);
+        img.setRGB(0, 0, W, H, argb, 0, W);
+        ImageIO.write(img, "png", f);
+    }
+}
diff --git a/oracle/compare.sh b/oracle/compare.sh
new file mode 100755 (executable)
index 0000000..b3f19cc
--- /dev/null
@@ -0,0 +1,20 @@
+#!/bin/sh
+# Oracle gate: temple scene through PoC and Aukio 3D, side by side + diff.
+# Run from anywhere; operates on the PoC repo root.
+set -e
+cd "${0%/*}/.."
+
+mvn -q compile
+
+AUKIO_3D=../aukio-3d
+if [ ! -d "$AUKIO_3D/target/classes" ]; then
+  (cd "$AUKIO_3D" && mvn -q compile)
+fi
+if [ ! -f "$AUKIO_3D/target/cp.txt" ]; then
+  (cd "$AUKIO_3D" && mvn -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt)
+fi
+
+CP="target/classes:$AUKIO_3D/target/classes:$(cat "$AUKIO_3D/target/cp.txt")"
+mkdir -p target/oracle-classes
+javac -cp "$CP" -d target/oracle-classes oracle/OracleTemple.java
+java -cp "target/oracle-classes:$CP" eu.svjatoslav.aukio.e3d2.oracle.OracleTemple target/oracle
diff --git a/oracle/gi-compare.sh b/oracle/gi-compare.sh
new file mode 100755 (executable)
index 0000000..ed634d0
--- /dev/null
@@ -0,0 +1,19 @@
+#!/bin/sh
+# GI oracle: blocker scene through PoC GI and Aukio 3D GI, side by side + diff.
+set -e
+cd "${0%/*}/.."
+
+mvn -q compile
+
+AUKIO_3D=../aukio-3d
+if [ ! -d "$AUKIO_3D/target/classes" ]; then
+  (cd "$AUKIO_3D" && mvn -q compile)
+fi
+if [ ! -f "$AUKIO_3D/target/cp.txt" ]; then
+  (cd "$AUKIO_3D" && mvn -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt)
+fi
+
+CP="target/classes:$AUKIO_3D/target/classes:$(cat "$AUKIO_3D/target/cp.txt")"
+mkdir -p target/oracle-classes
+javac -cp "$CP" -d target/oracle-classes oracle/GiOracle.java
+java -cp "target/oracle-classes:$CP" eu.svjatoslav.aukio.e3d2.oracle.GiOracle target/gi-oracle
diff --git a/pom.xml b/pom.xml
new file mode 100644 (file)
index 0000000..086f585
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <groupId>eu.svjatoslav</groupId>
+  <artifactId>aukio-3d-2</artifactId>
+  <version>2.0.0-SNAPSHOT</version>
+  <packaging>jar</packaging>
+
+  <properties>
+    <maven.compiler.release>21</maven.compiler.release>
+    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+  </properties>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-surefire-plugin</artifactId>
+        <version>3.5.2</version>
+      </plugin>
+    </plugins>
+  </build>
+
+  <dependencies>
+    <dependency>
+      <groupId>net.java.dev.jna</groupId>
+      <artifactId>jna</artifactId>
+      <version>5.14.0</version>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter</artifactId>
+      <version>5.11.4</version>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+
+  <!-- Regression gate for the TLA+ specs: mvn verify -Ptla-check
+       re-runs the small (seconds-scale) configurations of every green
+       spec. Bug-expectation cfgs (*_buggy.cfg) are NOT run — they
+       violate by design as non-vacuity probes. Full-scale runs
+       (GiScheduler5_p1/p2.cfg, N=3) stay manual. -->
+  <profiles>
+    <profile>
+      <id>tla-check</id>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.codehaus.mojo</groupId>
+            <artifactId>exec-maven-plugin</artifactId>
+            <version>3.5.0</version>
+            <executions>
+              <execution>
+                <id>tla-bvh-snapshot</id>
+                <phase>verify</phase>
+                <goals><goal>exec</goal></goals>
+                <configuration>
+                  <executable>java</executable>
+                  <workingDirectory>${project.basedir}/Model</workingDirectory>
+                  <arguments>
+                    <argument>-cp</argument><argument>tla2tools.jar</argument>
+                    <argument>tlc2.TLC</argument>
+                    <argument>-workers</argument><argument>4</argument>
+                    <argument>-config</argument><argument>BvhSnapshot_fix.cfg</argument>
+                    <argument>BvhSnapshot.tla</argument>
+                  </arguments>
+                </configuration>
+              </execution>
+              <execution>
+                <id>tla-mesh-publish</id>
+                <phase>verify</phase>
+                <goals><goal>exec</goal></goals>
+                <configuration>
+                  <executable>java</executable>
+                  <workingDirectory>${project.basedir}/Model</workingDirectory>
+                  <arguments>
+                    <argument>-cp</argument><argument>tla2tools.jar</argument>
+                    <argument>tlc2.TLC</argument>
+                    <argument>-workers</argument><argument>4</argument>
+                    <argument>-deadlock</argument>
+                    <argument>-config</argument><argument>MeshPublish_fix.cfg</argument>
+                    <argument>MeshPublish.tla</argument>
+                  </arguments>
+                </configuration>
+              </execution>
+              <execution>
+                <id>tla-scheduler-p1</id>
+                <phase>verify</phase>
+                <goals><goal>exec</goal></goals>
+                <configuration>
+                  <executable>java</executable>
+                  <workingDirectory>${project.basedir}/Model</workingDirectory>
+                  <arguments>
+                    <argument>-cp</argument><argument>tla2tools.jar</argument>
+                    <argument>tlc2.TLC</argument>
+                    <argument>-workers</argument><argument>4</argument>
+                    <argument>-config</argument><argument>GiScheduler5_p1_gate.cfg</argument>
+                    <argument>GiScheduler5.tla</argument>
+                  </arguments>
+                </configuration>
+              </execution>
+              <execution>
+                <id>tla-scheduler-p2</id>
+                <phase>verify</phase>
+                <goals><goal>exec</goal></goals>
+                <configuration>
+                  <executable>java</executable>
+                  <workingDirectory>${project.basedir}/Model</workingDirectory>
+                  <arguments>
+                    <argument>-cp</argument><argument>tla2tools.jar</argument>
+                    <argument>tlc2.TLC</argument>
+                    <argument>-workers</argument><argument>4</argument>
+                    <argument>-config</argument><argument>GiScheduler5_p2_gate.cfg</argument>
+                    <argument>GiScheduler5.tla</argument>
+                  </arguments>
+                </configuration>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+  </profiles>
+
+</project>
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/BvhBenchMain.java b/src/main/java/eu/svjatoslav/aukio/e3d2/BvhBenchMain.java
new file mode 100644 (file)
index 0000000..54bc5e4
--- /dev/null
@@ -0,0 +1,41 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.gi.GiPass;
+
+/** GI bake timing on a grid terrain — brute force vs BVH comparison bed. */
+public final class BvhBenchMain {
+
+    public static void main(final String[] args) throws InterruptedException {
+        final int grid = args.length > 0 ? Integer.parseInt(args[0]) : 45;
+        final Substrate data = new Substrate();
+        // (grid x grid) quad terrain with a height ripple => 2*grid*grid tris
+        for (int i = 0; i < grid; i++) {
+            for (int j = 0; j < grid; j++) {
+                final double x0 = i * 2.0, z0 = j * 2.0, x1 = x0 + 2, z1 = z0 + 2;
+                final double h00 = Math.sin(i * 0.7) + Math.cos(j * 0.7);
+                final double h10 = Math.sin((i + 1) * 0.7) + Math.cos(j * 0.7);
+                final double h01 = Math.sin(i * 0.7) + Math.cos((j + 1) * 0.7);
+                final double h11 = Math.sin((i + 1) * 0.7) + Math.cos((j + 1) * 0.7);
+                data.triangles.addTriangle(x0, h00, z0, x1, h10, z0, x1, h11, z1, 0xFF909090);
+                data.triangles.addTriangle(x0, h00, z0, x1, h11, z1, x0, h01, z1, 0xFF909090);
+                // GI windings (two-sided convention)
+                data.triangles.addTriangle(x0, h00, z0, x1, h11, z1, x1, h10, z0, 0xFF909090);
+                data.triangles.addTriangle(x0, h00, z0, x0, h01, z1, x1, h11, z1, 0xFF909090);
+            }
+        }
+        data.lights.add(grid, 40, grid);
+        final int tris = data.triangles.triangleCount();
+
+        final GiPass gi = new GiPass(4, 1e9);
+        final long t0 = System.nanoTime();
+        gi.apply(data);                      // kicks the bake
+        final long dl = System.currentTimeMillis() + 600_000;
+        while (!gi.isQuiescent() && System.currentTimeMillis() < dl) {
+            Thread.sleep(20);
+        }
+        final long ms = (System.nanoTime() - t0) / 1_000_000;
+        gi.stop();
+        System.out.println("tris=" + tris + " converged=" + gi.isConverged() + " bake=" + ms + "ms");
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/CornellDemoMain.java b/src/main/java/eu/svjatoslav/aukio/e3d2/CornellDemoMain.java
new file mode 100644 (file)
index 0000000..28699bd
--- /dev/null
@@ -0,0 +1,72 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import eu.svjatoslav.aukio.e3d2.systems.gi.GiPass;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * Cornell-corner GI demo: white floor, red wall, one sun — red bleed on
+ * the floor is the visible proof of indirect bounce. Headless.
+ */
+public final class CornellDemoMain {
+
+    public static void main(final String[] args) throws Exception {
+        final Substrate data = new Substrate();
+        // Surfaces are TWO-SIDED: each face exists in both windings. The cull
+        // keeps exactly one per view (no z-fight); GI gathers with the normal
+        // that points where the bounce should go. This is the thin-geometry
+        // convention, same as Aukio's two-sided rule.
+        final int white = 0xFFC0C0C0;
+        // floor, render faces (normal -Y: the PoC cull keeps normal-away)
+        data.triangles.addTriangle(-10, 0, 0, 0, 0, 0, 0, 0, 20, white);
+        data.triangles.addTriangle(0, 0, 0, 10, 0, 0, 10, 0, 20, white);
+        // floor, GI faces (+Y: gathers the wall's bounce)
+        data.triangles.addTriangle(-10, 0, 0, 0, 0, 20, 0, 0, 0, white);
+        data.triangles.addTriangle(0, 0, 0, 10, 0, 20, 10, 0, 0, white);
+        final int red = 0xFFC03030;
+        // wall, GI faces (+X: sprays the floor)
+        data.triangles.addTriangle(-5, 0, 2, -5, 12, 2, -5, 12, 16, red);
+        data.triangles.addTriangle(-5, 0, 2, -5, 12, 16, -5, 0, 16, red);
+        // wall, render faces (-X)
+        data.triangles.addTriangle(-5, 0, 2, -5, 12, 16, -5, 12, 2, red);
+        data.triangles.addTriangle(-5, 0, 2, -5, 0, 16, -5, 12, 16, red);
+        data.lights.add(5, 20, 10);
+
+        final GiPass gi = new GiPass(4, 1e9);
+        gi.apply(data);
+        final long deadline = System.currentTimeMillis() + 30_000;
+        while (!gi.isQuiescent() && System.currentTimeMillis() < deadline) {
+            Thread.sleep(10);
+        }
+        System.out.println("GI converged=" + gi.isConverged());
+        gi.stop();
+
+        data.image = new ImageStore(640, 480);
+        data.image.clear(0xFF102030);
+        // analytic look-at: camera (10,10,-8) -> floor patch (-2,0,8)
+        final double fx = -2 - 10, fy = 0 - 10, fz = 8 - (-8);
+        final double fl = Math.sqrt(fx * fx + fy * fy + fz * fz);
+        final double yaw = Math.atan2(fx, fz);
+        final double pitch = Math.asin(fy / fl);
+        data.camera.set(10, 10, -8, yaw, pitch, 300);
+        final Kernel kernel = new Kernel();
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        kernel.runOnce(data);
+
+        final BufferedImage img = new BufferedImage(640, 480, BufferedImage.TYPE_INT_ARGB);
+        img.setRGB(0, 0, 640, 480, data.image.pixels, 0, 640);
+        final File out = new File(args.length > 0 ? args[0] : "target/cornell.png");
+        ImageIO.write(img, "png", out);
+        System.out.println("wrote " + out.getAbsolutePath());
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/Fo4ExamMain.java b/src/main/java/eu/svjatoslav/aukio/e3d2/Fo4ExamMain.java
new file mode 100644 (file)
index 0000000..bce4ed5
--- /dev/null
@@ -0,0 +1,82 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.ChunkFrustumCullPass;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import eu.svjatoslav.aukio.e3d2.systems.WorldStreamer;
+
+/**
+ * The synthetic FO4 exam: stream a dense city world (radius = exam
+ * scale), fly slowly, report frame cost and heap. Args:
+ * radius frames [noraster] [chunkcull].
+ *
+ * The wall this exposes: without chunk-level frustum culling the
+ * pipeline transforms EVERY live triangle every frame — the exact
+ * wall the real loader hit (and answered with composite culling).
+ */
+public final class Fo4ExamMain {
+
+    public static void main(final String[] args) {
+        final int radius = args.length > 0 ? Integer.parseInt(args[0]) : 5;
+        final int frames = args.length > 1 ? Integer.parseInt(args[1]) : 20;
+        final boolean noRaster = args.length > 2 && args[2].equals("noraster");
+        final boolean chunkCull = args.length > 3 && args[3].equals("chunkcull");
+        final int nearTess = args.length > 4 ? Integer.parseInt(args[4]) : 14;
+
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(960, 540);
+        data.camera.set(16, 12, 16, 0.35, -0.22, 450);
+
+        final Kernel kernel = new Kernel();
+        kernel.add(new WorldStreamer(new SyntheticCitySource(nearTess, 2, 2), 32, radius));
+        if (chunkCull) {
+            kernel.add(new ChunkFrustumCullPass());
+        }
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        if (!noRaster) {
+            kernel.add(new RasterPass());
+        }
+
+        // load the world around the spawn
+        final long lt0 = System.nanoTime();
+        kernel.runOnce(data);
+        final long loadMs = (System.nanoTime() - lt0) / 1_000_000;
+
+        long liveTris = 0;
+        for (final var c : data.chunks.all()) {
+            liveTris += c.triCount;
+        }
+
+        // warm then measured flight
+        final long t0 = System.nanoTime();
+        for (int f = 0; f < frames; f++) {
+            data.camera.set(16, 12, 16 + f * 0.8, 0.35, -0.22, 450);
+            data.image.clear(0xFF102030);
+            kernel.runOnce(data);
+        }
+        final long ms = (System.nanoTime() - t0) / 1_000_000;
+        if (!noRaster) {
+            final java.awt.image.BufferedImage img = new java.awt.image.BufferedImage(
+                    data.image.width, data.image.height, java.awt.image.BufferedImage.TYPE_INT_ARGB);
+            img.setRGB(0, 0, data.image.width, data.image.height,
+                    data.image.pixels, 0, data.image.width);
+            try {
+                javax.imageio.ImageIO.write(img, "png", new java.io.File("target/fo4-exam.png"));
+            } catch (final java.io.IOException e) {
+                throw new RuntimeException(e);
+            }
+        }
+        System.gc();
+        final long heapMB = (Runtime.getRuntime().totalMemory()
+                - Runtime.getRuntime().freeMemory()) / (1024 * 1024);
+        System.out.printf("radius=%d chunkCull=%b noraster=%b | liveChunks=%d liveTris=%d "
+                        + "appended=%d | load=%dms frame=%.2fms heap=%dMB%n",
+                radius, chunkCull, noRaster, data.chunks.liveCount(), liveTris,
+                data.triangles.triangleCount(), loadMs, (double) ms / frames, heapMB);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/GuiDemoMain.java b/src/main/java/eu/svjatoslav/aukio/e3d2/GuiDemoMain.java
new file mode 100644 (file)
index 0000000..9877ebd
--- /dev/null
@@ -0,0 +1,48 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.GuiStore;
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import eu.svjatoslav.aukio.e3d2.systems.WorldStreamer;
+import eu.svjatoslav.aukio.e3d2.systems.gui.GuiRenderPass;
+import eu.svjatoslav.aukio.e3d2.systems.gui.SdfFont;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/** GUI over the streaming world: panel + SDF title + button, one frame. */
+public final class GuiDemoMain {
+
+    public static void main(final String[] args) throws Exception {
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(640, 480);
+        data.camera.set(16, 9, 16, 0.35, -0.28, 300);
+        data.guiFont = SdfFont.build(48);
+
+        data.gui.panels.add(new GuiStore.Panel(0, 0, 640, 44, 0xC0182028));
+        data.gui.labels.add(new GuiStore.Label(12, 33, 0.45, 0xFFEEE8D5, "AUKIO 3D — streaming"));
+        data.gui.buttons.add(new GuiStore.Button(520, 7, 108, 30, "quit", "Quit"));
+        data.gui.buttons.get(0).hover = true;
+        data.gui.labels.add(new GuiStore.Label(12, 470, 0.22, 0xFF9AB0C0,
+                "sdf text stays crisp at any scale"));
+
+        final Kernel kernel = new Kernel();
+        kernel.add(new WorldStreamer(new ProceduralTerrainSource(), 32, 3));
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        kernel.add(new GuiRenderPass());
+        data.image.clear(0xFF102030);
+        kernel.runOnce(data);
+
+        final BufferedImage img = new BufferedImage(640, 480, BufferedImage.TYPE_INT_ARGB);
+        img.setRGB(0, 0, 640, 480, data.image.pixels, 0, 640);
+        ImageIO.write(img, "png", new File("target/gui-demo.png"));
+        System.out.println("wrote target/gui-demo.png");
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/LightmapDemoMain.java b/src/main/java/eu/svjatoslav/aukio/e3d2/LightmapDemoMain.java
new file mode 100644 (file)
index 0000000..f184d71
--- /dev/null
@@ -0,0 +1,64 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import eu.svjatoslav.aukio.e3d2.systems.gi.GiPass;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * Lightmap demo: floor + floating blocker, one sun. The blocker's shadow
+ * lands as a per-TEXEL gradient on the floor — the visible difference
+ * from per-triangle shading. Headless.
+ */
+public final class LightmapDemoMain {
+
+    public static void main(final String[] args) throws Exception {
+        final Substrate data = new Substrate();
+        final int floorColor = 0xFF808080;
+        data.triangles.addTriangle(-10, 0, -2, 0, 0, -2, 0, 0, 12, floorColor);
+        data.triangles.addTriangle(0, 0, -2, 10, 0, -2, 10, 0, 12, floorColor);
+        data.triangles.addTriangle(-10, 0, -2, 0, 0, 12, -10, 0, 12, floorColor);
+        data.triangles.addTriangle(0, 0, -2, 10, 0, 12, 0, 0, 12, floorColor);
+        final int blockerColor = 0xFF404040;
+        // blocker plate floating over the left half (two-sided)
+        data.triangles.addTriangle(-6, 5, 2, -1, 5, 2, -1, 5, 8, blockerColor);
+        data.triangles.addTriangle(-6, 5, 2, -1, 5, 8, -6, 5, 8, blockerColor);
+        data.triangles.addTriangle(-6, 5, 2, -1, 5, 8, -1, 5, 2, blockerColor);
+        data.triangles.addTriangle(-6, 5, 2, -6, 5, 8, -1, 5, 8, blockerColor);
+        data.lights.add(2, 20, 6);
+
+        final GiPass gi = new GiPass(4, 1e9);
+        gi.apply(data);
+        final long deadline = System.currentTimeMillis() + 30_000;
+        while (!gi.isQuiescent() && System.currentTimeMillis() < deadline) {
+            Thread.sleep(10);
+        }
+        System.out.println("GI converged=" + gi.isConverged());
+        gi.stop();
+
+        data.image = new ImageStore(640, 480);
+        data.image.clear(0xFF102030);
+        // analytic look-at: (8, 14, -6) -> floor center (-2, 0, 5)
+        final double fx = -2 - 8, fy = 0 - 14, fz = 5 - (-6);
+        final double fl = Math.sqrt(fx * fx + fy * fy + fz * fz);
+        data.camera.set(8, 14, -6, Math.atan2(fx, fz), Math.asin(fy / fl), 300);
+        final Kernel kernel = new Kernel();
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        kernel.runOnce(data);
+
+        final BufferedImage img = new BufferedImage(640, 480, BufferedImage.TYPE_INT_ARGB);
+        img.setRGB(0, 0, 640, 480, data.image.pixels, 0, 640);
+        final File out = new File(args.length > 0 ? args[0] : "target/lightmap.png");
+        ImageIO.write(img, "png", out);
+        System.out.println("wrote " + out.getAbsolutePath());
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/Main.java b/src/main/java/eu/svjatoslav/aukio/e3d2/Main.java
new file mode 100644 (file)
index 0000000..604634d
--- /dev/null
@@ -0,0 +1,21 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.DebugStatsPass;
+
+/**
+ * Smoke entry point: proves skeleton compiles and the kernel runs passes.
+ */
+public final class Main {
+
+    public static void main(final String[] args) {
+        final Substrate data = new Substrate();
+        data.triangles.addTriangle(0, 0, 0, 1, 0, 0, 0, 1, 0);
+        data.triangles.addTriangle(0, 0, 1, 1, 0, 1, 0, 1, 1);
+
+        final Kernel kernel = new Kernel();
+        kernel.add(new DebugStatsPass());
+        kernel.runOnce(data);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/ProceduralTerrainSource.java b/src/main/java/eu/svjatoslav/aukio/e3d2/ProceduralTerrainSource.java
new file mode 100644 (file)
index 0000000..fa4397f
--- /dev/null
@@ -0,0 +1,45 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.MeshStore;
+import eu.svjatoslav.aukio.e3d2.systems.WorldStreamer;
+
+/**
+ * Infinite deterministic terrain: 32x32 quad grid per chunk (2048
+ * triangles = exactly 2 blocks). Height and color are pure functions of
+ * world coordinates + a per-chunk hash, so a chunk regenerates
+ * identically after eviction — eviction and reload are lossless.
+ */
+public final class ProceduralTerrainSource implements WorldStreamer.Source {
+
+    private static double height(final double x, final double z) {
+        return 2.0 * Math.sin(x * 0.11) * Math.cos(z * 0.13)
+                + 0.8 * Math.sin(x * 0.31 + z * 0.17)
+                + 0.4 * Math.cos(x * 0.53 - z * 0.41);
+    }
+
+    private static int colorFor(final int cx, final int cz) {
+        int h = (cx * 374761393 + cz * 668265263);
+        h = (h ^ (h >> 13)) * 1274126177;
+        final int v = 0x70 + ((h ^ (h >> 16)) & 0x3F);
+        return 0xFF000000 | (v << 16) | ((v * 3 / 4 + 40) << 8) | (v / 2 + 30);
+    }
+
+    @Override
+    public int generate(final int cx, final int cz, final MeshStore out) {
+        final int color = colorFor(cx, cz);
+        final double x0 = cx * 32.0, z0 = cz * 32.0;
+        int n = 0;
+        for (int i = 0; i < 32; i++) {
+            for (int j = 0; j < 32; j++) {
+                final double ax = x0 + i, az = z0 + j;
+                final double bx = ax + 1, bz = az + 1;
+                final double h00 = height(ax, az), h10 = height(bx, az);
+                final double h01 = height(ax, bz), h11 = height(bx, bz);
+                out.addTriangle(ax, h00, az, bx, h10, az, bx, h11, bz, color);
+                out.addTriangle(ax, h00, az, bx, h11, bz, ax, h01, bz, color);
+                n += 2;
+            }
+        }
+        return n;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/RasterBenchMain.java b/src/main/java/eu/svjatoslav/aukio/e3d2/RasterBenchMain.java
new file mode 100644 (file)
index 0000000..f96db1f
--- /dev/null
@@ -0,0 +1,56 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+
+/** Raster timing on the grid terrain — single-thread vs tile-parallel. */
+public final class RasterBenchMain {
+
+    public static void main(final String[] args) {
+        final int grid = args.length > 0 ? Integer.parseInt(args[0]) : 45;
+        final int frames = args.length > 1 ? Integer.parseInt(args[1]) : 30;
+        final Substrate data = new Substrate();
+        for (int i = 0; i < grid; i++) {
+            for (int j = 0; j < grid; j++) {
+                final double x0 = i * 2.0, z0 = j * 2.0, x1 = x0 + 2, z1 = z0 + 2;
+                final double h00 = Math.sin(i * 0.7) + Math.cos(j * 0.7);
+                final double h10 = Math.sin((i + 1) * 0.7) + Math.cos(j * 0.7);
+                final double h01 = Math.sin(i * 0.7) + Math.cos((j + 1) * 0.7);
+                final double h11 = Math.sin((i + 1) * 0.7) + Math.cos((j + 1) * 0.7);
+                data.triangles.addTriangle(x0, h00, z0, x1, h10, z0, x1, h11, z1, 0xFF909090);
+                data.triangles.addTriangle(x0, h00, z0, x1, h11, z1, x0, h01, z1, 0xFF909090);
+            }
+        }
+        data.camera.set(grid, 14, -10, Math.atan2(grid, grid + 10), -0.5,
+                (args.length > 3 ? Integer.parseInt(args[3]) : 640) / 2.133);
+        final Kernel kernel = new Kernel();
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        final boolean noRaster = args.length > 2 && args[2].equals("noraster");
+        if (!noRaster) {
+            kernel.add(new RasterPass());
+        }
+        final int width = args.length > 3 ? Integer.parseInt(args[3]) : 640;
+        final int height = args.length > 4 ? Integer.parseInt(args[4]) : 480;
+        data.image = new ImageStore(width, height);
+        data.image.clear(0xFF102030);
+
+        // warmup
+        for (int f = 0; f < 5; f++) {
+            data.image.clear(0xFF102030);
+            kernel.runOnce(data);
+        }
+        final long t0 = System.nanoTime();
+        for (int f = 0; f < frames; f++) {
+            data.image.clear(0xFF102030);
+            kernel.runOnce(data);
+        }
+        final long ms = (System.nanoTime() - t0) / 1_000_000;
+        System.out.printf("tris=%d frames=%d total=%dms (%.2f ms/frame)%n",
+                data.triangles.triangleCount(), frames, ms, (double) ms / frames);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/RenderMain.java b/src/main/java/eu/svjatoslav/aukio/e3d2/RenderMain.java
new file mode 100644 (file)
index 0000000..8fa05a3
--- /dev/null
@@ -0,0 +1,107 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * Headless render driver: builds the "temple" scene (floor + three colored
+ * pyramids at increasing depth), runs the pipeline once, writes a PNG.
+ * Ground truth for the pipeline's visual state; oracle scenes later reuse
+ * this shape.
+ */
+public final class RenderMain {
+
+    public static final int WIDTH = 640;
+    public static final int HEIGHT = 480;
+    public static final int SKY = 0xFF102030;
+    public static final double CAMERA_X = 0, CAMERA_Y = 2.5, CAMERA_Z = -6;
+    public static final double SCALE = 300.0;
+
+    /** Pyramid: apex above a square base center, 4 side triangles.
+     *  Winding authored for the PoC cull convention (positive screen area
+     *  when front-facing). */
+    public static void addPyramid(final Substrate data, final double cx, final double cz,
+                                  final double half, final double height, final int color) {
+        final double apexY = height;
+        final double[][] corners = {
+                {cx - half, 0, cz - half},
+                {cx + half, 0, cz - half},
+                {cx + half, 0, cz + half},
+                {cx - half, 0, cz + half},
+        };
+        for (int k = 0; k < 4; k++) {
+            final double[] a = corners[k];
+            final double[] b = corners[(k + 1) % 4];
+            // winding b,a,apex: outward-facing normal (verified by render)
+            data.triangles.addTriangle(b[0], b[1], b[2], a[0], a[1], a[2],
+                    cx, apexY, cz, color);
+        }
+    }
+
+    public static Substrate buildScene() {
+        final Substrate data = new Substrate();
+        // floor: two triangles, y = 0, x in [-15, 15], z in [-4, 40]
+        // (z starts in FRONT of the camera: v1 has no near-plane clipping,
+        //  a straddling triangle is culled whole — documented simplification)
+        // winding matches the wall-triangle convention (negative screen area =
+        // front under the y-down projection — pinned by RasterPipelineTest)
+        final int floor = 0xFF505860;
+        data.triangles.addTriangle(-15, 0, -4, 15, 0, -4, 15, 0, 40, floor);
+        data.triangles.addTriangle(-15, 0, -4, 15, 0, 40, -15, 0, 40, floor);
+        addPyramid(data, -3.0, 10, 1.2, 3.0, 0xFFC04040);   // red, near
+        addPyramid(data, 2.0, 16, 1.5, 4.5, 0xFF40A040);    // green, mid
+        addPyramid(data, -1.0, 24, 2.0, 6.0, 0xFF4060C0);   // blue, far
+        return data;
+    }
+
+    public static void render(final Substrate data, final int background) {
+        data.image = new ImageStore(WIDTH, HEIGHT);
+        data.image.clear(background);
+        data.camera.set(CAMERA_X, CAMERA_Y, CAMERA_Z, 0, 0, SCALE);
+        Kernel.discover().runOnce(data);
+    }
+
+    public static void main(final String[] args) throws Exception {
+        final Substrate data = buildScene();
+        final boolean gi = args.length > 1 && args[1].equals("gi");
+        eu.svjatoslav.aukio.e3d2.systems.gi.GiPass giPass = null;
+        if (gi) {
+            data.lights.add(5, 25, 0);   // sun, up-right-near
+            giPass = new eu.svjatoslav.aukio.e3d2.systems.gi.GiPass(4, 1e9);
+            giPass.apply(data);
+            final long deadline = System.currentTimeMillis() + 30_000;
+            while (!giPass.isQuiescent() && System.currentTimeMillis() < deadline) {
+                Thread.sleep(10);
+            }
+            System.out.println("GI converged=" + giPass.isConverged());
+        }
+        if (gi) {
+            data.image = new ImageStore(WIDTH, HEIGHT);   // explicit pipeline:
+            data.image.clear(SKY);                        // no fog, so GI
+            data.camera.set(CAMERA_X, CAMERA_Y, CAMERA_Z, 0, 0, SCALE);
+            final Kernel kernel = new Kernel();           // shading is readable
+            kernel.add(new eu.svjatoslav.aukio.e3d2.systems.TransformPass());
+            kernel.add(new eu.svjatoslav.aukio.e3d2.systems.CullPass());
+            kernel.add(new eu.svjatoslav.aukio.e3d2.systems.RasterPass());
+            kernel.runOnce(data);
+        } else {
+            render(data, SKY);
+        }
+        if (giPass != null) {
+            giPass.stop();
+        }
+        final BufferedImage img = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_ARGB);
+        img.setRGB(0, 0, WIDTH, HEIGHT, data.image.pixels, 0, WIDTH);
+        final File out = new File(args.length > 0 ? args[0] : "target/temple.png");
+        ImageIO.write(img, "png", out);
+        System.out.println("wrote " + out.getAbsolutePath());
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/StereoDemoMain.java b/src/main/java/eu/svjatoslav/aukio/e3d2/StereoDemoMain.java
new file mode 100644 (file)
index 0000000..d3e02d2
--- /dev/null
@@ -0,0 +1,92 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.CameraStore;
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.StereoMergePass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * Stereo rig: runs the MONO pipeline twice per frame, once per eye,
+ * into per-eye images; a merge pass composites. The rig lives at the
+ * APP layer (passes never reference passes); only the merge is a pass.
+ *
+ * Eye model: parallel cameras offset along the view-right axis by
+ * +-IOD/2 (toe-in converges at one distance and strains; parallel is
+ * the standard).
+ */
+public final class StereoDemoMain {
+
+    /** Interocular distance in world units (exaggerated for terrain
+     *  visibility; physical is ~0.065). */
+    private static final double IOD = 0.4;
+
+    /** Eye pose = base pose offset along view-right by d (world units). */
+    public static void eyePose(final CameraStore cam, final double d) {
+        final double rx = Math.cos(cam.yaw);
+        final double rz = -Math.sin(cam.yaw);
+        cam.x += rx * d;
+        cam.z += rz * d;
+    }
+
+    public static void main(final String[] args) throws Exception {
+        final int w = 640, h = 480;
+        final Substrate data = new Substrate();
+        // temple-style scene: floor + pyramids at varied depths for parallax
+        for (int i = -8; i < 8; i++) {
+            for (int j = 0; j < 16; j++) {
+                data.triangles.addTriangle(i * 2.0, 0, j * 2.0, i * 2.0 + 2, 0, j * 2.0,
+                        i * 2.0 + 2, 0, j * 2.0 + 2, 0xFF808080);
+                data.triangles.addTriangle(i * 2.0, 0, j * 2.0, i * 2.0 + 2, 0, j * 2.0 + 2,
+                        i * 2.0, 0, j * 2.0 + 2, 0xFF808080);
+            }
+        }
+        for (int p = 0; p < 5; p++) {
+            final double px = -6 + p * 3, pz = 6 + p * 5, ph = 2 + p % 3;
+            data.triangles.addTriangle(px - 1.5, 0, pz - 1.5, px + 1.5, 0, pz - 1.5, px, ph, pz, 0xFFC05050);
+            data.triangles.addTriangle(px + 1.5, 0, pz - 1.5, px + 1.5, 0, pz + 1.5, px, ph, pz, 0xFF50C050);
+            data.triangles.addTriangle(px + 1.5, 0, pz + 1.5, px - 1.5, 0, pz + 1.5, px, ph, pz, 0xFF5050C0);
+            data.triangles.addTriangle(px - 1.5, 0, pz + 1.5, px - 1.5, 0, pz - 1.5, px, ph, pz, 0xFFC0C050);
+        }
+        data.image = new ImageStore(w, h);
+        data.imageLeft = new ImageStore(w, h);
+        data.imageRight = new ImageStore(w, h);
+
+        final Kernel eyePipe = new Kernel();
+        eyePipe.add(new TransformPass());
+        eyePipe.add(new CullPass());
+        eyePipe.add(new RasterPass());
+        final Kernel mergePipe = new Kernel();
+        mergePipe.add(new StereoMergePass(StereoMergePass.Mode.ANAGLYPH));
+
+        data.camera.set(0, 3, -4, 0.15, -0.18, 300);
+        for (int eye = 0; eye < 2; eye++) {
+            eyePose(data.camera, eye == 0 ? -IOD / 2 : IOD / 2);
+            final ImageStore img = eye == 0 ? data.imageLeft : data.imageRight;
+            img.clear(0xFF102030);
+            data.image = img;
+            eyePipe.runOnce(data);
+            eyePose(data.camera, eye == 0 ? IOD / 2 : -IOD / 2); // restore
+        }
+        data.image = new ImageStore(w, h);
+        mergePipe.runOnce(data);
+
+        final BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
+        out.setRGB(0, 0, w, h, data.image.pixels, 0, w);
+        ImageIO.write(out, "png", new File("target/stereo-anaglyph.png"));
+        // also dump the raw eyes for inspection
+        for (int eye = 0; eye < 2; eye++) {
+            final BufferedImage ei = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
+            ei.setRGB(0, 0, w, h, eye == 0 ? data.imageLeft.pixels : data.imageRight.pixels, 0, w);
+            ImageIO.write(ei, "png", new File("target/stereo-eye" + eye + ".png"));
+        }
+        System.out.println("wrote target/stereo-{anaglyph,eye0,eye1}.png");
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/StreamingDemoMain.java b/src/main/java/eu/svjatoslav/aukio/e3d2/StreamingDemoMain.java
new file mode 100644 (file)
index 0000000..1be5d77
--- /dev/null
@@ -0,0 +1,59 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import eu.svjatoslav.aukio.e3d2.systems.WorldStreamer;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * Flying camera over infinite procedural terrain. Proves the streaming
+ * loop end to end: live-set tracks the camera, total appended triangles
+ * grow forever, but LIVE memory stays bounded by the radius.
+ */
+public final class StreamingDemoMain {
+
+    public static void main(final String[] args) throws Exception {
+        final int frames = args.length > 0 ? Integer.parseInt(args[0]) : 300;
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(640, 480);
+        final Kernel kernel = new Kernel();
+        kernel.add(new WorldStreamer(new ProceduralTerrainSource(), 32, 3));
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+
+        long maxLiveTris = 0;
+        final long t0 = System.nanoTime();
+        for (int f = 0; f < frames; f++) {
+            final double z = f * 1.5;
+            data.camera.set(16, 9, z, 0.35, -0.28, 300);
+            data.image.clear(0xFF102030);
+            kernel.runOnce(data);
+            long live = 0;
+            for (final var c : data.chunks.all()) {
+                live += c.triCount;
+            }
+            maxLiveTris = Math.max(maxLiveTris, live);
+            if (f == 150) {
+                final BufferedImage img = new BufferedImage(640, 480, BufferedImage.TYPE_INT_ARGB);
+                img.setRGB(0, 0, 640, 480, data.image.pixels, 0, 640);
+                ImageIO.write(img, "png", new File("target/streaming-f150.png"));
+            }
+        }
+        final long ms = (System.nanoTime() - t0) / 1_000_000;
+        final long usedMB = (Runtime.getRuntime().totalMemory()
+                - Runtime.getRuntime().freeMemory()) / (1024 * 1024);
+        System.out.printf("frames=%d (%.2f ms/frame) liveChunks=%d maxLiveTris=%d "
+                        + "appendedTris=%d heapUsedMB=%d%n",
+                frames, (double) ms / frames, data.chunks.liveCount(),
+                maxLiveTris, data.triangles.triangleCount(), usedMB);
+        System.out.println("snapshot: target/streaming-f150.png");
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/StreamingFlightMain.java b/src/main/java/eu/svjatoslav/aukio/e3d2/StreamingFlightMain.java
new file mode 100644 (file)
index 0000000..29dd689
--- /dev/null
@@ -0,0 +1,60 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CameraPass;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import eu.svjatoslav.aukio.e3d2.systems.WorldStreamer;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * Scripted flight over the streaming terrain: the InputStore is fed a
+ * script (forward + a banked turn), the CameraPass flies the camera,
+ * checkpoints render to PNG. This is the input pipeline exercised the
+ * way the slice-4 windowing shell will drive it — events as data.
+ */
+public final class StreamingFlightMain {
+
+    public static void main(final String[] args) throws Exception {
+        final int frames = args.length > 0 ? Integer.parseInt(args[0]) : 240;
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(640, 480);
+        data.camera.set(16, 9, 16, 0.2, -0.15, 300);
+        final CameraPass cam = new CameraPass();
+        final Kernel kernel = new Kernel();
+        kernel.add(new WorldStreamer(new ProceduralTerrainSource(), 32, 3));
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+
+        data.input.keyDown(CameraPass.KEY_W);
+        final long t0 = System.nanoTime();
+        for (int f = 0; f < frames; f++) {
+            if (f == 120) {
+                // 165° bank right + 0.2 rad down at the legacy /50 rate
+                data.input.addMouseDelta(144, 10);
+            }
+            cam.tick(data, 1.0 / 60);
+            data.image.clear(0xFF102030);
+            kernel.runOnce(data);
+            if (f == 60 || f == 180 || f == frames - 1) {
+                final BufferedImage img = new BufferedImage(640, 480, BufferedImage.TYPE_INT_ARGB);
+                img.setRGB(0, 0, 640, 480, data.image.pixels, 0, 640);
+                ImageIO.write(img, "png", new File("target/flight-f" + f + ".png"));
+            }
+        }
+        final long ms = (System.nanoTime() - t0) / 1_000_000;
+        System.out.printf("frames=%d (%.2f ms/frame) final pose x=%.1f y=%.1f z=%.1f "
+                        + "yaw=%.2f liveChunks=%d%n",
+                frames, (double) ms / frames,
+                data.camera.x, data.camera.y, data.camera.z,
+                data.camera.yaw, data.chunks.liveCount());
+        System.out.println("snapshots: target/flight-f{60,180," + (frames - 1) + "}.png");
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/SyntheticCitySource.java b/src/main/java/eu/svjatoslav/aukio/e3d2/SyntheticCitySource.java
new file mode 100644 (file)
index 0000000..ca26115
--- /dev/null
@@ -0,0 +1,124 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.MeshStore;
+import eu.svjatoslav.aukio.e3d2.systems.WorldStreamer;
+
+/**
+ * FO4-class synthetic city: deterministic per chunk, ~20k triangles
+ * (tessellated building boxes + terrain patch). Dense enough that a
+ * radius-5 live set (121 chunks) holds ~2.4M triangles — the spawn
+ * workload class of the real loader, without the game files.
+ */
+public final class SyntheticCitySource implements WorldStreamer.Source {
+
+    /** Tessellation by camera distance, mimicking FO4's LOD rungs:
+     *  near chunks full detail, far chunks decimated. The streamer
+     *  passes the camera's chunk distance via generate()'s cx/cz — the
+     *  source itself is pure function of (cx, cz, detail). */
+    private final int nearTess;
+    private final int farTess;
+    private final int lodRadiusChunks;
+
+    public SyntheticCitySource() {
+        this(14, 2, 2);
+    }
+
+    public SyntheticCitySource(final int nearTess, final int farTess,
+                               final int lodRadiusChunks) {
+        this.nearTess = nearTess;
+        this.farTess = farTess;
+        this.lodRadiusChunks = lodRadiusChunks;
+    }
+
+    private static double hash(final int cx, final int cz, final int i) {
+        int h = cx * 374761393 + cz * 668265263 + i * 224682251;
+        h = (h ^ (h >> 13)) * 127412617;
+        return ((h ^ (h >> 16)) & 0xFFFF) / 65535.0;
+    }
+
+    /** LOD-aware variant: detail level chosen by the chunk's distance
+     *  from the camera chunk. Same world, coarser far field. */
+    public int generate(final int cx, final int cz, final MeshStore out,
+                        final int camCx, final int camCz) {
+        final int ring = Math.max(Math.abs(cx - camCx), Math.abs(cz - camCz));
+        final int tess = ring <= lodRadiusChunks ? nearTess : farTess;
+        return generateWithTess(cx, cz, out, tess);
+    }
+
+    @Override
+    public int generate(final int cx, final int cz, final MeshStore out) {
+        return generateWithTess(cx, cz, out, nearTess);
+    }
+
+    private int generateWithTess(final int cx, final int cz, final MeshStore out,
+                                 final int tess) {
+        int n = 0;
+        final double ox = cx * 32.0, oz = cz * 32.0;
+        // terrain patch: 8x8 quads with a gentle roll
+        for (int i = 0; i < 8; i++) {
+            for (int j = 0; j < 8; j++) {
+                final double x0 = ox + i * 4, z0 = oz + j * 4, x1 = x0 + 4, z1 = z0 + 4;
+                final double h00 = Math.sin(x0 * 0.1) + Math.cos(z0 * 0.1);
+                final double h10 = Math.sin(x1 * 0.1) + Math.cos(z0 * 0.1);
+                final double h01 = Math.sin(x0 * 0.1) + Math.cos(z1 * 0.1);
+                final double h11 = Math.sin(x1 * 0.1) + Math.cos(z1 * 0.1);
+                out.addTriangle(x0, h00, z0, x1, h10, z0, x1, h11, z1, 0xFF707058);
+                out.addTriangle(x0, h00, z0, x1, h11, z1, x0, h01, z1, 0xFF707058);
+                n += 2;
+            }
+        }
+        // buildings: 3x3 grid, deterministic size/tessellation/color
+        for (int b = 0; b < 9; b++) {
+            final double bx = ox + 3 + (b % 3) * 10 + hash(cx, cz, b) * 2;
+            final double bz = oz + 3 + (b / 3) * 10 + hash(cx, cz, b + 50) * 2;
+            final double w = 4 + hash(cx, cz, b + 100) * 4;
+            final double d = 4 + hash(cx, cz, b + 150) * 4;
+            final double hgt = 6 + hash(cx, cz, b + 200) * 18;
+            final int color = 0xFF000000 | (0x60 + (int) (hash(cx, cz, b + 250) * 0x50)) << 16
+                    | (0x50 + (int) (hash(cx, cz, b + 300) * 0x40)) << 8
+                    | (0x50 + (int) (hash(cx, cz, b + 350) * 0x30));
+            n += box(out, bx, bz, w, d, hgt, tess, color);
+        }
+        return n;
+    }
+
+    /** Tessellated box: 4 walls + roof, each a tessxtess grid of quads. */
+    private static int box(final MeshStore out, final double bx, final double bz,
+                           final double w, final double d, final double h,
+                           final int tess, final int color) {
+        int n = 0;
+        for (int i = 0; i < tess; i++) {
+            for (int j = 0; j < tess; j++) {
+                final double u0 = (double) i / tess, u1 = (double) (i + 1) / tess;
+                final double v0 = (double) j / tess, v1 = (double) (j + 1) / tess;
+                // south wall (z = bz)
+                n += quad(out, bx + u0 * w, 0, bz, bx + u1 * w, 0, bz,
+                        bx + u1 * w, v1 * h, bz, bx + u0 * w, v1 * h, bz, color);
+                // north wall
+                n += quad(out, bx + u1 * w, 0, bz + d, bx + u0 * w, 0, bz + d,
+                        bx + u0 * w, v1 * h, bz + d, bx + u1 * w, v1 * h, bz + d, color);
+                // west wall
+                n += quad(out, bx, 0, bz + u0 * d, bx, 0, bz + u1 * d,
+                        bx, v1 * h, bz + u1 * d, bx, v1 * h, bz + u0 * d, color);
+                // east wall
+                n += quad(out, bx + w, 0, bz + u1 * d, bx + w, 0, bz + u0 * d,
+                        bx + w, v1 * h, bz + u0 * d, bx + w, v1 * h, bz + u1 * d, color);
+                // roof
+                n += quad(out, bx + u0 * w, h, bz + v0 * d, bx + u1 * w, h, bz + v0 * d,
+                        bx + u1 * w, h, bz + v1 * d, bx + u0 * w, h, bz + v1 * d, color);
+            }
+        }
+        return n;
+    }
+
+    private static int quad(final MeshStore out,
+                            final double x0, final double y0, final double z0,
+                            final double x1, final double y1, final double z1,
+                            final double x2, final double y2, final double z2,
+                            final double x3, final double y3, final double z3,
+                            final int color) {
+        out.addTriangle(x0, y0, z0, x1, y1, z1, x2, y2, z2, color);
+        out.addTriangle(x0, y0, z0, x2, y2, z2, x3, y3, z3, color);
+        return 2;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/TexturedDemoMain.java b/src/main/java/eu/svjatoslav/aukio/e3d2/TexturedDemoMain.java
new file mode 100644 (file)
index 0000000..89a8899
--- /dev/null
@@ -0,0 +1,59 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * Slice-1 demo: checkerboard floor + pyramids. The checkerboard at a
+ * grazing angle is THE visual test for perspective-correct uv — affine
+ * interpolation would warp the pattern across each triangle's diagonal
+ * seam; correct interpolation keeps every checker square's edges straight.
+ */
+public final class TexturedDemoMain {
+
+    public static void main(final String[] args) throws Exception {
+        final Substrate data = new Substrate();
+        final int checker = data.textures.add(2, 2, new int[]{
+                0xFFE0E0E0, 0xFF404040, 0xFF404040, 0xFFE0E0E0});
+
+        // floor with tiled checkerboard (uv 0..10 → 10x10 checkers)
+        data.triangles.addTriangle(-10, 0, -2, 0, 0, -2, 0, 0, 12, 0xFFFFFFFF,
+                0, 0, 5, 0, 5, 10, checker);
+        data.triangles.addTriangle(0, 0, -2, 10, 0, -2, 10, 0, 12, 0xFFFFFFFF,
+                5, 0, 10, 0, 10, 10, checker);
+        data.triangles.addTriangle(-10, 0, -2, 0, 0, 12, -10, 0, 12, 0xFFFFFFFF,
+                0, 0, 5, 10, 0, 10, checker);
+        data.triangles.addTriangle(0, 0, -2, 10, 0, 12, 0, 0, 12, 0xFFFFFFFF,
+                5, 0, 10, 10, 5, 10, checker);
+        // flat pyramids (untextured path still intact)
+        RenderMain.addPyramid(data, -3, 5, 1.8, 3.0, 0xFF4060C0);
+        RenderMain.addPyramid(data, 3, 8, 2.6, 4.5, 0xFFC04040);
+        RenderMain.addPyramid(data, 0, 1.5, 1.2, 2.0, 0xFF40C060);
+
+        data.image = new ImageStore(640, 480);
+        data.image.clear(0xFF102030);
+        data.camera.set(8, 14, -6, 0, 0, 300);
+        {
+            final double fx = -2 - data.camera.x, fy = 0 - data.camera.y,
+                    fz = 5 - data.camera.z;
+            final double fl = Math.sqrt(fx * fx + fy * fy + fz * fz);
+            data.camera.set(data.camera.x, data.camera.y, data.camera.z,
+                    Math.atan2(fx, fz), Math.asin(fy / fl), data.camera.scale);
+        }
+        Kernel.discover().runOnce(data);
+
+        final File out = new File("target/render-textured.png");
+        out.getParentFile().mkdirs();
+        final BufferedImage img = new BufferedImage(
+                data.image.width, data.image.height, BufferedImage.TYPE_INT_ARGB);
+        img.setRGB(0, 0, data.image.width, data.image.height, data.image.pixels,
+                0, data.image.width);
+        ImageIO.write(img, "png", out);
+        System.out.println("wrote " + out.getAbsolutePath());
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/app/AwtShell.java b/src/main/java/eu/svjatoslav/aukio/e3d2/app/AwtShell.java
new file mode 100644 (file)
index 0000000..fb1fb7c
--- /dev/null
@@ -0,0 +1,777 @@
+package eu.svjatoslav.aukio.e3d2.app;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.SpaceMousePass;
+
+import javax.swing.JFrame;
+import java.awt.BorderLayout;
+import java.awt.Canvas;
+import java.awt.Graphics;
+import java.awt.Toolkit;
+import java.awt.event.KeyEvent;
+import java.awt.event.KeyListener;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseListener;
+import java.awt.event.MouseMotionListener;
+import java.awt.event.MouseWheelEvent;
+import java.awt.event.MouseWheelListener;
+import java.awt.image.BufferStrategy;
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBufferInt;
+import java.awt.image.DirectColorModel;
+import java.awt.image.WritableRaster;
+import java.awt.image.Raster;
+import java.util.IdentityHashMap;
+import java.util.Map;
+import java.util.concurrent.Semaphore;
+
+/**
+ * The windowing shell: a JFrame displaying the ImageStore, AWT
+ * listeners feeding the InputStore, and a loop thread running the
+ * kernel. This is the ONLY place AWT events enter the engine — they
+ * become data in InputStore; passes stay AWT-free.
+ *
+ * RENDER-ON-DEMAND: the loop runs the kernel only when the frame
+ * SIGNATURE changes (world version, camera pose, GUI state, input
+ * activity). A static world with a still camera costs the poll tick,
+ * not a frame.
+ *
+ * PRESENT PATH (ported from legacy ViewPanel — the fullscreen-FPS
+ * design): the raster writes the SAME int[] that backs the presented
+ * BufferedImage (zero copy — a setRGB-per-frame at 2560x1440 was 17.6ms
+ * and capped the display at ~57fps before X overhead; see the slice-4
+ * doc section), and a dedicated present thread page-flips via
+ * BufferStrategy. Excess frames are dropped from the one-slot mailbox,
+ * so the screen always shows the LATEST complete frame. FPS is counted
+ * at blit completion (legacy noteFrameBlitted): the number the user
+ * sees is the SCREEN rate, never the production rate.
+ *
+ * Buffer protocol (frameLock): a buffer is the render BACK only if it
+ * is not the front, not being presented, and not sitting unclaimed in
+ * the mailbox; if the only candidate is mid-present the loop WAITS
+ * (present backpressure = the display's frame pacing). The loop
+ * renders its back buffer lock-free; the protocol guarantees no one
+ * reads it.
+ *
+ * Test seams: the listeners are fields (synthetic events can be fired
+ * headlessly), and blit() can draw into any Graphics (a BufferedImage
+ * in tests). The present thread exists only when open() runs with a
+ * display.
+ */
+public final class AwtShell {
+
+    private final Substrate data;
+    private final Kernel kernel;
+    private final JFrame frame;
+    private final Canvas canvas;
+    private volatile boolean running;
+
+    /** Last rendered frame's signature; null = never rendered. */
+    private String lastSignature;
+
+    /** Guards front/presenting/mailbox and the back-buffer choice. */
+    private final Object frameLock = new Object();
+
+    /** NanoTime of the last present draw (drawImage) and of show()+sync(). */
+    private volatile long lastPresentDrawNanos;
+    private volatile long lastPresentShowNanos;
+
+    /** The published COMPLETE frame; only complete frames are blitted. */
+    private ImageStore front;
+
+    /** The frame the present thread is drawing RIGHT NOW (or null). */
+    private ImageStore presenting;
+
+    /** One-slot frame mailbox; a newer publish REPLACES the occupant
+     *  (dropped frame — the screen skips to the latest). */
+    private ImageStore mailbox;
+
+    /** Released once per published frame. */
+    private final Semaphore presentSignal = new Semaphore(0);
+
+    private volatile boolean presentRunning;
+    private BufferStrategy strategy;
+
+    /** Zero-copy BufferedImage views of ImageStore pixel arrays
+     *  (identity-keyed; 2–3 live entries, cleared on resize). */
+    private final Map<ImageStore, BufferedImage> views = new IdentityHashMap<>();
+
+    /** Every ImageStore allocated for this view (front / presenting /
+     *  mailbox / back). The loop renders into whichever entry is free, so
+     *  rendering and the page-flip OVERLAP: with only the old two-buffer
+     *  swap the loop had to wait for a 4K blit to finish, and the frame
+     *  cost became render + present instead of max(render, present) —
+     *  measured 22.7 ms/frame live against 11.8 ms of render on the
+     *  HouseDemo at 3840x2091. Bounded: three buffers cover front,
+     *  presenting and back; at most four exist. */
+    private final java.util.List<ImageStore> buffers = new java.util.ArrayList<>();
+
+    /** Drag anchor for mouse-look (legacy InputManager rule: deltas
+     *  accumulate only while a button is held; release resets). */
+    private int dragLastX = -1, dragLastY = -1;
+
+    /** Frame pacing (legacy ViewPanel rule): target FPS <= 0 means
+     *  UNLIMITED — benchmark mode, render every tick, no pacing sleep. */
+    private volatile int targetFPS = 60;
+
+    /** Measured FPS over a sliding 500 ms window of BLITTED frames
+     *  (legacy noteFrameBlitted) — the screen rate. Goes STALE at rest
+     *  (render-on-demand blits nothing), so callers must check
+     *  {@link #getLastPublishNanos()} and show "idle", not the stale
+     *  number (a decaying 0.2 fps reading is a lie: idle is 0 fps). */
+    private volatile double measuredFPS = 0;
+    private long fpsWindowStartNanos, fpsWindowFrames;
+
+    /** NanoTime of the last blitted frame; 0 = never. */
+    private volatile long lastPublishNanos;
+
+    /** Fixed-step pacing deadline (legacy maintainTargetFps). */
+    private long nextFrameNanos;
+
+    /** Continuous frame stream (2 slots, waves continue without joins);
+     *  null when the kernel contains a whole-frame painter or
+     *  post-processor that cannot run per slot — those keep the
+     *  sequential path. */
+    private eu.svjatoslav.aukio.e3d2.kernel.FrameStream stream;
+
+    /** The F12 developer tools window; null while closed. */
+    private DeveloperToolsWindow devTools;
+
+    /** 3Dconnexion hot-plug manager feeding InputStore (legacy
+     *  ViewPanel rule: auto-start with the shell, unplug returns
+     *  control to mouse/keyboard). Null when disabled or not opened. */
+    private SpaceMouseManager spaceMouse;
+
+    public int getTargetFPS() { return targetFPS; }
+
+    /** Legacy setFrameRate: 0 or negative = unlimited (benchmark mode). */
+    public void setFrameRate(final int fps) { targetFPS = fps; }
+
+    public double getMeasuredFPS() { return measuredFPS; }
+
+    /** NanoTime of the last blitted frame; 0 = never blitted. */
+    public long getLastPublishNanos() { return lastPublishNanos; }
+
+    public AwtShell(final Substrate data, final Kernel kernel, final String title) {
+        this.data = data;
+        this.kernel = kernel;
+        // Persistent session log as early as possible (idempotent):
+        // the bug report tool collects it. Before any pass wiring so
+        // construction-time output lands in the log too.
+        PersistentLog.install();
+        // The shell owns its input source (SpaceMouseManager, started in
+        // open()) — so it also owns the pass that consumes it. An app
+        // that opens an AwtShell flies with the 3D mouse with zero
+        // wiring (legacy ViewPanel behavior); an app that registered
+        // its own SpaceMousePass keeps it (no duplicate).
+        if (!kernel.has(SpaceMousePass.class)) {
+            kernel.add(new SpaceMousePass());
+        }
+        // A heavyweight Canvas, like legacy ViewPanel: BufferStrategy
+        // page-flipping needs a Canvas (or Window), not a JPanel.
+        canvas = new Canvas() {
+            @Override
+            public void paint(final Graphics g) {
+                blit(g);
+            }
+        };
+        canvas.setBackground(java.awt.Color.BLACK);
+        canvas.setFocusable(true);
+        canvas.addKeyListener(keyListener);
+        canvas.addMouseListener(mouseListener);
+        canvas.addMouseMotionListener(mouseMotionListener);
+        canvas.addMouseWheelListener(mouseWheelListener);
+        frame = new JFrame(title);
+        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
+        frame.setLayout(new BorderLayout());
+        frame.add(canvas, BorderLayout.CENTER);
+        frame.setSize(data.image.width, data.image.height);
+        frame.setResizable(true);
+        // Legacy ViewFrame(title) opens MAXIMIZED_BOTH — every v1 demo
+        // used that constructor, so a v2 demo opens the same way. The
+        // maximized size reaches the loop as an ordinary resize (the
+        // back buffer and projectionScale follow the canvas). Opt out
+        // with -De3d2.maximized=false for fixed-size scripted runs.
+        if (!"false".equals(System.getProperty("e3d2.maximized"))) {
+            frame.setExtendedState(JFrame.MAXIMIZED_BOTH);
+        }
+        // resize only INVALIDATES the frame: the actual reallocation of
+        // the back buffer happens on the loop thread in loopTick, so the
+        // EDT never swaps the store the renderer is writing mid-frame
+        canvas.addComponentListener(new java.awt.event.ComponentAdapter() {
+            @Override
+            public void componentResized(final java.awt.event.ComponentEvent e) {
+                lastSignature = null;
+            }
+        });
+    }
+
+    /** Projection scale (pixels per unit of x/z at z=1) for a viewport
+     *  width. Default is the legacy rule width/3 — content scales with
+     *  window width, vertical FOV follows height. Apps override for a
+     *  different FOV policy. */
+    protected double projectionScale(final int width) {
+        return width / 3.0;
+    }
+
+    /** Allocates an ImageStore whose pixels ARE a TYPE_INT_RGB
+     *  BufferedImage's backing array (zero copy, and the native 32-bit
+     *  format the blit path wants). Engine pixels are always
+     *  opaque 0xFF-alpha, which TYPE_INT_RGB treats as opaque anyway. */
+    private ImageStore newImageStore(final int w, final int h) {
+        final BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
+        final int[] px = ((DataBufferInt) img.getRaster().getDataBuffer()).getData();
+        final ImageStore store = new ImageStore(w, h, px);
+        synchronized (frameLock) {
+            views.put(store, img);
+        }
+        return store;
+    }
+
+    private BufferedImage viewOf(final ImageStore img) {
+        synchronized (frameLock) {
+            final BufferedImage view = views.get(img);
+            if (view != null) {
+                return view;
+            }
+            // A view must ALWAYS share the store's array. A copying fallback
+            // here was the "every second frame is a frozen image" bug: the
+            // cached copy froze whatever the buffer held when the view was
+            // first needed (blank right after a resize), and every rotation
+            // through that buffer re-presented the freeze.
+            final BufferedImage created = new BufferedImage(
+                    new DirectColorModel(24, 0x00FF0000, 0x0000FF00, 0x000000FF),
+                    Raster.createPackedRaster(
+                            new DataBufferInt(img.pixels, img.pixels.length),
+                            img.width, img.height, img.width,
+                            new int[]{0x00FF0000, 0x0000FF00, 0x000000FF}, null),
+                    false, null);
+            views.put(img, created);
+            return created;
+        }
+    }
+
+    /** Fallback paint (expose events, tests): draws the PUBLISHED
+     *  frame — a complete render. Falls back to data.image only before
+     *  the first publish (headless test seam). The drawImage is NOT
+     *  lock-held: front is never written while it is front (the swap
+     *  protocol), and a rare expose paint racing a swap can tear
+     *  cosmetically at worst — the present thread owns the screen. */
+    void blit(final Graphics g) {
+        final ImageStore img;
+        synchronized (frameLock) {
+            img = front != null ? front : data.image;
+        }
+        g.drawImage(viewOf(img), 0, 0, null);
+        noteFramePublished();
+    }
+
+    /**
+     * The pipeline renders into per-frame screen slots and runs stage 10/20
+     * passes concurrently. Passes that paint or post-process the WHOLE
+     * frame outside a slot (overlays, fog, GUI, stereo merge, diagnostics)
+     * cannot take a slot, so a kernel containing them keeps the sequential
+     * path — same pixels, no pipelining.
+     */
+    private boolean streamEligible() {
+        // Measured 2026-10-22 at 4K: the stream is output-identical and
+        // slightly SLOWER than the inline path (live 44 vs 48-49 fps,
+        // headless 5.45-5.83 vs 5.33-5.61 ms), because the pool is already
+        // saturated at this machine's 12 physical cores (95.7% utilisation
+        // at 12 threads, 70% of the 24 logical SMT threads). Kept as an
+        // opt-in path: -De3d2.stream=true.
+        if (!Boolean.getBoolean("e3d2.stream")) {
+            return false;
+        }
+        return !kernel.has(eu.svjatoslav.aukio.e3d2.systems.LinePass.class)
+                && !kernel.has(eu.svjatoslav.aukio.e3d2.systems.FogPass.class)
+                && !kernel.has(eu.svjatoslav.aukio.e3d2.systems.StereoMergePass.class)
+                && !kernel.has(eu.svjatoslav.aukio.e3d2.systems.gui.GuiRenderPass.class)
+                && !kernel.has(eu.svjatoslav.aukio.e3d2.systems.gui.DiagnosticsHudPass.class);
+    }
+
+    /** Runs whole-frame (stage 0) passes on the loop thread, in order():
+     *  camera/input first, then the background systems (GI). */
+    private void runStageZeroPasses() {
+        for (final eu.svjatoslav.aukio.e3d2.kernel.Pass pass : kernel.passList()) {
+            if (pass.stage() == 0) {
+                pass.apply(data);
+            }
+        }
+    }
+
+    /** A buffer nobody is reading: not front, not mid-present, not the
+     *  unclaimed mailbox entry. Grows the pool (bounded) instead of
+     *  waiting; waits only when the pool is capped and all are busy. */
+    private ImageStore acquireFreeImage() {
+        synchronized (frameLock) {
+            while (true) {
+                for (final ImageStore b : buffers) {
+                    if (b != front && b != presenting && b != mailbox
+                            && b.width == data.image.width
+                            && b.height == data.image.height) {
+                        return b;
+                    }
+                }
+                if (buffers.size() < 4) {
+                    final ImageStore created = newImageStore(data.image.width, data.image.height);
+                    buffers.add(created);
+                    return created;
+                }
+                try {
+                    frameLock.wait();
+                } catch (final InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    throw new IllegalStateException("acquire interrupted", e);
+                }
+            }
+        }
+    }
+
+    /** Publishes a COMPLETE frame (pipeline completion callback). */
+    private void publishImage(final ImageStore done) {
+        synchronized (frameLock) {
+            front = done;
+            mailbox = done;
+            presentSignal.release();
+            frameLock.notifyAll();
+        }
+    }
+
+    /** Frame-change signature: render only when this changes. Note:
+     *  input is NOT read here — mouse-driven changes surface through
+     *  the camera pose; reading (consuming) input would steal it from
+     *  the CameraPass. */
+    static String signature(final Substrate data) {
+        final StringBuilder sb = new StringBuilder(96);
+        sb.append(data.triangles.version()).append('|')
+                .append(data.camera.x).append(',').append(data.camera.y)
+                .append(',').append(data.camera.z).append(',')
+                .append(data.camera.yaw).append(',').append(data.camera.pitch);
+        for (final var b : data.gui.buttons) {
+            sb.append(b.hover ? 'h' : '.').append(b.pressed ? 'p' : '.');
+        }
+        sb.append(data.gui.clicks.size());
+        // GI progress is FRAME INPUT: the raster samples the light field,
+        // which starts at zero and fills during the bake. Without these
+        // stamps a scene rendered before its first generation lands stays
+        // black on screen until something else (camera, input) changes the
+        // signature — the HouseDemo opened black until the user moved.
+        sb.append('|').append(data.giLight == null ? 0 : data.giLight.version())
+                .append('|')
+                .append(data.giLightmaps == null ? 0 : data.giLightmaps.version());
+        return sb.toString();
+    }
+
+    public void open() {
+        running = true;
+        frame.setVisible(true);
+        // legacy ViewPanel: SpaceMouse support starts with the shell
+        // (disable with -De3d2.spacemouse=false, legacy -De3d.spacemouse)
+        if (!"false".equals(System.getProperty("e3d2.spacemouse"))) {
+            spaceMouse = new SpaceMouseManager(data.input);
+            spaceMouse.start();
+        }
+        // legacy ViewPanel: with a BufferStrategy the OS-repaint path is
+        // dead weight — every visible pixel comes from the present thread
+        canvas.setIgnoreRepaint(true);
+        canvas.createBufferStrategy(3);
+        strategy = canvas.getBufferStrategy();
+        if (streamEligible()) {
+            final eu.svjatoslav.aukio.e3d2.kernel.FrameStream candidate =
+                    new eu.svjatoslav.aukio.e3d2.kernel.FrameStream(
+                            data, kernel, this::acquireFreeImage, this::publishImage);
+            if (candidate.hasWaves()) {
+                stream = candidate;
+            }
+        }
+        presentRunning = true;
+        final Thread present = new Thread(this::presentLoop, "awt-shell-present");
+        present.setDaemon(true);
+        present.start();
+        canvas.requestFocusInWindow();
+        // focus requests right after setVisible can silently lose — retry
+        // once the window is actually up (keyboard input depends on it)
+        javax.swing.SwingUtilities.invokeLater(canvas::requestFocusInWindow);
+        final Thread loop = new Thread(this::loop, "awt-shell-loop");
+        loop.setDaemon(true);
+        loop.start();
+    }
+
+    /** The present thread (legacy ViewPanel presentLoop): drains the
+     *  mailbox, page-flips, counts the blit. Never holds frameLock
+     *  during the slow drawImage — the back-buffer protocol makes that
+     *  safe, and holding it would serialize present against publish.
+     *
+     *  HEARTBEAT: when no frame arrives for 250 ms, re-present the
+     *  current front. Render-on-demand publishes once and idles, but
+     *  the X window can be erased AFTER the show (background erase on
+     *  map/resize with ignoreRepaint, lost buffers not reported by
+     *  contentsLost — reproduced on bare Xvfb: a single show() stays
+     *  black while continuous shows persist). The heartbeat makes every
+     *  such loss self-heal within 250 ms at the cost of 4 idle blits/s. */
+    /** Nanos of the last present draw (drawImage). Diagnostic. */
+    public long presentDrawNanos() {
+        return lastPresentDrawNanos;
+    }
+
+    /** Nanos of the last show()+sync(). Diagnostic. */
+    public long presentShowNanos() {
+        return lastPresentShowNanos;
+    }
+
+    private void presentLoop() {
+        while (presentRunning) {
+            try {
+                presentSignal.tryAcquire(250, java.util.concurrent.TimeUnit.MILLISECONDS);
+            } catch (final InterruptedException e) {
+                break;
+            }
+            final ImageStore img;
+            final boolean fresh;
+            synchronized (frameLock) {
+                if (mailbox != null) {
+                    img = mailbox;
+                    mailbox = null;
+                    fresh = true;
+                } else {
+                    img = front; // heartbeat re-present
+                    fresh = false;
+                }
+                presenting = img;
+            }
+            final boolean trace = Boolean.getBoolean("e3d2.present.trace");
+            if (trace) {
+                System.out.println("present: claimed "
+                        + (img == null ? "null" : img.width + "x" + img.height));
+            }
+            if (img == null) {
+                continue;
+            }
+            try {
+                BufferStrategy bs = strategy;
+                if (bs == null) {
+                    continue;
+                }
+                // The full BufferStrategy idiom: contentsRestored()
+                // re-does the DRAW, contentsLost() re-does the SHOW.
+                // Dropping the outer loop was the resize-to-black bug:
+                // a resize evicts the back buffer, show() presents a
+                // lost (black) frame, and render-on-demand publishes
+                // only ONE frame after a resize — the black persisted.
+                int iter = 0;
+                long drawNanos = 0;
+                do {
+                    do {
+                        final Graphics g = bs.getDrawGraphics();
+                        final long drawStart = System.nanoTime();
+                        try {
+                            g.drawImage(viewOf(img), 0, 0, null);
+                        } finally {
+                            g.dispose();
+                        }
+                        drawNanos = System.nanoTime() - drawStart;
+                        iter++;
+                        if (iter > 100) { // pathological loss storm: drop
+                            break;
+                        }
+                    } while (bs.contentsRestored());
+                    final long showStart = System.nanoTime();
+                    bs.show();
+                    Toolkit.getDefaultToolkit().sync();
+                    lastPresentShowNanos = System.nanoTime() - showStart;
+                    lastPresentDrawNanos = drawNanos;
+                    if (trace) {
+                        System.out.println("present: img=" + img.width + "x"
+                                + img.height + " iter=" + iter + " lost="
+                                + bs.contentsLost());
+                    }
+                    if (iter > 100) {
+                        break;
+                    }
+                } while (bs.contentsLost());
+                // only REAL frames count into the FPS meter — heartbeat
+                // re-presents would fake a ~4 fps reading at idle and
+                // defeat the dev-tools idle detection
+                if (fresh) {
+                    noteFramePublished();
+                }
+            } catch (final IllegalStateException e) {
+                // strategy died with a window transition: recreate once,
+                // drop this frame — the next publish lands on the new one
+                if (trace) {
+                    System.out.println("present: IllegalStateException ("
+                            + e.getMessage() + ") — recreating strategy");
+                }
+                try {
+                    canvas.createBufferStrategy(3);
+                    strategy = canvas.getBufferStrategy();
+                } catch (final IllegalStateException ignored) {
+                    // window going away; close() will end this thread
+                }
+            } finally {
+                synchronized (frameLock) {
+                    presenting = null;
+                    frameLock.notifyAll();
+                }
+            }
+        }
+    }
+
+    private void loop() {
+        nextFrameNanos = System.nanoTime();
+        while (running && frame.isDisplayable()) {
+            loopTick();
+            if (!paceFrame()) {
+                return;
+            }
+        }
+    }
+
+    /** Pacing between ticks (legacy maintainTargetFps): fixed-step
+     *  deadline, sleep the remainder, resync when fallen behind instead
+     *  of catching up. Unlimited FPS (targetFPS <= 0) paces NOTHING —
+     *  benchmark mode runs the loop back to back. Returns false when
+     *  interrupted. */
+    private boolean paceFrame() {
+        final int cap = targetFPS;
+        if (cap <= 0) {
+            return true;
+        }
+        final long now = System.nanoTime();
+        nextFrameNanos += 1_000_000_000L / cap;
+        if (nextFrameNanos < now) {
+            nextFrameNanos = now; // fell behind: resync, don't catch up
+        }
+        final long sleep = nextFrameNanos - now;
+        if (sleep > 0) {
+            try {
+                Thread.sleep(sleep / 1_000_000L, (int) (sleep % 1_000_000L));
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /** Counts one blitted frame into the FPS measurement window. */
+    void noteFramePublished() {
+        fpsWindowFrames++;
+        final long now = System.nanoTime();
+        lastPublishNanos = now;
+        if (fpsWindowStartNanos == 0) {
+            fpsWindowStartNanos = now;
+            fpsWindowFrames = 0;
+            return;
+        }
+        final long elapsed = now - fpsWindowStartNanos;
+        if (elapsed >= 500_000_000L) {
+            measuredFPS = fpsWindowFrames * 1e9 / elapsed;
+            fpsWindowStartNanos = now;
+            fpsWindowFrames = 0;
+        }
+    }
+
+    /** F12 toggles the developer tools window (legacy
+     *  ViewPanel.showDeveloperToolsPanel: a second F12 disposes it).
+     *  Headless = no-op, so synthetic F12 in tests stays cheap. */
+    public void showDeveloperTools() {
+        if (java.awt.GraphicsEnvironment.isHeadless()) {
+            return;
+        }
+        if (devTools != null && devTools.isVisible()) {
+            devTools.dispose();
+            devTools = null;
+            return;
+        }
+        devTools = new DeveloperToolsWindow(frame, this, data);
+        devTools.setVisible(true);
+    }
+
+    /** One render-on-demand tick. Renders when the signature moved OR
+     *  when input activity is pending — activity must be a CONDITION,
+     *  never a signature ingredient: as an ingredient it renders only
+     *  on the idle->active transition, then starves with deltas
+     *  pending while the flag sits constant (the drag-look freeze).
+     *  The passes consume the activity inside runOnce; when it is
+     *  fully consumed and the world is still, the loop idles. */
+    void loopTick() {
+        final boolean trace = Boolean.getBoolean("e3d2.present.trace");
+        // resize handling on the LOOP thread: grow/shrink the back buffer
+        // to the canvas size + legacy projection rule (content grows with
+        // window width). The EDT never touches the render target.
+        final int pw = canvas.getWidth(), ph = canvas.getHeight();
+        if (pw > 0 && ph > 0) {
+            final int w = Math.max(64, pw), h = Math.max(64, ph);
+            if (w != data.image.width || h != data.image.height) {
+                if (trace) {
+                    System.out.println("loop: resize " + data.image.width + "x"
+                            + data.image.height + " -> " + w + "x" + h);
+                }
+                data.camera.scale = projectionScale(w);
+                lastSignature = null;
+                synchronized (frameLock) {
+                    // order matters: clear first, then allocate — allocating
+                    // registers the new store's view, and clearing afterwards
+                    // used to drop it (the stale-view bug above)
+                    views.clear();
+                    buffers.clear();
+                    data.image = newImageStore(w, h);
+                    buffers.add(data.image);
+                }
+            }
+        }
+        final String sig = signature(data);
+        // Unlimited FPS is benchmark mode (legacy ViewPanel rule):
+        // render continuously even when the scene is static, so the
+        // measured rate reflects maximum achievable throughput.
+        if (targetFPS <= 0 || !sig.equals(lastSignature) || data.input.hasActivity()) {
+            lastSignature = sig;
+            if (stream != null) {
+                // Whole-frame passes (camera, background systems) on this
+                // thread; then the frame's waves run on the pool, two
+                // frames deep, no joins — see FrameStream.
+                runStageZeroPasses();
+                stream.startFrame();
+            } else {
+                // NO frame clear here: RasterPass clears each tile itself,
+                // in parallel, right before it rasters.
+                kernel.runOnce(data);
+            }
+            if (trace) {
+                System.out.println("loop: rendered, entering swap");
+            }
+            // publish the COMPLETE frame and pick the next back buffer.
+            // The back must be a buffer nobody reads: not the frame just
+            // published (front/mailbox), not the one mid-present. Any
+            // OTHER pooled buffer is free — that is what lets the render
+            // loop run while the present thread is still blitting. Only
+            // when the pool cannot supply one (first frames after a
+            // resize) does it wait, and that wait resolves as soon as the
+            // blit ends.
+            synchronized (frameLock) {
+                final ImageStore done = data.image;
+                if (!buffers.contains(done)) {
+                    buffers.add(done);
+                }
+                ImageStore back = null;
+                for (final ImageStore b : buffers) {
+                    if (b != done && b != front && b != presenting && b != mailbox
+                            && b.width == done.width && b.height == done.height) {
+                        back = b;
+                        break;
+                    }
+                }
+                if (back == null && buffers.size() < 4) {
+                    back = newImageStore(done.width, done.height);
+                    buffers.add(back);
+                }
+                while (back == null) {
+                    try {
+                        frameLock.wait();
+                    } catch (final InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                        return;
+                    }
+                    for (final ImageStore b : buffers) {
+                        if (b != done && b != front && b != presenting && b != mailbox
+                                && b.width == done.width && b.height == done.height) {
+                            back = b;
+                            break;
+                        }
+                    }
+                }
+                data.image = back;
+                front = done;
+                mailbox = done;
+                presentSignal.release();
+                if (Boolean.getBoolean("e3d2.present.trace")) {
+                    System.out.println("publish: " + done.width + "x" + done.height);
+                }
+            }
+        }
+    }
+
+    public void close() {
+        running = false;
+        stream = null;
+        presentRunning = false;
+        presentSignal.release();
+        if (spaceMouse != null) {
+            spaceMouse.stop();
+            spaceMouse = null;
+        }
+        synchronized (frameLock) {
+            frameLock.notifyAll();
+        }
+        if (devTools != null) {
+            devTools.dispose();
+            devTools = null;
+        }
+        frame.dispose();
+    }
+
+    // ---- input adapters: AWT events -> InputStore data ----
+
+    final KeyListener keyListener = new KeyListener() {
+        @Override public void keyTyped(final KeyEvent e) { }
+        @Override public void keyPressed(final KeyEvent e) {
+            // F12 is a SHELL key (legacy InputManager rule): it toggles
+            // the developer tools window and never reaches the input data.
+            if (e.getKeyCode() == KeyEvent.VK_F12) {
+                showDeveloperTools();
+                return;
+            }
+            data.input.keyDown(e.getKeyCode());
+        }
+        @Override public void keyReleased(final KeyEvent e) {
+            data.input.keyUp(e.getKeyCode());
+        }
+    };
+
+    final MouseListener mouseListener = new MouseListener() {
+        @Override public void mouseClicked(final MouseEvent e) { }
+        @Override public void mousePressed(final MouseEvent e) {
+            data.input.setMouseButton(e.getButton(), true);
+        }
+        @Override public void mouseReleased(final MouseEvent e) {
+            data.input.setMouseButton(e.getButton(), false);
+            // next drag starts a fresh anchor — no jump (legacy:
+            // oldMouseCoordinatesWhenDragging = null)
+            dragLastX = -1;
+            dragLastY = -1;
+        }
+        @Override public void mouseEntered(final MouseEvent e) { }
+        @Override public void mouseExited(final MouseEvent e) { }
+    };
+
+    final MouseWheelListener mouseWheelListener = new MouseWheelListener() {
+        /** Legacy handleMouseScrolling: wheel goes to the camera as
+         *  vertical thrust (scroll up = fly up). AWT sign: positive
+         *  rotation = wheel away = scroll DOWN. */
+        @Override public void mouseWheelMoved(final MouseWheelEvent e) {
+            data.input.addWheel(e.getWheelRotation());
+        }
+    };
+
+    final MouseMotionListener mouseMotionListener = new MouseMotionListener() {
+        /** Hover tracks position only (GUI hit-testing). Camera motion
+         *  comes from DRAGGING, exactly like the legacy InputManager:
+         *  deltas accumulate only while a button is held. */
+        @Override public void mouseMoved(final MouseEvent e) {
+            data.input.setMousePos(e.getX(), e.getY());
+        }
+
+        @Override public void mouseDragged(final MouseEvent e) {
+            data.input.setMousePos(e.getX(), e.getY());
+            if (dragLastX >= 0) {
+                data.input.addMouseDelta(e.getX() - dragLastX, e.getY() - dragLastY);
+            }
+            dragLastX = e.getX();
+            dragLastY = e.getY();
+        }
+    };
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/app/BugReport.java b/src/main/java/eu/svjatoslav/aukio/e3d2/app/BugReport.java
new file mode 100644 (file)
index 0000000..2ed949f
--- /dev/null
@@ -0,0 +1,244 @@
+package eu.svjatoslav.aukio.e3d2.app;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+import javax.imageio.ImageIO;
+import java.awt.GraphicsDevice;
+import java.awt.GraphicsEnvironment;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+
+/**
+ * Writes a self-contained bug report directory — the e3d2 port of
+ * legacy gui.BugReport (2026-10-13). A new directory
+ * {@code bugreport-<timestamp>} is created under the report root
+ * ({@code -De3d2.bugreport.dir}, default
+ * {@code ~/.local/share/aukio/bugreports} — same as legacy so all
+ * reports land in one place) containing:
+ * <ul>
+ *   <li>{@code description.txt} — what the user wrote in the popup</li>
+ *   <li>{@code info.txt} — camera pose (five fields; e3d2 has no
+ *       roll), view/screen resolution, measured fps, heap, java/os
+ *       versions, launch command</li>
+ *   <li>{@code screenshot.png} — a copy of the current frame buffer</li>
+ *   <li>{@code histogram.txt} — live-object histogram from
+ *       {@code jcmd <pid> GC.class_histogram} (skipped when jcmd is
+ *       unavailable or hangs)</li>
+ *   <li>{@code threads.txt} — full thread dump (hang diagnosis)</li>
+ *   <li>{@code e3d2.log} / {@code e3d2.log.1} — the persistent logs
+ *       ({@link PersistentLog}), including output from a session that
+ *       froze before the report could be made</li>
+ * </ul>
+ * Legacy also copies a telemetry line; e3d2 has no Telemetry subsystem
+ * yet — noted in info.txt instead.
+ */
+public final class BugReport {
+
+    private static final DateTimeFormatter DIR_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
+
+    private BugReport() {
+    }
+
+    /** Report root: -De3d2.bugreport.dir, default shared with legacy. */
+    public static File reportRoot() {
+        final String override = System.getProperty("e3d2.bugreport.dir");
+        if (override != null) {
+            return new File(override);
+        }
+        return new File(System.getProperty("user.home"),
+                ".local/share/aukio/bugreports");
+    }
+
+    /**
+     * Creates a bug report.
+     *
+     * @param data        the substrate (camera pose + frame buffer);
+     *                    must not be null
+     * @param shell       the shell for measured FPS, or null (headless)
+     * @param description free-form user description of the problem
+     * @return the created report directory
+     */
+    public static File create(final Substrate data, final AwtShell shell,
+                              final String description) throws IOException {
+        final File root = reportRoot();
+        File dir = new File(root,
+                "bugreport-" + LocalDateTime.now().format(DIR_FORMATTER));
+        int suffix = 1;
+        while (dir.exists()) {
+            dir = new File(dir.getPath() + "-" + (++suffix));
+        }
+        Files.createDirectories(dir.toPath());
+
+        Files.writeString(new File(dir, "description.txt").toPath(),
+                description == null ? "" : description);
+        writeInfo(new File(dir, "info.txt"), data, shell);
+        writeScreenshot(dir, data.image);
+        copyLogs(dir);
+        writeHistogram(dir);
+        writeThreadDump(new File(dir, "threads.txt"));
+
+        System.out.println("[BUGREPORT] written to "
+                + dir.getAbsolutePath());
+        return dir;
+    }
+
+    private static void writeInfo(final File file, final Substrate data,
+                                  final AwtShell shell) throws IOException {
+        final Runtime rt = Runtime.getRuntime();
+        final long used = rt.totalMemory() - rt.freeMemory();
+        final StringBuilder sb = new StringBuilder();
+        sb.append("engine = aukio-3d-2 (poc)\n");
+        sb.append("time = ").append(LocalDateTime.now()).append('\n');
+        // which app this report is about: exact launch command + cwd
+        sb.append("app.command = ")
+                .append(System.getProperty("sun.java.command", "unknown"))
+                .append('\n');
+        sb.append("app.user.dir = ")
+                .append(System.getProperty("user.dir", "unknown"))
+                .append('\n');
+        sb.append(String.format(
+                "camera = (%.2f, %.2f, %.2f, %.2f, %.2f)"
+                        + "  # x, y, z, yaw, pitch (e3d2: no roll)%n",
+                data.camera.x, data.camera.y, data.camera.z,
+                data.camera.yaw, data.camera.pitch));
+        final ImageStore image = data.image;
+        if (image != null) {
+            sb.append(String.format("view.size = %dx%d%n",
+                    image.width, image.height));
+        }
+        sb.append(String.format("camera.scale = %.2f%n", data.camera.scale));
+        if (shell != null) {
+            sb.append("measured.fps = ").append(String.format("%.1f",
+                    shell.getMeasuredFPS())).append('\n');
+        }
+        try {
+            final GraphicsDevice device = GraphicsEnvironment
+                    .getLocalGraphicsEnvironment()
+                    .getDefaultScreenDevice();
+            sb.append(String.format("screen.resolution = %dx%d@%dHz%n",
+                    device.getDisplayMode().getWidth(),
+                    device.getDisplayMode().getHeight(),
+                    device.getDisplayMode().getRefreshRate()));
+        } catch (final Throwable t) {
+            sb.append("screen.resolution = unavailable (")
+                    .append(t.getMessage()).append(")\n");
+        }
+        sb.append(String.format("heap.used = %d MB%nheap.max = %d MB%n",
+                used / (1024 * 1024), rt.maxMemory() / (1024 * 1024)));
+        sb.append("telemetry = n/a (e3d2 has no Telemetry subsystem)\n");
+        final File log = PersistentLog.getLogFile();
+        sb.append("persistent.log = ").append(log != null
+                        ? log.getAbsolutePath()
+                        : "none (PersistentLog not installed — headless?)")
+                .append('\n');
+        sb.append("java.version = ")
+                .append(System.getProperty("java.version")).append('\n');
+        sb.append("os = ").append(System.getProperty("os.name"))
+                .append(' ').append(System.getProperty("os.version"))
+                .append('\n');
+        Files.writeString(file.toPath(), sb.toString());
+    }
+
+    /** The current frame buffer, copied out of the shared int[] so a
+     *  render racing the report cannot tear the image. */
+    private static void writeScreenshot(final File dir,
+                                        final ImageStore image)
+            throws IOException {
+        if (image == null) {
+            Files.writeString(new File(dir, "screenshot-missing.txt")
+                            .toPath(),
+                    "no frame had been rendered yet\n");
+            return;
+        }
+        final int w = image.width;
+        final int h = image.height;
+        final BufferedImage copy = new BufferedImage(w, h,
+                BufferedImage.TYPE_INT_RGB);
+        final int[] pixels = new int[w * h];
+        System.arraycopy(image.pixels, 0, pixels, 0, pixels.length);
+        copy.setRGB(0, 0, w, h, pixels, 0, w);
+        ImageIO.write(copy, "png", new File(dir, "screenshot.png"));
+    }
+
+    /** Live-object histogram (the OOM smoking gun). Best effort: jcmd
+     *  self-attach can hang, and a bug report must still complete. */
+    private static void writeHistogram(final File dir) {
+        final String pid = String.valueOf(ProcessHandle.current().pid());
+        final File out = new File(dir, "histogram.txt");
+        try {
+            final Process process = new ProcessBuilder("jcmd", pid,
+                    "GC.class_histogram").redirectErrorStream(true)
+                    .redirectOutput(out).start();
+            final boolean done = process.waitFor(30,
+                    java.util.concurrent.TimeUnit.SECONDS);
+            if (done && process.exitValue() == 0) {
+                return;
+            }
+            if (!done) {
+                process.destroyForcibly();
+            }
+            out.delete();
+            Files.writeString(
+                    new File(dir, "histogram-unavailable.txt").toPath(),
+                    "jcmd GC.class_histogram did not finish within 30s\n");
+        } catch (final Throwable t) {
+            try {
+                Files.writeString(
+                        new File(dir, "histogram-unavailable.txt").toPath(),
+                        "jcmd GC.class_histogram failed: " + t + "\n");
+            } catch (final IOException ignored) {
+            }
+        }
+    }
+
+    /** Copies the persistent logs (current + rotated backup) into the
+     *  report. Best effort: absent or unreadable logs yield a note, not
+     *  a failed report. */
+    private static void copyLogs(final File dir) throws IOException {
+        final File log = PersistentLog.getLogFile();
+        if (log == null || !log.isFile()) {
+            Files.writeString(new File(dir, "log-missing.txt").toPath(),
+                    "no persistent log (PersistentLog not installed or "
+                            + "unwritable log dir)\n");
+            return;
+        }
+        Files.copy(log.toPath(), new File(dir, log.getName()).toPath(),
+                StandardCopyOption.REPLACE_EXISTING);
+        final File backup = new File(log.getParentFile(),
+                log.getName() + ".1");
+        if (backup.isFile()) {
+            Files.copy(backup.toPath(),
+                    new File(dir, backup.getName()).toPath(),
+                    StandardCopyOption.REPLACE_EXISTING);
+        }
+    }
+
+    private static void writeThreadDump(final File file)
+            throws IOException {
+        final StringBuilder sb = new StringBuilder();
+        for (final Map.Entry<Thread, StackTraceElement[]> entry
+                : Thread.getAllStackTraces().entrySet()) {
+            final Thread thread = entry.getKey();
+            sb.append('"').append(thread.getName()).append('"')
+                    .append(thread.isDaemon() ? " daemon" : "")
+                    .append(" state=").append(thread.getState())
+                    .append('\n');
+            for (final StackTraceElement element : entry.getValue()) {
+                sb.append("    at ").append(element).append('\n');
+            }
+            sb.append('\n');
+        }
+        sb.append("vm = ").append(ManagementFactory.getRuntimeMXBean()
+                .getVmName()).append('\n');
+        Files.writeString(file.toPath(), sb.toString());
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/app/DeveloperToolsWindow.java b/src/main/java/eu/svjatoslav/aukio/e3d2/app/DeveloperToolsWindow.java
new file mode 100644 (file)
index 0000000..2633b5f
--- /dev/null
@@ -0,0 +1,434 @@
+package eu.svjatoslav.aukio.e3d2.app;
+
+import eu.svjatoslav.aukio.e3d2.kernel.CameraStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JToggleButton;
+import javax.swing.Timer;
+import java.awt.BorderLayout;
+import java.awt.FlowLayout;
+import java.awt.Font;
+import java.awt.Frame;
+import java.awt.GridLayout;
+import java.awt.Toolkit;
+import java.awt.datatransfer.StringSelection;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+
+/**
+ * The developer tools window, opened with F12 — the e3d2 port of the
+ * legacy DeveloperToolsPanel. Ported panels: Frame Rate (target FPS,
+ * measured FPS, fixed/unlimited toggle — unlocking remembers the
+ * locked rate, re-locking restores it) and Camera (live pose readout
+ * with copy/paste, the legacy six-field format minus roll — e3d2's
+ * camera has no roll: SpaceMouse roll is measured, not applied) and
+ * Rendering (alternate-segments overdraw debug, segment boundaries,
+ * Make Bug Report — legacy flow, off-EDT writer thread).
+ * The remaining legacy panels (culling stats, GI rows, log viewer,
+ * thread timeline) port later, on demand.
+ *
+ * Clipboard interchange is PoC-to-PoC only: legacy poses live in the
+ * legacy -Y-up convention and are NOT meaningful pasted here verbatim.
+ *
+ * This is APP-layer code (same layer as {@link AwtShell}): it reads
+ * shell counters and substrate data and calls shell setters; no pass
+ * is involved.
+ */
+public final class DeveloperToolsWindow extends JFrame {
+
+    /** Display refresh interval, ms (legacy UPDATE_INTERVAL_MS). */
+    private static final int UPDATE_INTERVAL_MS = 200;
+
+    private final AwtShell shell;
+    private final Substrate data;
+    private final JLabel targetFpsLabel;
+    private final JLabel measuredFpsLabel;
+    private final JToggleButton unlockFpsButton;
+    private final JLabel cameraLabel;
+
+    /** Target FPS to restore when re-locking after an unlock (legacy). */
+    private int lockedFPS = 60;
+
+    private final Timer updateTimer;
+
+    public DeveloperToolsWindow(final Frame parent, final AwtShell shell,
+                                final Substrate data) {
+        super("Developer Tools");
+        this.shell = shell;
+        this.data = data;
+        setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
+        setLayout(new BorderLayout(8, 8));
+
+        targetFpsLabel = monoLabel("unlimited");   // placeholder width: widest value
+        measuredFpsLabel = monoLabel("0000.0");    // placeholder width: widest value
+        cameraLabel = monoLabel("-00000.00, -00000.00, -00000.00, -00.00, -00.00");
+
+        unlockFpsButton = new JToggleButton("Unlock FPS");
+        unlockFpsButton.setToolTipText(
+                "Disable the frame rate cap so the application renders "
+                        + "as fast as it can (benchmark mode)");
+        unlockFpsButton.setSelected(shell.getTargetFPS() <= 0);
+        unlockFpsButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                if (unlockFpsButton.isSelected()) {
+                    final int current = shell.getTargetFPS();
+                    if (current > 0) {
+                        lockedFPS = current;
+                    }
+                    shell.setFrameRate(0);
+                } else {
+                    shell.setFrameRate(lockedFPS);
+                }
+                updateFrameRateLabels();
+            }
+        });
+
+        final JPanel panel = new JPanel();
+        panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+        panel.setBorder(BorderFactory.createCompoundBorder(
+                BorderFactory.createEmptyBorder(8, 8, 8, 8),
+                BorderFactory.createTitledBorder("Frame Rate")));
+        final JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2));
+        row.add(new JLabel("Target:"));
+        row.add(targetFpsLabel);
+        row.add(new JLabel("   Measured:"));
+        row.add(measuredFpsLabel);
+        row.add(unlockFpsButton);
+        panel.add(row);
+        add(panel, BorderLayout.NORTH);
+        add(createCameraPanel(), BorderLayout.CENTER);
+        add(createRenderingPanel(), BorderLayout.SOUTH);
+
+        pack();
+        setLocationRelativeTo(parent);
+
+        updateTimer = new Timer(UPDATE_INTERVAL_MS, new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                updateFrameRateLabels();
+                updateCameraLabel();
+            }
+        });
+        addWindowListener(new WindowAdapter() {
+            @Override
+            public void windowOpened(final WindowEvent e) {
+                updateFrameRateLabels();
+                updateCameraLabel();
+                updateTimer.start();
+            }
+
+            @Override
+            public void windowClosed(final WindowEvent e) {
+                updateTimer.stop();
+            }
+        });
+    }
+
+    private static JLabel monoLabel(final String placeholder) {
+        // Placeholder text sets the PREFERRED width before pack(): the
+        // 200ms timer later writes real values, and without worst-case
+        // width reserved at pack time the row outgrows the window, the
+        // FlowLayout wraps the button to a clipped second line (the
+        // squashed-button bug, seen on the user's desktop 2026-10-12).
+        final JLabel label = new JLabel(placeholder);
+        label.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
+        return label;
+    }
+
+    private void updateFrameRateLabels() {
+        final int target = shell.getTargetFPS();
+        targetFpsLabel.setText(target > 0 ? String.valueOf(target) : "unlimited");
+        // Render-on-demand publishes NOTHING at rest; a stale window
+        // average (0.2 fps) is a lie. No frame for over a second = idle.
+        final long last = shell.getLastPublishNanos();
+        final boolean idle = last == 0
+                || System.nanoTime() - last > 1_000_000_000L;
+        measuredFpsLabel.setText(idle ? "idle"
+                : String.format("%.1f", shell.getMeasuredFPS()));
+    }
+
+    /** The legacy Camera panel: live pose readout + copy/paste. */
+    private JPanel createCameraPanel() {
+        final JPanel panel = new JPanel(new BorderLayout(4, 4));
+        panel.setBorder(BorderFactory.createCompoundBorder(
+                BorderFactory.createEmptyBorder(8, 8, 8, 8),
+                BorderFactory.createTitledBorder("Camera (x, y, z, yaw, pitch)")));
+        panel.add(cameraLabel, BorderLayout.CENTER);
+
+        final JButton copyButton = new JButton("Copy");
+        copyButton.setToolTipText("Copy camera pose to clipboard");
+        copyButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                final String text = cameraLabel.getText();
+                if (text != null && !text.trim().isEmpty()) {
+                    final StringSelection sel = new StringSelection(text);
+                    Toolkit.getDefaultToolkit().getSystemClipboard()
+                            .setContents(sel, null);
+                }
+            }
+        });
+
+        final JButton pasteButton = new JButton("Paste");
+        pasteButton.setToolTipText(
+                "Set camera pose from clipboard (five comma-separated "
+                        + "numbers: x, y, z, yaw, pitch); does nothing if "
+                        + "the clipboard holds anything else");
+        pasteButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                pasteCameraPose();
+            }
+        });
+
+        final JPanel buttons = new JPanel(new GridLayout(0, 1, 0, 4));
+        buttons.add(copyButton);
+        buttons.add(pasteButton);
+        panel.add(buttons, BorderLayout.EAST);
+        return panel;
+    }
+
+    private void updateCameraLabel() {
+        cameraLabel.setText(formatPose(data.camera));
+    }
+
+    /** The legacy Rendering panel: segment debug checkboxes + the
+     *  bug report button. */
+    private JPanel createRenderingPanel() {
+        final JPanel panel = new JPanel();
+        panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+        panel.setBorder(BorderFactory.createCompoundBorder(
+                BorderFactory.createEmptyBorder(8, 8, 8, 8),
+                BorderFactory.createTitledBorder("Rendering")));
+
+        final javax.swing.JCheckBox alternateSegments =
+                new javax.swing.JCheckBox(
+                        "Render alternate segments (overdraw debug)");
+        alternateSegments.setToolTipText(
+                "Paint only even-numbered raster tiles; skipped tiles "
+                        + "stay at the background color");
+        alternateSegments.setSelected(data.debug.alternateSegments);
+        alternateSegments.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                data.debug.alternateSegments =
+                        alternateSegments.isSelected();
+            }
+        });
+
+        final javax.swing.JCheckBox segmentBoundaries =
+                new javax.swing.JCheckBox("Show segment boundaries");
+        segmentBoundaries.setToolTipText(
+                "Overlay red lines at raster tile boundaries");
+        segmentBoundaries.setSelected(data.debug.showBoundaries);
+        segmentBoundaries.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                data.debug.showBoundaries =
+                        segmentBoundaries.isSelected();
+            }
+        });
+
+        final JButton bugReportButton = new JButton("Make Bug Report...");
+        bugReportButton.setToolTipText(
+                "Write a bug report directory: your description, a "
+                        + "screenshot, camera pose, system info, thread "
+                        + "dump and object histogram");
+        bugReportButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                showBugReportDialog();
+            }
+        });
+
+        panel.add(alternateSegments);
+        panel.add(segmentBoundaries);
+        panel.add(bugReportButton);
+        return panel;
+    }
+
+    /** Popup asking what happened, then writes a bug report directory
+     *  off the EDT (jcmd can take seconds) — legacy flow. */
+    private void showBugReportDialog() {
+        final javax.swing.JTextArea area = new javax.swing.JTextArea(6, 40);
+        area.setLineWrap(true);
+        area.setWrapStyleWord(true);
+        final int result = javax.swing.JOptionPane.showConfirmDialog(this,
+                new javax.swing.JScrollPane(area),
+                "Make Bug Report — describe the problem",
+                javax.swing.JOptionPane.OK_CANCEL_OPTION,
+                javax.swing.JOptionPane.QUESTION_MESSAGE);
+        if (result != javax.swing.JOptionPane.OK_OPTION) {
+            return;
+        }
+        final String description = area.getText();
+        new Thread(() -> {
+            try {
+                final java.io.File dir =
+                        BugReport.create(data, shell, description);
+                javax.swing.SwingUtilities.invokeLater(() ->
+                        showBugReportResultDialog(dir));
+            } catch (final java.io.IOException ex) {
+                javax.swing.SwingUtilities.invokeLater(() ->
+                        javax.swing.JOptionPane.showMessageDialog(this,
+                                "Bug report failed: " + ex.getMessage(),
+                                "Bug Report",
+                                javax.swing.JOptionPane.ERROR_MESSAGE));
+            }
+        }, "bug-report").start();
+    }
+
+    /**
+     * Shows the created bug report's location in a selectable text
+     * field (copy-paste with Ctrl+C works) plus explicit Copy Path and
+     * Open Folder buttons — the legacy DeveloperToolsPanel result
+     * dialog; a plain JOptionPane message is neither selectable nor
+     * actionable.
+     */
+    private void showBugReportResultDialog(final java.io.File dir) {
+        final javax.swing.JDialog dialog =
+                new javax.swing.JDialog(this, "Bug Report", true);
+        dialog.setLayout(new BorderLayout(8, 8));
+
+        final JPanel centerPanel = new JPanel(new BorderLayout(4, 4));
+        centerPanel.setBorder(BorderFactory.createEmptyBorder(8, 8, 0, 8));
+        centerPanel.add(new JLabel(
+                "Bug report written to (point the developer at this"
+                        + " directory):"), BorderLayout.NORTH);
+        final javax.swing.JTextField pathField =
+                new javax.swing.JTextField(dir.getAbsolutePath());
+        pathField.setEditable(false);
+        pathField.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
+        centerPanel.add(pathField, BorderLayout.CENTER);
+        dialog.add(centerPanel, BorderLayout.CENTER);
+
+        final JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+
+        final JButton copyButton = new JButton("Copy Path");
+        copyButton.setToolTipText("Copy the report path to the clipboard");
+        copyButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                final StringSelection sel =
+                        new StringSelection(dir.getAbsolutePath());
+                Toolkit.getDefaultToolkit().getSystemClipboard()
+                        .setContents(sel, null);
+            }
+        });
+        buttonPanel.add(copyButton);
+
+        final boolean openSupported = java.awt.Desktop.isDesktopSupported()
+                && java.awt.Desktop.getDesktop()
+                        .isSupported(java.awt.Desktop.Action.OPEN);
+        final JButton openButton = new JButton("Open Folder");
+        openButton.setToolTipText(openSupported
+                ? "Open the report directory in the file manager"
+                : "Opening folders is not supported on this desktop");
+        openButton.setEnabled(openSupported);
+        if (openSupported) {
+            openButton.addActionListener(new ActionListener() {
+                @Override
+                public void actionPerformed(final ActionEvent e) {
+                    try {
+                        java.awt.Desktop.getDesktop().open(dir);
+                    } catch (final Exception ex) {
+                        javax.swing.JOptionPane.showMessageDialog(dialog,
+                                "Could not open folder: " + ex.getMessage(),
+                                "Bug Report",
+                                javax.swing.JOptionPane.ERROR_MESSAGE);
+                    }
+                }
+            });
+        }
+        buttonPanel.add(openButton);
+
+        final JButton closeButton = new JButton("Close");
+        closeButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                dialog.dispose();
+            }
+        });
+        buttonPanel.add(closeButton);
+
+        dialog.add(buttonPanel, BorderLayout.SOUTH);
+        dialog.pack();
+        dialog.setLocationRelativeTo(this);
+        dialog.setVisible(true);
+    }
+
+    /** The clipboard format: x, y, z, yaw, pitch — the legacy format
+     *  minus roll (e3d2's camera has no roll). */
+    static String formatPose(final CameraStore camera) {
+        return String.format("%.2f, %.2f, %.2f, %.2f, %.2f",
+                camera.x, camera.y, camera.z, camera.yaw, camera.pitch);
+    }
+
+    /**
+     * Strict decimal number: rejects NaN/Infinity/hex-float spellings
+     * that {@link Double#parseDouble(String)} would otherwise accept
+     * (legacy DeveloperToolsPanel rule).
+     */
+    private static final java.util.regex.Pattern NUMBER =
+            java.util.regex.Pattern.compile(
+                    "[+-]?(\\d+\\.?\\d*|\\.\\d+)([eE][+-]?\\d+)?");
+
+    /** Parses the clipboard format; null when the text is anything but
+     *  exactly five comma-separated strict numbers. */
+    static double[] parsePose(final String text) {
+        if (text == null) {
+            return null;
+        }
+        final String[] parts = text.trim().split(",", -1);
+        if (parts.length != 5) {
+            return null;
+        }
+        final double[] values = new double[5];
+        for (int i = 0; i < 5; i++) {
+            final String token = parts[i].trim();
+            if (!NUMBER.matcher(token).matches()) {
+                return null;
+            }
+            values[i] = Double.parseDouble(token);
+        }
+        return values;
+    }
+
+    /** Reads the system clipboard and, if it holds a pose in the
+     *  {@link #formatPose} format, teleports the camera (scale is
+     *  preserved — it is a viewport property, not a pose). Anything
+     *  else in the clipboard is ignored silently (legacy semantics). */
+    private void pasteCameraPose() {
+        final String text;
+        try {
+            final java.awt.datatransfer.Clipboard clipboard =
+                    Toolkit.getDefaultToolkit().getSystemClipboard();
+            if (!clipboard.isDataFlavorAvailable(
+                    java.awt.datatransfer.DataFlavor.stringFlavor)) {
+                return;
+            }
+            text = (String) clipboard.getData(
+                    java.awt.datatransfer.DataFlavor.stringFlavor);
+        } catch (final Exception ex) {
+            return;
+        }
+        final double[] pose = parsePose(text);
+        if (pose == null) {
+            return;
+        }
+        final CameraStore camera = data.camera;
+        camera.set(pose[0], pose[1], pose[2], pose[3], pose[4], camera.scale);
+        updateCameraLabel();
+        // render-on-demand picks the teleport up by itself: the camera
+        // is part of the frame signature
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/app/PersistentLog.java b/src/main/java/eu/svjatoslav/aukio/e3d2/app/PersistentLog.java
new file mode 100644 (file)
index 0000000..6823f2a
--- /dev/null
@@ -0,0 +1,150 @@
+package eu.svjatoslav.aukio.e3d2.app;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+
+/**
+ * Persistent rolling log — the e3d2 port of legacy diag.PersistentLog
+ * (ported 2026-10-14, when the bug report tool needed a log to
+ * collect). Tees System.out and System.err into a rolling file so a
+ * bug report can include the session's output — including output from
+ * a session that froze before the report could be made.
+ *
+ * <p>File: {@code e3d2.log} in the log dir ({@code -De3d2.log.dir},
+ * default {@code ~/.cache/aukio/logs} — the legacy directory; the
+ * distinct file name lets both engines log side by side without
+ * interleaving). Rotates to {@code e3d2.log.1} past 4 MB.</p>
+ *
+ * <p>Install once, as early as possible: {@link AwtShell}'s
+ * constructor calls {@link #install()} (idempotent), so every
+ * shell-driven app is logged with zero wiring. Headless renderers
+ * (RenderMain, oracles) do not install — their output belongs to the
+ * invoking shell.</p>
+ */
+public final class PersistentLog {
+
+    /** Rotate the log once it exceeds this size. */
+    private static final long MAX_LOG_BYTES = 4L * 1024 * 1024;
+
+    private static volatile boolean installed = false;
+    private static volatile File logFile;
+
+    private PersistentLog() {
+    }
+
+    /** Log directory: -De3d2.log.dir, default shared with legacy. */
+    public static File logDir() {
+        final String override = System.getProperty("e3d2.log.dir");
+        if (override != null) {
+            return new File(override);
+        }
+        return new File(System.getProperty("user.home"),
+                ".cache/aukio/logs");
+    }
+
+    /**
+     * Installs the tee. Idempotent; failures (unwritable directory)
+     * fall back to console-only logging and are reported on stderr.
+     */
+    public static synchronized void install() {
+        if (installed) {
+            return;
+        }
+        installed = true;
+        final File dir = logDir();
+        try {
+            Files.createDirectories(dir.toPath());
+            logFile = new File(dir, "e3d2.log");
+            final SharedFileOutput shared = new SharedFileOutput(logFile);
+            System.setOut(new PrintStream(
+                    new TeeStream(System.out, shared), true));
+            System.setErr(new PrintStream(
+                    new TeeStream(System.err, shared), true));
+            System.out.println("[LOG] persistent log: "
+                    + logFile.getAbsolutePath());
+        } catch (final IOException e) {
+            System.err.println("[LOG] persistent logging unavailable in "
+                    + dir + ": " + e.getMessage());
+            logFile = null;
+        }
+    }
+
+    /** The current log file, or null when persistent logging failed
+     *  or was never installed (headless). */
+    public static File getLogFile() {
+        return logFile;
+    }
+
+    /**
+     * Single shared append stream to the log file; both the stdout and
+     * stderr tees write through it. Rotates the file once it grows past
+     * {@link #MAX_LOG_BYTES}.
+     */
+    private static final class SharedFileOutput {
+
+        private final File file;
+        private OutputStream out;
+
+        SharedFileOutput(final File file) throws IOException {
+            this.file = file;
+            this.out = new FileOutputStream(file, true);
+        }
+
+        synchronized void write(final int b) throws IOException {
+            rotateIfNeeded();
+            out.write(b);
+            if (b == '\n') {
+                out.flush();
+            }
+        }
+
+        synchronized void write(final byte[] b, final int off,
+                                final int len) throws IOException {
+            rotateIfNeeded();
+            out.write(b, off, len);
+            out.flush();
+        }
+
+        private void rotateIfNeeded() throws IOException {
+            if (file.length() < MAX_LOG_BYTES) {
+                return;
+            }
+            out.close();
+            final File backup = new File(file.getParentFile(),
+                    file.getName() + ".1");
+            Files.move(file.toPath(), backup.toPath(),
+                    StandardCopyOption.REPLACE_EXISTING);
+            out = new FileOutputStream(file, true);
+        }
+    }
+
+    /** Mirrors writes to the console and to the shared log file. */
+    private static final class TeeStream extends OutputStream {
+
+        private final PrintStream console;
+        private final SharedFileOutput file;
+
+        TeeStream(final PrintStream console, final SharedFileOutput file) {
+            this.console = console;
+            this.file = file;
+        }
+
+        @Override
+        public void write(final int b) throws IOException {
+            console.write(b);
+            file.write(b);
+        }
+
+        @Override
+        public void write(final byte[] b, final int off, final int len)
+                throws IOException {
+            console.write(b, off, len);
+            file.write(b, off, len);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/app/SpaceMouseManager.java b/src/main/java/eu/svjatoslav/aukio/e3d2/app/SpaceMouseManager.java
new file mode 100644 (file)
index 0000000..a3ae150
--- /dev/null
@@ -0,0 +1,107 @@
+package eu.svjatoslav.aukio.e3d2.app;
+
+import eu.svjatoslav.aukio.e3d2.kernel.InputStore;
+
+/**
+ * Hot-plug manager for 3Dconnexion 6DOF mice (SpaceNavigator over USB,
+ * SpaceMouse Wireless over Bluetooth — see
+ * {@link SpaceNavigatorHid.Model}). Ported from legacy
+ * SpaceMouseManager; polls for the device every two seconds:
+ *
+ *   - plugged in → open the HID pipe; the reader thread writes cap
+ *     axes into the InputStore (device events are data — the same seam
+ *     the AWT listeners use)
+ *   - unplugged (read error) → stop, zero the cap channel
+ *   - plugged back in → fresh device instance
+ *
+ * Permission failures are reported once, then retried silently — the
+ * user may install the udev rule while the application runs.
+ */
+public final class SpaceMouseManager {
+
+    private static final long POLL_INTERVAL_MS = 2000;
+
+    private final InputStore input;
+    private final Thread thread;
+
+    private volatile boolean running = true;
+    private SpaceNavigatorHid device;
+    private boolean failureReported;
+
+    public SpaceMouseManager(final InputStore input) {
+        this.input = input;
+        thread = new Thread(this::pollLoop, "spacemouse-hotplug");
+        thread.setDaemon(true);
+    }
+
+    public void start() {
+        thread.start();
+    }
+
+    /** Active device, or null when no supported SpaceMouse is connected. */
+    public SpaceNavigatorHid getDevice() {
+        return device;
+    }
+
+    public void stop() {
+        running = false;
+        // Wake the 2 s poll sleep; without the interrupt the join below
+        // burns its full 1 s timeout on the EDT at every window close.
+        thread.interrupt();
+        try {
+            thread.join(1000);
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+        disconnect();
+    }
+
+    private void pollLoop() {
+        while (running) {
+            poll();
+            try {
+                Thread.sleep(POLL_INTERVAL_MS);
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return;
+            }
+        }
+    }
+
+    private void poll() {
+        if (device != null && !device.isRunning()) {
+            System.out.println("spacemouse: disconnected");
+            disconnect();
+        }
+        if (device != null) {
+            return;
+        }
+
+        try {
+            final SpaceNavigatorHid opened = SpaceNavigatorHid.open(input);
+            if (opened == null) {
+                return;
+            }
+            opened.start();
+            device = opened;
+            failureReported = false;
+            System.out.println("spacemouse: " + opened.getModel()
+                    + " detected — cap moves the camera, "
+                    + "left button brakes");
+        } catch (final Exception e) {
+            device = null;
+            if (!failureReported) {
+                failureReported = true;
+                System.err.println("spacemouse unavailable: "
+                        + e.getMessage());
+            }
+        }
+    }
+
+    private void disconnect() {
+        if (device != null) {
+            device.stop(); // also zeroes the cap channel
+            device = null;
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/app/SpaceNavigatorHid.java b/src/main/java/eu/svjatoslav/aukio/e3d2/app/SpaceNavigatorHid.java
new file mode 100644 (file)
index 0000000..aecdc32
--- /dev/null
@@ -0,0 +1,323 @@
+package eu.svjatoslav.aukio.e3d2.app;
+
+import com.sun.jna.Library;
+import com.sun.jna.Memory;
+import com.sun.jna.Native;
+import eu.svjatoslav.aukio.e3d2.kernel.InputStore;
+
+import java.io.File;
+
+/**
+ * HID transport for 3Dconnexion 6DOF mice, read via the Linux hidraw
+ * interface using JNA. Ported from legacy aukio-3d SpaceNavigatorHid;
+ * the only change is the output side: parsed reports are written into
+ * the substrate InputStore (device events are data) instead of being
+ * held as private fields polled by a frame listener.
+ *
+ * Supported models are listed in {@link Model}; all share the same
+ * raw axis sign conventions and ±350 full deflection, so
+ * systems/SpaceMousePass drives them identically.
+ *
+ * Protocol, SpaceNavigator USB (from libspnav/spacenavd
+ * documentation): 7-byte input reports, sent ONLY while the cap is
+ * deflected or a button changes — at rest the pipe is silent (silence
+ * is normal, there is no stream watchdog):
+ *
+ *   - report[0] = 1 — translation; int16 LE at [1,2]=X, [3,4]=Y,
+ *     [5,6]=Z; full deflection ≈ ±350
+ *   - report[0] = 2 — rotation; int16 LE at [1,2]=RX, [3,4]=RY,
+ *     [5,6]=RZ
+ *   - report[0] = 3 — buttons; report[1] bit0 = left, bit1 = right
+ *
+ * Protocol, SpaceMouse Wireless over Bluetooth (from the device's own
+ * HID report descriptor):
+ *
+ *   - report[0] = 1 — all six axes at once; int16 LE at [1,2]=X ...
+ *     [11,12]=RZ (13 bytes total)
+ *   - report[0] = 3 — buttons; report[1] bit0 = left, bit1 = right
+ *   - report[0] = 0x17 — battery; report[1] = charge 0..100,
+ *     report[2] bit0 = charging
+ *
+ * Raw axis sign conventions: TX push cap right +; TY pull cap toward
+ * you +; TZ PRESS CAP DOWN + (pull up reads negative — probe-verified
+ * 2026-10-13 on the USB SpaceNavigator: pull-up = -419, press-down =
+ * +444; the legacy javadoc's "pull cap up +" was inverted); RX tilt
+ * cap top away +; RY tilt cap top right +; RZ twist cap clockwise
+ * (seen from above) +.
+ *
+ * Detection scans /sys/class/hidraw for the HID_ID of any known
+ * model — the hidraw node changes on replug, so never cache the path.
+ * Unplug is detected by a read error.
+ */
+public final class SpaceNavigatorHid {
+
+    /**
+     * A supported 3Dconnexion device. {@code hidId} is matched against
+     * HID_ID in /sys/class/hidraw/<N>/device/uevent (bus prefix
+     * 0003 = USB, 0005 = Bluetooth).
+     */
+    public enum Model {
+        SPACENAVIGATOR_USB("0003:0000046D:0000C626",
+                "SpaceNavigator (USB)"),
+        SPACEMOUSE_WIRELESS_BT("0005:0000256F:0000C63A",
+                "SpaceMouse Wireless (Bluetooth)");
+
+        private final String hidId;
+        private final String displayName;
+
+        Model(final String hidId, final String displayName) {
+            this.hidId = hidId;
+            this.displayName = displayName;
+        }
+
+        /** First model whose HID_ID appears in the uevent text, else null. */
+        static Model match(final String ueventContent) {
+            for (final Model model : values()) {
+                if (ueventContent.contains(model.hidId)) {
+                    return model;
+                }
+            }
+            return null;
+        }
+
+        @Override
+        public String toString() {
+            return displayName;
+        }
+    }
+
+    /** Full deflection of any axis, per the HID descriptor. */
+    public static final double FULL_DEFLECTION = 350.0;
+
+    /**
+     * Same JNA calling convention as the proven legacy transport:
+     * static singleton mapping, Memory buffers (a raw byte[] mapping
+     * correlated with native heap corruption — "free(): invalid
+     * pointer" — under active report streaming).
+     */
+    private interface CLib extends Library {
+        CLib INSTANCE = Native.load("c", CLib.class);
+
+        int open(String path, int flags);
+
+        int close(int fd);
+
+        int read(int fd, Memory buffer, int count);
+
+        /** poll(2) on a single-element struct pollfd (8 bytes: fd, events, revents). */
+        int poll(Memory fds, int nfds, int timeout);
+    }
+
+    private final InputStore input;
+    private final File deviceNode;
+    private final Model model;
+    private final Memory readBuffer = new Memory(64);
+    private final byte[] parseBuffer = new byte[64];
+
+    private int fd = -1;
+    private Thread reader;
+    private volatile boolean running;
+
+    /** Battery charge 0..100, or -1 while unknown (wired models never report it). */
+    private volatile int batteryPercent = -1;
+    private volatile boolean charging;
+    /** Set when a read error (typically unplug) killed the reader. */
+    private volatile boolean broken;
+
+    private SpaceNavigatorHid(final InputStore input,
+                              final File deviceNode, final Model model) {
+        this.input = input;
+        this.deviceNode = deviceNode;
+        this.model = model;
+    }
+
+    /** Test seam: parsing only, no device node. */
+    SpaceNavigatorHid(final InputStore input, final Model model) {
+        this(input, null, model);
+    }
+
+    /**
+     * Finds and opens the first supported SpaceMouse on the system, or
+     * null when none is connected. Throws when a device is present but
+     * cannot be opened (permissions — see the udev rule,
+     * legacy aukio-3d udev/99-spacenavigator.rules).
+     */
+    public static SpaceNavigatorHid open(final InputStore input) {
+        final File hidrawDir = new File("/sys/class/hidraw");
+        final File[] entries = hidrawDir.listFiles();
+        if (entries == null) {
+            return null;
+        }
+        for (final File entry : entries) {
+            final File uevent = new File(entry, "device/uevent");
+            if (!uevent.isFile()) {
+                continue;
+            }
+            try {
+                final String content = new String(
+                        java.nio.file.Files.readAllBytes(uevent.toPath()));
+                final Model model = Model.match(content);
+                if (model == null) {
+                    continue;
+                }
+                final File node = new File("/dev", entry.getName());
+                final SpaceNavigatorHid hid =
+                        new SpaceNavigatorHid(input, node, model);
+                hid.openNode();
+                return hid;
+            } catch (final Exception e) {
+                throw new RuntimeException("SpaceMouse found at "
+                        + entry.getName() + " but cannot be opened: "
+                        + e.getMessage());
+            }
+        }
+        return null;
+    }
+
+    private void openNode() {
+        fd = CLib.INSTANCE.open(deviceNode.getAbsolutePath(),
+                2 /* O_RDWR */);
+        if (fd < 0) {
+            throw new RuntimeException("open(" + deviceNode
+                    + ") failed: " + Native.getLastError());
+        }
+    }
+
+    public void start() {
+        running = true;
+        reader = new Thread(this::readLoop, "spacenavigator-hid");
+        reader.setDaemon(true);
+        reader.start();
+    }
+
+    private void readLoop() {
+        // Poll before reading: the device is SILENT at rest, so a plain
+        // blocking read never returns when idle — and close() from stop()
+        // does not wake a blocked read on Linux, which made every legacy
+        // window close wait out the full 1 s join timeout.
+        final Memory pollfd = new Memory(8);
+        while (running) {
+            pollfd.clear();
+            pollfd.setInt(0, fd);
+            pollfd.setShort(4, (short) 0x1); // events = POLLIN
+            final int ready = CLib.INSTANCE.poll(pollfd, 1, 250);
+            if (ready == 0) {
+                continue; // idle device: re-check running
+            }
+            if (ready < 0) {
+                if (Native.getLastError() == 4) { // EINTR
+                    continue;
+                }
+                fail();
+                return;
+            }
+            final short revents = pollfd.getShort(6);
+            if ((revents & 0x1) == 0) { // no POLLIN: ERR/HUP/NVAL = unplugged
+                fail();
+                return;
+            }
+            final int n = CLib.INSTANCE.read(fd, readBuffer, 64);
+            if (n < 0) {
+                fail(); // unplugged
+                return;
+            }
+            readBuffer.read(0, parseBuffer, 0, n);
+            parseReport(parseBuffer, n);
+        }
+    }
+
+    /** The device died mid-deflection: zero the cap channel so the
+     *  camera does not drift on a stale level value until the manager's
+     *  next 2 s poll notices. */
+    private void fail() {
+        broken = true;
+        input.setCapAxes(0, 0, 0, 0, 0, 0);
+        input.setCapButtons(0);
+    }
+
+    /** Parse one input report into the InputStore. Package-private for
+     *  tests (byte offsets breaking silently is the failure mode). */
+    void parseReport(final byte[] report, final int n) {
+        final int reportId = report[0] & 0xFF;
+        if (model == Model.SPACEMOUSE_WIRELESS_BT) {
+            // One 13-byte report carries all six axes; the button
+            // report is shorter than 7 bytes, so length checks are
+            // per-report here (a shared n < 7 guard would drop it).
+            if (reportId == 1 && n >= 13) {
+                input.setCapAxes(le16(report, 1), le16(report, 3),
+                        le16(report, 5), le16(report, 7),
+                        le16(report, 9), le16(report, 11));
+            } else if (reportId == 3 && n >= 2) {
+                input.setCapButtons(report[1] & 0xFF);
+            } else if (reportId == 0x17 && n >= 3) {
+                batteryPercent = report[1] & 0xFF;
+                charging = (report[2] & 1) != 0;
+            }
+            return;
+        }
+        // SpaceNavigator USB: 7-byte reports, translation and rotation
+        // delivered separately — merge with the current axis state so
+        // one report never zeroes the other triple.
+        if (n < 7) {
+            return;
+        }
+        if (reportId == 1) {
+            input.setCapAxes(le16(report, 1), le16(report, 3),
+                    le16(report, 5), input.capRx(), input.capRy(),
+                    input.capRz());
+        } else if (reportId == 2) {
+            input.setCapAxes(input.capTx(), input.capTy(), input.capTz(),
+                    le16(report, 1), le16(report, 3), le16(report, 5));
+        } else if (reportId == 3) {
+            input.setCapButtons(report[1] & 0xFF);
+        }
+    }
+
+    private static int le16(final byte[] b, final int off) {
+        return (short) ((b[off] & 0xFF) | (b[off + 1] << 8));
+    }
+
+    /** True while the device is connected and the reader is alive. */
+    public boolean isRunning() {
+        return running && !broken;
+    }
+
+    /** Which supported device this instance talks to. */
+    public Model getModel() {
+        return model;
+    }
+
+    /**
+     * Battery charge 0..100 as last reported by a wireless device,
+     * or -1 when the device has not reported it (wired models never do).
+     */
+    public int getBatteryPercent() {
+        return batteryPercent;
+    }
+
+    /** True when a wireless device last reported itself charging. */
+    public boolean isCharging() {
+        return charging;
+    }
+
+    public void stop() {
+        running = false;
+        // Join first: the poll loop notices the flag within 250 ms. Closing
+        // the fd before the reader is dead risks the fd number being reused
+        // under a concurrent open while the reader is mid-read.
+        if (reader != null) {
+            try {
+                reader.join(1000);
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+            reader = null;
+        }
+        if (fd >= 0) {
+            CLib.INSTANCE.close(fd);
+            fd = -1;
+        }
+        input.setCapAxes(0, 0, 0, 0, 0, 0);
+        input.setCapButtons(0);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/BvhStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/BvhStore.java
new file mode 100644 (file)
index 0000000..c2f0dee
--- /dev/null
@@ -0,0 +1,344 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.Arrays;
+
+/**
+ * Bounding-volume hierarchy over the MeshStore: the engine's ray-query
+ * index. Substrate data — derived from the mesh, rebuilt lazily by
+ * {@link #ensure(MeshStore)} when the mesh version changes (idempotent,
+ * O(1) steady state). Consumers: GI gather rays today, picking and
+ * occlusion tomorrow.
+ *
+ * SNAPSHOT CONSISTENCY (design verified by Model/BvhSnapshot.tla,
+ * behavior pinned by BvhRaceStressTest): queries run unsynchronized
+ * from many threads while a rebuild may be in flight. The built tree is
+ * therefore published as ONE immutable {@link Tree} through a single
+ * volatile reference — RCU-style. A query captures the reference once
+ * at entry and traverses only that snapshot; a rebuild never mutates a
+ * published snapshot, it publishes a new one. Never add setters or
+ * growable arrays to Tree.
+ *
+ * Median split on the longest axis; leaf threshold 4. Queries use a
+ * fixed traversal stack.
+ *
+ * Contains the ONE copy of the triangle-intersection math
+ * (Möller–Trumbore) — {@code Raycast} delegates here so the math is
+ * never duplicated and never out of sync.
+ */
+public final class BvhStore {
+
+    private static final int LEAF = 4;
+
+    /** Immutable published snapshot. Fully built before the single
+     *  volatile publish; final fields + volatile reference give JMM
+     *  safe publication. The mesh block tables are PINNED (shallow
+     *  clones) so this snapshot keeps serving its version's world even
+     *  after the live mesh evicts those blocks (Model/SlotLifecycle.tla
+     *  shadow rule). */
+    private static final class Tree {
+        final int meshVersion;
+        final int nodeCount;
+        final int[] order;
+        final double[] minX, minY, minZ, maxX, maxY, maxZ;
+        final int[] left, right, start, count;
+        final MeshStore.Blocks blocks;   // pinned mesh tables
+
+        Tree(final int meshVersion, final int nodeCount, final int[] order,
+             final double[] minX, final double[] minY, final double[] minZ,
+             final double[] maxX, final double[] maxY, final double[] maxZ,
+             final int[] left, final int[] right, final int[] start, final int[] count,
+             final MeshStore.Blocks blocks) {
+            this.meshVersion = meshVersion;
+            this.nodeCount = nodeCount;
+            this.order = order;
+            this.minX = minX; this.minY = minY; this.minZ = minZ;
+            this.maxX = maxX; this.maxY = maxY; this.maxZ = maxZ;
+            this.left = left; this.right = right;
+            this.start = start; this.count = count;
+            this.blocks = blocks;
+        }
+    }
+
+    /** The ONE published reference. Volatile: queries capture it once. */
+    private volatile Tree tree;
+
+    /** Rebuild if the mesh changed since the last build. Single builder
+     *  at a time; readers never block. */
+    public synchronized void ensure(final MeshStore mesh) {
+        final Tree current = tree;
+        if (current != null && current.meshVersion == mesh.version()) {
+            return;
+        }
+        tree = build(mesh);
+    }
+
+    /** Build a complete immutable snapshot off to the side. The tree is
+     *  tagged with the mesh VERSION captured once at build start — tag
+     *  and content come from ONE read (Model/MeshPublish.tla). Only
+     *  triangles in LIVE blocks are indexed: count never shrinks but
+     *  evicted blocks are skipped, so the index matches the live world
+     *  at the captured version. */
+    private static Tree build(final MeshStore mesh) {
+        final int tag = mesh.version();
+        final int n = mesh.triangleCount();
+        final int[] order = new int[Math.max(n, 16)];
+        final double[] centroid = new double[Math.max(n * 3, 48)];
+        int m = 0;
+        for (int i = 0; i < n; i++) {
+            if (!mesh.blockAlive(i >>> MeshStore.BLOCK_SHIFT)) {
+                continue;   // evicted block: not part of the live world
+            }
+            order[m] = i;
+            centroid[m * 3]     = (mesh.at(i, 0, 0) + mesh.at(i, 1, 0) + mesh.at(i, 2, 0)) / 3;
+            centroid[m * 3 + 1] = (mesh.at(i, 0, 1) + mesh.at(i, 1, 1) + mesh.at(i, 2, 1)) / 3;
+            centroid[m * 3 + 2] = (mesh.at(i, 0, 2) + mesh.at(i, 1, 2) + mesh.at(i, 2, 2)) / 3;
+            m++;
+        }
+        final Nodes nodes = new Nodes(Math.max(2 * m / LEAF + 8, 16));
+        if (m > 0) {
+            buildNode(mesh, order, centroid, nodes, 0, m);
+        }
+        return new Tree(tag, nodes.nodeCount, order,
+                nodes.minX, nodes.minY, nodes.minZ,
+                nodes.maxX, nodes.maxY, nodes.maxZ,
+                nodes.left, nodes.right, nodes.start, nodes.count,
+                mesh.pinBlocks());
+    }
+
+    /** Growable node arrays used ONLY during build; never published. */
+    private static final class Nodes {
+        double[] minX, minY, minZ, maxX, maxY, maxZ;
+        int[] left, right, start, count;
+        int nodeCount;
+
+        Nodes(final int capacity) {
+            minX = new double[capacity]; minY = new double[capacity]; minZ = new double[capacity];
+            maxX = new double[capacity]; maxY = new double[capacity]; maxZ = new double[capacity];
+            left = new int[capacity]; right = new int[capacity];
+            start = new int[capacity]; count = new int[capacity];
+        }
+
+        void grow() {
+            final int cap = minX.length * 2;
+            minX = Arrays.copyOf(minX, cap); minY = Arrays.copyOf(minY, cap); minZ = Arrays.copyOf(minZ, cap);
+            maxX = Arrays.copyOf(maxX, cap); maxY = Arrays.copyOf(maxY, cap); maxZ = Arrays.copyOf(maxZ, cap);
+            left = Arrays.copyOf(left, cap); right = Arrays.copyOf(right, cap);
+            start = Arrays.copyOf(start, cap); count = Arrays.copyOf(count, cap);
+        }
+    }
+
+    private static int buildNode(final MeshStore mesh, final int[] order,
+                                 final double[] centroid, final Nodes nodes,
+                                 final int lo, final int hi) {
+        if (nodes.nodeCount == nodes.minX.length) {
+            nodes.grow();
+        }
+        final int node = nodes.nodeCount++;
+        double nx = Double.POSITIVE_INFINITY, ny = nx, nz = nx;
+        double xx = Double.NEGATIVE_INFINITY, xy = xx, xz = xx;
+        for (int i = lo; i < hi; i++) {
+            final int t = order[i];
+            for (int v = 0; v < 3; v++) {
+                nx = Math.min(nx, mesh.at(t, v, 0)); xx = Math.max(xx, mesh.at(t, v, 0));
+                ny = Math.min(ny, mesh.at(t, v, 1)); xy = Math.max(xy, mesh.at(t, v, 1));
+                nz = Math.min(nz, mesh.at(t, v, 2)); xz = Math.max(xz, mesh.at(t, v, 2));
+            }
+        }
+        nodes.minX[node] = nx; nodes.minY[node] = ny; nodes.minZ[node] = nz;
+        nodes.maxX[node] = xx; nodes.maxY[node] = xy; nodes.maxZ[node] = xz;
+        final int n = hi - lo;
+        if (n <= LEAF) {
+            nodes.count[node] = n;
+            nodes.start[node] = lo;
+            nodes.left[node] = nodes.right[node] = -1;
+            return node;
+        }
+        nodes.count[node] = 0;
+        final double sx = xx - nx, sy = xy - ny, sz = xz - nz;
+        final int axis = sx >= sy && sx >= sz ? 0 : (sy >= sz ? 1 : 2);
+        // sort range by centroid along axis
+        final Integer[] idx = new Integer[n];
+        for (int i = 0; i < n; i++) {
+            idx[i] = order[lo + i];
+        }
+        Arrays.sort(idx, (a, b) -> Double.compare(centroid[a * 3 + axis], centroid[b * 3 + axis]));
+        for (int i = 0; i < n; i++) {
+            order[lo + i] = idx[i];
+        }
+        final int mid = lo + n / 2;
+        // NOTE: never write "nodes.left[node] = buildNode(...)" — the child
+        // recursion can grow (reallocate) the arrays, and Java captures the
+        // array reference BEFORE the RHS runs, losing the write. Locals
+        // first, then store into the CURRENT arrays.
+        final int l = buildNode(mesh, order, centroid, nodes, lo, mid);
+        final int r = buildNode(mesh, order, centroid, nodes, mid, hi);
+        nodes.left[node] = l;
+        nodes.right[node] = r;
+        return node;
+    }
+
+    /** Nearest hit triangle along the ray with 0 &lt; t &lt; maxDist, or -1.
+     *  uvOut (if non-null) receives barycentric {u, v} at the hit.
+     *  Safe to call concurrently with {@link #ensure}. */
+    public int nearest(final MeshStore mesh, final int self,
+                       final double ox, final double oy, final double oz,
+                       final double dx, final double dy, final double dz,
+                       final double maxDist, final double[] uvOut) {
+        final Tree t = tree; // single volatile read: THE snapshot capture
+        if (t == null) {
+            return -1;
+        }
+        int hit = -1;
+        double best = maxDist;
+        final int[] stack = new int[64];
+        int sp = 0;
+        if (t.nodeCount > 0) {
+            stack[sp++] = 0;
+        }
+        final double idx = 1.0 / dx, idy = 1.0 / dy, idz = 1.0 / dz;
+        while (sp > 0) {
+            final int node = stack[--sp];
+            if (!boxHit(t, node, ox, oy, oz, idx, idy, idz, best)) {
+                continue;
+            }
+            if (t.count[node] > 0) {
+                final int end = t.start[node] + t.count[node];
+                for (int i = t.start[node]; i < end; i++) {
+                    final int tri = t.order[i];
+                    if (tri == self) {
+                        continue;
+                    }
+                    final double hitT = intersectTuv(t.blocks, tri, ox, oy, oz,
+                            dx, dy, dz, best, uvOut);
+                    if (hitT > 0 && hitT < best) {
+                        best = hitT;
+                        hit = tri;
+                    }
+                }
+            } else {
+                stack[sp++] = t.left[node];
+                stack[sp++] = t.right[node];
+            }
+        }
+        return hit;
+    }
+
+    /** True if any triangle except {@code self} is hit with 0 &lt; t &lt;
+     *  maxDist. Safe to call concurrently with {@link #ensure}. */
+    public boolean occluded(final MeshStore mesh, final int self,
+                            final double ox, final double oy, final double oz,
+                            final double dx, final double dy, final double dz,
+                            final double maxDist) {
+        final Tree t = tree; // single volatile read: THE snapshot capture
+        if (t == null) {
+            return false;
+        }
+        final int[] stack = new int[64];
+        int sp = 0;
+        if (t.nodeCount > 0) {
+            stack[sp++] = 0;
+        }
+        final double idx = 1.0 / dx, idy = 1.0 / dy, idz = 1.0 / dz;
+        while (sp > 0) {
+            final int node = stack[--sp];
+            if (!boxHit(t, node, ox, oy, oz, idx, idy, idz, maxDist)) {
+                continue;
+            }
+            if (t.count[node] > 0) {
+                final int end = t.start[node] + t.count[node];
+                for (int i = t.start[node]; i < end; i++) {
+                    final int tri = t.order[i];
+                    if (tri != self
+                            && intersectTuv(t.blocks, tri, ox, oy, oz,
+                                    dx, dy, dz, maxDist, null) > 0) {
+                        return true;
+                    }
+                }
+            } else {
+                stack[sp++] = t.left[node];
+                stack[sp++] = t.right[node];
+            }
+        }
+        return false;
+    }
+
+    /** Slab test: does the ray hit the node box within [0, maxT]? */
+    private static boolean boxHit(final Tree t, final int node,
+                                  final double ox, final double oy, final double oz,
+                                  final double idx, final double idy, final double idz,
+                                  final double maxT) {
+        double t0 = (t.minX[node] - ox) * idx, t1 = (t.maxX[node] - ox) * idx;
+        double tmin = Math.min(t0, t1), tmax = Math.max(t0, t1);
+        t0 = (t.minY[node] - oy) * idy; t1 = (t.maxY[node] - oy) * idy;
+        tmin = Math.max(tmin, Math.min(t0, t1)); tmax = Math.min(tmax, Math.max(t0, t1));
+        t0 = (t.minZ[node] - oz) * idz; t1 = (t.maxZ[node] - oz) * idz;
+        tmin = Math.max(tmin, Math.min(t0, t1)); tmax = Math.min(tmax, Math.max(t0, t1));
+        return tmax >= Math.max(tmin, 0.0) && tmin <= maxT;
+    }
+
+    /** Möller–Trumbore over PINNED block tables: hit distance, or -1.
+     *  Reads the snapshot's pinned mesh — immune to live-mesh eviction. */
+    private static double intersectTuv(final MeshStore.Blocks b, final int tri,
+                                       final double ox, final double oy, final double oz,
+                                       final double dx, final double dy, final double dz,
+                                       final double maxDist, final double[] uvOut) {
+        final double[] data = b.data()[tri >>> MeshStore.BLOCK_SHIFT];
+        final int base = (tri & ((1 << MeshStore.BLOCK_SHIFT) - 1)) * 9;
+        final double ax = data[base], ay = data[base + 1], az = data[base + 2];
+        final double e1x = data[base + 3] - ax, e1y = data[base + 4] - ay, e1z = data[base + 5] - az;
+        final double e2x = data[base + 6] - ax, e2y = data[base + 7] - ay, e2z = data[base + 8] - az;
+        return finishIntersect(ax, ay, az, e1x, e1y, e1z, e2x, e2y, e2z,
+                ox, oy, oz, dx, dy, dz, maxDist, uvOut);
+    }
+
+    /** Möller–Trumbore with barycentric uv out: hit distance, or -1.
+     *  THE single copy of triangle-intersection math in the engine. */
+    public static double intersectTuv(final MeshStore m, final int tri,
+                                      final double ox, final double oy, final double oz,
+                                      final double dx, final double dy, final double dz,
+                                      final double maxDist, final double[] uvOut) {
+        final double ax = m.at(tri, 0, 0), ay = m.at(tri, 0, 1), az = m.at(tri, 0, 2);
+        final double e1x = m.at(tri, 1, 0) - ax, e1y = m.at(tri, 1, 1) - ay, e1z = m.at(tri, 1, 2) - az;
+        final double e2x = m.at(tri, 2, 0) - ax, e2y = m.at(tri, 2, 1) - ay, e2z = m.at(tri, 2, 2) - az;
+        return finishIntersect(ax, ay, az, e1x, e1y, e1z, e2x, e2y, e2z,
+                ox, oy, oz, dx, dy, dz, maxDist, uvOut);
+    }
+
+    private static double finishIntersect(final double ax, final double ay, final double az,
+                                          final double e1x, final double e1y, final double e1z,
+                                          final double e2x, final double e2y, final double e2z,
+                                          final double ox, final double oy, final double oz,
+                                          final double dx, final double dy, final double dz,
+                                          final double maxDist, final double[] uvOut) {
+
+        final double px = dy * e2z - dz * e2y;
+        final double py = dz * e2x - dx * e2z;
+        final double pz = dx * e2y - dy * e2x;
+        final double det = e1x * px + e1y * py + e1z * pz;
+        if (Math.abs(det) < 1e-12) {
+            return -1.0;
+        }
+        final double inv = 1.0 / det;
+        final double tx = ox - ax, ty = oy - ay, tz = oz - az;
+        final double u = (tx * px + ty * py + tz * pz) * inv;
+        if (u < 0.0 || u > 1.0) {
+            return -1.0;
+        }
+        final double qx = ty * e1z - tz * e1y;
+        final double qy = tz * e1x - tx * e1z;
+        final double qz = tx * e1y - ty * e1x;
+        final double v = (dx * qx + dy * qy + dz * qz) * inv;
+        if (v < 0.0 || u + v > 1.0) {
+            return -1.0;
+        }
+        final double t = (e2x * qx + e2y * qy + e2z * qz) * inv;
+        if (t > 1e-4 && t < maxDist) {
+            if (uvOut != null) {
+                uvOut[0] = u;
+                uvOut[1] = v;
+            }
+            return t;
+        }
+        return -1.0;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/CameraStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/CameraStore.java
new file mode 100644 (file)
index 0000000..3940ef5
--- /dev/null
@@ -0,0 +1,20 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+/**
+ * Camera as substrate data: passes read it, the app shell sets it.
+ * Convention: +Y up; yaw=0, pitch=0 looks +Z; +yaw right; +pitch up;
+ * scale = pixels per unit of x/z.
+ */
+public final class CameraStore {
+
+    public double x, y, z;
+    public double yaw, pitch;
+    public double scale = 300.0;
+
+    public void set(final double x, final double y, final double z,
+                    final double yaw, final double pitch, final double scale) {
+        this.x = x; this.y = y; this.z = z;
+        this.yaw = yaw; this.pitch = pitch;
+        this.scale = scale;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/ChunkStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/ChunkStore.java
new file mode 100644 (file)
index 0000000..f22ca97
--- /dev/null
@@ -0,0 +1,89 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Streaming chunk registry. A chunk owns a contiguous, block-aligned
+ * range of MeshStore triangles (firstTri..firstTri+triCount-1, padded
+ * so firstBlock..firstBlock+blockCount-1 are wholly its own — eviction
+ * never touches a neighbor's storage).
+ *
+ * Lifecycle (protocol proven by Model/SlotLifecycle.tla): chunks go
+ * LOADING -> LIVE -> (removed). All structural transitions — publish
+ * AND evict — are applied by the WorldStreamer pass at a frame point
+ * where no reader holds the world (the frame barrier). Readers that
+ * must outlive a transition pin via MeshStore.pinBlocks() (the BVH
+ * Tree does exactly this).
+ */
+public final class ChunkStore {
+
+    public enum State { LOADING, LIVE }
+
+    public static final class Chunk {
+        public final int cx, cz;
+        public State state;
+        public int firstTri, triCount;
+        public int firstBlock, blockCount;
+        /** World AABB (computed lazily by ChunkFrustumCullPass). */
+        public double minX, minY, minZ, maxX, maxY, maxZ;
+        public boolean boundsComputed;
+        /** Frustum visibility for THIS frame (ChunkFrustumCullPass). */
+        public boolean visible = true;
+
+        Chunk(final int cx, final int cz) {
+            this.cx = cx;
+            this.cz = cz;
+            this.state = State.LOADING;
+        }
+
+        /** Conservative bounding sphere radius around the AABB center. */
+        public double radius() {
+            final double dx = (maxX - minX) / 2, dy = (maxY - minY) / 2, dz = (maxZ - minZ) / 2;
+            return Math.sqrt(dx * dx + dy * dy + dz * dz);
+        }
+    }
+
+    private final Map<Long, Chunk> byKey = new HashMap<>();
+
+    public static long key(final int cx, final int cz) {
+        return (((long) cx) << 32) | (cz & 0xFFFFFFFFL);
+    }
+
+    public Chunk get(final int cx, final int cz) {
+        return byKey.get(key(cx, cz));
+    }
+
+    public Chunk beginLoad(final int cx, final int cz) {
+        final Chunk c = new Chunk(cx, cz);
+        byKey.put(key(cx, cz), c);
+        return c;
+    }
+
+    public void publish(final Chunk c, final int firstTri, final int triCount,
+                        final int firstBlock, final int blockCount) {
+        c.firstTri = firstTri;
+        c.triCount = triCount;
+        c.firstBlock = firstBlock;
+        c.blockCount = blockCount;
+        c.state = State.LIVE;
+    }
+
+    public void remove(final Chunk c) {
+        byKey.remove(key(c.cx, c.cz));
+    }
+
+    public int liveCount() {
+        int n = 0;
+        for (final Chunk c : byKey.values()) {
+            if (c.state == State.LIVE) {
+                n++;
+            }
+        }
+        return n;
+    }
+
+    public Iterable<Chunk> all() {
+        return byKey.values();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/DebugStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/DebugStore.java
new file mode 100644 (file)
index 0000000..5b7c526
--- /dev/null
@@ -0,0 +1,22 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+/**
+ * Debug visualization flags (the developer-tools window writes them,
+ * rendering passes read them — substrate data, no pass references).
+ * Port of legacy DeveloperTools fields (2026-10-13).
+ */
+public final class DebugStore {
+
+    /** Legacy DeveloperTools.renderAlternateSegments: when true,
+     *  RasterPass paints only tiles where (tx+ty) is EVEN — a true
+     *  checkerboard (user request 2026-10-13), so horizontal and
+     *  vertical overdraw are both visible. Linear tile-index parity
+     *  degenerates to horizontal stripes when the column count is
+     *  even. Skipped tiles stay at the background clear color. */
+    public volatile boolean alternateSegments;
+
+    /** Legacy DeveloperTools.showSegmentBoundaries: when true,
+     *  RasterPass overlays red 1px lines at tile boundaries after
+     *  painting. */
+    public volatile boolean showBoundaries;
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FramePipeline.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FramePipeline.java
new file mode 100644 (file)
index 0000000..8bc18b2
--- /dev/null
@@ -0,0 +1,177 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.TreeMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Semaphore;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+/**
+ * Two-slot frame pipeline: geometry (stage 10) and paint (stage 20) of
+ * different frames run at the same time on the shared worker pool, so a
+ * worker never runs out of work at a stage barrier.
+ *
+ * <p>Why two and not three frames: the pipeline overlaps stages of
+ * ADJACENT frames, not three deep copies of the renderer. A frame needs
+ * its own screen slot only while its stages run, and slots are recycled
+ * as soon as paint finishes (the image travels to the presenter, and the
+ * presenter must see complete frames only). Three frames of state buy
+ * nothing extra because there is exactly one paint stage per frame.</p>
+ *
+ * <p>Frame lifetime: acquire a free image from the app, take a slot,
+ * snapshot pose + pinned lightmap store into a {@link FrameView}, then on
+ * a dedicated stage thread run stage 10 passes, then stage 20 passes.
+ * The slot is released as soon as stages finish; the image is handed to
+ * {@code publish} IN SUBMISSION ORDER (a slow frame must never let a newer
+ * one overtake it — the screen would jump backwards).</p>
+ *
+ * <p>Backpressure: {@link #startFrame} blocks when both slots are busy,
+ * which is exactly when the pool is already saturated. It never blocks
+ * while a single frame is in flight.</p>
+ */
+public final class FramePipeline {
+
+    /** In-flight frames (each needs a ScreenStore slot). */
+    private static final int SLOTS = 2;
+
+    private final Substrate data;
+    private final Kernel kernel;
+    private final Supplier<ImageStore> acquireImage;
+    private final Consumer<ImageStore> publish;
+
+    private final List<Pass> geometryPasses = new ArrayList<>();
+    private final List<Pass> paintPasses = new ArrayList<>();
+
+    private final ExecutorService stageThreads = Executors.newFixedThreadPool(
+            SLOTS, r -> {
+                final Thread t = new Thread(r, "frame-stage");
+                t.setDaemon(true);
+                return t;
+            });
+
+    /** Limits frames in flight to SLOTS; also the no-idle backpressure. */
+    private final Semaphore inFlight = new Semaphore(SLOTS);
+
+    private final Object slotLock = new Object();
+    private final boolean[] slotBusy = new boolean[SLOTS];
+
+    private final Object publishLock = new Object();
+    private final TreeMap<Long, ImageStore> completed = new TreeMap<>();
+    private long nextSeq;
+    private long nextPublish;
+
+    public FramePipeline(final Substrate data, final Kernel kernel,
+                         final Supplier<ImageStore> acquireImage,
+                         final Consumer<ImageStore> publish) {
+        this.data = data;
+        this.kernel = kernel;
+        this.acquireImage = acquireImage;
+        this.publish = publish;
+        for (final Pass pass : kernel.passList()) {
+            if (pass.stage() == 10) {
+                geometryPasses.add(pass);
+            } else if (pass.stage() == 20) {
+                paintPasses.add(pass);
+            }
+        }
+    }
+
+    /** True when the kernel actually has pipelinable stages. */
+    public boolean hasStages() {
+        return !geometryPasses.isEmpty() && !paintPasses.isEmpty();
+    }
+
+    /**
+     * Starts one frame. Returns as soon as the frame is queued; blocks only
+     * when both slots are busy (the pool is saturated anyway).
+     */
+    public void startFrame() {
+        inFlight.acquireUninterruptibly();
+        final ImageStore image = acquireImage.get();
+        final int slot;
+        synchronized (slotLock) {
+            int free = -1;
+            for (int i = 0; i < SLOTS; i++) {
+                if (!slotBusy[i]) {
+                    free = i;
+                    break;
+                }
+            }
+            if (free < 0) {
+                // cannot happen: permits and slots are the same count
+                throw new IllegalStateException("no free frame slot");
+            }
+            slotBusy[free] = true;
+            slot = free;
+        }
+        final FrameView view = new FrameView(slot, data.screens[slot], image,
+                data.giLightmaps,
+                data.camera.x, data.camera.y, data.camera.z,
+                data.camera.yaw, data.camera.pitch, data.camera.scale);
+        final long seq;
+        synchronized (publishLock) {
+            seq = nextSeq++;
+        }
+        stageThreads.submit(() -> runFrame(seq, slot, view));
+    }
+
+    /** PROBE: per-stage wall time accumulation. */
+    public static final java.util.concurrent.atomic.LongAdder PROBE_GEOMETRY_NS =
+            new java.util.concurrent.atomic.LongAdder();
+    public static final java.util.concurrent.atomic.LongAdder PROBE_PAINT_NS =
+            new java.util.concurrent.atomic.LongAdder();
+
+    private void runFrame(final long seq, final int slot, final FrameView view) {
+        try {
+            final long t0 = System.nanoTime();
+            for (final Pass pass : geometryPasses) {
+                pass.apply(data, view);
+            }
+            final long t1 = System.nanoTime();
+            for (final Pass pass : paintPasses) {
+                pass.apply(data, view);
+            }
+            final long t2 = System.nanoTime();
+            PROBE_GEOMETRY_NS.add(t1 - t0);
+            PROBE_PAINT_NS.add(t2 - t1);
+        } finally {
+            synchronized (slotLock) {
+                slotBusy[slot] = false;
+            }
+            inFlight.release();
+            complete(seq, view.image);
+        }
+    }
+
+    /** Publishes every frame that is now next in submission order. */
+    private void complete(final long seq, final ImageStore image) {
+        final List<ImageStore> toPublish = new ArrayList<>(2);
+        synchronized (publishLock) {
+            completed.put(seq, image);
+            while (true) {
+                final ImageStore next = completed.remove(nextPublish);
+                if (next == null) {
+                    break;
+                }
+                nextPublish++;
+                toPublish.add(next);
+            }
+        }
+        for (final ImageStore img : toPublish) {
+            publish.accept(img);
+        }
+    }
+
+    /** Blocks until every started frame has finished (close / tests). */
+    public void drain() {
+        inFlight.acquireUninterruptibly(SLOTS);
+        inFlight.release(SLOTS);
+    }
+
+    public void stop() {
+        stageThreads.shutdownNow();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FrameStream.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FrameStream.java
new file mode 100644 (file)
index 0000000..243b445
--- /dev/null
@@ -0,0 +1,235 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.TreeMap;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+/**
+ * Continuous frame stream: every pass contributes WAVES of independent
+ * items to ONE queue, and wave k+1 is submitted by the last worker to
+ * finish wave k. There is no join anywhere, so a worker never waits for a
+ * stage boundary — if this frame has nothing ready, the next frame's items
+ * are already queued behind it.
+ *
+ * <p>Contrast with the abandoned {@code FramePipeline} (two stage threads
+ * that submitted and joined per stage): the pool went empty at every join
+ * because both frames were in the same stage at the same time. Here the
+ * queue is the only synchronisation point and frames overlap for free.</p>
+ *
+ * <p>Frame lifetime: acquire an image, take a screen slot, snapshot pose
+ * and pinned lightmap store into a {@link FrameView}, run every pass's
+ * {@link StreamedPass#beginFrame} on the calling thread, submit wave 0.
+ * The slot is released and the frame published by whichever worker
+ * finishes the LAST wave, in submission order.</p>
+ *
+ * <p>Backpressure: {@link #startFrame} blocks when both slots are busy.
+ * Two slots is the minimum that lets transform of frame N+1 overlap paint
+ * of frame N; more slot copies buy nothing because paint publishes.</p>
+ */
+public final class FrameStream {
+
+    /** Frames that can be transforming/painting at once. Two is the
+     *  minimum that overlaps transform(N+1) with paint(N); more slots let
+     *  the pool start the next frame's TILES while the previous frame's
+     *  last tiles straggle, at one ScreenStore per slot. */
+    private static final int SLOTS = Math.max(2, Math.min(4,
+            Integer.getInteger("e3d2.slots", 2)));
+
+    private final Substrate data;
+    private final List<StreamedPass> passes = new ArrayList<>();
+    private final Supplier<ImageStore> acquireImage;
+    private final Consumer<ImageStore> publish;
+
+    /** (pass, wave) flattened, in execution order. */
+    private StreamedPass[] wavePass;
+    private int[] waveIndex;
+    private int waveCount;
+
+    private final Semaphore inFlight = new Semaphore(SLOTS);
+    private final Object slotLock = new Object();
+    private final boolean[] slotBusy = new boolean[SLOTS];
+
+    private final Object publishLock = new Object();
+    private final TreeMap<Long, ImageStore> completed = new TreeMap<>();
+    private long nextSeq;
+    private long nextPublish;
+
+    public FrameStream(final Substrate data, final Kernel kernel,
+                       final Supplier<ImageStore> acquireImage,
+                       final Consumer<ImageStore> publish) {
+        this.data = data;
+        this.acquireImage = acquireImage;
+        this.publish = publish;
+        final List<StreamedPass> ps = new ArrayList<>();
+        final List<Integer> ws = new ArrayList<>();
+        for (final Pass pass : kernel.passList()) {
+            if (!(pass instanceof final StreamedPass sp)) {
+                continue;
+            }
+            for (int w = 0; w < sp.waveCount(); w++) {
+                ps.add(sp);
+                ws.add(w);
+            }
+        }
+        waveCount = ws.size();
+        wavePass = ps.toArray(new StreamedPass[0]);
+        passes.addAll(new java.util.LinkedHashSet<>(ps));
+        waveIndex = new int[waveCount];
+        for (int i = 0; i < waveCount; i++) {
+            waveIndex[i] = ws.get(i);
+        }
+    }
+
+    /** True when at least one wave exists (otherwise use runOnce). */
+    public boolean hasWaves() {
+        return waveCount > 0;
+    }
+
+    /** Starts a frame and returns; blocks only when both slots are busy. */
+    public void startFrame() {
+        inFlight.acquireUninterruptibly();
+        final ImageStore image = acquireImage.get();
+        final int slot;
+        synchronized (slotLock) {
+            int free = -1;
+            for (int i = 0; i < SLOTS; i++) {
+                if (!slotBusy[i]) {
+                    free = i;
+                    break;
+                }
+            }
+            if (free < 0) {
+                throw new IllegalStateException("no free frame slot");
+            }
+            slotBusy[free] = true;
+            slot = free;
+        }
+        final FrameView view = new FrameView(slot, data.screens[slot], image,
+                data.giLightmaps,
+                data.camera.x, data.camera.y, data.camera.z,
+                data.camera.yaw, data.camera.pitch, data.camera.scale);
+        final long seq;
+        synchronized (publishLock) {
+            seq = nextSeq++;
+        }
+        for (final StreamedPass pass : passes) {
+            pass.beginFrame(data, view);
+        }
+        startWave(seq, 0, view);
+    }
+
+    /** Submits every item of wave {@code w}; the last finisher continues. */
+    private static final boolean TRACE = Boolean.getBoolean("e3d2.trace");
+
+    private void startWave(final long seq, final int w, final FrameView view) {
+        if (TRACE) {
+            System.err.println("seq=" + seq + " wave=" + w + "/" + waveCount);
+        }
+        if (w >= waveCount) {
+            finish(seq, view);
+            return;
+        }
+        final StreamedPass pass = wavePass[w];
+        final int wave = waveIndex[w];
+        final int count = pass.itemCount(wave, data, view);
+        if (count <= 0) {
+            startWave(seq, w + 1, view);
+            return;
+        }
+        if (pass.ticketWave(wave)) {
+            final int workers = Math.min(count, Parallel.poolSize());
+            final AtomicInteger remaining = new AtomicInteger(workers);
+            final AtomicInteger ticket = new AtomicInteger();
+            for (int i = 0; i < workers; i++) {
+                Parallel.submit(() -> {
+                    int item;
+                    try {
+                        if (TRACE) {
+                            System.err.println("  w" + w + " worker up");
+                        }
+                        while ((item = ticket.getAndIncrement()) < count) {
+                            try {
+                                if (TRACE) {
+                                    System.err.println("  w" + w + " item " + item
+                                            + " start (" + pass.name() + ")");
+                                }
+                                pass.runItem(wave, item, data, view);
+                            } catch (final Throwable e) {
+                                reportFailure(e);
+                            }
+                            if (TRACE) {
+                                System.err.println("  w" + w + " item " + item
+                                        + " done on " + Thread.currentThread().getName());
+                            }
+                        }
+                    } finally {
+                        final int left = remaining.decrementAndGet();
+                        if (TRACE) {
+                            System.err.println("  w" + w + " worker exit, left=" + left);
+                        }
+                        if (left == 0) {
+                            startWave(seq, w + 1, view);
+                        }
+                    }
+                });
+            }
+            return;
+        }
+        final AtomicInteger remaining = new AtomicInteger(count);
+        for (int i = 0; i < count; i++) {
+            final int item = i;
+            Parallel.submit(() -> {
+                try {
+                    try {
+                        pass.runItem(wave, item, data, view);
+                    } catch (final Throwable e) {
+                        reportFailure(e);
+                    }
+                } finally {
+                    if (remaining.decrementAndGet() == 0) {
+                        startWave(seq, w + 1, view);
+                    }
+                }
+            });
+        }
+    }
+
+    private static void reportFailure(final Throwable e) {
+        if (FAILED.compareAndSet(false, true)) {
+            System.err.println("frame stream item failed: " + e);
+            e.printStackTrace();
+        }
+    }
+
+    private static final java.util.concurrent.atomic.AtomicBoolean FAILED =
+            new java.util.concurrent.atomic.AtomicBoolean();
+
+    private void finish(final long seq, final FrameView view) {
+        if (TRACE) {
+            System.err.println("seq=" + seq + " FINISH");
+        }
+        synchronized (slotLock) {
+            slotBusy[view.slot] = false;
+        }
+        inFlight.release();
+        final List<ImageStore> toPublish = new ArrayList<>(2);
+        synchronized (publishLock) {
+            completed.put(seq, view.image);
+            while (true) {
+                final ImageStore next = completed.remove(nextPublish);
+                if (next == null) {
+                    break;
+                }
+                nextPublish++;
+                toPublish.add(next);
+            }
+        }
+        for (final ImageStore img : toPublish) {
+            publish.accept(img);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FrameView.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FrameView.java
new file mode 100644 (file)
index 0000000..5087dc5
--- /dev/null
@@ -0,0 +1,60 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+/**
+ * Per-frame render state handed to a pass. The frame pipeline gives each
+ * in-flight frame its OWN view, so two frames can be transformed and
+ * rasterized at the same time without sharing a mutable table:
+ *
+ * <ul>
+ *   <li>{@link #slot} — which {@link ScreenStore} slot this frame owns;</li>
+ *   <li>{@link #screen} — that slot (transform output, raster input);</li>
+ *   <li>{@link #image} — the buffer this frame paints into;</li>
+ *   <li>{@link #lightmaps} — the lightmap store PINNED at frame start: a
+ *   GI rebuild's fresh store takes effect for the NEXT frame, so a frame
+ *   never samples a store that is being re-allocated underneath it;</li>
+ *   <li>the camera pose this frame renders ({@code x..scale}) — sampled
+ *   once when the frame is created, because the live camera moves on.</li>
+ * </ul>
+ *
+ * <p>A view is immutable. The single-frame path (tests, benches, the
+ * oracles) uses {@link #ofDefault}, which reads the substrate's default
+ * slot and live camera.</p>
+ */
+public final class FrameView {
+
+    public final int slot;
+    public final ScreenStore screen;
+    public final ImageStore image;
+    public final LightmapStore lightmaps;
+    public final double x, y, z, yaw, pitch, scale;
+
+    /** Per-frame scratch. A pass that spans several waves (RasterPass bins
+     *  a frame in one wave and paints it in the next) keeps its state here:
+     *  the array belongs to THIS frame, so two in-flight frames never
+     *  share it. */
+    public final Object[] scratch = new Object[2];
+
+    public FrameView(final int slot, final ScreenStore screen, final ImageStore image,
+                     final LightmapStore lightmaps,
+                     final double x, final double y, final double z,
+                     final double yaw, final double pitch, final double scale) {
+        this.slot = slot;
+        this.screen = screen;
+        this.image = image;
+        this.lightmaps = lightmaps;
+        this.x = x;
+        this.y = y;
+        this.z = z;
+        this.yaw = yaw;
+        this.pitch = pitch;
+        this.scale = scale;
+    }
+
+    /** View over the substrate's default slot and live camera — what every
+     *  single-frame renderer (tests, benches, oracles) uses. */
+    public static FrameView ofDefault(final Substrate data) {
+        return new FrameView(0, data.screen, data.image, data.giLightmaps,
+                data.camera.x, data.camera.y, data.camera.z,
+                data.camera.yaw, data.camera.pitch, data.camera.scale);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/GiStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/GiStore.java
new file mode 100644 (file)
index 0000000..085584e
--- /dev/null
@@ -0,0 +1,104 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.Arrays;
+
+/**
+ * Per-triangle baked light, RGB, substrate table.
+ *
+ * directR/G/B: generation-0 direct light.
+ * curR/G/B:    total being accumulated this generation (direct + indirect).
+ * prevR/G/B:   frozen at the last barrier — bounce rays gather from this
+ *              field (the model's "previous generation"), RasterPass
+ *              displays it. flip() at the barrier: prev = cur.
+ *
+ * Written only by the scheduler's epoch-guarded fold step (rule 7).
+ */
+public final class GiStore {
+
+    /** Monotonic process-wide counter: a REBUILT store must not reuse a
+     *  version, or the frame signature cannot tell the new epoch from the
+     *  old one. */
+    private static final java.util.concurrent.atomic.AtomicInteger VERSIONS =
+            new java.util.concurrent.atomic.AtomicInteger();
+
+    /** Frame-input stamp: bumps on every publish that changes what the
+     *  raster displays (barrier flip, catch-up entry publish). */
+    private volatile int version = VERSIONS.incrementAndGet();
+
+    public final int[] directR, directG, directB;
+    public final int[] curR, curG, curB;
+    public final int[] prevR, prevG, prevB;
+
+    public GiStore() {
+        this(0);
+    }
+
+    public GiStore(final int triangleCount) {
+        directR = new int[triangleCount]; directG = new int[triangleCount];
+        directB = new int[triangleCount];
+        curR = new int[triangleCount]; curG = new int[triangleCount];
+        curB = new int[triangleCount];
+        prevR = new int[triangleCount]; prevG = new int[triangleCount];
+        prevB = new int[triangleCount];
+        java.util.Arrays.fill(prevR, 255); java.util.Arrays.fill(prevG, 255);
+        java.util.Arrays.fill(prevB, 255);   // display starts full-light
+    }
+
+    public void setSize(final int triangleCount) {
+        if (directR.length != triangleCount) {
+            throw new IllegalStateException("GiStore sized " + directR.length
+                    + " but scene has " + triangleCount + " triangles");
+        }
+    }
+
+    /** gen 0: direct light; gen &gt;= 1: new total (direct + indirect). */
+    public void setShade(final int entry, final int rgb, final int gen) {
+        final int r = (rgb >> 16) & 0xFF, g = (rgb >> 8) & 0xFF, b = rgb & 0xFF;
+        if (gen == 0) {
+            directR[entry] = r; directG[entry] = g; directB[entry] = b;
+        }
+        curR[entry] = r; curG[entry] = g; curB[entry] = b;
+    }
+
+    /** Barrier rollover: the frozen field becomes the just-baked one. */
+    public void flip() {
+        System.arraycopy(curR, 0, prevR, 0, curR.length);
+        System.arraycopy(curG, 0, prevG, 0, curG.length);
+        System.arraycopy(curB, 0, prevB, 0, curB.length);
+        version = VERSIONS.incrementAndGet();
+    }
+
+    /** Catch-up publish: one entry's cur becomes its prev (local bake, no
+     *  barrier, no generation — rule 6). */
+    public void publishEntry(final int entry) {
+        prevR[entry] = curR[entry];
+        prevG[entry] = curG[entry];
+        prevB[entry] = curB[entry];
+        version = VERSIONS.incrementAndGet();
+    }
+
+    /** Frame-input stamp for the shell's render-on-demand signature: every
+     *  publish that changes the DISPLAYED field bumps it. */
+    public int version() { return version; }
+
+    /** Mean |cur − prev| across channels — the delta convergence signal. */
+    public double meanDelta() {
+        long sum = 0;
+        final int n = curR.length;
+        for (int e = 0; e < n; e++) {
+            sum += Math.abs(curR[e] - prevR[e])
+                    + Math.abs(curG[e] - prevG[e])
+                    + Math.abs(curB[e] - prevB[e]);
+        }
+        return n == 0 ? 0.0 : (double) sum / (n * 3);
+    }
+
+    public int shadeR(final int entry) { return prevR[entry]; }
+    public int shadeG(final int entry) { return prevG[entry]; }
+    public int shadeB(final int entry) { return prevB[entry]; }
+
+    /** Packed displayed shade (for tests). */
+    public int shade(final int entry) {
+        return (prevR[entry] << 16) | (prevG[entry] << 8) | prevB[entry];
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/GuiStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/GuiStore.java
new file mode 100644 (file)
index 0000000..88d3388
--- /dev/null
@@ -0,0 +1,64 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * GUI components as DATA. Panels, labels, buttons live here as plain
+ * records; GuiPass runs the logic (hit-test, hover, click publish),
+ * GuiRenderPass draws them. A new widget instance = new data, never a
+ * code change. Click outcomes are published as id strings on
+ * {@link #clicks} for the app to poll — passes stay free of callbacks.
+ */
+public final class GuiStore {
+
+    public static final class Panel {
+        public int x, y, w, h;
+        public int argb;
+
+        public Panel(final int x, final int y, final int w, final int h, final int argb) {
+            this.x = x; this.y = y; this.w = w; this.h = h; this.argb = argb;
+        }
+    }
+
+    public static final class Label {
+        public int x, y;
+        public double scale;
+        public int argb;
+        public String text;
+
+        public Label(final int x, final int y, final double scale,
+                     final int argb, final String text) {
+            this.x = x; this.y = y; this.scale = scale;
+            this.argb = argb; this.text = text;
+        }
+    }
+
+    public static final class Button {
+        public int x, y, w, h;
+        public final String id;
+        public String text;
+        public boolean hover, pressed;
+        public int argb = 0xFF404860;
+        public int hoverArgb = 0xFF506080;
+        public int textArgb = 0xFFEEEEEE;
+
+        public Button(final int x, final int y, final int w, final int h,
+                      final String id, final String text) {
+            this.x = x; this.y = y; this.w = w; this.h = h;
+            this.id = id; this.text = text;
+        }
+
+        public boolean contains(final int px, final int py) {
+            return px >= x && px < x + w && py >= y && py < y + h;
+        }
+    }
+
+    public final List<Panel> panels = new ArrayList<>();
+    public final List<Label> labels = new ArrayList<>();
+    public final List<Button> buttons = new ArrayList<>();
+
+    /** Clicked button ids since the last poll (written by GuiPass,
+     *  drained by the app). */
+    public final List<String> clicks = new ArrayList<>();
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/ImageStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/ImageStore.java
new file mode 100644 (file)
index 0000000..a6c4941
--- /dev/null
@@ -0,0 +1,52 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.Arrays;
+
+/**
+ * The frame buffer: ARGB pixels + depth (stored as w = 1/z, larger = closer;
+ * background = 0.0 = infinitely far). One per frame, reused across renders.
+ */
+public final class ImageStore {
+
+    public final int width;
+    public final int height;
+    public final int[] pixels;
+    public final float[] depth;
+
+    /** Background colour of this buffer. {@link #clear(int)} records it, so
+     *  a pass that clears lazily (RasterPass clears per tile, in parallel —
+     *  the legacy clearSegmentPixels model) fills the SAME colour the app
+     *  asked for. */
+    public int background = 0xFF102030;
+
+    public ImageStore(final int width, final int height) {
+        this.width = width;
+        this.height = height;
+        this.pixels = new int[width * height];
+        this.depth = new float[width * height];
+    }
+
+    /**
+     * Wraps an EXTERNAL pixel array. The shell passes a
+     * {@code TYPE_INT_RGB} BufferedImage's own backing array, so the
+     * page-flip blits a native 32-bit image with no per-pixel format
+     * conversion (measured at 4K: a 24-bit wrapped raster made the blit
+     * compete with the render for CPU — 56 fps live against 74 fps for
+     * the legacy engine that blits a TYPE_INT_RGB image).
+     */
+    public ImageStore(final int width, final int height, final int[] pixels) {
+        if (pixels.length != width * height) {
+            throw new IllegalArgumentException("pixel count != w*h");
+        }
+        this.width = width;
+        this.height = height;
+        this.pixels = pixels;
+        this.depth = new float[width * height];
+    }
+
+    public void clear(final int background) {
+        this.background = background;
+        Arrays.fill(pixels, background);
+        Arrays.fill(depth, 0f);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/InputStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/InputStore.java
new file mode 100644 (file)
index 0000000..a908a5a
--- /dev/null
@@ -0,0 +1,129 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Input state: the substrate table for keyboard/mouse. Written by the
+ * app-side adapter (an AWT listener in slice 4's windowing shell, or by
+ * tests/scripts directly), read by passes (CameraPass today, GUI
+ * tomorrow). This is the ONLY way events enter the engine: events are
+ * data, passes are consumers — no pass ever touches AWT.
+ *
+ * Frame semantics: mouse deltas ACCUMULATE between frames; the
+ * consuming pass takes them once per frame (consumeMouseDelta zeroes
+ * the accumulator), so one frame's worth of motion is applied exactly
+ * once. Key state is level-based (down or not).
+ */
+public final class InputStore {
+
+    private final Set<Integer> keysDown = ConcurrentHashMap.newKeySet();
+    private volatile double mouseDx, mouseDy;
+    private volatile int wheel;
+    private volatile int mouseX, mouseY;
+    private final Set<Integer> mouseButtons = ConcurrentHashMap.newKeySet();
+
+    // ---- 3Dconnexion 6DOF cap (written by app/SpaceMouseManager's
+    // device reader; consumed by systems/SpaceMousePass) ----
+    // LEVEL state, latest report wins — unlike mouse deltas these do
+    // NOT accumulate: the device streams its current deflection while
+    // the cap is held and reports zeros on release. Raw device units
+    // (±350 full deflection); shaping happens in the pass.
+    private volatile int capTx, capTy, capTz, capRx, capRy, capRz;
+    private volatile int capButtons;
+
+    public void setCapAxes(final int tx, final int ty, final int tz,
+                           final int rx, final int ry, final int rz) {
+        capTx = tx;
+        capTy = ty;
+        capTz = tz;
+        capRx = rx;
+        capRy = ry;
+        capRz = rz;
+    }
+
+    public void setCapButtons(final int buttons) {
+        capButtons = buttons;
+    }
+
+    public int capTx() { return capTx; }
+    public int capTy() { return capTy; }
+    public int capTz() { return capTz; }
+    public int capRx() { return capRx; }
+    public int capRy() { return capRy; }
+    public int capRz() { return capRz; }
+    public int capButtons() { return capButtons; }
+
+    public void setMousePos(final int x, final int y) {
+        mouseX = x;
+        mouseY = y;
+    }
+
+    public int mouseX() {
+        return mouseX;
+    }
+
+    public int mouseY() {
+        return mouseY;
+    }
+
+    public void setMouseButton(final int button, final boolean down) {
+        if (down) {
+            mouseButtons.add(button);
+        } else {
+            mouseButtons.remove(button);
+        }
+    }
+
+    public boolean isMouseDown(final int button) {
+        return mouseButtons.contains(button);
+    }
+
+    /** True when un-consumed input exists (any key held, accumulated
+     *  mouse motion, wheel clicks). PEEK — never consumes. The shell's
+     *  render-on-demand signature needs this: input-driven change is
+     *  invisible to world/camera state until a pass consumes it, and
+     *  passes run only inside rendered frames. */
+    public boolean hasActivity() {
+        return !keysDown.isEmpty() || !mouseButtons.isEmpty()
+                || mouseDx != 0 || mouseDy != 0 || wheel != 0
+                || capTx != 0 || capTy != 0 || capTz != 0
+                || capRx != 0 || capRy != 0 || capRz != 0
+                || capButtons != 0;
+    }
+
+    public void keyDown(final int keyCode) {
+        keysDown.add(keyCode);
+    }
+
+    public void keyUp(final int keyCode) {
+        keysDown.remove(keyCode);
+    }
+
+    public boolean isDown(final int keyCode) {
+        return keysDown.contains(keyCode);
+    }
+
+    public void addMouseDelta(final double dx, final double dy) {
+        mouseDx += dx;
+        mouseDy += dy;
+    }
+
+    /** Take and zero the accumulated mouse motion (once per frame). */
+    public double[] consumeMouseDelta() {
+        final double[] d = {mouseDx, mouseDy};
+        mouseDx = 0;
+        mouseDy = 0;
+        return d;
+    }
+
+    public void addWheel(final int clicks) {
+        wheel += clicks;
+    }
+
+    public int consumeWheel() {
+        final int w = wheel;
+        wheel = 0;
+        return w;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Kernel.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Kernel.java
new file mode 100644 (file)
index 0000000..cad100f
--- /dev/null
@@ -0,0 +1,92 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.ServiceLoader;
+
+/**
+ * The fixed pipeline. Owns the pass list and runs it in Pass.order()
+ * (ascending; equal orders keep insertion order).
+ *
+ * The kernel is the only code allowed to invoke passes, and the only code
+ * whose growth is bounded on purpose: it must stay small enough to hold in
+ * one head. World complexity belongs in data, not here.
+ *
+ * Passes self-register via ServiceLoader
+ * (META-INF/services/eu.svjatoslav.aukio.e3d2.kernel.Pass) — a new
+ * feature must be a new file plus a registration line, never an edit
+ * to an existing file.
+ */
+public final class Kernel {
+
+    private final List<Pass> passes = new ArrayList<>();
+
+    /** All registered passes, sorted by Pass.order() (registration order
+     *  breaks ties). */
+    public static Kernel discover() {
+        final Kernel kernel = new Kernel();
+        final List<Pass> found = new ArrayList<>();
+        for (final Pass pass : ServiceLoader.load(Pass.class)) {
+            found.add(pass);
+        }
+        for (final Pass pass : sortedByOrder(found)) {
+            kernel.add(pass);
+        }
+        return kernel;
+    }
+
+    /** Stable ascending sort by Pass.order(). Visible for tests. */
+    static List<Pass> sortedByOrder(final List<Pass> passes) {
+        final List<Pass> copy = new ArrayList<>(passes);
+        copy.sort(java.util.Comparator.comparingInt(Pass::order));
+        return copy;
+    }
+
+    /** Sorted insert by Pass.order() (stable: lands after existing
+     *  passes of equal order). Explicit kernels get the same ordering
+     *  guarantee as discover() — a pass dropped in anywhere runs at
+     *  its declared priority. */
+    public void add(final Pass pass) {
+        int at = passes.size();
+        for (int i = 0; i < passes.size(); i++) {
+            if (passes.get(i).order() > pass.order()) {
+                at = i;
+                break;
+            }
+        }
+        passes.add(at, pass);
+    }
+
+    /** The ordered pass list. Read-only: the frame pipeline walks it to
+     *  find each stage's passes; only the kernel still invokes them. */
+    public List<Pass> passList() {
+        return java.util.List.copyOf(passes);
+    }
+
+    /** True when a pass of this exact class is registered (the shell
+     *  uses it to auto-wire its input consumers without duplicating
+     *  an app-registered one). */
+    public boolean has(final Class<? extends Pass> type) {
+        for (final Pass pass : passes) {
+            if (pass.getClass() == type) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public void runOnce(final Substrate data) {
+        for (final Pass pass : passes) {
+            pass.apply(data);
+        }
+    }
+
+    public int passCount() {
+        return passes.size();
+    }
+
+    /** Introspection for tests and diagnostics. */
+    public String passName(final int index) {
+        return passes.get(index).name();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/LightStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/LightStore.java
new file mode 100644 (file)
index 0000000..1b21e1c
--- /dev/null
@@ -0,0 +1,39 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.Arrays;
+
+/**
+ * Point lights, SoA: x,y,z position + r,g,b intensity (folded into color).
+ * Radiometry reads this; nothing else does.
+ */
+public final class LightStore {
+
+    private static final int DOUBLES_PER_LIGHT = 6;
+
+    private double[] data = new double[DOUBLES_PER_LIGHT * 16];
+    private int count = 0;
+
+    public int add(final double x, final double y, final double z) {
+        return add(x, y, z, 255, 255, 255);
+    }
+
+    public int add(final double x, final double y, final double z,
+                   final double r, final double g, final double b) {
+        if ((count + 1) * DOUBLES_PER_LIGHT > data.length) {
+            data = Arrays.copyOf(data, data.length * 2);
+        }
+        final int base = count * DOUBLES_PER_LIGHT;
+        data[base] = x; data[base + 1] = y; data[base + 2] = z;
+        data[base + 3] = r; data[base + 4] = g; data[base + 5] = b;
+        return count++;
+    }
+
+    public int count() {
+        return count;
+    }
+
+    /** component = 0(x),1(y),2(z),3(r),4(g),5(b). */
+    public double at(final int light, final int component) {
+        return data[light * DOUBLES_PER_LIGHT + component];
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/LightmapStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/LightmapStore.java
new file mode 100644 (file)
index 0000000..d8a4853
--- /dev/null
@@ -0,0 +1,584 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.Arrays;
+
+/**
+ * Per-triangle lightmaps, substrate table, CSR layout.
+ *
+ * Each triangle gets an orthogonal bounding-rect frame on its plane:
+ * the longest edge is the u-axis, the in-plane perpendicular the v-axis;
+ * triangle corners in frame uv are (cornerU[k], cornerV[k]) per ORIGINAL
+ * vertex k (values are a permutation of (0,0),(1,0),(apexU,1)). Raster
+ * samples uv from barycentric interpolation of these corners; the bake
+ * computes texel world positions from the stored frame vectors.
+ *
+ * Texels hold packed RGB shades. Texels outside the triangle are filled
+ * with the centroid shade at bake time (cheap neighbor-fill).
+ *
+ * One store per epoch: a rebuild allocates a fresh store and swaps the
+ * volatile Substrate reference; stale workers write into the orphaned
+ * one (self-healing, same discipline as the real snapshot).
+ */
+public final class LightmapStore {
+
+    /** Un-baked surfaces display at FULL irradiance, exactly like v1
+     *  (=Lightmap.INITIAL_IRRADIANCE= 255): albedo-only placeholder, never
+     *  black. The first bake generation replaces it at its first flip.
+     *  =-De3d2.initialIrradiance= (0..255) dims the placeholder. */
+    private static final int INITIAL_LIGHT;
+
+    static {
+        int v = Integer.getInteger("e3d2.initialIrradiance", 255);
+        INITIAL_LIGHT = Math.max(0, Math.min(255, v));
+    }
+
+    /** Monotonic process-wide counter: a rebuild allocates a NEW store and
+     *  the frame signature must see that epoch as new, so versions never
+     *  restart at 0. */
+    private static final java.util.concurrent.atomic.AtomicInteger VERSIONS =
+            new java.util.concurrent.atomic.AtomicInteger();
+
+    /** Frame-input stamp: bumps on every publish that changes what the
+     *  raster samples (barrier flip, catch-up entry publish). Texels start
+     *  at zero, so a frame rendered before the first flip is BLACK — the
+     *  shell must render again when this stamp moves. */
+    private volatile int version = VERSIONS.incrementAndGet();
+
+    private int n;
+    private int[] texelOffset;      // CSR offset per tri
+    private int[] width, height;    // dims per tri
+    private double[] cornerU, cornerV;  // 3 per tri (per original vertex)
+    private double[] frameE1, frameE2;  // 3 doubles each per tri (world vectors)
+    private double[] frameOrigin;       // 3 doubles per tri
+    private int[] texelsCur;        // packed RGB being accumulated this generation
+    private int[] texelsPrev;       // frozen at the barrier: gather + display LIGHT field
+    private int[] texelsDirect;     // gen-0 direct per texel (bounce adds onto it)
+    private byte[] valid;           // per texel: computed by the bake?
+    private int[] baseTexels;       // per texel: base albedo = color x texture (STATIC)
+    private int[] texelsComposite;  // per texel: DISPLAY = baseTexels x texelsPrev
+    private byte[] composited;      // per triangle: 1 = composite display usable
+
+    /** Frame + dims for one triangle (called once per epoch per tri).
+     *  Longest edge becomes the u-axis; corners are permuted so the apex
+     *  lands at (apexU, 1). */
+    public void allocate(final Substrate data, final int tri,
+                         final double unitsPerTexel) {
+        ensureCapacity(data.triangles.triangleCount());
+        // pick the longest edge as (i -> j); apex is the remaining vertex k
+        final double[][] v = new double[3][3];
+        for (int k = 0; k < 3; k++) {
+            for (int a = 0; a < 3; a++) {
+                v[k][a] = data.triangles.at(tri, k, a);
+            }
+        }
+        int i = 0, j = 1, k = 2;
+        double best = dist(v[0], v[1]);
+        if (dist(v[1], v[2]) > best) { i = 1; j = 2; k = 0; best = dist(v[1], v[2]); }
+        if (dist(v[2], v[0]) > best) { i = 2; j = 0; k = 1; }
+
+        final double e1x = v[j][0] - v[i][0], e1y = v[j][1] - v[i][1], e1z = v[j][2] - v[i][2];
+        final double e1l = Math.sqrt(e1x * e1x + e1y * e1y + e1z * e1z);
+        final double u1x = e1x / e1l, u1y = e1y / e1l, u1z = e1z / e1l;
+        final double rx = v[k][0] - v[i][0], ry = v[k][1] - v[i][1], rz = v[k][2] - v[i][2];
+        final double proj = rx * u1x + ry * u1y + rz * u1z;
+        final double e2x = rx - proj * u1x, e2y = ry - proj * u1y, e2z = rz - proj * u1z;
+        final double e2l = Math.sqrt(e2x * e2x + e2y * e2y + e2z * e2z);
+        final double apexU = proj / e1l;
+
+        final int w = (int) Math.max(4, Math.min(32, Math.round(e1l / unitsPerTexel)));
+        final int h = (int) Math.max(4, Math.min(32, Math.round(e2l / unitsPerTexel)));
+
+        width[tri] = w; height[tri] = h;
+        cornerU[3 * tri + i] = 0; cornerV[3 * tri + i] = 0;
+        cornerU[3 * tri + j] = 1; cornerV[3 * tri + j] = 0;
+        cornerU[3 * tri + k] = apexU; cornerV[3 * tri + k] = 1;
+        frameOrigin[3 * tri] = v[i][0]; frameOrigin[3 * tri + 1] = v[i][1];
+        frameOrigin[3 * tri + 2] = v[i][2];
+        frameE1[3 * tri] = e1x; frameE1[3 * tri + 1] = e1y; frameE1[3 * tri + 2] = e1z;
+        frameE2[3 * tri] = e2x; frameE2[3 * tri + 1] = e2y; frameE2[3 * tri + 2] = e2z;
+        texelOffset[tri] = (tri == 0) ? 0 : texelOffset[tri - 1]
+                + width[tri - 1] * height[tri - 1];
+        ensureTexels(texelOffset[tri] + w * h);
+        prepareComposite(data, tri, w, h, apexU);
+    }
+
+    /**
+     * DISPLAY COMPOSITE (2026-10-21) — the legacy Aukio 3D display design.
+     *
+     * <p>An opaque lightmapped triangle used to cost FOUR filtered lightmap
+     * texels plus a base-color and a texture multiply per fragment; measured
+     * 17.7 ms of raster at 3840x2091, which made v2 half the speed of Aukio
+     * 3D v1 on the same HouseDemo view. v1 instead bakes light x albedo into
+     * one texture and reads ONE texel. This does the same, and the user's
+     * requirement is explicit: v2 must not lose FPS to v1, so the smoothness
+     * that bilinear filtering bought is traded away deliberately — the
+     * single tap shows the lightmap's per-texel mottling and a faint dotted
+     * grid on cell boundaries, exactly the character v1 has at high
+     * resolution.</p>
+     *
+     * <p>Responsibilities, so no pass needs to know another pass:</p>
+     * <ul>
+     *   <li>{@code allocate()} resamples the triangle's base albedo once per
+     *   epoch. The frame's corners are always a permutation of (0,0), (1,0),
+     *   (apexU,1), so a texel's barycentric weights are closed form — the
+     *   same parameterization the bake uses, no extra mapping table;</li>
+     *   <li>{@code flip()} / {@code publishEntry()} refresh the composite
+     *   from the frozen light field, so the display follows the bake exactly
+     *   as it did before;</li>
+     *   <li>the raster reads {@link #compositeAt} when
+     *   {@link #compositeReady} is true, and otherwise keeps the
+     *   multiplicative path.</li>
+     * </ul>
+     *
+     * <p>Opaque only: a composite carries albedo, so it cannot honour a
+     * translucent triangle's blending — such triangles keep the base path.
+     * {@code texelsPrev} stays PURE LIGHT because GI gathers read it.</p>
+     */
+    private void prepareComposite(final Substrate data, final int tri,
+                                  final int w, final int h, final double apexU) {
+        final int color = data.triangles.color(tri);
+        composited[tri] = (byte) (((color >>> 24) & 0xFF) == 0xFF ? 1 : 0);
+        final int texId = data.triangles.textureId(tri);
+        int vA = -1, vB = -1, vC = -1;
+        for (int k = 0; k < 3; k++) {
+            if (cornerV[3 * tri + k] == 1) {
+                vC = k;
+            } else if (cornerU[3 * tri + k] == 1) {
+                vB = k;
+            } else {
+                vA = k;
+            }
+        }
+        double au = 0, av = 0, bu = 0, bv = 0, cu = 0, cv = 0;
+        if (texId >= 0) {
+            au = data.triangles.uvAt(tri, vA, 0); av = data.triangles.uvAt(tri, vA, 1);
+            bu = data.triangles.uvAt(tri, vB, 0); bv = data.triangles.uvAt(tri, vB, 1);
+            cu = data.triangles.uvAt(tri, vC, 0); cv = data.triangles.uvAt(tri, vC, 1);
+        }
+        final int base = texelOffset[tri];
+        for (int ty = 0; ty < h; ty++) {
+            final double tv = (ty + 0.5) / h;
+            final double wC = tv;
+            for (int tx = 0; tx < w; tx++) {
+                final double tu = (tx + 0.5) / w;
+                int albedo = color;
+                if (texId >= 0) {
+                    final double wB = tu - apexU * tv;
+                    final double wA = 1.0 - wB - wC;
+                    final int texel = data.textures.sample(texId,
+                            wA * au + wB * bu + wC * cu,
+                            wA * av + wB * bv + wC * cv);
+                    albedo = 0xFF000000
+                            | (((((color >> 16) & 0xFF) * ((texel >> 16) & 0xFF)) / 255) << 16)
+                            | (((((color >> 8) & 0xFF) * ((texel >> 8) & 0xFF)) / 255) << 8)
+                            | (((color & 0xFF) * (texel & 0xFF)) / 255);
+                }
+                baseTexels[base + ty * w + tx] = albedo;
+            }
+        }
+        // Placeholder until the first bake flip (v1 INITIAL_IRRADIANCE):
+        // the light field starts at full brightness, so the display shows
+        // ALBEDO instead of black while the first generation bakes. The
+        // multiplicative path (translucent triangles) reads the same field.
+        Arrays.fill(texelsPrev, base, base + w * h,
+                (INITIAL_LIGHT << 16) | (INITIAL_LIGHT << 8) | INITIAL_LIGHT);
+        recomposite(tri);
+    }
+
+    /** Display composite = base albedo x light, for one triangle's block. */
+    private void recomposite(final int tri) {
+        final int w = width[tri], h = height[tri], base = texelOffset[tri];
+        final int end = base + w * h;
+        for (int i = base; i < end; i++) {
+            final int b = baseTexels[i], l = texelsPrev[i];
+            texelsComposite[i] = 0xFF000000
+                    | (((((b >> 16) & 0xFF) * ((l >> 16) & 0xFF)) / 255) << 16)
+                    | (((((b >> 8) & 0xFF) * ((l >> 8) & 0xFF)) / 255) << 8)
+                    | (((b & 0xFF) * (l & 0xFF)) / 255);
+        }
+    }
+
+    public void fill(final int tri, final int rgb) {
+        Arrays.fill(texelsCur, texelOffset[tri], texelOffset[tri] + width[tri] * height[tri], rgb);
+    }
+
+    /** Bake-side: texel world position and inside-triangle test. */
+    public double worldX(final int tri, final double u, final double v, final int axis) {
+        return frameOrigin[3 * tri + axis] + u * frameE1[3 * tri + axis]
+                + v * frameE2[3 * tri + axis];
+    }
+
+    public boolean inside(final int tri, final double u, final double v) {
+        final double apexU = apexUOf(tri);
+        return v >= 0 && v <= 1 && u >= apexU * v && u <= 1 - (1 - apexU) * v;
+    }
+
+    private double apexUOf(final int tri) {
+        for (int k = 0; k < 3; k++) {
+            if (cornerV[3 * tri + k] == 1) {
+                return cornerU[3 * tri + k];
+            }
+        }
+        return 0;
+    }
+
+    /** Bake write. gen 0 also records the direct term (bounce generations
+     *  add indirect on top of it); every generation writes cur only. */
+    public void setTexel(final int tri, final int tx, final int ty, final int rgb,
+                         final boolean isDirect) {
+        final int idx = texelOffset[tri] + ty * width[tri] + tx;
+        texelsCur[idx] = rgb;
+        if (isDirect) {
+            texelsDirect[idx] = rgb;
+        }
+        valid[idx] = 1;
+    }
+
+    /** The u of the triangle's apex corner (the corner with v == 1). */
+    private double apexU(final int tri) {
+        for (int c = 0; c < 3; c++) {
+            if (cornerV[3 * tri + c] > 0.5) {
+                return cornerU[3 * tri + c];
+            }
+        }
+        return 0.5;
+    }
+
+    /**
+     * Closest point inside the triangle's UV triangle, written into {@code out}.
+     *
+     * <p>A triangle's texel grid covers the whole (u,v) square, but the
+     * triangle only covers the region under the (1,0)-(apexU,1) edge, so the
+     * outer ring of every grid lies OUTSIDE the surface. Skipping those
+     * texels left them at bounce-only light (direct = 0) while bilinear
+     * filtering at the triangle's edge samples exactly that ring — every
+     * triangle edge painted a dotted dark seam (bugreport-20261022-085712:
+     * edge texels baked at half the interior's direct light). Baking the
+     * ring at its nearest interior point gives the border the edge's real
+     * light, the standard lightmap border extension.</p>
+     */
+    public void clampToTriangle(final int tri, final double u, final double v,
+                                final double[] out) {
+        final double au = apexU(tri);
+        if (insideTri(u, v, au)) {
+            out[0] = u;
+            out[1] = v;
+            return;
+        }
+        // keep the closest of the three edge projections
+        double bestD = Double.MAX_VALUE;
+        double bestU = 0;
+        double bestV = 0;
+        // edge A(0,0)-B(1,0)
+        double t = clamp01(u);
+        double d = sq(u - t) + sq(v);
+        if (d < bestD) {
+            bestD = d;
+            bestU = t;
+            bestV = 0;
+        }
+        // edge B(1,0)-C(au,1)
+        {
+            final double ex = au - 1;
+            final double tt = clamp01(((u - 1) * ex + v) / (ex * ex + 1));
+            final double qx = 1 + tt * ex;
+            final double qy = tt;
+            d = sq(u - qx) + sq(v - qy);
+            if (d < bestD) {
+                bestD = d;
+                bestU = qx;
+                bestV = qy;
+            }
+        }
+        // edge C(au,1)-A(0,0)
+        {
+            final double ex = -au;
+            final double tt = clamp01(((u - au) * ex + (v - 1) * -1) / (ex * ex + 1));
+            final double qx = au + tt * ex;
+            final double qy = 1 - tt;
+            d = sq(u - qx) + sq(v - qy);
+            if (d < bestD) {
+                bestU = qx;
+                bestV = qy;
+            }
+        }
+        out[0] = bestU;
+        out[1] = bestV;
+    }
+
+    private static double clamp01(final double t) {
+        return t < 0 ? 0 : Math.min(t, 1);
+    }
+
+    private static double sq(final double d) {
+        return d * d;
+    }
+
+    private static boolean insideTri(final double u, final double v, final double au) {
+        // (0,0), (1,0), (au,1) — with a half-texel of slack so the true
+        // border texels are treated as inside and keep their own sample.
+        if (v < -1e-9 || u < -1e-9) {
+            return false;
+        }
+        final double edge = 1 + (au - 1) * v;    // u <= edge (line from (1,0) to (au,1))
+        return u <= edge + 1e-9;
+    }
+
+    /** True when the bake wrote this texel (inside the triangle). Diagnostic. */
+    public boolean isValid(final int tri, final int tx, final int ty) {
+        return valid[texelOffset[tri] + ty * width[tri] + tx] != 0;
+    }
+
+    public int directTexelAt(final int tri, final int tx, final int ty) {
+        return texelsDirect[texelOffset[tri] + ty * width[tri] + tx];
+    }
+
+    /** Barrier rollover for the lightmap field: prev = cur, then refresh
+     *  every opaque triangle's display composite from the frozen field. */
+    public void flip() {
+        System.arraycopy(texelsCur, 0, texelsPrev, 0,
+                texelOffset[n - 1] + width[n - 1] * height[n - 1]);
+        for (int tri = 0; tri < n; tri++) {
+            if (composited[tri] != 0 && width[tri] > 0) {
+                recomposite(tri);
+            }
+        }
+        version = VERSIONS.incrementAndGet();
+    }
+
+    /** Catch-up publish for one triangle's texels (local bake, no barrier). */
+    public void publishEntry(final int tri) {
+        System.arraycopy(texelsCur, texelOffset[tri], texelsPrev, texelOffset[tri],
+                width[tri] * height[tri]);
+        if (composited[tri] != 0) {
+            recomposite(tri);
+        }
+        version = VERSIONS.incrementAndGet();
+    }
+
+    /** Frame-input stamp for the shell's render-on-demand signature. */
+    public int version() { return version; }
+
+    /** Mean |cur − prev| over all allocated texels — texel convergence signal. */
+    public double meanDelta() {
+        long sum = 0;
+        long cnt = 0;
+        final int end = texelOffset[n - 1] + width[n - 1] * height[n - 1];
+        for (int i = 0; i < end; i++) {
+            final int a = texelsCur[i], b = texelsPrev[i];
+            sum += Math.abs(((a >> 16) & 0xFF) - ((b >> 16) & 0xFF))
+                    + Math.abs(((a >> 8) & 0xFF) - ((b >> 8) & 0xFF))
+                    + Math.abs((a & 0xFF) - (b & 0xFF));
+            cnt += 3;
+        }
+        return cnt == 0 ? 0.0 : (double) sum / cnt;
+    }
+
+    /** True neighbor-fill: every texel the bake did NOT compute (outside the
+     *  triangle) copies the NEAREST computed 4-neighbor (dilation, the
+     *  lightmap-UV padding pattern), iterated until full. Copying preserves
+     *  intensity across gradients; AVERAGING neighbors painted a darker
+     *  edge band that survived bilinear sampling as dotted quad seams. */
+    public void fillInvalidFromNeighbors(final int tri) {
+        final int w = width[tri], h = height[tri], base = texelOffset[tri];
+        for (int iter = 0; iter < w + h; iter++) {
+            boolean remaining = false;
+            for (int ty = 0; ty < h; ty++) {
+                for (int tx = 0; tx < w; tx++) {
+                    final int idx = base + ty * w + tx;
+                    if (valid[idx] != 0) {
+                        continue;
+                    }
+                    int found = 0;
+                    boolean ok = false;
+                    if (tx > 0 && valid[idx - 1] == 1) { found = texelsCur[idx - 1]; ok = true; }
+                    else if (tx < w - 1 && valid[idx + 1] == 1) { found = texelsCur[idx + 1]; ok = true; }
+                    else if (ty > 0 && valid[idx - w] == 1) { found = texelsCur[idx - w]; ok = true; }
+                    else if (ty < h - 1 && valid[idx + w] == 1) { found = texelsCur[idx + w]; ok = true; }
+                    else if (tx > 0 && valid[idx - 1] != 0) { found = texelsCur[idx - 1]; ok = true; }
+                    else if (tx < w - 1 && valid[idx + 1] != 0) { found = texelsCur[idx + 1]; ok = true; }
+                    else if (ty > 0 && valid[idx - w] != 0) { found = texelsCur[idx - w]; ok = true; }
+                    else if (ty < h - 1 && valid[idx + w] != 0) { found = texelsCur[idx + w]; ok = true; }
+                    if (ok) {
+                        texelsCur[idx] = found;
+                        valid[idx] = 2;
+                    } else {
+                        remaining = true;
+                    }
+                }
+            }
+            if (!remaining) {
+                return;
+            }
+        }
+    }
+
+    /** Raster-side: uv -> shade, nearest texel. */
+    public int sample(final int tri, final double u, final double v) {
+        final int tx = clampi((int) (u * width[tri]), 0, width[tri] - 1);
+        final int ty = clampi((int) (v * height[tri]), 0, height[tri] - 1);
+        return texelsPrev[texelOffset[tri] + ty * width[tri] + tx];
+    }
+
+    /** Raster-side: uv -> shade, BILINEAR over texel centers (the standard
+     *  lightmap filter — nearest-neighbor shows the per-texel bake noise as
+     *  a checkerboard). Texels outside the triangle are neighbor-filled at
+     *  bake time, so filtering across the triangle edge is safe. */
+    /**
+     * Per-triangle bilinear sampler with a CACHED TEXEL CELL.
+     *
+     * <p>The raster hot path calls this once per fragment. The old entry
+     * point re-read three per-triangle arrays (width, height, texelOffset)
+     * and four texels on every call. At 4K one lightmap texel covers ~8
+     * screen pixels, so a span of 8 fragments repeats the same cell — the
+     * sampler binds the triangle once, refetches only when the cell index
+     * actually changes, and keeps the arithmetic EXACTLY as before
+     * (same association, same order), so output is bit-identical.</p>
+     */
+    public final class Sampler {
+
+        private int w, h, base;
+        private int cx = Integer.MIN_VALUE;
+        private int cy = Integer.MIN_VALUE;
+        private int p00, p10, p01, p11;
+
+        private Sampler() {
+        }
+
+        /** Binds this sampler to one triangle for the current frame. */
+        public void bind(final int tri) {
+            w = width[tri];
+            h = height[tri];
+            base = texelOffset[tri];
+            cx = Integer.MIN_VALUE;
+            cy = Integer.MIN_VALUE;
+        }
+
+        /** Bilinear light sample, identical in value to sampleBilinear. */
+        public int bilinear(final double u, final double v) {
+            final double fx = clampd(u * w - 0.5, 0, w - 1);
+            final double fy = clampd(v * h - 0.5, 0, h - 1);
+            final int x0 = (int) fx, y0 = (int) fy;
+            if (x0 != cx || y0 != cy) {
+                cx = x0;
+                cy = y0;
+                final int x1 = Math.min(x0 + 1, w - 1);
+                final int y1 = Math.min(y0 + 1, h - 1);
+                final int row0 = base + y0 * w;
+                final int row1 = base + y1 * w;
+                p00 = texelsPrev[row0 + x0];
+                p10 = texelsPrev[row0 + x1];
+                p01 = texelsPrev[row1 + x0];
+                p11 = texelsPrev[row1 + x1];
+            }
+            final double ax = fx - x0, ay = fy - y0;
+            int rgb = 0xFF000000;
+            for (int ch = 0; ch < 3; ch++) {
+                final int sh = ch * 8;
+                final double c = ((p00 >> sh) & 0xFF) * (1 - ax) * (1 - ay)
+                        + ((p10 >> sh) & 0xFF) * ax * (1 - ay)
+                        + ((p01 >> sh) & 0xFF) * (1 - ax) * ay
+                        + ((p11 >> sh) & 0xFF) * ax * ay;
+                rgb |= ((int) c) << sh;
+            }
+            return rgb;
+        }
+    }
+
+    /** A sampler for one triangle (bind before use, per triangle). */
+    public Sampler newSampler() {
+        return new Sampler();
+    }
+
+    public int sampleBilinear(final int tri, final double u, final double v) {
+        final int w = width[tri], h = height[tri], base = texelOffset[tri];
+        final double fx = clampd(u * w - 0.5, 0, w - 1);
+        final double fy = clampd(v * h - 0.5, 0, h - 1);
+        final int x0 = (int) fx, y0 = (int) fy;
+        final int x1 = Math.min(x0 + 1, w - 1), y1 = Math.min(y0 + 1, h - 1);
+        final double ax = fx - x0, ay = fy - y0;
+        final int p00 = texelsPrev[base + y0 * w + x0];
+        final int p10 = texelsPrev[base + y0 * w + x1];
+        final int p01 = texelsPrev[base + y1 * w + x0];
+        final int p11 = texelsPrev[base + y1 * w + x1];
+        int rgb = 0xFF000000;
+        for (int ch = 0; ch < 3; ch++) {
+            final int sh = ch * 8;
+            final double c = ((p00 >> sh) & 0xFF) * (1 - ax) * (1 - ay)
+                    + ((p10 >> sh) & 0xFF) * ax * (1 - ay)
+                    + ((p01 >> sh) & 0xFF) * (1 - ax) * ay
+                    + ((p11 >> sh) & 0xFF) * ax * ay;
+            rgb |= ((int) c) << sh;
+        }
+        return rgb;
+    }
+
+    /** Raster-side: true when this triangle has an opaque display
+     *  composite (light x albedo) ready to sample with ONE tap. */
+    public boolean compositeReady(final int tri) {
+        return composited[tri] != 0 && width[tri] > 0;
+    }
+
+    /** Raster-side: composite texel, single tap (legacy composite sampling). */
+    public int compositeAt(final int tri, final double u, final double v) {
+        final int tx = clampi((int) (u * width[tri]), 0, width[tri] - 1);
+        final int ty = clampi((int) (v * height[tri]), 0, height[tri] - 1);
+        return texelsComposite[texelOffset[tri] + ty * width[tri] + tx];
+    }
+
+    public boolean hasDims(final int tri) {
+        return width[tri] > 0;
+    }
+
+    public int width(final int tri) { return width[tri]; }
+    public int height(final int tri) { return height[tri]; }
+    public double cornerU(final int tri, final int k) { return cornerU[3 * tri + k]; }
+    public double cornerV(final int tri, final int k) { return cornerV[3 * tri + k]; }
+    public int texelAt(final int tri, final int tx, final int ty) {
+        return texelsPrev[texelOffset[tri] + ty * width[tri] + tx];
+    }
+
+    private void ensureCapacity(final int triangleCount) {
+        if (width != null && width.length >= triangleCount) {
+            return;
+        }
+        n = triangleCount;
+        texelOffset = new int[n];
+        width = new int[n];
+        height = new int[n];
+        cornerU = new double[3 * n];
+        cornerV = new double[3 * n];
+        frameE1 = new double[3 * n];
+        frameE2 = new double[3 * n];
+        frameOrigin = new double[3 * n];
+        texelsCur = new int[1024]; texelsPrev = new int[1024]; texelsDirect = new int[1024];
+        baseTexels = new int[1024]; texelsComposite = new int[1024];
+        valid = new byte[1024];
+        composited = new byte[n];
+    }
+
+    private void ensureTexels(final int size) {
+        if (texelsCur.length < size) {
+            final int grown = Math.max(size, texelsCur.length * 2);
+            texelsCur = Arrays.copyOf(texelsCur, grown);
+            texelsPrev = Arrays.copyOf(texelsPrev, grown);
+            texelsDirect = Arrays.copyOf(texelsDirect, grown);
+            baseTexels = Arrays.copyOf(baseTexels, grown);
+            texelsComposite = Arrays.copyOf(texelsComposite, grown);
+            valid = Arrays.copyOf(valid, grown);
+        }
+    }
+
+    private static double dist(final double[] a, final double[] b) {
+        final double dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2];
+        return Math.sqrt(dx * dx + dy * dy + dz * dz);
+    }
+
+    private static int clampi(final int v, final int lo, final int hi) {
+        return v < lo ? lo : Math.min(v, hi);
+    }
+
+    private static double clampd(final double v, final double lo, final double hi) {
+        return v < lo ? lo : Math.min(v, hi);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/LineStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/LineStore.java
new file mode 100644 (file)
index 0000000..7fa0f58
--- /dev/null
@@ -0,0 +1,53 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.Arrays;
+
+/**
+ * Line segments with world-space endpoints. Parallel arrays, same
+ * discipline as MeshStore. A line carries a flat ARGB color (alpha is
+ * meaningful: LinePass blends) and a width in world units that the
+ * renderer projects to a perspective screen thickness.
+ */
+public final class LineStore {
+
+    private static final int INITIAL_CAPACITY = 64;
+
+    public double[] x1 = new double[INITIAL_CAPACITY];
+    public double[] y1 = new double[INITIAL_CAPACITY];
+    public double[] z1 = new double[INITIAL_CAPACITY];
+    public double[] x2 = new double[INITIAL_CAPACITY];
+    public double[] y2 = new double[INITIAL_CAPACITY];
+    public double[] z2 = new double[INITIAL_CAPACITY];
+    public int[] color = new int[INITIAL_CAPACITY];
+    public double[] width = new double[INITIAL_CAPACITY];
+
+    public int count;
+
+    /** Adds a line from (x1,y1,z1) to (x2,y2,z2); argb color; width in
+     *  world units. Returns the line id. */
+    public int add(final double x1, final double y1, final double z1,
+                   final double x2, final double y2, final double z2,
+                   final int argb, final double width) {
+        if (count == this.x1.length) {
+            final int n = this.x1.length * 2;
+            this.x1 = Arrays.copyOf(this.x1, n);
+            this.y1 = Arrays.copyOf(this.y1, n);
+            this.z1 = Arrays.copyOf(this.z1, n);
+            this.x2 = Arrays.copyOf(this.x2, n);
+            this.y2 = Arrays.copyOf(this.y2, n);
+            this.z2 = Arrays.copyOf(this.z2, n);
+            this.color = Arrays.copyOf(this.color, n);
+            this.width = Arrays.copyOf(this.width, n);
+        }
+        final int id = count++;
+        this.x1[id] = x1;
+        this.y1[id] = y1;
+        this.z1[id] = z1;
+        this.x2[id] = x2;
+        this.y2[id] = y2;
+        this.z2[id] = z2;
+        this.color[id] = argb;
+        this.width[id] = width;
+        return id;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/MeshStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/MeshStore.java
new file mode 100644 (file)
index 0000000..76cd545
--- /dev/null
@@ -0,0 +1,178 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.Arrays;
+
+/**
+ * Uniform triangle storage, structure-of-arrays: 9 doubles per triangle
+ * (x,y,z per vertex), world space. This is the ONLY shape geometry takes
+ * in the system — no per-feature geometry classes.
+ *
+ * Storage layout: fixed-capacity BLOCKS of 8192 triangles (the
+ * TriangleMeshBlock idea, learned from Aukio 3D). Growth allocates one
+ * new block — there is no whole-store reallocation copy (at FO4 scale a
+ * single growable array means copying hundreds of MB on every doubling).
+ * Blocks are also the future unit of streaming eviction; the public API
+ * is unchanged, so no caller (passes, BVH, lightmaps) sees the layout.
+ *
+ * Concurrency: same protocol as before (verified by
+ * Model/MeshPublish.tla). Block allocation and outer-array growth happen
+ * BEFORE the volatile version bump in program order, so a reader that
+ * acquires version V sees every block and array reference it needs
+ * through index V-1. Concurrent readers during appends may see the old
+ * or new outer array — both are prefix-consistent for the triangles
+ * their BVH snapshot can reference.
+ */
+public final class MeshStore {
+
+    private static final int DOUBLES_PER_TRIANGLE = 9;
+    /** Block geometry is public so pinned-table readers (BvhStore) can
+     *  index identically. */
+    public static final int BLOCK_SHIFT = 10;               // 1024 tris
+    private static final int BLOCK_TRIS = 1 << BLOCK_SHIFT;
+    private static final int BLOCK_MASK = BLOCK_TRIS - 1;
+
+    /** Shallow-cloned outer tables: pinning these pins every live block
+     *  array (eviction nulls only the LIVE table's slots — clones keep
+     *  the arrays reachable for in-flight readers; RCU by GC). */
+    public record Blocks(double[][] data, double[][] uv,
+                         int[][] tex, int[][] argb) { }
+
+    private double[][] dataBlocks = new double[4][];
+    private double[][] uvBlocks = new double[4][];
+    private int[][] texBlocks = new int[4][];
+    private int[][] argbBlocks = new int[4][];
+    private int count = 0;
+
+    /**
+     * Commit counter for derived indexes (BvhStore), bumped LAST after
+     * all triangle data and count are written. Volatile: the write is a
+     * release, so a reader that sees version V also sees all triangle
+     * data through index V-1 (protocol verified by Model/MeshPublish.tla
+     * — version-before-count allows a permanent index miss; count-first
+     * version-last does not). Appends are single-writer: external
+     * synchronization required if multiple threads add triangles.
+     */
+    private volatile int version = 0;
+
+    /** Mutation counter: incremented on every addTriangle, LAST. */
+    public int version() {
+        return version;
+    }
+
+    /** Pin a consistent view of the block tables (shallow clones — cheap).
+     *  Readers that must survive eviction (BVH Trees) hold a Blocks. */
+    public Blocks pinBlocks() {
+        return new Blocks(dataBlocks.clone(), uvBlocks.clone(),
+                texBlocks.clone(), argbBlocks.clone());
+    }
+
+    /** Append degenerate (zero-area, far below the world) triangles until
+     *  the store sits on a block boundary, so the next chunk starts on a
+     *  fresh block and eviction never touches a neighbor chunk's block.
+     *  Degenerates: culled by the rasterizer's zero-area test, zero-size
+     *  boxes in the BVH, placed at y=-1e6 so no ray hits them. */
+    public void padToBlockBoundary() {
+        while ((count & BLOCK_MASK) != 0) {
+            addTriangle(0, -1e6, 0, 0, -1e6, 0, 0, -1e6, 0, 0);
+        }
+    }
+
+    /** True while a block holds live data (false after evictBlock). */
+    public boolean blockAlive(final int block) {
+        return block < dataBlocks.length && dataBlocks[block] != null;
+    }
+
+    /** Evict a whole block: drop its storage and bump the version (LAST).
+     *  FRAME-BARRIER RULE (Model/SlotLifecycle.tla): callers must apply
+     *  evictions only when no reader (query, BVH build, bake) holds the
+     *  mesh — readers that must survive hold a {@link #pinBlocks()}. */
+    public void evictBlock(final int block) {
+        if (block < dataBlocks.length && dataBlocks[block] != null) {
+            dataBlocks[block] = null;
+            uvBlocks[block] = null;
+            texBlocks[block] = null;
+            argbBlocks[block] = null;
+            version++; // commit LAST
+        }
+    }
+
+    public int addTriangle(final double x0, final double y0, final double z0,
+                           final double x1, final double y1, final double z1,
+                           final double x2, final double y2, final double z2) {
+        return addTriangle(x0, y0, z0, x1, y1, z1, x2, y2, z2, 0xFF808080);
+    }
+
+    public int addTriangle(final double x0, final double y0, final double z0,
+                           final double x1, final double y1, final double z1,
+                           final double x2, final double y2, final double z2,
+                           final int color) {
+        return addTriangle(x0, y0, z0, x1, y1, z1, x2, y2, z2, color,
+                0, 0, 0, 0, 0, 0, -1);
+    }
+
+    public int addTriangle(final double x0, final double y0, final double z0,
+                           final double x1, final double y1, final double z1,
+                           final double x2, final double y2, final double z2,
+                           final int color,
+                           final double u0, final double v0,
+                           final double u1, final double v1,
+                           final double u2, final double v2,
+                           final int textureId) {
+        final int block = count >>> BLOCK_SHIFT;
+        if (block == dataBlocks.length) {
+            // outer-array growth: happens BEFORE data writes and the
+            // version bump, so the release/acquire chain covers it
+            dataBlocks = Arrays.copyOf(dataBlocks, dataBlocks.length * 2);
+            uvBlocks = Arrays.copyOf(uvBlocks, uvBlocks.length * 2);
+            texBlocks = Arrays.copyOf(texBlocks, texBlocks.length * 2);
+            argbBlocks = Arrays.copyOf(argbBlocks, argbBlocks.length * 2);
+        }
+        if (dataBlocks[block] == null) {
+            dataBlocks[block] = new double[BLOCK_TRIS * DOUBLES_PER_TRIANGLE];
+            uvBlocks[block] = new double[BLOCK_TRIS * 6];
+            texBlocks[block] = new int[BLOCK_TRIS];
+            argbBlocks[block] = new int[BLOCK_TRIS];
+        }
+        final double[] data = dataBlocks[block];
+        final double[] uv = uvBlocks[block];
+        final int off = count & BLOCK_MASK;
+        final int base = off * DOUBLES_PER_TRIANGLE;
+        data[base]     = x0; data[base + 1] = y0; data[base + 2] = z0;
+        data[base + 3] = x1; data[base + 4] = y1; data[base + 5] = z1;
+        data[base + 6] = x2; data[base + 7] = y2; data[base + 8] = z2;
+        argbBlocks[block][off] = color;
+        uv[off * 6]     = u0; uv[off * 6 + 1] = v0;
+        uv[off * 6 + 2] = u1; uv[off * 6 + 3] = v1;
+        uv[off * 6 + 4] = u2; uv[off * 6 + 5] = v2;
+        texBlocks[block][off] = textureId;
+        final int id = count;
+        count = id + 1;
+        version = id + 1; // commit flag LAST (volatile release)
+        return id;
+    }
+
+    public int triangleCount() {
+        return count;
+    }
+
+    /** Flat per-triangle color, ARGB. */
+    public int color(final int tri) {
+        return argbBlocks[tri >>> BLOCK_SHIFT][tri & BLOCK_MASK];
+    }
+
+    /** Texture uv: vertex = 0..2, component = 0(u),1(v). */
+    public double uvAt(final int tri, final int vertex, final int component) {
+        return uvBlocks[tri >>> BLOCK_SHIFT][(tri & BLOCK_MASK) * 6 + vertex * 2 + component];
+    }
+
+    /** Texture id, -1 = untextured. */
+    public int textureId(final int tri) {
+        return texBlocks[tri >>> BLOCK_SHIFT][tri & BLOCK_MASK];
+    }
+
+    /** Component access: tri = triangle index, vertex = 0..2, axis = 0(x),1(y),2(z). */
+    public double at(final int tri, final int vertex, final int axis) {
+        return dataBlocks[tri >>> BLOCK_SHIFT]
+                [(tri & BLOCK_MASK) * DOUBLES_PER_TRIANGLE + vertex * 3 + axis];
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Parallel.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Parallel.java
new file mode 100644 (file)
index 0000000..e70b864
--- /dev/null
@@ -0,0 +1,122 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+/**
+ * Static-partition parallel-for for passes. ONE shared daemon pool for
+ * the whole engine (per-pass pools would oversubscribe cores).
+ *
+ * The contract: the body is invoked with disjoint [from, to) ranges;
+ * whatever the body writes inside its range is never touched by another
+ * invocation in the same call. Range writes are what make pass output
+ * byte-identical to the single-threaded loop (pinned by oracles and
+ * the determinism tests) — preserve them: no shared counters, no
+ * append-to-shared-list inside the body.
+ */
+public final class Parallel {
+
+    @FunctionalInterface
+    public interface RangeBody {
+        void run(int from, int to);
+    }
+
+    /** Busy time inside pool tasks (nanoseconds). Utilisation = busy /
+     *  (wall x poolSize); the frame-pipeline work is judged by it. */
+    private static final java.util.concurrent.atomic.LongAdder BUSY_NS =
+            new java.util.concurrent.atomic.LongAdder();
+
+    private static final java.util.concurrent.ExecutorService POOL =
+            Executors.newFixedThreadPool(
+                    Math.max(2, Runtime.getRuntime().availableProcessors()),
+                    r -> {
+                        final Thread t = new Thread(r, "pass-worker");
+                        t.setDaemon(true);
+                        return t;
+                    });
+
+    private Parallel() {
+    }
+
+    /** Nanoseconds workers have spent inside submitted tasks. */
+    public static long busyNanos() {
+        return BUSY_NS.sum();
+    }
+
+    /** Number of worker threads in the shared pool. */
+    public static int poolSize() {
+        return Math.max(2, Runtime.getRuntime().availableProcessors());
+    }
+
+    /** Wraps a task so its busy time is accounted. */
+    private static Runnable timed(final Runnable body) {
+        return () -> {
+            final long t0 = System.nanoTime();
+            try {
+                body.run();
+            } finally {
+                BUSY_NS.add(System.nanoTime() - t0);
+            }
+        };
+    }
+
+    /** Timed submit for passes with custom task shapes (RasterPass tiles). */
+    public static java.util.concurrent.Future<?> submit(final Runnable body) {
+        return POOL.submit(timed(body));
+    }
+
+    /** Timed submit for tasks that return a value (RasterPass binning). */
+    public static <T> java.util.concurrent.Future<T> submit(
+            final java.util.concurrent.Callable<T> body) {
+        return POOL.submit(() -> {
+            final long t0 = System.nanoTime();
+            try {
+                return body.call();
+            } finally {
+                BUSY_NS.add(System.nanoTime() - t0);
+            }
+        });
+    }
+
+    /** The shared pool, for passes with custom task shapes (RasterPass's
+     *  tile tasks). */
+    public static java.util.concurrent.ExecutorService pool() {
+        return POOL;
+    }
+
+    /** Run body over [0, n) split into ~2x-core-count contiguous ranges;
+     *  returns after all ranges complete. */
+    public static void forRange(final int n, final RangeBody body) {
+        final int workers = Math.max(2, Runtime.getRuntime().availableProcessors());
+        final int chunks = Math.min(n, workers * 2);
+        if (chunks <= 1) {
+            body.run(0, n);
+            return;
+        }
+        final int step = (n + chunks - 1) / chunks;
+        final List<Future<?>> futures = new ArrayList<>(chunks);
+        for (int from = 0; from < n; from += step) {
+            final int f = from;
+            final int t = Math.min(from + step, n);
+            futures.add(POOL.submit(timed(() -> body.run(f, t))));
+        }
+        joinAll(futures);
+    }
+
+    /** Join a set of submitted tasks, surfacing worker failures loudly. */
+    public static void joinAll(final List<Future<?>> futures) {
+        for (final Future<?> f : futures) {
+            try {
+                f.get();
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new IllegalStateException("pass interrupted", e);
+            } catch (final ExecutionException e) {
+                throw new IllegalStateException("pass worker failed", e.getCause());
+            }
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Pass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Pass.java
new file mode 100644 (file)
index 0000000..f4c30a1
--- /dev/null
@@ -0,0 +1,56 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+/**
+ * A system: a function over the substrate.
+ *
+ * The interaction rule (load-bearing): a Pass may read and write substrate
+ * stores, but must never hold a reference to another Pass or call into one.
+ * Passes that violate this are architecture violations, equivalent to a
+ * compile error.
+ */
+public interface Pass {
+
+    String name();
+
+    void apply(Substrate data);
+
+    /**
+     * Pipeline stage this pass belongs to. The frame pipeline runs each
+     * stage on a frame of its own, two frames in flight:
+     *
+     * <ul>
+     *   <li>{@code 0} (default) — a whole-frame pass that knows nothing
+     *   about frames-in-flight; the pipeline runs it on the loop thread
+     *   when a frame starts;</li>
+     *   <li>{@code 10} — geometry: transform (+ cull);</li>
+     *   <li>{@code 20} — paint: raster reading the slot's screen table.</li>
+     * </ul>
+     *
+     * <p>Passes that paint or post-process OUTSIDE a slot (overlays,
+     * fog, GUI) stay at 0; a kernel containing them runs the sequential
+     * path (see AwtShell).</p>
+     */
+    default int stage() {
+        return 0;
+    }
+
+    /**
+     * Stage-aware entry point: the pipeline hands each in-flight frame its
+     * own {@link FrameView} (screen slot, target image, pinned lightmap
+     * store, pose snapshot). The default delegates to the single-frame
+     * form, so a pass that does not care about pipelining needs no change.
+     */
+    default void apply(final Substrate data, final FrameView frame) {
+        apply(data);
+    }
+
+    /**
+     * Run priority in Kernel.discover()'s pipeline. Kernel passes use
+     * &lt; 100, features 100+, app-level 200+. Registration order breaks
+     * ties. This field is what makes a plugin JAR drop-in: ordering is
+     * declared by the plugin, not by a central file.
+     */
+    default int order() {
+        return 100;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/ScreenStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/ScreenStore.java
new file mode 100644 (file)
index 0000000..c6bb913
--- /dev/null
@@ -0,0 +1,66 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.Arrays;
+
+/**
+ * Screen-space triangle table, SoA. Written by the transform pass
+ * (WITH near-plane clipping: a straddling triangle becomes a 3- or
+ * 4-vertex loop, never dropped whole), filtered by the cull pass,
+ * consumed by the raster pass (fan-triangulated when count == 4).
+ *
+ * Per triangle: up to 4 vertices × 7 doubles:
+ *   0 sx, 1 sy, 2 w(=1/z), 3 texU, 4 texV, 5 lmU, 6 lmV
+ * tex* = texture uv, lm* = lightmap uv (frame corners interpolated as
+ * per-vertex attributes, so clip points get correct values by lerp).
+ */
+public final class ScreenStore {
+
+    public static final int DOUBLES_PER_VERTEX = 7;
+    public static final int MAX_VERTICES = 4;
+    private static final int STRIDE = DOUBLES_PER_VERTEX * MAX_VERTICES;
+
+    private double[] data = new double[STRIDE * 1024];
+    private boolean[] culled = new boolean[1024];
+    private int[] count = new int[1024];
+
+    public void setSize(final int triangleCount) {
+        if (triangleCount * STRIDE > data.length) {
+            data = new double[triangleCount * STRIDE];
+            culled = new boolean[triangleCount];
+            count = new int[triangleCount];
+        }
+        Arrays.fill(culled, 0, triangleCount, false);
+        Arrays.fill(count, 0, triangleCount, 0);
+    }
+
+    public void setVertex(final int tri, final int vertex,
+                          final double sx, final double sy, final double w,
+                          final double texU, final double texV,
+                          final double lmU, final double lmV) {
+        final int base = tri * STRIDE + vertex * DOUBLES_PER_VERTEX;
+        data[base]     = sx;  data[base + 1] = sy; data[base + 2] = w;
+        data[base + 3] = texU; data[base + 4] = texV;
+        data[base + 5] = lmU; data[base + 6] = lmV;
+    }
+
+    public void setCount(final int tri, final int n) {
+        count[tri] = n;
+    }
+
+    public int count(final int tri) {
+        return count[tri];
+    }
+
+    /** vertex = 0..3, component: 0 sx, 1 sy, 2 w, 3 texU, 4 texV, 5 lmU, 6 lmV. */
+    public double at(final int tri, final int vertex, final int component) {
+        return data[tri * STRIDE + vertex * DOUBLES_PER_VERTEX + component];
+    }
+
+    public boolean isCulled(final int tri) {
+        return culled[tri];
+    }
+
+    public void cull(final int tri) {
+        culled[tri] = true;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/SdfFontStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/SdfFontStore.java
new file mode 100644 (file)
index 0000000..8faa31c
--- /dev/null
@@ -0,0 +1,39 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+/**
+ * SDF font data: a signed-distance-field glyph atlas plus per-glyph
+ * advance widths. Pure data — the atlas is BUILT once by
+ * systems.gui.SdfFont (AWT allowed there, never in a pass) and sampled
+ * by the GUI render pass.
+ *
+ * Atlas layout: 95 printable ASCII glyphs (32..126), each in a
+ * CELL x CELL cell, row-major. Distance encoding: 128 = on the edge,
+ * >128 inside the glyph, <128 outside; SPREAD is the distance scale in
+ * atlas pixels (d_stored = 128 + 255 * clamp(dist, -S, +S) / (2*S)).
+ */
+public final class SdfFontStore {
+
+    public static final int FIRST = 32, LAST = 126;
+    public static final int CELL = 64;
+    public static final int COUNT = LAST - FIRST + 1;
+    public static final int SPREAD = 8;
+
+    /** COUNT * CELL * CELL distance bytes, row-major per glyph. */
+    public final int[] dist = new int[COUNT * CELL * CELL];
+    /** Per-glyph advance in atlas pixels (at the generation font size). */
+    public final int[] advance = new int[COUNT];
+    /** Per-glyph ink bounds (atlas px) — text draws the ink box at the
+     *  pen, so adjacent glyphs never double-stamp pixels. */
+    public final int[] inkLeft = new int[COUNT];
+    public final int[] inkRight = new int[COUNT];
+    /** Font size the atlas was generated at (scale reference). */
+    public int baseSize;
+
+    public int distAt(final int glyph, final int x, final int y) {
+        return dist[(glyph - FIRST) * CELL * CELL + y * CELL + x];
+    }
+
+    /** Atlas-space baseline (cell y of the glyph baseline), set at
+     *  build time — text is positioned by baseline, not by quad top. */
+    public int baseline;
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/StreamedPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/StreamedPass.java
new file mode 100644 (file)
index 0000000..cbd0841
--- /dev/null
@@ -0,0 +1,39 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+/**
+ * A pass whose frame work can be expressed as WAVES of independent items,
+ * so the frame stream can interleave it with other in-flight frames
+ * instead of blocking at a stage boundary.
+ *
+ * <p>Item (wave, index) pairs never depend on each other inside a wave;
+ * between waves the stream enforces a counter: wave k+1 is submitted by
+ * the LAST worker to finish wave k (a continuation), never by a thread
+ * that waits. That is the whole point — no join, so no worker idles at a
+ * stage boundary while another frame still has work queued.</p>
+ *
+ * <p>When {@link #ticketWave} is true the stream submits one item per
+ * worker and hands out index {@code 0..itemCount-1} through a ticket,
+ * which is what keeps handoff cost out of fine-grained waves (tiles).</p>
+ */
+public interface StreamedPass extends Pass {
+
+    /** Per-frame setup that must happen on the loop thread BEFORE any
+     *  wave of this pass is submitted (buffer sizing, bin allocation). */
+    default void beginFrame(final Substrate data, final FrameView frame) {
+    }
+
+    /** Number of waves this pass contributes to the frame. */
+    int waveCount();
+
+    /** Item count of one wave, for this frame. */
+    int itemCount(int wave, Substrate data, FrameView frame);
+
+    /** Runs one item. May be called from any worker. */
+    void runItem(int wave, int item, Substrate data, FrameView frame);
+
+    /** True when a wave should be handed out by ticket instead of one
+     *  item per submitted task. */
+    default boolean ticketWave(final int wave) {
+        return false;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Substrate.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Substrate.java
new file mode 100644 (file)
index 0000000..e4c2de3
--- /dev/null
@@ -0,0 +1,85 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+/**
+ * The single universal interaction medium.
+ *
+ * All data lives here as plain tables (stores). Passes (systems) read and
+ * write stores; they NEVER reference each other. Adding a feature = adding
+ * a store and/or a pass — existing files stay untouched.
+ *
+ * This class intentionally stays dumb: no behavior beyond allocation and
+ * bounds checks. Policy lives in passes.
+ */
+public final class Substrate {
+
+    /** All renderable geometry, SoA. Grows; never shrinks during a frame. */
+    public final MeshStore triangles = new MeshStore();
+
+    /** Camera pose; passes read it at apply() time. */
+    public final CameraStore camera = new CameraStore();
+
+    /** Input state (keys, mouse deltas); written by adapters/tests. */
+    public final InputStore input = new InputStore();
+
+    /** Screen-space triangles (transform/cull output, raster input) —
+     *  the DEFAULT slot, used by every single-frame renderer. The frame
+     *  pipeline renders into {@link #screens} slots instead, one per
+     *  in-flight frame, so two frames never share transform output. */
+    public final ScreenStore screen = new ScreenStore();
+
+    /** Per-frame screen slots for the pipeline (2 = at most 2 frames of
+     *  transform state alive at once). Slot 0 doubles as {@link #screen}. */
+    public final ScreenStore[] screens = { screen, new ScreenStore(),
+            new ScreenStore(), new ScreenStore() };
+
+    /** Point lights. */
+    public final LightStore lights = new LightStore();
+
+    /** Line segments (LinePass: blended, glow-edged, depth-tested). */
+    public final LineStore lines = new LineStore();
+
+    /** Debug visualization flags (developer tools writes, passes read). */
+    public final DebugStore debug = new DebugStore();
+
+    /** Streaming chunk registry (WorldStreamer maintains it). */
+    public final ChunkStore chunks = new ChunkStore();
+
+    /** GUI components (GuiPass logic, GuiRenderPass drawing). */
+    public final GuiStore gui = new GuiStore();
+
+    /** SDF font atlas for GUI text; set by the app at startup via
+     *  systems.gui.SdfFont.build (tooling, not a pass). Null = no text. */
+    public SdfFontStore guiFont;
+
+    /** Textures by id. */
+    public final TextureStore textures = new TextureStore();
+
+    /** Ray-query index over the mesh (BVH). Derived data; rebuilt lazily
+     *  by version check. Consumers: GI gather, picking, occlusion. */
+    public final BvhStore bvh = new BvhStore();
+
+    /** Per-triangle baked light; null = no GI system active. */
+    public GiStore giLight;
+
+    /** Optional override colors (shading pass output); null = use base. */
+    public int[] shaded;
+
+    /** Per-triangle lightmaps; null = no GI lightmaps baked. Volatile:
+     *  swapped atomically on rebuild (stale workers write the orphan). */
+    public volatile LightmapStore giLightmaps;
+
+    /** The frame buffer. Created by the app shell before the raster pass.
+     *  Volatile: the shell's resize listener swaps it on the EDT while
+     *  the loop thread renders. */
+    public volatile ImageStore image;
+
+    /** Background color each frame is cleared to (the sky). The shell
+     *  clears image to this before every rendered frame. */
+    public int background = 0xFF102030;
+
+    /** Per-eye frame buffers for stereo (written by the app's stereo
+     *  rig running the mono pipeline twice; read by StereoMergePass). */
+    public ImageStore imageLeft, imageRight;
+
+    // Future tables (eligibility, animation) land here as fields.
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/TextureStore.java b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/TextureStore.java
new file mode 100644 (file)
index 0000000..6f9851c
--- /dev/null
@@ -0,0 +1,49 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Textures, indexed by id. Nearest sampling, clamp (no wrap, no mips —
+ * v1; mip chains land as a new file when minification matters).
+ */
+public final class TextureStore {
+
+    private final List<int[]> pixels = new ArrayList<>();
+    private final List<Integer> widths = new ArrayList<>();
+    private final List<Integer> heights = new ArrayList<>();
+
+    public int add(final int width, final int height, final int[] argb) {
+        if (argb.length != width * height) {
+            throw new IllegalArgumentException("pixel count != w*h");
+        }
+        pixels.add(argb);
+        widths.add(width);
+        heights.add(height);
+        return pixels.size() - 1;
+    }
+
+    /** Nearest sample at (u, v) with REPEAT wrap (u mod 1); tiled floors
+     *  need wrap, clamps can be emulated with uv in [0,1). */
+    public int sample(final int id, final double u, final double v) {
+        final int w = widths.get(id), h = heights.get(id);
+        double uu = u % 1.0;
+        if (uu < 0) {
+            uu += 1.0;
+        }
+        double vv = v % 1.0;
+        if (vv < 0) {
+            vv += 1.0;
+        }
+        final int tx = Math.min((int) (uu * w), w - 1);
+        final int ty = Math.min((int) (vv * h), h - 1);
+        return pixels.get(id)[ty * w + tx];
+    }
+
+    public int width(final int id) { return widths.get(id); }
+    public int height(final int id) { return heights.get(id); }
+
+    private static int clamp(final int v, final int lo, final int hi) {
+        return v < lo ? lo : Math.min(v, hi);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/shapes/Shapes.java b/src/main/java/eu/svjatoslav/aukio/e3d2/shapes/Shapes.java
new file mode 100644 (file)
index 0000000..ff7812e
--- /dev/null
@@ -0,0 +1,343 @@
+package eu.svjatoslav.aukio.e3d2.shapes;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * Shape gallery for e3d2 — the port of legacy's
+ * renderer.raster.shapes.composite.solid.* / wireframe.Grid3D
+ * collection (2026-10-13). Each method appends geometry to the
+ * substrate: triangles into MeshStore, lines into LineStore.
+ *
+ * Conventions:
+ * - All solids use CONSISTENT INWARD-POINTING WINDING NORMALS: e3d2
+ *   CullPass keeps the winding whose normal points AWAY from the viewer
+ *   (the +Z-forward projection with +x mapped to screen-right is a
+ *   mirror of the OpenGL convention — proven empirically 2026-10-14:
+ *   a quad wound normal-toward-camera is culled, normal-away draws).
+ *   A solid viewed from outside must therefore be wound with normals
+ *   pointing INTO the solid, or it renders inside-out — invisible under
+ *   flat albedo, blatant under GI (user bugreport 20261014-214114:
+ *   "I see back side of the cube").
+ * - Call sites must wind every face with its normal pointing INTO the
+ *   solid; the engine-facing rule is pinned per triangle by ShapesTest
+ *   (assertWindingIntoSolid), not by convention prose.
+ * - Winding correctness is pinned by ShapesTest: per-triangle winding
+ *   normals must point toward the solid's interior reference point,
+ *   plus a render from six directions (the render alone cannot see
+ *   inside-out — flat albedo hides it).
+ * - Triangle colors are ARGB; alpha selects opaque vs the translucent
+ *   painter pass in RasterPass.
+ */
+public final class Shapes {
+
+    private Shapes() {
+    }
+
+    // ------------------------------------------------------------ cube
+
+    /** Cube centered at (cx,cy,cz) with the given edge size. 12 tris. */
+    public static void cube(final Substrate d,
+                            final double cx, final double cy, final double cz,
+                            final double size, final int argb) {
+        final double h = size / 2;
+        // each face: two tris wound with the normal pointing INTO the
+        // cube (CullPass keeps the normal-away winding)
+        // -z face (inward = +z)
+        quad(d, cx - h, cy - h, cz - h, cx + h, cy - h, cz - h,
+                cx + h, cy + h, cz - h, cx - h, cy + h, cz - h, argb);
+        // +z face (inward = -z)
+        quad(d, cx + h, cy - h, cz + h, cx - h, cy - h, cz + h,
+                cx - h, cy + h, cz + h, cx + h, cy + h, cz + h, argb);
+        // +x face (inward = -x)
+        quad(d, cx + h, cy - h, cz - h, cx + h, cy - h, cz + h,
+                cx + h, cy + h, cz + h, cx + h, cy + h, cz - h, argb);
+        // -x face (inward = +x)
+        quad(d, cx - h, cy - h, cz + h, cx - h, cy - h, cz - h,
+                cx - h, cy + h, cz - h, cx - h, cy + h, cz + h, argb);
+        // +y face / top (inward = -y)
+        quad(d, cx - h, cy + h, cz - h, cx + h, cy + h, cz - h,
+                cx + h, cy + h, cz + h, cx - h, cy + h, cz + h, argb);
+        // -y face / bottom (inward = +y)
+        quad(d, cx - h, cy - h, cz + h, cx + h, cy - h, cz + h,
+                cx + h, cy - h, cz - h, cx - h, cy - h, cz - h, argb);
+    }
+
+    // -------------------------------------------------------- cylinder
+
+    /** Cylinder from base center (bx,by,bz) along (dx,dy,dz) — the
+     *  vector sets both direction and length — with both caps.
+     *  sides*4 tris. */
+    public static void cylinder(final Substrate d,
+                                final double bx, final double by, final double bz,
+                                final double dx, final double dy, final double dz,
+                                final double radius, final int sides,
+                                final int argb) {
+        final Basis b = new Basis(bx, by, bz, dx, dy, dz);
+        // every tri wound with the normal pointing INTO the cylinder
+        // (CullPass keeps the normal-away winding)
+        for (int i = 0; i < sides; i++) {
+            final double a0 = i * 2 * Math.PI / sides;
+            final double a1 = (i + 1) * 2 * Math.PI / sides;
+            // side quad: (bottom a0) (top a0) (top a1) / + (bottom a1)
+            quad(d,
+                    b.x(a0, radius, 0), b.y(a0, radius, 0), b.z(a0, radius, 0),
+                    b.x(a0, radius, 1), b.y(a0, radius, 1), b.z(a0, radius, 1),
+                    b.x(a1, radius, 1), b.y(a1, radius, 1), b.z(a1, radius, 1),
+                    b.x(a1, radius, 0), b.y(a1, radius, 0), b.z(a1, radius, 0),
+                    argb);
+            // top cap fan: (center, a_i+1, a_i) — inward = -axis
+            tri(d,
+                    b.topX(), b.topY(), b.topZ(),
+                    b.x(a1, radius, 1), b.y(a1, radius, 1), b.z(a1, radius, 1),
+                    b.x(a0, radius, 1), b.y(a0, radius, 1), b.z(a0, radius, 1),
+                    argb);
+            // bottom cap fan: (center, a_i, a_i+1) — inward = +axis
+            tri(d,
+                    bx, by, bz,
+                    b.x(a0, radius, 0), b.y(a0, radius, 0), b.z(a0, radius, 0),
+                    b.x(a1, radius, 0), b.y(a1, radius, 0), b.z(a1, radius, 0),
+                    argb);
+        }
+    }
+
+    // ------------------------------------------------------------ cone
+
+    /** Cone from base center (bx,by,bz) along (dx,dy,dz) to the apex,
+     *  with base cap. sides*2 tris. */
+    public static void cone(final Substrate d,
+                            final double bx, final double by, final double bz,
+                            final double dx, final double dy, final double dz,
+                            final double radius, final int sides,
+                            final int argb) {
+        final Basis b = new Basis(bx, by, bz, dx, dy, dz);
+        for (int i = 0; i < sides; i++) {
+            final double a0 = i * 2 * Math.PI / sides;
+            final double a1 = (i + 1) * 2 * Math.PI / sides;
+            // side: (base a0) apex (base a1) — normal points inward
+            tri(d,
+                    b.x(a0, radius, 0), b.y(a0, radius, 0), b.z(a0, radius, 0),
+                    b.topX(), b.topY(), b.topZ(),
+                    b.x(a1, radius, 0), b.y(a1, radius, 0), b.z(a1, radius, 0),
+                    argb);
+            // base cap fan: (center, a_i, a_i+1) — inward = +axis
+            tri(d,
+                    bx, by, bz,
+                    b.x(a0, radius, 0), b.y(a0, radius, 0), b.z(a0, radius, 0),
+                    b.x(a1, radius, 0), b.y(a1, radius, 0), b.z(a1, radius, 0),
+                    argb);
+        }
+    }
+
+    // ------------------------------------------------------------ arrow
+
+    /** Arrow from base point (bx,by,bz) along (dx,dy,dz), legacy
+     *  SolidPolygonArrow proportions: tip radius = 2.5x body radius,
+     *  tip length = 5x body radius. Octagonal shaft with base cap.
+     *  8*6 tris. */
+    public static void arrow(final Substrate d,
+                             final double bx, final double by, final double bz,
+                             final double dx, final double dy, final double dz,
+                             final double bodyRadius, final int argb) {
+        final double len = Math.sqrt(dx * dx + dy * dy + dz * dz);
+        final double tipLength = bodyRadius * 5.0;   // legacy ratio
+        final double tipRadius = bodyRadius * 2.5;   // legacy ratio
+        final double shaft = (len - tipLength) / len;
+        cylinder(d, bx, by, bz, dx * shaft, dy * shaft, dz * shaft,
+                bodyRadius, 8, argb);
+        cone(d, bx + dx * shaft, by + dy * shaft, bz + dz * shaft,
+                dx * (1 - shaft), dy * (1 - shaft), dz * (1 - shaft),
+                tipRadius, 8, argb);
+    }
+
+    // ---------------------------------------------------------- pyramid
+
+    /** Square pyramid: base centered at (bx,by,bz) with the given half
+     *  size, apex at height above the base. 8 tris. */
+    public static void pyramid(final Substrate d,
+                               final double bx, final double by, final double bz,
+                               final double halfSize, final double height,
+                               final int argb) {
+        final double h = halfSize;
+        // base corners, increasing angle around +Y
+        final double[][] c = {
+                {bx + h, by, bz + h},
+                {bx - h, by, bz + h},
+                {bx - h, by, bz - h},
+                {bx + h, by, bz - h},
+        };
+        final double ax = bx, ay = by + height, az = bz;
+        for (int i = 0; i < 4; i++) {
+            final double[] c0 = c[i];
+            final double[] c1 = c[(i + 1) % 4];
+            // side: ll = c_i, lr = c_i+1, apex
+            tri(d, c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], ax, ay, az, argb);
+            // base fan: reversed
+            tri(d, bx, by, bz, c1[0], c1[1], c1[2], c0[0], c0[1], c0[2], argb);
+        }
+    }
+
+    // ---------------------------------------------------------- sphere
+
+    /** UV sphere centered at (cx,cy,cz). slices around +Y, stacks from
+     *  the south to the north pole. slices*(stacks-1)*2 tris (pole
+     *  rows degenerate to fans). */
+    public static void sphere(final Substrate d,
+                              final double cx, final double cy, final double cz,
+                              final double radius,
+                              final int slices, final int stacks,
+                              final int argb) {
+        for (int i = 0; i < stacks; i++) {
+            final double phi0 = -Math.PI / 2 + Math.PI * i / stacks;
+            final double phi1 = -Math.PI / 2 + Math.PI * (i + 1) / stacks;
+            for (int j = 0; j < slices; j++) {
+                final double t0 = j * 2 * Math.PI / slices;
+                final double t1 = (j + 1) * 2 * Math.PI / slices;
+                // ll = (i, j), lr = (i, j+1), ur = (i+1, j+1), ul = (i+1, j)
+                quad(d,
+                        sx(cx, radius, phi0, t0), sy(cy, radius, phi0), sz(cz, radius, phi0, t0),
+                        sx(cx, radius, phi0, t1), sy(cy, radius, phi0), sz(cz, radius, phi0, t1),
+                        sx(cx, radius, phi1, t1), sy(cy, radius, phi1), sz(cz, radius, phi1, t1),
+                        sx(cx, radius, phi1, t0), sy(cy, radius, phi1), sz(cz, radius, phi1, t0),
+                        argb);
+            }
+        }
+    }
+
+    private static double sx(final double c, final double r,
+                             final double phi, final double theta) {
+        return c + r * Math.cos(phi) * Math.cos(theta);
+    }
+
+    private static double sy(final double c, final double r,
+                             final double phi) {
+        return c + r * Math.sin(phi);
+    }
+
+    private static double sz(final double c, final double r,
+                             final double phi, final double theta) {
+        return c + r * Math.cos(phi) * Math.sin(theta);
+    }
+
+    // ------------------------------------------------------------ grid
+
+    /** Reference grid on a horizontal plane (port of legacy Grid3D):
+     *  lines from (x0,y,z0) to (x1,y,z1) with the given step, as
+     *  LineStore segments (glow, alpha fade at distance — LinePass). */
+    public static void grid(final Substrate d,
+                            final double x0, final double z0,
+                            final double x1, final double z1,
+                            final double y, final double step,
+                            final int argb, final double width) {
+        for (double x = x0; x <= x1 + 1e-9; x += step) {
+            d.lines.add(x, y, z0, x, y, z1, argb, width);
+        }
+        for (double z = z0; z <= z1 + 1e-9; z += step) {
+            d.lines.add(x0, y, z, x1, y, z, argb, width);
+        }
+    }
+
+    // ---------------------------------------------------------- helpers
+
+    /** One quad as two tris: (a,b,c) + (a,c,d4). The caller orders the
+     *  corners (ll, lr, ur, ul) as seen from OUTSIDE the solid. */
+    private static void quad(final Substrate d,
+                             final double ax, final double ay, final double az,
+                             final double bx, final double by, final double bz,
+                             final double cx, final double cy, final double cz,
+                             final double dx, final double dy, final double dz,
+                             final int argb) {
+        tri(d, ax, ay, az, bx, by, bz, cx, cy, cz, argb);
+        tri(d, ax, ay, az, cx, cy, cz, dx, dy, dz, argb);
+    }
+
+    /** THE winding seam. Call sites pass vertices in the order their
+     *  shape builder computed; the ENGINE convention (CullPass keeps
+     *  the winding whose normal points AWAY from the viewer) means a
+     *  solid's faces must be wound with normals pointing INTO the
+     *  solid. ShapesTest.assertWindingIntoSolid pins this per
+     *  triangle — the gallery's faces were authored in mixed
+     *  conventions during the −Y→+Y port (cube y-faces were outward,
+     *  its side faces inward; user bugreport 20261014-214114), so
+     *  correctness is enforced face by face, NOT by a global swap
+     *  here (a swap just inverts which faces are wrong). */
+    private static void tri(final Substrate d,
+                            final double ax, final double ay, final double az,
+                            final double bx, final double by, final double bz,
+                            final double cx, final double cy, final double cz,
+                            final int argb) {
+        d.triangles.addTriangle(ax, ay, az, bx, by, bz, cx, cy, cz, argb);
+    }
+
+    /** Orthonormal basis for axis-aligned-in-local-space shapes:
+     *  local (u, v, w) with w along the given direction vector;
+     *  wLen normalizes local w to [0,1]. */
+    private static final class Basis {
+        private final double ox, oy, oz;
+        private final double ux, uy, uz;
+        private final double vx, vy, vz;
+        private final double wx, wy, wz;
+
+        Basis(final double ox, final double oy, final double oz,
+              final double dx, final double dy, final double dz) {
+            this.ox = ox;
+            this.oy = oy;
+            this.oz = oz;
+            final double len = Math.sqrt(dx * dx + dy * dy + dz * dz);
+            // w = direction (NOT normalized: local w in [0,1])
+            wx = dx;
+            wy = dy;
+            wz = dz;
+            // helper not parallel to w
+            final double hx, hy, hz;
+            if (Math.abs(dy / len) < 0.9) {
+                hx = 0; hy = 1; hz = 0;
+            } else {
+                hx = 1; hy = 0; hz = 0;
+            }
+            // u = normalize(w x h)
+            double x = wy * hz - wz * hy;
+            double y = wz * hx - wx * hz;
+            double z = wx * hy - wy * hx;
+            final double ul = Math.sqrt(x * x + y * y + z * z);
+            ux = x / ul;
+            uy = y / ul;
+            uz = z / ul;
+            // v = w x u (normalized)
+            x = wy * uz - wz * uy;
+            y = wz * ux - wx * uz;
+            z = wx * uy - wy * ux;
+            final double vl = Math.sqrt(x * x + y * y + z * z);
+            vx = x / vl;
+            vy = y / vl;
+            vz = z / vl;
+        }
+
+        /** Local (angle, radius, w in [0,1]) -> world. */
+        double x(final double angle, final double radius, final double w) {
+            return ox + ux * Math.cos(angle) * radius
+                    + vx * Math.sin(angle) * radius + wx * w;
+        }
+
+        double y(final double angle, final double radius, final double w) {
+            return oy + uy * Math.cos(angle) * radius
+                    + vy * Math.sin(angle) * radius + wy * w;
+        }
+
+        double z(final double angle, final double radius, final double w) {
+            return oz + uz * Math.cos(angle) * radius
+                    + vz * Math.sin(angle) * radius + wz * w;
+        }
+
+        double topX() {
+            return ox + wx;
+        }
+
+        double topY() {
+            return oy + wy;
+        }
+
+        double topZ() {
+            return oz + wz;
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/CameraPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/CameraPass.java
new file mode 100644 (file)
index 0000000..00b7e8c
--- /dev/null
@@ -0,0 +1,142 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * Camera physics: reads InputStore, writes CameraStore. Fly camera —
+ * mouse look (yaw/pitch), WASD/arrows/Dvorak strafe + Q/R down/up,
+ * exponential damping so motion is smooth and self-stopping.
+ *
+ * Constants ported from legacy Aukio 3D Camera/InputManager feel:
+ * mouse /50 rad-per-pixel; movement — cameraAcceleration 0.1/ms with
+ * its (1 + speed/10) curve and SPEED_MULTIPLIER 0.02 translate to
+ * 2000 u/s^2 at rest growing with speed, friction /1.005 per ms to
+ * ~5/s damping, SPEED_LIMIT 30 to 600 u/s top speed.
+ *
+ * Deterministic: frame dt comes from an injectable clock (System
+ * nanotime by default; tests drive tick() with explicit dt). All state
+ * lives in the pass (velocity) and CameraStore (pose) — no AWT, no
+ * statics.
+ */
+public final class CameraPass implements Pass {
+
+    /** Key codes (AWT KeyEvent VK_* values — the adapter's dialect).
+     *  Arrows mirror WASD, like legacy WorldNavigationUserInputTracker.
+     *  Dvorak cluster mirrors the physical WASD positions: ',' = forward,
+     *  A = left (same physical key), O = back, E = right — so E strafes
+     *  and QWERTY "up" lives on R; Dvorak vertical at the physical
+     *  Q/E positions is quote (down) / period (up). */
+    public static final int KEY_W = 87, KEY_A = 65, KEY_S = 83, KEY_D = 68;
+    public static final int KEY_Q = 81, KEY_R = 82;
+    public static final int KEY_E = 69, KEY_O = 79, KEY_COMMA = 44;
+    public static final int KEY_QUOTE = 222, KEY_PERIOD = 46;
+    public static final int KEY_UP = 38, KEY_DOWN = 40, KEY_LEFT = 37, KEY_RIGHT = 39;
+
+    /** Legacy InputManager rate: yaw -= dx/50, pitch -= dy/50 (rad/px). */
+    private static final double MOUSE_SENS = 0.02;
+    private static final double ACCEL = 2000.0;      // u/s^2 at rest
+    private static final double ACCEL_SPEED_REF = 200.0; // accel grows (1 + v/ref)
+    private static final double DAMPING = 5.0;       // 1/s exponential
+    private static final double MAX_SPEED = 600.0;   // u/s
+    /** Legacy handleMouseScrolling: one notch = 100 u/s of vertical
+     *  velocity (50 x cameraAcceleration 0.1, in legacy velocity units
+     *  x20 world conversion), scaled by the same speed curve. */
+    private static final double WHEEL_ACCEL = 100.0;  // u/s per notch
+    private static final double MAX_DT = 0.05;       // clamp huge frames
+    private static final double PITCH_LIMIT = 1.5;   // ~86 degrees
+
+    private double vx, vy, vz;
+    private long lastNanos = -1;
+    private boolean capBrakeWas;
+
+    @Override
+    public int order() {
+        return 8;
+    }
+
+    @Override
+    public String name() {
+        return "camera";
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        final long now = System.nanoTime();
+        double dt = lastNanos < 0 ? 0.0 : (now - lastNanos) / 1e9;
+        lastNanos = now;
+        dt = Math.min(dt, MAX_DT);
+        tick(data, dt);
+    }
+
+    /** One physics step with explicit dt — the testable core. */
+    public void tick(final Substrate data, final double dt) {
+        // mouse look (legacy Aukio 3D InputManager signs: drag right =
+        // turn right, drag UP = look up — dy is screen-down positive,
+        // so pitch goes DOWN as dy grows)
+        final double[] md = data.input.consumeMouseDelta();
+        data.camera.yaw += md[0] * MOUSE_SENS;
+        data.camera.pitch = clamp(data.camera.pitch - md[1] * MOUSE_SENS,
+                -PITCH_LIMIT, PITCH_LIMIT);
+
+        // movement basis from yaw (y-up world: forward/strafe in the
+        // XZ plane; pitch affects only look direction)
+        final double sy = Math.sin(data.camera.yaw);
+        final double cy = Math.cos(data.camera.yaw);
+        double ax = 0, ay = 0, az = 0;
+        if (data.input.isDown(KEY_W) || data.input.isDown(KEY_UP)
+                || data.input.isDown(KEY_COMMA)) { ax += sy; az += cy; }
+        if (data.input.isDown(KEY_S) || data.input.isDown(KEY_DOWN)
+                || data.input.isDown(KEY_O)) { ax -= sy; az -= cy; }
+        if (data.input.isDown(KEY_D) || data.input.isDown(KEY_RIGHT)
+                || data.input.isDown(KEY_E)) { ax += cy; az -= sy; }
+        if (data.input.isDown(KEY_A) || data.input.isDown(KEY_LEFT)) { ax -= cy; az += sy; }
+        if (data.input.isDown(KEY_R) || data.input.isDown(KEY_PERIOD)) { ay += 1; }
+        if (data.input.isDown(KEY_Q) || data.input.isDown(KEY_QUOTE)) { ay -= 1; }
+        final double len = Math.sqrt(ax * ax + ay * ay + az * az);
+        if (len > 0) {
+            // legacy acceleration curve: grows with current speed
+            final double speed = Math.sqrt(vx * vx + vy * vy + vz * vz);
+            final double a = ACCEL * (1 + speed / ACCEL_SPEED_REF);
+            vx += ax / len * a * dt;
+            vy += ay / len * a * dt;
+            vz += az / len * a * dt;
+        }
+        // mouse wheel = vertical thrust (legacy handleMouseScrolling:
+        // scroll up = fly up; GUI focus consumers don't exist yet in
+        // e3d2, so the wheel always reaches the camera)
+        final int wheel = data.input.consumeWheel();
+        if (wheel != 0) {
+            final double speed = Math.sqrt(vx * vx + vy * vy + vz * vz);
+            vy -= wheel * WHEEL_ACCEL * (1 + speed / ACCEL_SPEED_REF);
+        }
+        // SpaceMouse left cap button = brake (legacy
+        // SpaceMouseController): on the rising edge, zero the
+        // keyboard/wheel movement vector. The cap itself needs no
+        // brake — direct drive stops when released. Edge-detected here
+        // because the velocity state lives in this pass.
+        final boolean capBrake = (data.input.capButtons() & 1) != 0;
+        if (capBrake && !capBrakeWas) {
+            vx = 0;
+            vy = 0;
+            vz = 0;
+        }
+        capBrakeWas = capBrake;
+
+        // damping + speed cap
+        final double damp = Math.exp(-DAMPING * dt);
+        vx *= damp; vy *= damp; vz *= damp;
+        final double speed = Math.sqrt(vx * vx + vy * vy + vz * vz);
+        if (speed > MAX_SPEED) {
+            final double k = MAX_SPEED / speed;
+            vx *= k; vy *= k; vz *= k;
+        }
+        data.camera.x += vx * dt;
+        data.camera.y += vy * dt;
+        data.camera.z += vz * dt;
+    }
+
+    private static double clamp(final double v, final double lo, final double hi) {
+        return v < lo ? lo : Math.min(v, hi);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/ChunkFrustumCullPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/ChunkFrustumCullPass.java
new file mode 100644 (file)
index 0000000..e6337ea
--- /dev/null
@@ -0,0 +1,78 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ChunkStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * Chunk-level frustum culling — the composite-cull answer to the
+ * full-scene transform wall: whole chunks outside the view cone are
+ * marked invisible, and TransformPass skips their triangle ranges
+ * entirely (writes count 0, never touches vertex data). AABB computed
+ * once per chunk at publish-time cost zero.
+ *
+ * The test is a conservative cone (never culls a visible chunk; may
+ * keep a corner straddler). Plane-exact frustum planes later if the
+ * false-positive rate matters.
+ */
+public final class ChunkFrustumCullPass implements Pass {
+
+    @Override
+    public int order() {
+        return 9;
+    }
+
+    @Override
+    public String name() {
+        return "chunk-cull";
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        // view basis
+        final double sy = Math.sin(data.camera.yaw), cy = Math.cos(data.camera.yaw);
+        final double sp = Math.sin(data.camera.pitch), cp = Math.cos(data.camera.pitch);
+        final double fx = sy * cp, fy = sp, fz = cy * cp;
+        // conservative cone: horizontal half-angle from the projection
+        // scale plus a margin, widened per chunk by its bounding sphere
+        final double tanHalf = (data.image.width / 2.0) / data.camera.scale * 1.3;
+
+        for (final ChunkStore.Chunk c : data.chunks.all()) {
+            if (!c.boundsComputed) {
+                computeBounds(data, c);
+            }
+            final double mx = (c.minX + c.maxX) / 2 - data.camera.x;
+            final double my = (c.minY + c.maxY) / 2 - data.camera.y;
+            final double mz = (c.minZ + c.maxZ) / 2 - data.camera.z;
+            final double dist = Math.sqrt(mx * mx + my * my + mz * mz);
+            if (dist < c.radius()) {
+                c.visible = true;   // camera inside the bounds
+                continue;
+            }
+            final double along = mx * fx + my * fy + mz * fz;
+            if (along < -c.radius()) {
+                c.visible = false;  // fully behind
+                continue;
+            }
+            final double lateral2 = dist * dist - along * along;
+            final double allowed = tanHalf * Math.max(along, 0.0) + c.radius();
+            c.visible = lateral2 <= allowed * allowed;
+        }
+    }
+
+    private static void computeBounds(final Substrate data, final ChunkStore.Chunk c) {
+        double nx = Double.POSITIVE_INFINITY, ny = nx, nz = nx;
+        double xx = Double.NEGATIVE_INFINITY, xy = xx, xz = xx;
+        final int end = c.firstTri + c.triCount;
+        for (int t = c.firstTri; t < end; t++) {
+            for (int v = 0; v < 3; v++) {
+                nx = Math.min(nx, data.triangles.at(t, v, 0)); xx = Math.max(xx, data.triangles.at(t, v, 0));
+                ny = Math.min(ny, data.triangles.at(t, v, 1)); xy = Math.max(xy, data.triangles.at(t, v, 1));
+                nz = Math.min(nz, data.triangles.at(t, v, 2)); xz = Math.max(xz, data.triangles.at(t, v, 2));
+            }
+        }
+        c.minX = nx; c.minY = ny; c.minZ = nz;
+        c.maxX = xx; c.maxY = xy; c.maxZ = xz;
+        c.boundsComputed = true;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/CullPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/CullPass.java
new file mode 100644 (file)
index 0000000..375c4f4
--- /dev/null
@@ -0,0 +1,126 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * Drops triangles that must not be rasterized:
+ * - the clip produced no loop (fully behind the near plane): count &lt; 3.
+ *   Near-plane clipping itself lives in TransformPass.
+ * - backfaces: screen-space signed area &gt;= 0 on the first 3 vertices of
+ *   the clipped loop. Winding contract, MEASURED (Probe4, 2026-10-14):
+ *   the KEPT face is the one whose winding normal points AWAY from the
+ *   camera. The camera looks along +Z while +x projects to screen-right
+ *   — a mirror of the usual OpenGL convention, which is why a triangle
+ *   that reads CCW as seen by the camera (normal away) is kept: the
+ *   y-down screen flip makes that NEGATIVE signed area in screen space,
+ *   so positive and degenerate triangles are dropped. Practical rule for
+ *   geometry authors: wind faces with normals pointing INTO solids /
+ *   AWAY from their intended viewers (Shapes does this in one seam).
+ * - fully outside the viewport: bounds taken from the CLIPPED vertices
+ *   (the Aukio rule: never from unclipped ones).
+ */
+public final class CullPass implements eu.svjatoslav.aukio.e3d2.kernel.StreamedPass {
+
+    @Override
+    public int order() {
+        return 20;
+    }
+
+    @Override
+    public String name() {
+        return "cull";
+    }
+
+    @Override
+    public int stage() {
+        return 10;
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        apply(data, eu.svjatoslav.aukio.e3d2.kernel.FrameView.ofDefault(data));
+    }
+
+    @Override
+    public void apply(final Substrate data,
+                      final eu.svjatoslav.aukio.e3d2.kernel.FrameView f) {
+
+        final int n = data.triangles.triangleCount();
+        final int w = f.image.width;
+        final int h = f.image.height;
+
+        // per-triangle independent: range partition, disjoint writes
+        // (culled flag per tri) — byte-identical to the serial loop
+        eu.svjatoslav.aukio.e3d2.kernel.Parallel.forRange(n, (from, to) -> {
+            for (int tri = from; tri < to; tri++) {
+                cullOne(data, f.screen, tri, w, h);
+            }
+        });
+    }
+
+    // ---- streamed frame ----
+
+    @Override
+    public int waveCount() {
+        return 1;
+    }
+
+    @Override
+    public boolean ticketWave(final int wave) {
+        return true;
+    }
+
+    @Override
+    public int itemCount(final int wave, final Substrate data,
+                         final eu.svjatoslav.aukio.e3d2.kernel.FrameView f) {
+        final int n = data.triangles.triangleCount();
+        return Math.max(1, Math.min(n,
+                32 * eu.svjatoslav.aukio.e3d2.kernel.Parallel.poolSize()));
+    }
+
+    @Override
+    public void runItem(final int wave, final int item, final Substrate data,
+                        final eu.svjatoslav.aukio.e3d2.kernel.FrameView f) {
+        final int n = data.triangles.triangleCount();
+        final int chunks = itemCount(wave, data, f);
+        final int from = (int) ((long) item * n / chunks);
+        final int to = (int) ((long) (item + 1) * n / chunks);
+        final int w = f.image.width;
+        final int h = f.image.height;
+        for (int tri = from; tri < to; tri++) {
+            cullOne(data, f.screen, tri, w, h);
+        }
+    }
+
+    private static void cullOne(final Substrate data,
+                                final eu.svjatoslav.aukio.e3d2.kernel.ScreenStore screen,
+                                final int tri, final int w, final int h) {
+            final int count = screen.count(tri);
+            if (count < 3) {
+                screen.cull(tri);
+                return;
+            }
+
+            final double sx0 = screen.at(tri, 0, 0), sy0 = screen.at(tri, 0, 1);
+            final double sx1 = screen.at(tri, 1, 0), sy1 = screen.at(tri, 1, 1);
+            final double sx2 = screen.at(tri, 2, 0), sy2 = screen.at(tri, 2, 1);
+
+            final double area = (sx1 - sx0) * (sy2 - sy0) - (sx2 - sx0) * (sy1 - sy0);
+            if (area >= 0) {
+                screen.cull(tri);
+                return;
+            }
+
+            double minX = sx0, maxX = sx0, minY = sy0, maxY = sy0;
+            for (int v = 1; v < count; v++) {
+                final double sx = screen.at(tri, v, 0);
+                final double sy = screen.at(tri, v, 1);
+                minX = Math.min(minX, sx); maxX = Math.max(maxX, sx);
+                minY = Math.min(minY, sy); maxY = Math.max(maxY, sy);
+            }
+            if (maxX < 0 || minX >= w || maxY < 0 || minY >= h) {
+                screen.cull(tri);
+            }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/DebugStatsPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/DebugStatsPass.java
new file mode 100644 (file)
index 0000000..6faaac4
--- /dev/null
@@ -0,0 +1,29 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * Trivial example system: counts triangles in the substrate.
+ * Exists to keep the skeleton runnable; real systems (transform, cull,
+ * raster, GI) follow the same shape.
+ */
+public final class DebugStatsPass implements Pass {
+
+    private int lastCount = -1;
+
+    @Override
+    public String name() {
+        return "debug-stats";
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        lastCount = data.triangles.triangleCount();
+        System.out.println("[debug-stats] triangles=" + lastCount);
+    }
+
+    public int lastCount() {
+        return lastCount;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/FogPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/FogPass.java
new file mode 100644 (file)
index 0000000..8b0f306
--- /dev/null
@@ -0,0 +1,48 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * Exponential distance fog as a post-process over the substrate image:
+ * f = 1 − exp(−z/scale); pixel = mix(pixel, fogColor, f). Sky pixels
+ * (depth == 0 = infinitely far) are left alone.
+ *
+ * Metric-test feature (2026-10-10): landed as ONE new file plus one
+ * registration line in META-INF/services — zero existing Java files
+ * edited. Reads only image.pixels + image.depth from the substrate.
+ */
+public final class FogPass implements Pass {
+
+    private static final double SCALE = 30.0;
+    private static final int FOG_R = 0x30, FOG_G = 0x38, FOG_B = 0x44;
+
+    @Override
+    public int order() {
+        return 90;
+    }
+
+    @Override
+    public String name() {
+        return "fog";
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        final int[] pixels = data.image.pixels;
+        final float[] depth = data.image.depth;
+        for (int i = 0; i < pixels.length; i++) {
+            final double w = depth[i];
+            if (w <= 0.0) {
+                continue;   // sky
+            }
+            final double f = 1.0 - Math.exp(-(1.0 / w) / SCALE);
+            final int p = pixels[i];
+            final int r = (p >> 16) & 0xFF, g = (p >> 8) & 0xFF, b = p & 0xFF;
+            pixels[i] = 0xFF000000
+                    | ((int) (r + (FOG_R - r) * f) << 16)
+                    | ((int) (g + (FOG_G - g) * f) << 8)
+                    | (int) (b + (FOG_B - b) * f);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/LinePass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/LinePass.java
new file mode 100644 (file)
index 0000000..9926d88
--- /dev/null
@@ -0,0 +1,286 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.LineStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Parallel;
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * Projects data.lines and paints them blended over the rasterized
+ * frame. Port of legacy e3d renderer.raster.shapes.basic.line.Line
+ * (2026-10-13) — the "glowing grid" the thin-quad emulation could not
+ * reproduce.
+ *
+ * Legacy semantics, kept:
+ * <ul>
+ *   <li>Width is in world units, projected per endpoint:
+ *       radius = imageWidth * 0.2 * width / z — perspective thickness.</li>
+ *   <li>Thick lines: alpha falls off linearly across the width
+ *       (1 - |d|/r) — the soft glow at the edges.</li>
+ *   <li>Thin lines (projected radius below a pixel): single-pixel
+ *       DDA with alpha scaled by the radius — far lines FADE instead
+ *       of shimmering, so a grid renders well at any distance.</li>
+ *   <li>Lines alpha-blend, depth-TEST against the opaque z-buffer
+ *       (solids occlude them) and never depth-WRITE (a line must not
+ *       occlude later geometry). Legacy's tunable depth margin
+ *       defaults to 0 there, so the port uses a plain greater-than.</li>
+ * </ul>
+ *
+ * Runs after raster (order 30), before gui-render (250).
+ */
+public final class LinePass implements Pass {
+
+    /** Same value as TransformPass NEAR. */
+    private static final double NEAR = 0.1;
+    /** Legacy Line.LINE_WIDTH_MULTIPLIER. */
+    private static final double LINE_WIDTH_MULTIPLIER = 0.2;
+    /** Legacy Line.MINIMUM_WIDTH_THRESHOLD. */
+    private static final double MINIMUM_WIDTH_THRESHOLD = 1;
+
+    @Override
+    public int order() {
+        return 40;
+    }
+
+    @Override
+    public String name() {
+        return "lines";
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        final LineStore lines = data.lines;
+        if (lines.count == 0 || data.image == null) {
+            return;
+        }
+        final double cosY = Math.cos(data.camera.yaw);
+        final double sinY = Math.sin(data.camera.yaw);
+        final double cosP = Math.cos(data.camera.pitch);
+        final double sinP = Math.sin(data.camera.pitch);
+        Parallel.forRange(lines.count, (from, to) -> {
+            for (int i = from; i < to; i++) {
+                render(data, lines, i, cosY, sinY, cosP, sinP);
+            }
+        });
+    }
+
+    private void render(final Substrate data, final LineStore lines,
+                        final int i, final double cosY, final double sinY,
+                        final double cosP, final double sinP) {
+        // camera space (exactly TransformPass's math)
+        final double[] pA = camera(data, lines.x1[i], lines.y1[i],
+                lines.z1[i], cosY, sinY, cosP, sinP);
+        final double[] pB = camera(data, lines.x2[i], lines.y2[i],
+                lines.z2[i], cosY, sinY, cosP, sinP);
+        double ax = pA[0], ay = pA[1], az = pA[2];
+        double bx = pB[0], by = pB[1], bz = pB[2];
+
+        if (az < NEAR && bz < NEAR) {
+            return;
+        }
+        // near-plane clip: shorten a straddling line
+        if (az < NEAR || bz < NEAR) {
+            final double t = (NEAR - az) / (bz - az);
+            final double ix = ax + (bx - ax) * t;
+            final double iy = ay + (by - ay) * t;
+            if (az < NEAR) {
+                ax = ix; ay = iy; az = NEAR;
+            } else {
+                bx = ix; by = iy; bz = NEAR;
+            }
+        }
+
+        final double scale = data.camera.scale;
+        final double cx = data.image.width / 2.0;
+        final double cy = data.image.height / 2.0;
+        final double p1x = cx + (ax / az) * scale;
+        final double p1y = cy - (ay / az) * scale;
+        final double p2x = cx + (bx / bz) * scale;
+        final double p2y = cy - (by / bz) * scale;
+        final double zw1 = 1.0 / az;
+        final double zw2 = 1.0 / bz;
+
+        final double r1 = data.image.width * LINE_WIDTH_MULTIPLIER
+                * lines.width[i] / az;
+        final double r2 = data.image.width * LINE_WIDTH_MULTIPLIER
+                * lines.width[i] / bz;
+
+        final int argb = lines.color[i];
+        final int a = (argb >>> 24) & 0xFF;
+        final int cr = (argb >> 16) & 0xFF;
+        final int cg = (argb >> 8) & 0xFF;
+        final int cb = argb & 0xFF;
+
+        if (r1 < MINIMUM_WIDTH_THRESHOLD || r2 < MINIMUM_WIDTH_THRESHOLD) {
+            // thin path: 1px line, alpha fades with projected radius
+            double averageRadius = (r1 + r2) / 2;
+            if (averageRadius > 1) {
+                averageRadius = 1;
+            }
+            final int alpha = (int) (a * averageRadius);
+            if (alpha < 2) {
+                return;
+            }
+            drawThin(data, p1x, p1y, p2x, p2y, zw1, zw2, alpha, cr, cg, cb);
+        } else {
+            drawThick(data, p1x, p1y, p2x, p2y, zw1, zw2, r1, r2,
+                    a, cr, cg, cb);
+        }
+    }
+
+    /** Single-pixel DDA along the major axis, constant alpha. */
+    private void drawThin(final Substrate data,
+                          final double p1x, final double p1y,
+                          final double p2x, final double p2y,
+                          final double zw1, final double zw2,
+                          final int alpha,
+                          final int cr, final int cg, final int cb) {
+        final int w = data.image.width;
+        final int h = data.image.height;
+        final int[] pixels = data.image.pixels;
+        final float[] depth = data.image.depth;
+        final double dx = p2x - p1x;
+        final double dy = p2y - p1y;
+        final boolean horizontalMajor = Math.abs(dx) > Math.abs(dy);
+        final double len = horizontalMajor ? dx : dy;
+        final int steps = (int) Math.abs(len);
+        if (steps == 0) {
+            return;
+        }
+        final double minorPerStep = (horizontalMajor ? dy : dx) / steps;
+        final int sign = len > 0 ? 1 : -1;
+        double minor = horizontalMajor ? p1y : p1x;
+        for (int s = 0; s <= steps; s++) {
+            final int major = (int) ((horizontalMajor ? p1x : p1y) + s * sign);
+            final int min = (int) minor;
+            final int x = horizontalMajor ? major : min;
+            final int y = horizontalMajor ? min : major;
+            // stepping major by sign(dx) needs minor += dy/|dx| — the
+            // sign is ALREADY correct in dy/dx; multiplying by sign
+            // again mirrors every left/down-pointing line (bug
+            // reported 2026-10-13: thin lines misplaced vs thick)
+            minor += minorPerStep;
+            if (x < 0 || x >= w || y < 0 || y >= h) {
+                continue;
+            }
+            final int offset = y * w + x;
+            final double t = (double) s / steps;
+            final double zw = zw1 + (zw2 - zw1) * t;
+            if (zw > depth[offset]) {
+                blend(pixels, offset, alpha, cr, cg, cb);
+            }
+        }
+    }
+
+    /**
+     * Capsule rasterization along the major axis: at each major step a
+     * run perpendicular to the line is painted with alpha falling off
+     * linearly from the center (legacy's 1-|d| glow). Per-pixel depth
+     * from the 2D-line parameter (projectively correct 1/z lerp).
+     */
+    private void drawThick(final Substrate data,
+                           final double p1x, final double p1y,
+                           final double p2x, final double p2y,
+                           final double zw1, final double zw2,
+                           final double r1, final double r2,
+                           final int alphaMax,
+                           final int cr, final int cg, final int cb) {
+        final int w = data.image.width;
+        final int h = data.image.height;
+        final int[] pixels = data.image.pixels;
+        final float[] depth = data.image.depth;
+        final double dx = p2x - p1x;
+        final double dy = p2y - p1y;
+        final double len = Math.sqrt(dx * dx + dy * dy);
+        if (len == 0) {
+            return;
+        }
+        final boolean horizontalMajor = Math.abs(dx) > Math.abs(dy);
+        // cosine of the line's angle to the major axis: converts a
+        // minor-axis pixel offset into true perpendicular distance
+        final double majorCos = (horizontalMajor ? Math.abs(dx)
+                : Math.abs(dy)) / len;
+        final double len2 = dx * dx + dy * dy;
+
+        final double majorStart = horizontalMajor ? Math.min(p1x, p2x)
+                : Math.min(p1y, p2y);
+        final double majorEnd = horizontalMajor ? Math.max(p1x, p2x)
+                : Math.max(p1y, p2y);
+        final double maxR = Math.max(r1, r2) / majorCos;
+        final int u0 = Math.max(0, (int) Math.floor(majorStart));
+        final int u1 = Math.min(horizontalMajor ? w - 1 : h - 1,
+                (int) Math.ceil(majorEnd));
+
+        for (int u = u0; u <= u1; u++) {
+            // line parameter at this major coordinate
+            final double t = horizontalMajor
+                    ? (u - p1x) / dx : (u - p1y) / dy;
+            if (t < 0 || t > 1) {
+                continue;
+            }
+            final double centerMinor = horizontalMajor
+                    ? p1y + dy * t : p1x + dx * t;
+            final double r = r1 + (r2 - r1) * t;
+            final double halfMinor = r / majorCos;
+            final int v0 = Math.max(0, (int) Math.floor(centerMinor - halfMinor));
+            final int v1 = Math.min(horizontalMajor ? h - 1 : w - 1,
+                    (int) Math.ceil(centerMinor + halfMinor));
+            for (int v = v0; v <= v1; v++) {
+                final int x = horizontalMajor ? u : v;
+                final int y = horizontalMajor ? v : u;
+                // true perpendicular distance + projectively correct t
+                final double pt = ((x - p1x) * dx + (y - p1y) * dy) / len2;
+                if (pt < 0 || pt > 1) {
+                    continue; // square caps, like legacy
+                }
+                final double d = (v - centerMinor) * majorCos;
+                final double coverage = 1 - Math.abs(d) / r;
+                if (coverage <= 0) {
+                    continue;
+                }
+                final int alpha = (int) (alphaMax * coverage);
+                if (alpha < 1) {
+                    continue;
+                }
+                final int offset = y * w + x;
+                final double zw = zw1 + (zw2 - zw1) * pt;
+                if (zw > depth[offset]) {
+                    blend(pixels, offset, alpha, cr, cg, cb);
+                }
+            }
+        }
+    }
+
+    /** Alpha-blends the line color over the destination pixel; the
+     *  destination alpha byte is preserved. */
+    private static void blend(final int[] pixels, final int offset,
+                              final int alpha,
+                              final int cr, final int cg, final int cb) {
+        final int backgroundAlpha = 255 - alpha;
+        final int dest = pixels[offset];
+        final int destR = (dest >> 16) & 0xFF;
+        final int destG = (dest >> 8) & 0xFF;
+        final int destB = dest & 0xFF;
+        pixels[offset] = (dest & 0xFF000000)
+                | ((((destR * backgroundAlpha) + (cr * alpha)) >> 8) << 16)
+                | ((((destG * backgroundAlpha) + (cg * alpha)) >> 8) << 8)
+                | (((destB * backgroundAlpha) + (cb * alpha)) >> 8);
+    }
+
+    /** World point → camera space, exactly TransformPass's math:
+     *  inverse yaw about +Y, then inverse pitch about +X.
+     *  Returns {x, y, z}. */
+    private static double[] camera(final Substrate data,
+                                   final double wx, final double wy,
+                                   final double wz,
+                                   final double cosY, final double sinY,
+                                   final double cosP, final double sinP) {
+        final double dx = wx - data.camera.x;
+        final double dy = wy - data.camera.y;
+        final double dz = wz - data.camera.z;
+        final double x1 = dx * cosY - dz * sinY;
+        final double z1 = dx * sinY + dz * cosY;
+        return new double[]{x1, dy * cosP - z1 * sinP,
+                dy * sinP + z1 * cosP};
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/RasterPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/RasterPass.java
new file mode 100644 (file)
index 0000000..fd26cf0
--- /dev/null
@@ -0,0 +1,701 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.LightmapStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * Z-buffered rasterizer over the ScreenStore: edge functions, strict depth
+ * test (closer wins). Clipped 4-vertex loops are fan-triangulated
+ * (0,1,2) + (0,2,3).
+ *
+ * FRAME CLEAR: this pass owns it. Each tile task fills its own pixel and
+ * depth band with the substrate background before rasterizing (the legacy
+ * ViewPanel clearSegmentPixels model) — the app loop must NOT clear.
+ *
+ * PARALLELISM: the frame is partitioned into TILE-pixel tiles; each tile
+ * is one task on a fixed worker pool. Pixels (and their depth entries)
+ * are written ONLY by their owning tile, and each pixel sees triangles
+ * in the same order as the single-threaded loop — output is
+ * byte-identical to single-threaded rasterization (pinned by the
+ * oracles and RasterParallelTest). No locks: the partition is static.
+ *
+ * Shading (one designed seam each, all detail behind tables):
+ * - texture: perspective-correct uv (u' = Σ l·(u·w) / Σ l·w), nearest
+ *   sample, clamp;
+ * - lightmap: same perspective-correct interpolation of the frame-corner
+ *   uv carried per vertex through the clip;
+ * - per-triangle GI shade; else flat color.
+ * Multiplied together when both texture and shading apply.
+ *
+ * TRANSLUCENCY (2026-10-13, port of legacy's pass-2 semantics): the
+ * triangle color's alpha byte selects the path. Alpha 255 = opaque:
+ * depth-tested AND depth-written, exactly as before. Alpha 1..254 =
+ * translucent: painted in a SECOND loop over the tile's bin (after all
+ * opaque triangles), alpha-BLENDED over the destination, depth-tested
+ * against the opaque z-buffer (solids occlude translucents), never
+ * depth-WRITTEN (a translucent face must not occlude anything painted
+ *   after it — later opaque geometry overwrites it freely). Alpha 0 is
+ *   skipped. Translucent triangles are sorted per tile back-to-front
+ *   (painter's algorithm on average camera z — the same model legacy
+ *   uses for its pass-2 shapes), so nearer translucents always blend
+ *   over farther ones regardless of emission order. The opaque path is
+ *   untouched, so opaque scenes stay byte-identical (oracles keep
+ *   pinning that).
+ */
+public final class RasterPass implements eu.svjatoslav.aukio.e3d2.kernel.StreamedPass {
+
+    /** Tile edge in pixels. 128 keeps task count near core count
+     *  (5x4 at 640x480) — submit/get overhead dominates below that. */
+    /** Explicit tile override (experiments, oracles). 0 = choose per frame. */
+    private static final int TILE_OVERRIDE = Integer.getInteger("e3d2.tile", 0);
+
+    /** Tiles per worker the frame is cut into. Measured at 4K: 9 tiles per
+     *  worker (192px) beat 5.6 (128px) by 3% — fewer, longer items pay less
+     *  per-item cost, and this CPU has 4 performance + 8 compact cores, so
+     *  16 of 24 threads are slow and every per-item overhead is paid
+     *  as often on them. At 1080p the same 192px left only 2.5 tiles per
+     *  worker and LOST 20%, so the size has to scale with the frame. */
+    private static final int TILES_PER_WORKER = 9;
+
+    /** Square tile edge for this frame, clamped to a sane range. */
+    private static final boolean NOLIGHTMAP = Boolean.getBoolean("e3d2.nolightmap");
+
+    /** A/B hook: force the old per-fragment sampleBilinear entry point. */
+    private static final boolean OLD_SAMPLE = Boolean.getBoolean("e3d2.oldsample");
+
+    /** A/B hook: ignore albedo textures (isolates the texture fetch). */
+    private static final boolean NOTEXTURE = Boolean.getBoolean("e3d2.notexture");
+
+    static int tileFor(final int width, final int height) {
+        if (TILE_OVERRIDE > 0) {
+            return TILE_OVERRIDE;
+        }
+        final int want = TILES_PER_WORKER
+                * Math.max(2, Runtime.getRuntime().availableProcessors());
+        final double edge = Math.sqrt((double) width * height / want);
+        return Math.max(64, Math.min(256, (int) (Math.round(edge / 16.0) * 16)));
+    }
+
+    @Override
+    public int order() {
+        return 30;
+    }
+
+    @Override
+    public String name() {
+        return "raster";
+    }
+
+    @Override
+    public int stage() {
+        return 20;
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        apply(data, eu.svjatoslav.aukio.e3d2.kernel.FrameView.ofDefault(data));
+    }
+
+    // ---- shared helpers (both the inline path and the streamed waves) ----
+
+    /** Appends every triangle of [fromTri, toTri) to the bins it overlaps. */
+    private static void binRange(final eu.svjatoslav.aukio.e3d2.kernel.FrameView f,
+                                 final int fromTri, final int toTri,
+                                 final int[][] bins, final int[] lens,
+                                 final int cols, final int rows,
+                                 final int tileSize) {
+        for (int tri = fromTri; tri < toTri; tri++) {
+            if (f.screen.isCulled(tri)) {
+                continue;
+            }
+            double bx0 = Double.POSITIVE_INFINITY, by0 = bx0;
+            double bx1 = Double.NEGATIVE_INFINITY, by1 = bx1;
+            final int count = f.screen.count(tri);
+            for (int v = 0; v < count; v++) {
+                bx0 = Math.min(bx0, f.screen.at(tri, v, 0));
+                by0 = Math.min(by0, f.screen.at(tri, v, 1));
+                bx1 = Math.max(bx1, f.screen.at(tri, v, 0));
+                by1 = Math.max(by1, f.screen.at(tri, v, 1));
+            }
+            final int tx0 = clamp((int) Math.floor(bx0) / tileSize, 0, cols - 1);
+            final int tx1 = clamp((int) Math.floor(bx1) / tileSize, 0, cols - 1);
+            final int ty0 = clamp((int) Math.floor(by0) / tileSize, 0, rows - 1);
+            final int ty1 = clamp((int) Math.floor(by1) / tileSize, 0, rows - 1);
+            for (int ty = ty0; ty <= ty1; ty++) {
+                for (int tx = tx0; tx <= tx1; tx++) {
+                    final int tile = ty * cols + tx;
+                    int[] bin = bins[tile];
+                    if (lens[tile] == bin.length) {
+                        bin = java.util.Arrays.copyOf(bin, bin.length * 2);
+                        bins[tile] = bin;
+                    }
+                    bin[lens[tile]++] = tri;
+                }
+            }
+        }
+    }
+
+    /** Concatenates partial bins for tiles [fromTile, toTile). */
+    private static void mergeTiles(final int[][] bins, final int[] binLen,
+                                   final int[][] part, final int[] plen,
+                                   final int fromTile, final int toTile) {
+        for (int tile = fromTile; tile < toTile; tile++) {
+            if (plen[tile] == 0) {
+                continue;
+            }
+            final int[] source = part[tile];
+            if (bins[tile] == null) {
+                bins[tile] = java.util.Arrays.copyOf(source, Math.max(8, plen[tile]));
+            } else if (binLen[tile] + plen[tile] > bins[tile].length) {
+                bins[tile] = java.util.Arrays.copyOf(bins[tile], binLen[tile] + plen[tile]);
+            }
+            System.arraycopy(source, 0, bins[tile], binLen[tile], plen[tile]);
+            binLen[tile] += plen[tile];
+        }
+    }
+
+    // NOTE (measured): handing out the heaviest tile first (LPT) made the
+    // 4K frame 12% SLOWER (5.45 -> 6.1 ms) — row-major tile order keeps
+    // the pixel/depth/lightmap working set cache-local, which outweighs
+    // balancing the tail. Do not "improve" this without measuring.
+
+    /** Clears one tile, then rasterizes its bin. */
+    private static void paintTile(final Substrate data,
+                                  final eu.svjatoslav.aukio.e3d2.kernel.FrameView f,
+                                  final int[][] bins, final int[] binLen,
+                                  final int tileIdx, final int cols,
+                                  final int tileSize,
+                                  final int width, final int height,
+                                  final int background) {
+        final int ty = tileIdx / cols;
+        final int tx = tileIdx % cols;
+        final int x0 = tx * tileSize;
+        final int y0 = ty * tileSize;
+        final int x1 = Math.min(x0 + tileSize - 1, width - 1);
+        final int y1 = Math.min(y0 + tileSize - 1, height - 1);
+        final int[] px = f.image.pixels;
+        final float[] dp = f.image.depth;
+        for (int row = y0; row <= y1; row++) {
+            final int base = row * width;
+            java.util.Arrays.fill(px, base + x0, base + x1 + 1, background);
+            java.util.Arrays.fill(dp, base + x0, base + x1 + 1, 0f);
+        }
+        if (binLen[tileIdx] == 0
+                || (data.debug.alternateSegments && ((tx + ty) % 2 == 1))) {
+            return;
+        }
+        rasterizeTile(data, f, bins[tileIdx], binLen[tileIdx], x0, x1, y0, y1);
+    }
+
+    /** Per-frame bins held across the streamed waves (tile -> triangles). */
+    private static final class Bins {
+        final int cols, rows, tileCount, binWorkers, tileSize;
+        final int[][][] partial;
+        final int[][] partialLen;
+        final int[][] bins;
+        final int[] binLen;
+
+        Bins(final int cols, final int rows, final int n, final int tileSize) {
+            this.cols = cols;
+            this.rows = rows;
+            this.tileSize = tileSize;
+            this.tileCount = cols * rows;
+            this.binWorkers = Math.min(n, Math.max(2,
+                    Runtime.getRuntime().availableProcessors()) * 2);
+            this.partial = new int[binWorkers][][];
+            this.partialLen = new int[binWorkers][tileCount];
+            this.bins = new int[tileCount][];
+            this.binLen = new int[tileCount];
+        }
+    }
+
+    // ---- streamed frame: wave 0 bins per triangle range, wave 1 merges
+    // per tile range, wave 2 paints tiles by ticket. No joins anywhere. ----
+
+    @Override
+    public void beginFrame(final Substrate data,
+                           final eu.svjatoslav.aukio.e3d2.kernel.FrameView f) {
+        final int n = data.triangles.triangleCount();
+        final int tile = tileFor(f.image.width, f.image.height);
+        final int cols = (f.image.width + tile - 1) / tile;
+        final int rows = (f.image.height + tile - 1) / tile;
+        f.scratch[0] = new Bins(cols, rows, n, tile);
+    }
+
+    @Override
+    public int waveCount() {
+        return 3;
+    }
+
+    @Override
+    public int itemCount(final int wave, final Substrate data,
+                         final eu.svjatoslav.aukio.e3d2.kernel.FrameView f) {
+        final Bins b = (Bins) f.scratch[0];
+        if (data.triangles.triangleCount() == 0) {
+            return 0;
+        }
+        return switch (wave) {
+            case 0 -> b.binWorkers;
+            case 1 -> Math.min(b.tileCount,
+                    eu.svjatoslav.aukio.e3d2.kernel.Parallel.poolSize());
+            default -> b.tileCount;
+        };
+    }
+
+    @Override
+    public boolean ticketWave(final int wave) {
+        return true;
+    }
+
+    @Override
+    public void runItem(final int wave, final int item, final Substrate data,
+                        final eu.svjatoslav.aukio.e3d2.kernel.FrameView f) {
+        final Bins b = (Bins) f.scratch[0];
+        final int n = data.triangles.triangleCount();
+        switch (wave) {
+            case 0 -> {
+                final int[][] bins = new int[b.tileCount][];
+                for (int t = 0; t < b.tileCount; t++) {
+                    bins[t] = new int[8];
+                }
+                final int from = (int) ((long) item * n / b.binWorkers);
+                final int to = (int) ((long) (item + 1) * n / b.binWorkers);
+                binRange(f, from, to, bins, b.partialLen[item], b.cols, b.rows,
+                        b.tileSize);
+                b.partial[item] = bins;
+            }
+            case 1 -> {
+                final int from = (int) ((long) item * b.tileCount / Math.min(
+                        b.tileCount, eu.svjatoslav.aukio.e3d2.kernel.Parallel.poolSize()));
+                final int to = (int) ((long) (item + 1) * b.tileCount / Math.min(
+                        b.tileCount, eu.svjatoslav.aukio.e3d2.kernel.Parallel.poolSize()));
+                for (int p = 0; p < b.binWorkers; p++) {
+                    final int[][] part = b.partial[p];
+                    if (part == null) {
+                        continue;
+                    }
+                    mergeTiles(b.bins, b.binLen, part, b.partialLen[p], from, to);
+                }
+            }
+            default -> paintTile(data, f, b.bins, b.binLen, item, b.cols,
+                    b.tileSize, f.image.width, f.image.height, f.image.background);
+        }
+    }
+
+    @Override
+    public void apply(final Substrate data,
+                      final eu.svjatoslav.aukio.e3d2.kernel.FrameView f) {
+        final int width = f.image.width;
+        final int height = f.image.height;
+        final int tile = tileFor(width, height);
+        final int cols = (width + tile - 1) / tile;
+        final int rows = (height + tile - 1) / tile;
+        final int n = data.triangles.triangleCount();
+        if (n == 0) {
+            return;
+        }
+
+        // Phase 1 (parallel): per-range partial bins, then merge in
+        // range order — concatenation preserves globally ascending
+        // triangle order per tile, so per-pixel order matches the
+        // single-threaded loop exactly (byte-identical output).
+        final int tileCount = cols * rows;
+        final int binWorkers = Math.min(n,
+                Math.max(2, Runtime.getRuntime().availableProcessors()) * 2);
+        final int step = (n + binWorkers - 1) / binWorkers;
+        final java.util.List<java.util.concurrent.Future<int[][]>> partials =
+                new java.util.ArrayList<>(binWorkers);
+        final java.util.List<int[]> partialLens = new java.util.ArrayList<>(binWorkers);
+        for (int base = 0; base < n; base += step) {
+            final int fromTri = base;
+            final int toTri = Math.min(base + step, n);
+            final int[] lens = new int[tileCount];
+            partialLens.add(lens);
+            partials.add(eu.svjatoslav.aukio.e3d2.kernel.Parallel.submit(() -> {
+                final int[][] bins = new int[tileCount][];
+                for (int t = 0; t < tileCount; t++) {
+                    bins[t] = new int[8];
+                }
+                binRange(f, fromTri, toTri, bins, lens, cols, rows, tile);
+                return bins;
+            }));
+        }
+
+        // merge partials in submission (ascending-triangle) order
+        final int[][] bins = new int[tileCount][];
+        final int[] binLen = new int[tileCount];
+        try {
+            for (int p = 0; p < partials.size(); p++) {
+                final int[][] part = partials.get(p).get();
+                final int[] plen = partialLens.get(p);
+                mergeTiles(bins, binLen, part, plen, 0, tileCount);
+            }
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("binning interrupted", e);
+        } catch (final java.util.concurrent.ExecutionException e) {
+            throw new IllegalStateException("binning worker failed", e.getCause());
+        }
+
+        // Phase 2: TICKET-BASED tile loop. One task per WORKER (not per
+        // tile): each worker pulls the next tile index off a shared
+        // counter until the frame is done. A Future per tile cost ~1020
+        // task handoffs per 4K frame and showed up as pool idle time
+        // (utilisation 73% vs 55% at 64px tiles — pure handoff cost).
+        // Pixels are unaffected: a tile is still rasterized by exactly one
+        // thread, in fixed (Z, shapeId) order, so output is bit-identical.
+        final int background = data.image.background;
+        final java.util.concurrent.atomic.AtomicInteger ticket =
+                new java.util.concurrent.atomic.AtomicInteger();
+        final int workers = Math.min(tileCount,
+                eu.svjatoslav.aukio.e3d2.kernel.Parallel.poolSize());
+        final java.util.List<java.util.concurrent.Future<?>> futures =
+                new java.util.ArrayList<>(workers);
+        for (int wkr = 0; wkr < workers; wkr++) {
+            futures.add(eu.svjatoslav.aukio.e3d2.kernel.Parallel.submit(() -> {
+                int tileIdx;
+                while ((tileIdx = ticket.getAndIncrement()) < tileCount) {
+                    paintTile(data, f, bins, binLen, tileIdx, cols, tile, width, height,
+                            background);
+                }
+            }));
+        }
+        eu.svjatoslav.aukio.e3d2.kernel.Parallel.joinAll(futures);
+
+        // legacy DeveloperTools.showSegmentBoundaries: red 1px lines at
+        // tile borders, drawn over the finished frame
+        if (data.debug.showBoundaries) {
+            drawBoundaries(f.image.pixels, width, height, tile);
+        }
+    }
+
+    /** Red tile-boundary overlay (legacy ViewPanel semantics: pure red,
+     *  horizontal rows and vertical columns at every TILE step). */
+    private static void drawBoundaries(final int[] pixels,
+                                       final int width, final int height,
+                                       final int tileSize) {
+        final int red = 0xFFFF0000;
+        for (int y = tileSize; y < height; y += tileSize) {
+            java.util.Arrays.fill(pixels, y * width, y * width + width, red);
+        }
+        for (int x = tileSize; x < width; x += tileSize) {
+            for (int y = 0; y < height; y++) {
+                pixels[y * width + x] = red;
+            }
+        }
+    }
+
+    /** Rasterize the binned triangles into one tile. Pixels outside
+     *  [x0,x1]x[y0,y1] are never touched. Opaque triangles first
+     *  (depth-written), translucent second (blended, depth-tested
+     *  only) — legacy's pass-1/pass-2 model, local to the tile so the
+     *  static partition still needs no locks. */
+    private static void rasterizeTile(final Substrate data,
+                                      final eu.svjatoslav.aukio.e3d2.kernel.FrameView f,
+                                      final int[] bin, final int binLen,
+                                      final int x0, final int x1,
+                                      final int y0, final int y1) {
+        for (int b = 0; b < binLen; b++) {
+            final int tri = bin[b];
+            if (((data.triangles.color(tri) >>> 24) & 0xFF) != 0xFF) {
+                continue;
+            }
+            final int count = f.screen.count(tri);
+            rasterizeSubTriangle(data, f, tri, 0, 1, 2, x0, x1, y0, y1, false);
+            if (count == 4) {
+                rasterizeSubTriangle(data, f, tri, 0, 2, 3, x0, x1, y0, y1, false);
+            }
+        }
+        // translucent loop: painter's algorithm, far to near
+        final int[] transTris = new int[binLen];
+        final double[] transKeys = new double[binLen];
+        final int transCount = sortTranslucentFarToNear(data, f, bin, binLen,
+                transTris, transKeys);
+        for (int b = 0; b < transCount; b++) {
+            final int tri = transTris[b];
+            final int count = f.screen.count(tri);
+            rasterizeSubTriangle(data, f, tri, 0, 1, 2, x0, x1, y0, y1, true);
+            if (count == 4) {
+                rasterizeSubTriangle(data, f, tri, 0, 2, 3, x0, x1, y0, y1, true);
+            }
+        }
+    }
+
+    /** Painter's algorithm for the translucent loop (2026-10-13, user
+     *  bugreport: blue Z arrow blended over the nearer red X arrow —
+     *  bin order is emission order, not depth order; legacy sorts
+     *  pass-2 shapes back-to-front, so do the same). Sort key: average
+     *  camera-space z of the first 3 vertices, far first. Per-tile
+     *  sort is sufficient: each pixel is owned by exactly one tile, so
+     *  per-pixel triangle order is globally consistent.
+     *  Fills tris[0..n) sorted; returns n. */
+    private static int sortTranslucentFarToNear(final Substrate data,
+                                                final eu.svjatoslav.aukio.e3d2.kernel.FrameView f,
+                                                final int[] bin,
+                                                final int binLen,
+                                                final int[] tris,
+                                                final double[] keys) {
+        int n = 0;
+        for (int b = 0; b < binLen; b++) {
+            final int tri = bin[b];
+            final int alpha = (data.triangles.color(tri) >>> 24) & 0xFF;
+            if (alpha == 0xFF || alpha == 0) {
+                continue;
+            }
+            // w = 1/z per vertex; average z = average of 1/w
+            final double z = 1.0 / f.screen.at(tri, 0, 2)
+                    + 1.0 / f.screen.at(tri, 1, 2)
+                    + 1.0 / f.screen.at(tri, 2, 2);
+            // insertion sort, far (larger z) first; translucent counts
+            // are small per tile
+            int i = n++;
+            while (i > 0 && keys[i - 1] < z) {
+                keys[i] = keys[i - 1];
+                tris[i] = tris[i - 1];
+                i--;
+            }
+            keys[i] = z;
+            tris[i] = tri;
+        }
+        return n;
+    }
+
+    /** Rasterizes one (possibly clipped) sub-triangle inside a tile.
+     *
+     *  <p>SPAN-INCREMENTAL (2026-10-21, bugreport-20261021-210017): the
+     *  barycentric weights, the depth and the texture/lightmap numerator
+     *  terms are evaluated exactly once per scanline at the first in-tile
+     *  pixel and then STEPPED by a constant per pixel. Before this every
+     *  fragment re-evaluated both edge functions, divided twice by the
+     *  triangle area, and re-read four (attribute × w) products out of the
+     *  screen table — measured 23.4 ms/frame of raster at 3840x2091 versus
+     *  legacy Aukio 3D's 5.4 ms for the same HouseDemo view. Row starts
+     *  use the same closed-form expressions as before; the step only
+     *  accumulates rounding (~1e-15 over a 128-pixel tile span), so a
+     *  handful of exact-tie pixels may resolve the other way and nothing
+     *  else changes — same coverage epsilon, same strict depth test, same
+     *  samplers.</p> */
+    private static void rasterizeSubTriangle(final Substrate data,
+                                             final eu.svjatoslav.aukio.e3d2.kernel.FrameView f,
+                                             final int tri,
+                                             final int v0, final int v1, final int v2,
+                                             final int tx0, final int tx1,
+                                             final int ty0, final int ty1,
+                                             final boolean translucent) {
+        final int width = f.image.width;
+        final int height = f.image.height;
+        final int[] pixels = f.image.pixels;
+        final float[] depth = f.image.depth;
+
+        final double x0 = f.screen.at(tri, v0, 0), y0 = f.screen.at(tri, v0, 1);
+        final double x1 = f.screen.at(tri, v1, 0), y1 = f.screen.at(tri, v1, 1);
+        final double x2 = f.screen.at(tri, v2, 0), y2 = f.screen.at(tri, v2, 1);
+        final double w0 = f.screen.at(tri, v0, 2);
+        final double w1 = f.screen.at(tri, v1, 2);
+        final double w2 = f.screen.at(tri, v2, 2);
+        final int texId = data.triangles.textureId(tri);
+        final int baseColor = data.triangles.color(tri);
+        final LightmapStore lm = f.lightmaps;
+        // ablation hook: -De3d2.nolightmap=true renders unlit (measures how
+        // much of the fragment loop the lightmap fetch still costs)
+        final boolean useMaps = !NOLIGHTMAP && lm != null && lm.hasDims(tri);
+        // Bound once per triangle: the sampler caches its texel cell, which
+        // removes 3 array reads and 4 texel fetches per fragment (~8 px per
+        // texel at 4K). Value-identical to lm.sampleBilinear.
+        final LightmapStore.Sampler lmSampler = useMaps ? lm.newSampler() : null;
+        if (lmSampler != null) {
+            lmSampler.bind(tri);
+        }
+
+        final double den = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2);
+        if (Math.abs(den) < 1e-9) {
+            return;   // degenerate: no area
+        }
+
+        // triangle bbox clipped to the tile: pixels outside the tile
+        // are never considered, so no other task can own them
+        final int minX = clamp((int) Math.floor(Math.min(x0, Math.min(x1, x2))), tx0, tx1);
+        final int maxX = clamp((int) Math.ceil(Math.max(x0, Math.max(x1, x2))), tx0, tx1);
+        final int minY = clamp((int) Math.floor(Math.min(y0, Math.min(y1, y2))), ty0, ty1);
+        final int maxY = clamp((int) Math.ceil(Math.max(y0, Math.max(y1, y2))), ty0, ty1);
+        if (minX > maxX || minY > maxY) {
+            return;   // triangle misses the tile
+        }
+
+        // --- per-triangle constants -------------------------------
+        // barycentric weights are affine in screen space:
+        //   l0 = (e0x*(x-x2) + e0y*(y-y2)) / den,  l1 likewise.
+        final double invDen = 1.0 / den;
+        final double e0x = y1 - y2, e0y = x2 - x1;
+        final double e1x = y2 - y0, e1y = x0 - x2;
+        final double dl0dx = e0x * invDen;
+        final double dl1dx = e1x * invDen;
+        // depth and attribute numerators follow from l0/l1 linearly
+        final double dwdx = dl0dx * (w0 - w2) + dl1dx * (w1 - w2);
+        // DISPLAY COMPOSITE: an opaque, lightmapped triangle samples ONE
+        // precomposited texel (light x albedo) — base color and texture are
+        // already in it, so both per-fragment multiplications disappear with
+        // the extra taps (raster 17.7 -> 7.1 ms at 4K; see LightmapStore).
+        final boolean composite = useMaps && lm.compositeReady(tri);
+        final boolean needTex = texId >= 0 && !composite && !NOTEXTURE;
+        final boolean needUv = needTex || useMaps;
+        // (attribute × w) per vertex — the perspective numerator terms
+        final double p0u = needUv && needTex ? f.screen.at(tri, v0, 3) * w0 : 0;
+        final double p1u = needUv && needTex ? f.screen.at(tri, v1, 3) * w1 : 0;
+        final double p2u = needUv && needTex ? f.screen.at(tri, v2, 3) * w2 : 0;
+        final double p0v = needUv && needTex ? f.screen.at(tri, v0, 4) * w0 : 0;
+        final double p1v = needUv && needTex ? f.screen.at(tri, v1, 4) * w1 : 0;
+        final double p2v = needUv && needTex ? f.screen.at(tri, v2, 4) * w2 : 0;
+        final double p0m = needUv && useMaps ? f.screen.at(tri, v0, 5) * w0 : 0;
+        final double p1m = needUv && useMaps ? f.screen.at(tri, v1, 5) * w1 : 0;
+        final double p2m = needUv && useMaps ? f.screen.at(tri, v2, 5) * w2 : 0;
+        final double p0n = needUv && useMaps ? f.screen.at(tri, v0, 6) * w0 : 0;
+        final double p1n = needUv && useMaps ? f.screen.at(tri, v1, 6) * w1 : 0;
+        final double p2n = needUv && useMaps ? f.screen.at(tri, v2, 6) * w2 : 0;
+        final double dsudx = dl0dx * (p0u - p2u) + dl1dx * (p1u - p2u);
+        final double dsvdx = dl0dx * (p0v - p2v) + dl1dx * (p1v - p2v);
+        final double dsmdx = dl0dx * (p0m - p2m) + dl1dx * (p1m - p2m);
+        final double dsndx = dl0dx * (p0n - p2n) + dl1dx * (p1n - p2n);
+        final int rowOffset = minY * width;
+
+        for (int py = minY; py <= maxY; py++) {
+            final double cy = py + 0.5;
+            // SPAN WALK: the triangle is convex, so on this scanline the
+            // covered x range is one interval. Solve the three linear
+            // barycentric inequalities for it (3 divisions per ROW instead
+            // of 3 compares per pixel), then walk only covered pixels.
+            // xa/xb are EDGE coordinates (a pixel p covers [p, p+1)), while
+            // minX/maxX are pixel INDICES. Starting xb at maxX clipped the
+            // tile's last column: floor(maxX - 0.5) == maxX - 1, so every
+            // tile left its right-most pixel at the clear colour — the
+            // 1-px dark stripes at 192px spacing in bugreport-20261022-085130.
+            double xa = minX;
+            double xb = maxX + 1.0;
+            // c0(x) = l0 = (e0x*(x-x2) + e0y*(cy-y2)) * invDen >= -eps
+            final double b0 = (e0y * (cy - y2)) * invDen;
+            final double b1 = (e1y * (cy - y2)) * invDen;
+            final double k0 = e0x * invDen;
+            final double k1 = e1x * invDen;
+            // l0: k0*x + (b0 - k0*x2) >= -eps
+            final double c00 = b0 - k0 * x2 + 1e-9;
+            final double c1 = b1 - k1 * x2 + 1e-9;
+            // l2 = 1 - l0 - l1 = -(k0+k1)*x + 1 - c00 - c1 + 2e-9 - eps
+            final double ks = k0 + k1;
+            final double cs = 1.0 - c00 - c1 + 1e-9;
+            if (Math.abs(k0) > 1e-12) {
+                final double e = -c00 / k0;
+                if (k0 > 0) { if (e > xa) xa = e; } else { if (e < xb) xb = e; }
+            } else if (c00 < 0) {
+                continue;
+            }
+            if (Math.abs(k1) > 1e-12) {
+                final double e = -c1 / k1;
+                if (k1 > 0) { if (e > xa) xa = e; } else { if (e < xb) xb = e; }
+            } else if (c1 < 0) {
+                continue;
+            }
+            if (Math.abs(ks) > 1e-12) {
+                final double e = cs / ks;
+                if (ks > 0) { if (e < xb) xb = e; } else { if (e > xa) xa = e; }
+            } else if (cs < 0) {
+                continue;
+            }
+            // pixel px is covered when its CENTRE (px+0.5) is inside
+            final int rowMin = Math.max(minX, (int) Math.ceil(xa - 0.5));
+            final int rowMax = Math.min(maxX, (int) Math.floor(xb - 0.5));
+            if (rowMin > rowMax) {
+                continue;
+            }
+            // exact closed-form weights at the row's first COVERED pixel
+            double l0 = (e0x * (rowMin + 0.5 - x2) + e0y * (cy - y2)) * invDen;
+            double l1 = (e1x * (rowMin + 0.5 - x2) + e1y * (cy - y2)) * invDen;
+            double w = l0 * w0 + l1 * w1 + (1.0 - l0 - l1) * w2;
+            double su = l0 * p0u + l1 * p1u + (1.0 - l0 - l1) * p2u;
+            double sv = l0 * p0v + l1 * p1v + (1.0 - l0 - l1) * p2v;
+            double sm = l0 * p0m + l1 * p1m + (1.0 - l0 - l1) * p2m;
+            double sn = l0 * p0n + l1 * p1n + (1.0 - l0 - l1) * p2n;
+            int i = rowOffset + (py - minY) * width + rowMin;
+
+            for (int px = rowMin; px <= rowMax; px++, i++) {
+                if (w > depth[i]) {
+                    double texU = 0, texV = 0, lmU = 0, lmV = 0;
+                    if (needUv) {
+                        final double invW = 1.0 / w;
+                        if (needTex) {
+                            texU = su * invW;
+                            texV = sv * invW;
+                        }
+                        if (useMaps) {
+                            lmU = sm * invW;
+                            lmV = sn * invW;
+                        }
+                    }
+
+                    int color;
+                    if (composite) {
+                        // ONE tap, no base/texture multiply, no shade pass
+                        color = lm.compositeAt(tri, lmU, lmV);
+                    } else {
+                        color = baseColor;
+                        if (texId >= 0 && !NOTEXTURE) {
+                            final int texel = data.textures.sample(texId, texU, texV);
+                            // multiply base color by texel (per channel /255)
+                            final int r = (((color >> 16) & 0xFF) * ((texel >> 16) & 0xFF)) / 255;
+                            final int g = (((color >> 8) & 0xFF) * ((texel >> 8) & 0xFF)) / 255;
+                            final int b = ((color & 0xFF) * (texel & 0xFF)) / 255;
+                            color = (color & 0xFF000000) | (r << 16) | (g << 8) | b;
+                        }
+                    }
+                    if (!composite && useMaps) {
+                        final int packed = (lmSampler != null && !OLD_SAMPLE)
+                            ? lmSampler.bilinear(lmU, lmV)
+                            : lm.sampleBilinear(tri, lmU, lmV);
+                        color = shadeColor(color, (packed >> 16) & 0xFF,
+                                (packed >> 8) & 0xFF, packed & 0xFF);
+                    } else if (!composite && data.giLight != null) {
+                        color = shadeColor(color, data.giLight.shadeR(tri),
+                                data.giLight.shadeG(tri), data.giLight.shadeB(tri));
+                    }
+                    if (translucent) {
+                        // blend over destination; depth-TESTED above,
+                        // never depth-WRITTEN (legacy pass-2)
+                        final int alpha = (baseColor >>> 24) & 0xFF;
+                        final int backgroundAlpha = 255 - alpha;
+                        final int dest = pixels[i];
+                        pixels[i] = (dest & 0xFF000000)
+                                | (((((dest >> 16) & 0xFF) * backgroundAlpha
+                                + ((color >> 16) & 0xFF) * alpha) >> 8) << 16)
+                                | (((((dest >> 8) & 0xFF) * backgroundAlpha
+                                + ((color >> 8) & 0xFF) * alpha) >> 8) << 8)
+                                | ((((dest & 0xFF) * backgroundAlpha
+                                + (color & 0xFF) * alpha) >> 8));
+                    } else {
+                        depth[i] = (float) w;
+                        pixels[i] = color;
+                    }
+                }
+                w += dwdx;
+                if (needUv) {
+                    su += dsudx;
+                    sv += dsvdx;
+                    sm += dsmdx;
+                    sn += dsndx;
+                }
+            }
+        }
+    }
+
+    private static int clamp(final int v, final int lo, final int hi) {
+        return v < lo ? lo : Math.min(v, hi);
+    }
+
+    /** The raster's light seam: color multiplied by the baked shade
+     *  (255 = full light). */
+    private static int shadeColor(final int argb, final int sr, final int sg, final int sb) {
+        final int r = (((argb >> 16) & 0xFF) * sr) / 255;
+        final int g = (((argb >> 8) & 0xFF) * sg) / 255;
+        final int b = ((argb & 0xFF) * sb) / 255;
+        return (argb & 0xFF000000) | (r << 16) | (g << 8) | b;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/SpaceMousePass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/SpaceMousePass.java
new file mode 100644 (file)
index 0000000..de8659c
--- /dev/null
@@ -0,0 +1,154 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * 3Dconnexion SpaceMouse drive: reads the 6DOF cap channel of
+ * InputStore (written by app/SpaceMouseManager — device events are
+ * data), writes CameraStore directly.
+ *
+ * Ported from legacy SpaceMouseController (aukio-3d), signs and rates
+ * live-verified there against the real device:
+ *
+ * - push/pull/slide the cap -> DIRECT proportional drive: deflection
+ *   maps to velocity (full deflection = TRANSLATION_SPEED_FACTOR x
+ *   camera top speed), applied to the position every frame. No
+ *   acceleration integrator: releasing the cap stops the camera this
+ *   very frame. Forward/strafe follow the VIEW direction (pitch
+ *   included, legacy matrix columns), vertical is world-up.
+ * - twist/tilt the cap -> yaw/pitch deltas on the current pose, so
+ *   cap rotation composes with mouse drag. Twist is tuned 2x as
+ *   sensitive as tilt (3.0 vs 1.5 degrees per frame at full
+ *   deflection, 60 fps baseline).
+ * - cap roll is measured but deliberately not applied (horizon stays
+ *   level, same as legacy).
+ * - left cap button = brake: zeroes the KEYBOARD/wheel velocity.
+ *   That velocity lives in CameraPass, so the brake edge is detected
+ *   there (one line of capButtons state reads) — this pass owns only
+ *   the direct drive.
+ *
+ * Sign port note (the axis-convention trap): legacy camera was
+ * "+yaw turns LEFT"; PoC CameraStore is "+yaw turns RIGHT" (and both
+ * are +pitch up). So the legacy YAW_SIGN of -1 flips to +1 here while
+ * PITCH_SIGN stays +1: twist clockwise -> turn right, tilt top away
+ * -> look up, identical to legacy feel.
+ *
+ * Deterministic: tick(data, dt) is the testable core; apply() clocks
+ * nanotime like CameraPass.
+ */
+public final class SpaceMousePass implements Pass {
+
+    /** Cap speed at full deflection as a multiple of the keyboard top
+     *  speed. Legacy TRANSLATION_SPEED_FACTOR = 5.0 (the doc page's
+     *  3.0 is stale — the code constant, raised 2026-09-10 because
+     *  "still too slow", is authoritative). */
+    private static final double TRANSLATION_SPEED_FACTOR = 5.0;
+
+    /** Camera top speed in u/s: legacy SPEED_LIMIT 30 x
+     *  SPEED_MULTIPLIER 0.02 per ms = 0.6 u/ms = 600 u/s. */
+    private static final double TOP_SPEED = 600.0;
+
+    /** Full-deflection rotation rates (legacy degrees per frame at 60
+     *  fps -> radians per second). */
+    private static final double YAW_RATE = Math.toRadians(3.0 * 60);
+    private static final double PITCH_RATE = Math.toRadians(1.5 * 60);
+
+    /** Raw counts below this are "hands off" (legacy DEADBAND). */
+    private static final int DEADBAND = 1;
+
+    /** Full deflection of any axis, per the HID descriptor. */
+    private static final double FULL_DEFLECTION = 350.0;
+
+    /** Same clamp as CameraPass. */
+    private static final double PITCH_LIMIT = 1.5;
+
+    private static final double MAX_DT = 0.05;
+
+    /** Legacy sensitivity default 5.0; -De3d2.spacemouse.sensitivity
+     *  overrides (PoC config = system properties, slice-4 decision). */
+    private final double sensitivity = Double.parseDouble(
+            System.getProperty("e3d2.spacemouse.sensitivity", "5.0"));
+
+    private long lastNanos = -1;
+
+    @Override
+    public int order() {
+        return 7; // before CameraPass (8)
+    }
+
+    @Override
+    public String name() {
+        return "spacemouse";
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        final long now = System.nanoTime();
+        double dt = lastNanos < 0 ? 0.0 : (now - lastNanos) / 1e9;
+        lastNanos = now;
+        tick(data, Math.min(dt, MAX_DT));
+    }
+
+    /** One cap-drive step with explicit dt — the testable core. */
+    public void tick(final Substrate data, final double dt) {
+        if (dt <= 0) {
+            return;
+        }
+        // Hardware sign conventions (legacy SpaceNavigatorHid, both
+        // models identical): TX push right +, TY pull toward you +,
+        // RX tilt top away +, RZ twist clockwise +. TZ: pull up reads
+        // NEGATIVE (probe-verified 2026-10-13: pull-up = -419, press
+        // down = +444 — the legacy javadoc's "pull up +" was wrong).
+        final double strafe = shaped(data.input.capTx());        // push right -> strafe right
+        final double forward = shaped(data.input.capTy()) * -1;  // push away -> forward
+        // VERTICAL: pull cap up must INCREASE y (PoC is +Y-up,
+        // TransformPass: sy = cy - (y/z)*scale; legacy aukio-3d was a
+        // -Y-up engine where the same raw axis with VERTICAL_SIGN +1
+        // felt correct). Pull-up reads negative tz, so the sign here
+        // is -1: porting legacy's +1 verbatim inverted the feel.
+        final double vertical = shaped(data.input.capTz()) * -1; // pull up -> +y up
+        if (strafe != 0 || forward != 0 || vertical != 0) {
+            final double sy = Math.sin(data.camera.yaw);
+            final double cy = Math.cos(data.camera.yaw);
+            final double sp = Math.sin(data.camera.pitch);
+            final double cp = Math.cos(data.camera.pitch);
+            // View basis (legacy rotation-matrix columns):
+            // forward f = (sy*cp, sp, cy*cp), strafe-right s = (cy, 0, -sy)
+            final double step = TRANSLATION_SPEED_FACTOR * TOP_SPEED
+                    * sensitivity * dt;
+            data.camera.x += (sy * cp * forward + cy * strafe) * step;
+            data.camera.y += sp * forward * step;
+            data.camera.z += (cy * cp * forward - sy * strafe) * step;
+            data.camera.y += vertical * step; // vertical is world-up
+        }
+
+        final double yawDelta = shaped(data.input.capRz());      // twist cw -> turn right (+yaw)
+        final double pitchDelta = shaped(data.input.capRx());    // tilt top away -> look up
+        if (yawDelta != 0 || pitchDelta != 0) {
+            data.camera.yaw += yawDelta * YAW_RATE * sensitivity * dt;
+            data.camera.pitch = clamp(
+                    data.camera.pitch + pitchDelta * PITCH_RATE
+                            * sensitivity * dt,
+                    -PITCH_LIMIT, PITCH_LIMIT);
+        }
+    }
+
+    /** Deadband + quadratic response: raw axis (-350..350) -> -1..1.
+     *  Quadratic keeps fine control near center, full speed at the rim
+     *  (legacy shaped()). */
+    static double shaped(final int raw) {
+        final int magnitude = Math.abs(raw);
+        if (magnitude <= DEADBAND) {
+            return 0;
+        }
+        final double v = (magnitude - DEADBAND)
+                / (FULL_DEFLECTION - DEADBAND);
+        return Math.copySign(v * v, raw);
+    }
+
+    private static double clamp(final double v, final double lo,
+                                final double hi) {
+        return v < lo ? lo : Math.min(v, hi);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/StereoMergePass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/StereoMergePass.java
new file mode 100644 (file)
index 0000000..846053f
--- /dev/null
@@ -0,0 +1,56 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * Stereo merge: composites the two per-eye images (imageLeft,
+ * imageRight — rendered by the app's stereo rig, which runs the normal
+ * mono pipeline twice with eye-offset cameras) into the output image.
+ *
+ * ANAGLYPH: left eye -> red channel, right eye -> green+blue (the
+ * red-cyan glass check). SIDE_BY_SIDE: left half / right half.
+ */
+public final class StereoMergePass implements Pass {
+
+    public enum Mode { ANAGLYPH, SIDE_BY_SIDE }
+
+    private final Mode mode;
+
+    public StereoMergePass(final Mode mode) {
+        this.mode = mode;
+    }
+
+    @Override
+    public int order() {
+        return 40;
+    }
+
+    @Override
+    public String name() {
+        return "stereo-merge";
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        final int w = data.image.width, h = data.image.height;
+        final int[] out = data.image.pixels;
+        final int[] l = data.imageLeft.pixels;
+        final int[] r = data.imageRight.pixels;
+        if (mode == Mode.ANAGLYPH) {
+            for (int i = 0; i < w * h; i++) {
+                out[i] = 0xFF000000 | (l[i] & 0x00FF0000) | (r[i] & 0x0000FFFF);
+            }
+        } else {
+            final int half = w / 2;
+            for (int y = 0; y < h; y++) {
+                for (int x = 0; x < half; x++) {
+                    // sample each eye's full width into its half
+                    final int lx = x * 2, rx = x * 2;
+                    out[y * w + x] = l[y * w + lx];
+                    out[y * w + half + x] = r[y * w + rx];
+                }
+            }
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/TransformPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/TransformPass.java
new file mode 100644 (file)
index 0000000..9b9665d
--- /dev/null
@@ -0,0 +1,245 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.FrameView;
+import eu.svjatoslav.aukio.e3d2.kernel.MeshStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * World space → camera space → screen space, per triangle vertex, using
+ * the substrate camera (data.camera), WITH near-plane clipping: a
+ * triangle straddling z = NEAR becomes a 3- or 4-vertex loop (position,
+ * texture uv and lightmap uv are lerped at the intersection); only a
+ * fully-behind triangle is dropped. Replaces the v1 simplification
+ * (whole-triangle straddler culling) that once ate a floor.
+ *
+ * PoC camera convention (its own API, deliberately not Aukio-3D's):
+ * +Y is up; yaw = 0, pitch = 0 looks along +Z; positive yaw turns right
+ * (toward +X); positive pitch looks up (toward +Y). Screen: y grows DOWN,
+ * so sy = cy − (y/z)·scale.
+ *
+ * Writes ScreenStore (sx, sy, w = 1/z, texU/V, lmU/V per output vertex,
+ * count = 3 or 4). Does NOT cull — that is CullPass's job.
+ */
+public final class TransformPass implements eu.svjatoslav.aukio.e3d2.kernel.StreamedPass {
+
+    private static final double NEAR = 0.1;
+    private static final int MAX_OUT = 4;
+
+    @Override
+    public String name() {
+        return "transform";
+    }
+
+    @Override
+    public int order() {
+        return 10;
+    }
+
+    @Override
+    public int stage() {
+        return 10;
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        apply(data, eu.svjatoslav.aukio.e3d2.kernel.FrameView.ofDefault(data));
+    }
+
+    @Override
+    public void apply(final Substrate data,
+                      final eu.svjatoslav.aukio.e3d2.kernel.FrameView f) {
+        final double cx = f.x, cy = f.y, cz = f.z;
+        final double yaw = f.yaw, pitch = f.pitch;
+        final double scale = f.scale;
+        final int n = data.triangles.triangleCount();
+        f.screen.setSize(n);
+        final double centerX = f.image.width / 2.0;
+        final double centerY = f.image.height / 2.0;
+        final double cosY = Math.cos(yaw), sinY = Math.sin(yaw);
+        final double cosP = Math.cos(pitch), sinP = Math.sin(pitch);
+        final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lm = f.lightmaps;
+
+        final boolean anyChunks = data.chunks.all().iterator().hasNext();
+        if (!anyChunks) {
+            transformRange(f.screen, data, 0, n, cx, cy, cz, cosY, sinY, cosP, sinP,
+                    scale, centerX, centerY, lm);
+            return;
+        }
+        // streamed world: static head (before the first chunk), then per
+        // chunk — visible ranges transform, invisible ranges zero out
+        // (ChunkFrustumCullPass sets the flags; skipping saves the whole
+        // vertex read, the composite-cull answer to the transform wall)
+        int legacyEnd = n;
+        for (final var c : data.chunks.all()) {
+            legacyEnd = Math.min(legacyEnd, c.firstTri);
+        }
+        transformRange(f.screen, data, 0, legacyEnd, cx, cy, cz, cosY, sinY, cosP, sinP,
+                scale, centerX, centerY, lm);
+        for (final var c : data.chunks.all()) {
+            if (c.visible) {
+                transformRange(f.screen, data, c.firstTri, c.firstTri + c.triCount,
+                        cx, cy, cz, cosY, sinY, cosP, sinP, scale, centerX, centerY, lm);
+            } else {
+                final int from = c.firstTri, to = c.firstTri + c.triCount;
+                eu.svjatoslav.aukio.e3d2.kernel.Parallel.forRange(to - from, (from2, to2) -> {
+                    for (int tri = from + from2; tri < from + to2; tri++) {
+                        f.screen.setCount(tri, 0);
+                    }
+                });
+            }
+        }
+    }
+
+    // ---- streamed frame (kernel/FrameStream): no forRange inside items,
+    // because a worker that joins would stall the whole pool ----
+
+    @Override
+    public void beginFrame(final Substrate data, final FrameView f) {
+        f.screen.setSize(data.triangles.triangleCount());
+    }
+
+    @Override
+    public int waveCount() {
+        return 1;
+    }
+
+    @Override
+    public boolean ticketWave(final int wave) {
+        return true;
+    }
+
+    @Override
+    public int itemCount(final int wave, final Substrate data, final FrameView f) {
+        return chunkCount(data.triangles.triangleCount());
+    }
+
+    @Override
+    public void runItem(final int wave, final int item, final Substrate data,
+                        final FrameView f) {
+        final int n = data.triangles.triangleCount();
+        final int chunks = chunkCount(n);
+        final int from = (int) ((long) item * n / chunks);
+        final int to = (int) ((long) (item + 1) * n / chunks);
+        final double centerX = f.image.width / 2.0;
+        final double centerY = f.image.height / 2.0;
+        transformRangeSerial(f.screen, data, from, to,
+                f.x, f.y, f.z,
+                Math.cos(f.yaw), Math.sin(f.yaw), Math.cos(f.pitch), Math.sin(f.pitch),
+                f.scale, centerX, centerY, f.lightmaps);
+    }
+
+    private static int chunkCount(final int n) {
+        return Math.max(1, Math.min(n, 16 * eu.svjatoslav.aukio.e3d2.kernel.Parallel.poolSize()));
+    }
+
+    /** Single-threaded range transform: the caller IS the worker. */
+    private static void transformRangeSerial(
+            final eu.svjatoslav.aukio.e3d2.kernel.ScreenStore screen,
+            final Substrate data, final int from, final int to,
+            final double cx, final double cy, final double cz,
+            final double cosY, final double sinY,
+            final double cosP, final double sinP,
+            final double scale, final double centerX, final double centerY,
+            final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lm) {
+        if (to <= from) {
+            return;
+        }
+        final double[][] in = new double[3][7];
+        final double[][] out = new double[MAX_OUT][7];
+        for (int tri = from; tri < to; tri++) {
+            transformOne(screen, data, tri, in, out,
+                    cx, cy, cz, cosY, sinY, cosP, sinP, scale,
+                    centerX, centerY, lm);
+        }
+    }
+
+    private static void transformRange(final eu.svjatoslav.aukio.e3d2.kernel.ScreenStore screen,
+                                       final Substrate data, final int from, final int to,
+                                       final double cx, final double cy, final double cz,
+                                       final double cosY, final double sinY,
+                                       final double cosP, final double sinP,
+                                       final double scale,
+                                       final double centerX, final double centerY,
+                                       final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lm) {
+        if (to <= from) {
+            return;
+        }
+        // per-range scratch: each worker transforms its own contiguous
+        // triangle range into its own ScreenStore slots — disjoint writes,
+        // byte-identical to the single-threaded loop
+        eu.svjatoslav.aukio.e3d2.kernel.Parallel.forRange(to - from, (from2, to2) -> {
+            // per-vertex attribute buffers: 0..2 = xyz camera space,
+            // 3,4 = tex uv, 5,6 = lightmap uv
+            final double[][] in = new double[3][7];
+            final double[][] out = new double[MAX_OUT][7];
+            for (int tri = from + from2; tri < from + to2; tri++) {
+                transformOne(screen, data, tri, in, out,
+                        cx, cy, cz, cosY, sinY, cosP, sinP, scale,
+                        centerX, centerY, lm);
+            }
+        });
+    }
+
+    private static void transformOne(final eu.svjatoslav.aukio.e3d2.kernel.ScreenStore screen,
+                                     final Substrate data, final int tri,
+                                     final double[][] in, final double[][] out,
+                                     final double cx, final double cy, final double cz,
+                                     final double cosY, final double sinY,
+                                     final double cosP, final double sinP,
+                                     final double scale,
+                                     final double centerX, final double centerY,
+                                     final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lm) {
+        if (!data.triangles.blockAlive(tri >>> MeshStore.BLOCK_SHIFT)) {
+            // evicted chunk: produce no output (count 0 => cull drops it,
+            // no stale screen data can ghost-render)
+            screen.setCount(tri, 0);
+            return;
+        }
+        for (int v = 0; v < 3; v++) {
+                final double dx = data.triangles.at(tri, v, 0) - cx;
+                final double dy = data.triangles.at(tri, v, 1) - cy;
+                final double dz = data.triangles.at(tri, v, 2) - cz;
+                // inverse yaw about +Y, then R_x(+pitch) about +X:
+                // rotates the camera's forward vector onto +Z
+                final double x1 = dx * cosY - dz * sinY;
+                final double z1 = dx * sinY + dz * cosY;
+                in[v][0] = x1;
+                in[v][1] = dy * cosP - z1 * sinP;
+                in[v][2] = dy * sinP + z1 * cosP;
+                in[v][3] = data.triangles.uvAt(tri, v, 0);
+                in[v][4] = data.triangles.uvAt(tri, v, 1);
+                in[v][5] = (lm != null && lm.hasDims(tri)) ? lm.cornerU(tri, v) : 0;
+                in[v][6] = (lm != null && lm.hasDims(tri)) ? lm.cornerV(tri, v) : 0;
+            }
+
+            // Sutherland–Hodgman against z >= NEAR
+            int outCount = 0;
+            for (int i = 0; i < 3; i++) {
+                final double[] a = in[i];
+                final double[] b = in[(i + 1) % 3];
+                final boolean aIn = a[2] >= NEAR;
+                final boolean bIn = b[2] >= NEAR;
+                if (aIn) {
+                    System.arraycopy(a, 0, out[outCount++], 0, 7);
+                }
+                if (aIn != bIn) {
+                    final double t = (NEAR - a[2]) / (b[2] - a[2]);
+                    for (int c = 0; c < 7; c++) {
+                        out[outCount][c] = a[c] + t * (b[c] - a[c]);
+                    }
+                    outCount++;
+                }
+            }
+            if (outCount >= 3) {
+                for (int v = 0; v < outCount; v++) {
+                    final double z = out[v][2];
+                    screen.setVertex(tri, v,
+                            centerX + (out[v][0] / z) * scale,
+                            centerY - (out[v][1] / z) * scale,
+                            1.0 / z, out[v][3], out[v][4], out[v][5], out[v][6]);
+                }
+            }
+            screen.setCount(tri, outCount >= 3 ? outCount : 0);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/WorldStreamer.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/WorldStreamer.java
new file mode 100644 (file)
index 0000000..691f7ab
--- /dev/null
@@ -0,0 +1,97 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ChunkStore;
+import eu.svjatoslav.aukio.e3d2.kernel.MeshStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Camera-driven chunk streaming. Decides load/evict sets from the
+ * camera's chunk and a radius, then APPLIES the transitions — loads
+ * (pad to a block boundary, generate, publish) and evicts (drop the
+ * chunk's blocks, remove the registry entry).
+ *
+ * FRAME BARRIER (Model/SlotLifecycle.tla, proven): this pass must run
+ * at a frame point where no reader holds the world — in practice:
+ * order 5, first in the pipeline, before any query/bake of the frame,
+ * and never concurrently with a GI bake. Readers that span transitions
+ * (BVH Trees) pin their inputs.
+ */
+public final class WorldStreamer implements Pass {
+
+    /** Chunk content generator. Returns the number of triangles appended
+     *  (before end-padding). Deterministic per (cx, cz). */
+    public interface Source {
+        int generate(int cx, int cz, MeshStore out);
+
+        /** LOD-aware variant: the streamer tells the source where the
+         *  camera is, so detail can fall with distance (FO4's LOD rungs).
+         *  Default ignores the camera — uniform detail. */
+        default int generate(final int cx, final int cz, final MeshStore out,
+                             final int camCx, final int camCz) {
+            return generate(cx, cz, out);
+        }
+    }
+
+    private final Source source;
+    private final int chunkSize;   // world units per chunk edge
+    private final int radius;      // chunks kept live in each direction
+
+    public WorldStreamer(final Source source, final int chunkSize, final int radius) {
+        this.source = source;
+        this.chunkSize = chunkSize;
+        this.radius = radius;
+    }
+
+    @Override
+    public int order() {
+        return 5;
+    }
+
+    @Override
+    public String name() {
+        return "stream";
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        final int ccx = (int) Math.floor(data.camera.x / chunkSize);
+        final int ccz = (int) Math.floor(data.camera.z / chunkSize);
+
+        // loads: every in-radius chunk that is not registered
+        for (int dx = -radius; dx <= radius; dx++) {
+            for (int dz = -radius; dz <= radius; dz++) {
+                final int cx = ccx + dx, cz = ccz + dz;
+                if (data.chunks.get(cx, cz) != null) {
+                    continue;
+                }
+                final ChunkStore.Chunk c = data.chunks.beginLoad(cx, cz);
+                data.triangles.padToBlockBoundary();
+                final int firstTri = data.triangles.triangleCount();
+                final int firstBlock = firstTri >>> MeshStore.BLOCK_SHIFT;
+                final int n = source.generate(cx, cz, data.triangles, ccx, ccz);
+                final int lastTri = firstTri + n - 1;
+                final int blockCount = n == 0 ? 0
+                        : (lastTri >>> MeshStore.BLOCK_SHIFT) - firstBlock + 1;
+                data.chunks.publish(c, firstTri, n, firstBlock, blockCount);
+            }
+        }
+
+        // evicts: live chunks outside the radius
+        final List<ChunkStore.Chunk> evict = new ArrayList<>();
+        for (final ChunkStore.Chunk c : data.chunks.all()) {
+            if (Math.abs(c.cx - ccx) > radius || Math.abs(c.cz - ccz) > radius) {
+                evict.add(c);
+            }
+        }
+        for (final ChunkStore.Chunk c : evict) {
+            for (int b = c.firstBlock; b < c.firstBlock + c.blockCount; b++) {
+                data.triangles.evictBlock(b);
+            }
+            data.chunks.remove(c);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/EntryWork.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/EntryWork.java
new file mode 100644 (file)
index 0000000..712c260
--- /dev/null
@@ -0,0 +1,14 @@
+package eu.svjatoslav.aukio.e3d2.systems.gi;
+
+/** The abstract unit of work for one entry and generation: returns the
+ *  entry's baked light as packed RGB (0xRRGGBB). Called OUTSIDE the
+ *  scheduler lock; the result is written into the GiStore by the
+ *  scheduler's epoch-guarded fold step — a stale-epoch result is
+ *  discarded, never written.
+ *
+ *  gen 0 = direct light; gen &gt;= 1 = the new total (direct + indirect),
+ *  gathering from the GiStore's frozen prev field only. */
+public interface EntryWork {
+
+    int computeLight(int entry, int gen);
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiPass.java
new file mode 100644 (file)
index 0000000..863cd0a
--- /dev/null
@@ -0,0 +1,463 @@
+package eu.svjatoslav.aukio.e3d2.systems.gi;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * Global illumination, stages 1+2: direct light, then bounce generations.
+ *
+ * gen 0: ambient + Lambert·visibility·falloff per light (shadow ray),
+ *   PER CHANNEL: shade_c = AMBIENT + (light_c − AMBIENT)·dot·att,
+ *   att = 1/(1 + 0.0001·d²) (legacy's attenuation curve), max-pooled over
+ *   lights. Intensity is folded into the color channels (LightStore r,g,b
+ *   may exceed 255; pack() clamps).
+ * gen &gt;= 1: SAMPLES cosine-weighted hemisphere rays per entry, gathering
+ *   albedo × frozen previous-generation light at the hit (the Neumann
+ *   series — each generation adds one bounce; totals converge).
+ *
+ * The bounce hemisphere is oriented toward the LIT side (litSide per
+ * triangle, voted at gen 0 from light directions at the centroid —
+ * geometric, not visibility-gated, so a fully shadowed triangle still
+ * orients toward the room): the winding normal is not a reliable gather
+ * direction — CullPass keeps the face whose normal points AWAY from the
+ * viewer, so render-visible geometry gathers away from the room it
+ * faces unless flipped. Light bounces off the side that received light.
+ *
+ * The bake is driven by the spec-verified GiScheduler (policy 1); this
+ * class supplies the radiometry (EntryWork), the convergence vote
+ * (mean bounce delta below threshold), and the barrier buffer flip.
+ * This class talks to no other pass.
+ */
+public final class GiPass implements Pass {
+
+    private static final int AMBIENT = 40;
+    private static final int SAMPLES = 8;
+    private static final int TEXEL_SAMPLES = 4;
+    private static final double GAIN = 0.65;
+    private static final double DELTA_THRESHOLD = 2.0;
+    private static final double UNITS_PER_TEXEL = 0.5;
+    /** Shadow-ray origin lift. Valid edge texels bake at HALF the direct
+     *  light of interior texels (25.8 vs 53.3 luminance), which is the dotted
+     *  seam lattice of bugreport-20261022-085712. */
+    private static final double BIAS;
+    static {
+        BIAS = Double.parseDouble(System.getProperty("e3d2.gibias", "0.001"));
+        if (Boolean.getBoolean("e3d2.gibias.trace")) {
+            System.out.println("[gi] shadow bias = " + BIAS);
+        }
+    }
+
+    /** Dim sky fill per missed bounce ray (keeps shadows lifted, not black). */
+    private static final int SKY_R = 18, SKY_G = 24, SKY_B = 34;
+
+    private final int threads;
+    private final double ringRadius;
+    private GiScheduler scheduler;
+    private volatile eu.svjatoslav.aukio.e3d2.kernel.LightmapStore maps;
+    /** Per-triangle bounce hemisphere orientation: -1 flips toward the lit
+     *  side. Written per-entry at gen 0 (disjoint per-entry writes, same
+     *  discipline as texel writes); 0/unset reads as +1. */
+    private volatile byte[] litSide;
+
+    public GiPass(final int threads, final double ringRadius) {
+        this.threads = threads;
+        this.ringRadius = ringRadius;
+    }
+
+    @Override
+    public String name() {
+        return "gi";
+    }
+
+    /** Fresh lightmap store with all frames allocated up front (single
+     *  thread — concurrent lazy allocation would race texel offsets). */
+    private static eu.svjatoslav.aukio.e3d2.kernel.LightmapStore newMaps(
+            final Substrate data) {
+        final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lm =
+                new eu.svjatoslav.aukio.e3d2.kernel.LightmapStore();
+        final int n = data.triangles.triangleCount();
+        for (int tri = 0; tri < n; tri++) {
+            lm.allocate(data, tri, UNITS_PER_TEXEL);
+        }
+        return lm;
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        data.bvh.ensure(data.triangles);
+        if (scheduler == null) {
+            if (data.giLight == null) {
+                data.giLight = new eu.svjatoslav.aukio.e3d2.kernel.GiStore(
+                        data.triangles.triangleCount());
+            }
+            litSide = newLitSide(data.triangles.triangleCount());
+            maps = newMaps(data);
+            data.giLightmaps = maps;
+            scheduler = new GiScheduler(data, threads, ringRadius, 6,
+                    data.giLight, (e, gen) -> computeLight(data, e, gen));
+            scheduler.setBarrierListener(g -> {
+                data.giLight.flip();
+                final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lm = maps;
+                if (lm != null) {
+                    lm.flip();
+                }
+            });
+            scheduler.setConvergenceVote(
+                    () -> data.giLight.meanDelta() < DELTA_THRESHOLD
+                            && (maps == null || maps.meanDelta() < DELTA_THRESHOLD));
+            scheduler.setCatchUpListener(e -> {
+                final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lm = maps;
+                if (lm != null) {
+                    lm.publishEntry(e);
+                }
+            });
+            scheduler.setRebuildListener(() -> {
+                maps = newMaps(data);
+                data.giLightmaps = maps;
+                litSide = newLitSide(data.triangles.triangleCount());
+            });
+            scheduler.start();
+        }
+        // bake runs in the background; frames read the frozen prev field
+    }
+
+    /** Fresh lit-side table, all +1 (unflipped) until gen-0 votes. */
+    private static byte[] newLitSide(final int triangleCount) {
+        final byte[] ls = new byte[triangleCount];
+        java.util.Arrays.fill(ls, (byte) 1);
+        return ls;
+    }
+
+    /** True once the bake drained (converged or capped). */
+    public boolean isQuiescent() {
+        return scheduler != null && scheduler.isQuiescent();
+    }
+
+    /** Package-visible for tests in this package (camera moves, rebuilds,
+     *  worker-error inspection). */
+    GiScheduler scheduler() {
+        return scheduler;
+    }
+
+    public boolean isConverged() {
+        return scheduler != null && scheduler.isConverged();
+    }
+
+    public void stop() {
+        if (scheduler != null) {
+            scheduler.stop();
+        }
+    }
+
+    private int computeLight(final Substrate data, final int tri, final int gen) {
+        if (gen == 0) {
+            final int packed = direct(data, tri);
+            bakeTexels(data, tri, packed);
+            return packed;
+        }
+        bounceTexels(data, tri);
+        return bounce(data, tri);
+    }
+
+    /** Stage 4: per-texel bounce. Each texel's rays gather the FROZEN prev
+     *  lightmap field (hit-texel shade × hit albedo; centroid prev as
+     *  fallback for undimensioned triangles). Total = directTexel +
+     *  indirect, written into cur; prev is untouched (rule: gather only
+     *  from the frozen field). */
+    private void bounceTexels(final Substrate data, final int tri) {
+        final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lm = maps;
+        if (lm == null) {
+            return;
+        }
+        final double[] n = litNormal(tri, normal(data, tri));
+        if (n == null) {
+            return;
+        }
+        final int w = lm.width(tri), h = lm.height(tri);
+        final double[] uv = new double[2];
+        final double[] cuv = new double[2];
+        for (int ty = 0; ty < h; ty++) {
+            final double v = (ty + 0.5) / h;
+            for (int tx = 0; tx < w; tx++) {
+                final double u = (tx + 0.5) / w;
+                lm.clampToTriangle(tri, u, v, cuv);
+                final double px = lm.worldX(tri, cuv[0], cuv[1], 0);
+                final double py = lm.worldX(tri, cuv[0], cuv[1], 1);
+                final double pz = lm.worldX(tri, cuv[0], cuv[1], 2);
+                double ir = 0, ig = 0, ib = 0;
+                for (int k = 0; k < TEXEL_SAMPLES; k++) {
+                    final double[] dir = fibonacciDir(k, tri * 4096 + ty * w + tx, n);
+                    final double cos = dir[3];
+                    final int hit = Raycast.nearest(data.triangles, data.bvh, tri,
+                            px + n[0] * 1e-3, py + n[1] * 1e-3, pz + n[2] * 1e-3,
+                            dir[0], dir[1], dir[2], 1e9, uv);
+                    if (hit >= 0) {
+                        final int albedo = data.triangles.color(hit);
+                        final int packed = lm.hasDims(hit)
+                                ? lm.sample(hit, uv[0], uv[1])
+                                : (data.giLight.prevR[hit] << 16)
+                                        | (data.giLight.prevG[hit] << 8)
+                                        | data.giLight.prevB[hit];
+                        ir += ((packed >> 16) & 0xFF) * (((albedo >> 16) & 0xFF) / 255.0) * cos;
+                        ig += ((packed >> 8) & 0xFF) * (((albedo >> 8) & 0xFF) / 255.0) * cos;
+                        ib += (packed & 0xFF) * ((albedo & 0xFF) / 255.0) * cos;
+                    } else {
+                        ir += SKY_R * cos;
+                        ig += SKY_G * cos;
+                        ib += SKY_B * cos;
+                    }
+                }
+                final double gain = GAIN / TEXEL_SAMPLES;
+                final int direct = lm.directTexelAt(tri, tx, ty);
+                final int dr = (direct >> 16) & 0xFF, dg = (direct >> 8) & 0xFF, db = direct & 0xFF;
+                lm.setTexel(tri, tx, ty,
+                        pack(dr + (int) (ir * gain), dg + (int) (ig * gain),
+                                db + (int) (ib * gain)), false);
+            }
+        }
+        lm.fillInvalidFromNeighbors(tri);
+    }
+
+    /** Per-texel direct bake, gen 0. Texels outside the triangle keep the
+     *  centroid fill (cheap neighbor-fill). Writes go to the store captured
+     *  NOW — a stale worker writes the orphaned epoch's store, never the
+     *  fresh one (self-healing, same discipline as the real snapshot). */
+    private void bakeTexels(final Substrate data, final int tri, final int centroidPacked) {
+        final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lm = maps;
+        if (lm == null) {
+            return;
+        }
+        lm.fill(tri, centroidPacked);
+        final double[] n = normal(data, tri);
+        if (n == null) {
+            return;
+        }
+        final int w = lm.width(tri), h = lm.height(tri);
+        final double[] cuv = new double[2];
+        for (int ty = 0; ty < h; ty++) {
+            final double v = (ty + 0.5) / h;
+            for (int tx = 0; tx < w; tx++) {
+                final double u = (tx + 0.5) / w;
+                // Border texels lie outside the triangle: bake them at the
+                // nearest interior point (lightmap border extension) instead
+                // of skipping them, which left the ring at bounce-only light
+                // and painted a dark seam along every triangle edge.
+                lm.clampToTriangle(tri, u, v, cuv);
+                final int packed = directAt(data, tri, n,
+                        lm.worldX(tri, cuv[0], cuv[1], 0), lm.worldX(tri, cuv[0], cuv[1], 1),
+                        lm.worldX(tri, cuv[0], cuv[1], 2), null);
+                lm.setTexel(tri, tx, ty, packed, true);
+            }
+        }
+        lm.fillInvalidFromNeighbors(tri);
+    }
+
+    /** Direct light: ambient + lambert·visibility per light, per channel.
+     *  Two-sided: the normal is oriented toward each light. Also casts the
+     *  triangle's lit-side vote (sum of light directions at the centroid,
+     *  geometric — shadow does not suppress it): the bounce hemisphere
+     *  flips toward the lit side. */
+    private int direct(final Substrate data, final int tri) {
+        final double[] c = centroid(data, tri);
+        final double[] n = normal(data, tri);
+        if (n == null) {
+            return pack(AMBIENT, AMBIENT, AMBIENT);
+        }
+        final double[] lit = new double[3];
+        final int packed = directAt(data, tri, n, c[0], c[1], c[2], lit);
+        final byte[] ls = litSide;
+        if (ls != null && tri < ls.length) {
+            final double toward = lit[0] * n[0] + lit[1] * n[1] + lit[2] * n[2];
+            ls[tri] = (byte) (toward < 0 ? -1 : 1);
+        }
+        return packed;
+    }
+
+    /** Direct light at an arbitrary surface point with the given normal.
+     *  {@code lit} (nullable) accumulates direction-to-light vectors
+     *  weighted by dot regardless of occlusion — the lit-side vote used
+     *  by gen 0 (orientation, not energy). */
+    /** Diagnostic: prints one texel's world position and per-light terms. */
+    public void debugTexel(final Substrate data, final int tri, final int tx, final int ty) {
+        final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lm = data.giLightmaps;
+        final int w = lm.width(tri), h = lm.height(tri);
+        final double u = (tx + 0.5) / w, v = (ty + 0.5) / h;
+        final double px = lm.worldX(tri, u, v, 0);
+        final double py = lm.worldX(tri, u, v, 1);
+        final double pz = lm.worldX(tri, u, v, 2);
+        final double[] n = litNormal(tri, normal(data, tri));
+        System.out.printf("texel %d,%d uv=%.3f,%.3f pos=%.3f,%.3f,%.3f inside=%b valid=%b direct=%d%n",
+                tx, ty, u, v, px, py, pz, lm.inside(tri, u, v),
+                lm.isValid(tri, tx, ty), lm.directTexelAt(tri, tx, ty));
+        for (int li = 0; li < data.lights.count(); li++) {
+            double lx = data.lights.at(li, 0) - px;
+            double ly = data.lights.at(li, 1) - py;
+            double lz = data.lights.at(li, 2) - pz;
+            final double dist = Math.sqrt(lx * lx + ly * ly + lz * lz);
+            if (dist < 1e-9) {
+                continue;
+            }
+            lx /= dist; ly /= dist; lz /= dist;
+            double dot = n[0] * lx + n[1] * ly + n[2] * lz;
+            if (dot < 0) {
+                dot = -dot;
+            }
+            final boolean occ = Raycast.occluded(data.triangles, data.bvh, tri,
+                    px + n[0] * BIAS, py + n[1] * BIAS, pz + n[2] * BIAS,
+                    lx, ly, lz, dist);
+            System.out.printf("   light %d dist=%.2f dot=%.3f occluded=%b%n", li, dist, dot, occ);
+        }
+    }
+
+    private int directAt(final Substrate data, final int tri, final double[] n,
+                         final double px, final double py, final double pz,
+                         final double[] lit) {
+        double nx = n[0], ny = n[1], nz = n[2];
+        double bestR = AMBIENT, bestG = AMBIENT, bestB = AMBIENT;
+        for (int li = 0; li < data.lights.count(); li++) {
+            double lx = data.lights.at(li, 0) - px;
+            double ly = data.lights.at(li, 1) - py;
+            double lz = data.lights.at(li, 2) - pz;
+            final double dist = Math.sqrt(lx * lx + ly * ly + lz * lz);
+            if (dist < 1e-9) {
+                continue;
+            }
+            lx /= dist; ly /= dist; lz /= dist;
+            double dot = nx * lx + ny * ly + nz * lz;
+            if (dot < 0) {
+                dot = -dot;   // two-sided: orient the normal TOWARD the light
+                nx = -nx;
+                ny = -ny;
+                nz = -nz;
+            }
+            if (dot <= 0) {
+                continue;
+            }
+            final boolean shadowed = Raycast.occluded(data.triangles, data.bvh, tri,
+                    px + nx * BIAS, py + ny * BIAS, pz + nz * BIAS,
+                    lx, ly, lz, dist);
+            if (lit != null) {
+                // Geometric vote, NOT visibility-gated: the lit side is about
+                // where emitters ARE, not whether this point sees them. A
+                // triangle fully shadowed at the centroid (sofa-shadowed floor,
+                // bugreport-20261014-221955) got NO vote and fell back to the
+                // winding-normal side — gathering bounce from the void below
+                // the floor and baking nearly black. Shadow belongs to energy,
+                // not orientation.
+                lit[0] += lx * dot;
+                lit[1] += ly * dot;
+                lit[2] += lz * dot;
+            }
+            if (!shadowed) {
+                // inverse-square-ish falloff (legacy attenuation curve,
+                // the standard point-light window): intensity folded into
+                // the color channels needs distance falloff to read as
+                // pools of light instead of uniform room tints
+                final double att = 1.0 / (1.0 + 0.0001 * dist * dist);
+                bestR = Math.max(bestR, AMBIENT + (data.lights.at(li, 3) - AMBIENT) * dot * att);
+                bestG = Math.max(bestG, AMBIENT + (data.lights.at(li, 4) - AMBIENT) * dot * att);
+                bestB = Math.max(bestB, AMBIENT + (data.lights.at(li, 5) - AMBIENT) * dot * att);
+            }
+        }
+        return pack((int) bestR, (int) bestG, (int) bestB);
+    }
+
+    /** One bounce generation: total = direct + gathered(prev field). */
+    private int bounce(final Substrate data, final int tri) {
+        final double[] c = centroid(data, tri);
+        final double[] n = litNormal(tri, normal(data, tri));
+        if (n == null) {
+            return pack(data.giLight.directR[tri], data.giLight.directG[tri],
+                    data.giLight.directB[tri]);
+        }
+        double ir = 0, ig = 0, ib = 0;
+        for (int k = 0; k < SAMPLES; k++) {
+            final double[] dir = fibonacciDir(k, tri, n);
+            final double cos = dir[3];
+            final int h = Raycast.nearest(data.triangles, data.bvh, tri,
+                    c[0] + n[0] * 1e-3, c[1] + n[1] * 1e-3, c[2] + n[2] * 1e-3,
+                    dir[0], dir[1], dir[2], 1e9);
+            if (h >= 0) {
+                final int albedo = data.triangles.color(h);
+                ir += data.giLight.prevR[h] * (((albedo >> 16) & 0xFF) / 255.0) * cos;
+                ig += data.giLight.prevG[h] * (((albedo >> 8) & 0xFF) / 255.0) * cos;
+                ib += data.giLight.prevB[h] * ((albedo & 0xFF) / 255.0) * cos;
+            } else {
+                ir += SKY_R * cos;
+                ig += SKY_G * cos;
+                ib += SKY_B * cos;
+            }
+        }
+        final double w = GAIN / SAMPLES;
+        return pack(
+                data.giLight.directR[tri] + (int) (ir * w),
+                data.giLight.directG[tri] + (int) (ig * w),
+                data.giLight.directB[tri] + (int) (ib * w));
+    }
+
+    /** Winding normal reoriented toward the lit side (gen-0 litSide vote);
+     *  the raw winding normal points AWAY from the visible face (CullPass
+     *  convention), so unflipped bounce would gather behind the wall. */
+    private double[] litNormal(final int tri, final double[] n) {
+        final byte[] ls = litSide;
+        if (n == null || ls == null || tri >= ls.length || ls[tri] >= 0) {
+            return n;
+        }
+        return new double[] {-n[0], -n[1], -n[2]};
+    }
+
+    /** Deterministic cosine hemisphere direction around n (Fibonacci
+     *  spiral, golden-angle twist per entry). Returns {dx, dy, dz, cos}. */
+    private static double[] fibonacciDir(final int k, final int entry, final double[] n) {
+        final double z = 1.0 - (2.0 * k + 1.0) / (2.0 * SAMPLES);
+        final double r = Math.sqrt(1.0 - z * z);
+        final double phi = k * 2.399963 + entry * 1.618034;
+        final double dx = r * Math.cos(phi);
+        final double dy = r * Math.sin(phi);
+        // orthonormal basis (t1, t2, n)
+        final double ux = Math.abs(n[1]) > 0.9 ? 1 : 0, uy = Math.abs(n[1]) > 0.9 ? 0 : 1, uz = 0;
+        double t1x = uy * n[2] - uz * n[1], t1y = uz * n[0] - ux * n[2], t1z = ux * n[1] - uy * n[0];
+        final double t1l = Math.sqrt(t1x * t1x + t1y * t1y + t1z * t1z);
+        t1x /= t1l; t1y /= t1l; t1z /= t1l;
+        final double t2x = n[1] * t1z - n[2] * t1y;
+        final double t2y = n[2] * t1x - n[0] * t1z;
+        final double t2z = n[0] * t1y - n[1] * t1x;
+        return new double[] {
+                dx * t1x + dy * t2x + z * n[0],
+                dx * t1y + dy * t2y + z * n[1],
+                dx * t1z + dy * t2z + z * n[2],
+                z};
+    }
+
+    private static double[] centroid(final Substrate data, final int tri) {
+        return new double[] {
+                (data.triangles.at(tri, 0, 0) + data.triangles.at(tri, 1, 0)
+                        + data.triangles.at(tri, 2, 0)) / 3.0,
+                (data.triangles.at(tri, 0, 1) + data.triangles.at(tri, 1, 1)
+                        + data.triangles.at(tri, 2, 1)) / 3.0,
+                (data.triangles.at(tri, 0, 2) + data.triangles.at(tri, 1, 2)
+                        + data.triangles.at(tri, 2, 2)) / 3.0};
+    }
+
+    /** Unit normal from winding, or null when degenerate. */
+    private static double[] normal(final Substrate data, final int tri) {
+        final double e1x = data.triangles.at(tri, 1, 0) - data.triangles.at(tri, 0, 0);
+        final double e1y = data.triangles.at(tri, 1, 1) - data.triangles.at(tri, 0, 1);
+        final double e1z = data.triangles.at(tri, 1, 2) - data.triangles.at(tri, 0, 2);
+        final double e2x = data.triangles.at(tri, 2, 0) - data.triangles.at(tri, 0, 0);
+        final double e2y = data.triangles.at(tri, 2, 1) - data.triangles.at(tri, 0, 1);
+        final double e2z = data.triangles.at(tri, 2, 2) - data.triangles.at(tri, 0, 2);
+        double nx = e1y * e2z - e1z * e2y;
+        double ny = e1z * e2x - e1x * e2z;
+        double nz = e1x * e2y - e1y * e2x;
+        final double nl = Math.sqrt(nx * nx + ny * ny + nz * nz);
+        if (nl < 1e-12) {
+            return null;
+        }
+        return new double[] {nx / nl, ny / nl, nz / nl};
+    }
+
+    private static int pack(final int r, final int g, final int b) {
+        return (Math.min(255, r) << 16) | (Math.min(255, g) << 8) | Math.min(255, b);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiScheduler.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiScheduler.java
new file mode 100644 (file)
index 0000000..a234b35
--- /dev/null
@@ -0,0 +1,429 @@
+package eu.svjatoslav.aukio.e3d2.systems.gi;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+/**
+ * GI bake scheduler — the Java implementation of Model/GiScheduler5.tla,
+ * movement policy 1 (never abort + barrier re-filter), per the normative
+ * rules in Documentation/index.org (M5 DECISION):
+ *
+ *  1. An in-flight generation always finishes; no abandonment.
+ *  2. Eligibility is consulted ONLY at barriers; the barrier re-filters
+ *     from the live camera position.
+ *  3. Late entrants wait for the next barrier or post-convergence catch-up.
+ *  4. Convergence certifies a RECORDED eligibility set (eligibleAtConv).
+ *  5. Post-convergence the barrier is a no-op; late work is catch-up's.
+ *  6. Catch-up never touches gen/cursor/inFlight/barrier; queue poll is atomic.
+ *  7. Writes are epoch-tagged; stale completions discarded; the barrier fires
+ *     for ANY worker once inFlight drains; a rebuild never touches inFlight.
+ *  8. No convergence and no disturbance after halt.
+ *
+ * All shared state is guarded by {@link #lock}, matching the model's
+ * atomicity. "Processing" an entry is abstract (a small sleep) — radiometry
+ * is a different system and never touches this class.
+ *
+ * Audit mode (-De3dpoc.gi.audit=true, default true) re-checks the TLC
+ * invariants at the exact points the model asserts them; a violation throws
+ * AssertionError naming the entry and state.
+ */
+public final class GiScheduler {
+
+    /** Invariant violation = bug, not bad luck. */
+    public static final class InvariantViolation extends AssertionError {
+        InvariantViolation(final String message) { super(message); }
+    }
+
+    private final Substrate data;
+    private final int workerCount;
+    private final double ringRadius;
+    private final int maxGenerations;
+    private final boolean audit =
+            Boolean.parseBoolean(System.getProperty("e3dpoc.gi.audit", "true"));
+
+    private final Object lock = new Object();
+    private final List<Thread> workers = new ArrayList<>();
+
+    /** Entry scheduling state, index = triangle index in the substrate. */
+    private int n;
+    private int[] doneGen;       // last folded generation, this epoch; -1 = none
+    private int[] doneEpoch;     // epoch of that fold
+    private long[] folded;       // bitset of generations folded, this epoch
+    private boolean[] caughtUp;  // served by catch-up
+    private boolean[] workSet;   // this generation's filtered set
+
+    private int epoch;
+    private int gen;
+    private int cursor;
+    private int inFlight;
+
+    private volatile double[] camera = {0, 0, 0};
+
+    private volatile java.util.function.IntConsumer barrierListener;
+    private volatile Runnable rebuildListener;
+    private volatile java.util.function.BooleanSupplier convergenceVote;
+    private volatile java.util.function.IntConsumer catchUpListener;
+
+    /** Called after each catch-up entry's bake is published, under the lock,
+     *  with the entry index. Radiometry publishes its per-entry lightmaps
+     *  here (post-convergence there is no barrier flip). */
+    public void setCatchUpListener(final java.util.function.IntConsumer listener) {
+        this.catchUpListener = listener;
+    }
+
+    /** Radiometry's say in convergence: the scheduler converges only when
+     *  eligibility is stable AND this vote passes (e.g. mean bounce delta
+     *  below threshold). Null = structural convergence only. */
+    public void setConvergenceVote(final java.util.function.BooleanSupplier vote) {
+        this.convergenceVote = vote;
+    }
+
+    /** Called at the end of every barrier, under the scheduler lock, with the
+     *  just-finished generation. Radiometry flips its light buffers here. */
+    public void setBarrierListener(final java.util.function.IntConsumer listener) {
+        this.barrierListener = listener;
+    }
+
+    /** Called at the end of rebuildWorld, under the lock. */
+    public void setRebuildListener(final Runnable listener) {
+        this.rebuildListener = listener;
+    }
+
+    private boolean converged;
+    private int convergedGen;
+    private int convergedEpoch;
+    private boolean[] eligibleAtConv;
+
+    private final ArrayDeque<Integer> catchUpQueue = new ArrayDeque<>();
+    private boolean running;
+    private final Random jitter = new Random(42);
+
+    /** Radiometry hook + its result table; both null = abstract mode. */
+    private final EntryWork work;
+    private final eu.svjatoslav.aukio.e3d2.kernel.GiStore giStore;
+
+    public GiScheduler(final Substrate data, final int workerCount,
+                       final double ringRadius, final int maxGenerations) {
+        this(data, workerCount, ringRadius, maxGenerations, null, null);
+    }
+
+    public GiScheduler(final Substrate data, final int workerCount,
+                       final double ringRadius, final int maxGenerations,
+                       final eu.svjatoslav.aukio.e3d2.kernel.GiStore giStore,
+                       final EntryWork work) {
+        this.data = data;
+        this.workerCount = workerCount;
+        this.ringRadius = ringRadius;
+        this.maxGenerations = maxGenerations;
+        this.giStore = giStore;
+        this.work = work;
+    }
+
+    public void start() {
+        synchronized (lock) {
+            n = data.triangles.triangleCount();
+            doneGen = new int[n];
+            doneEpoch = new int[n];
+            folded = new long[n];
+            caughtUp = new boolean[n];
+            java.util.Arrays.fill(doneGen, -1);
+            workSet = computeEligibility();
+            if (giStore != null) {
+                giStore.setSize(n);
+            }
+            running = true;
+            for (int i = 0; i < workerCount; i++) {
+                final Thread t = new Thread(this::workLoop, "poc-gi-" + i);
+                t.setDaemon(true);
+                workers.add(t);
+                t.start();
+            }
+        }
+    }
+
+    public void stop() {
+        synchronized (lock) {
+            running = false;
+            lock.notifyAll();
+        }
+        for (final Thread t : workers) {
+            try {
+                t.join(2000);
+            } catch (final InterruptedException ie) {
+                Thread.currentThread().interrupt();
+            }
+        }
+    }
+
+    /** The camera moved. Mid-bake: the next barrier sees it (rule 2).
+     *  Post-convergence: late entrants queue for catch-up (rules 3, 5, 6). */
+    public void moveCamera(final double x, final double y, final double z) {
+        synchronized (lock) {
+            camera = new double[] {x, y, z};
+            if (converged && running) {
+                final boolean[] now = computeEligibility();
+                for (int e = 0; e < n; e++) {
+                    if (now[e] && !served(e)) {
+                        catchUpQueue.add(e);
+                    }
+                }
+            }
+            lock.notifyAll();
+        }
+    }
+
+    /** World changed (geometry/light). Rebuild: epoch++, harvest/restore all
+     *  entries at gen-0-equivalent, re-filter. NEVER touches inFlight
+     *  (rule 7). */
+    public void rebuildWorld() {
+        synchronized (lock) {
+            epoch++;
+            gen = 0;
+            cursor = 0;
+            for (int e = 0; e < n; e++) {
+                folded[e] = 1L;          // gen 0 considered restored
+                doneGen[e] = 0;
+                doneEpoch[e] = epoch;
+                caughtUp[e] = false;
+            }
+            catchUpQueue.clear();
+            converged = false;
+            workSet = computeEligibility();
+            lock.notifyAll();
+            if (rebuildListener != null) {
+                rebuildListener.run();
+            }
+            auditFrozen();
+        }
+    }
+
+    private volatile Throwable firstWorkerError;
+
+    private void workLoop() {
+        try {
+            workLoopInner();
+        } catch (final Throwable t) {
+            firstWorkerError = t;   // audits fire on worker threads; surface them
+            synchronized (lock) {
+                lock.notifyAll();
+            }
+        }
+    }
+
+    private void workLoopInner() {
+        while (true) {
+            final int e;
+            final int we;
+            final Integer catchUp;
+            synchronized (lock) {
+                while (running && cursor >= n && catchUpQueue.isEmpty()) {
+                    try {
+                        lock.wait(100);
+                    } catch (final InterruptedException ie) {
+                        return;
+                    }
+                }
+                if (!running) {
+                    return;
+                }
+                if (cursor < n) {
+                    e = cursor++;
+                    we = epoch;
+                    inFlight++;
+                    catchUp = null;
+                } else {
+                    e = -1;
+                    we = 0;
+                    catchUp = catchUpQueue.poll();   // atomic grab (rule 6)
+                }
+            }
+
+            if (e >= 0) {
+                final int light = (work == null) ? fakeWork() : work.computeLight(e, gen);
+                synchronized (lock) {
+                    // epoch-tagged write: stale completions discarded (rule 7)
+                    if (we == epoch && workSet[e] && (folded[e] & (1L << gen)) == 0) {
+                        folded[e] |= 1L << gen;
+                        doneGen[e] = gen;
+                        doneEpoch[e] = epoch;
+                        if (giStore != null) {
+                            giStore.setShade(e, light, gen);
+                        }
+                    }
+                    inFlight--;
+                    auditInFlight();
+                    if (inFlight == 0 && cursor >= n) {
+                        barrier();
+                    }
+                }
+            } else if (catchUp != null) {
+                // catch-up = local bake (rule 6): direct + ONE bounce vs the
+                // frozen converged field, published per-entry. Never touches
+                // gen, cursor, inFlight, or the barrier.
+                if (work != null && giStore != null) {
+                    final int d = work.computeLight(catchUp, 0);
+                    final int b = work.computeLight(catchUp, 1);
+                    synchronized (lock) {
+                        giStore.setShade(catchUp, d, 0);
+                        giStore.setShade(catchUp, b, 1);
+                        giStore.publishEntry(catchUp);
+                    }
+                }
+                synchronized (lock) {
+                    caughtUp[catchUp] = true;
+                    if (catchUpListener != null) {
+                        catchUpListener.accept(catchUp);
+                    }
+                }
+                auditFrozen();
+            }
+        }
+    }
+
+    /** Barrier: certify or roll over. Fires for ANY worker once inFlight
+     *  drains (rule 7); post-convergence it is a no-op (rule 5). */
+    private void barrier() {
+        auditBarrierIntegrity();
+        final boolean[] now = computeEligibility();
+        final boolean radiometryCalm = convergenceVote == null
+                || convergenceVote.getAsBoolean();
+        if (running && subsetOf(now, workSet) && radiometryCalm) {
+            converged = true;                 // certificate over a RECORDED set
+            convergedGen = gen;               // (rule 4)
+            convergedEpoch = epoch;
+            eligibleAtConv = workSet.clone();
+        } else if (!converged) {
+            if (gen + 1 >= maxGenerations) {
+                return;                       // cap: bake stops unconverged
+            }
+            gen++;
+            workSet = now;
+            cursor = 0;
+        }
+        if (barrierListener != null) {
+            barrierListener.accept(gen);
+        }
+        lock.notifyAll();
+        auditFrozen();
+    }
+
+    private boolean[] computeEligibility() {
+        final boolean[] eligible = new boolean[n];
+        final double[] c = camera;
+        for (int e = 0; e < n; e++) {
+            final double cx = (data.triangles.at(e, 0, 0) + data.triangles.at(e, 1, 0)
+                    + data.triangles.at(e, 2, 0)) / 3.0;
+            final double cy = (data.triangles.at(e, 0, 1) + data.triangles.at(e, 1, 1)
+                    + data.triangles.at(e, 2, 1)) / 3.0;
+            final double cz = (data.triangles.at(e, 0, 2) + data.triangles.at(e, 1, 2)
+                    + data.triangles.at(e, 2, 2)) / 3.0;
+            final double dx = cx - c[0], dy = cy - c[1], dz = cz - c[2];
+            eligible[e] = dx * dx + dy * dy + dz * dz <= ringRadius * ringRadius;
+        }
+        return eligible;
+    }
+
+    private boolean served(final int e) {
+        return (doneEpoch[e] == epoch && doneGen[e] >= 0) || caughtUp[e];
+    }
+
+    private static boolean subsetOf(final boolean[] a, final boolean[] b) {
+        for (int i = 0; i < a.length; i++) {
+            if (a[i] && !b[i]) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /** Abstract work placeholder when no radiometry is attached. Sleeps
+     *  briefly so the stress harness explores interleavings. */
+    private int fakeWork() {
+        try {
+            Thread.sleep(jitter.nextInt(2));
+        } catch (final InterruptedException ie) {
+            Thread.currentThread().interrupt();
+        }
+        return 255;
+    }
+
+    // --- audit: the TLC invariants, executable ---------------------------
+
+    private void auditInFlight() {
+        if (audit && inFlight < 0) {
+            throw new InvariantViolation("inFlight=" + inFlight
+                    + " epoch=" + epoch + " gen=" + gen);
+        }
+    }
+
+    private void auditBarrierIntegrity() {
+        if (!audit) {
+            return;
+        }
+        for (int e = 0; e < n; e++) {
+            if (workSet[e] && (doneGen[e] != gen || doneEpoch[e] != epoch)) {
+                throw new InvariantViolation("entry " + e + " unserved at barrier: doneGen="
+                        + doneGen[e] + " doneEpoch=" + doneEpoch[e]
+                        + " but gen=" + gen + " epoch=" + epoch);
+            }
+        }
+    }
+
+    private void auditFrozen() {
+        if (audit && converged && (gen != convergedGen || epoch != convergedEpoch)) {
+            throw new InvariantViolation("bake moved after convergence: gen=" + gen
+                    + " convergedGen=" + convergedGen + " epoch=" + epoch
+                    + " convergedEpoch=" + convergedEpoch);
+        }
+    }
+
+    // --- harness-facing state --------------------------------------------
+
+    /** Quiescent = all work drained (bake converged or capped, catch-up empty). */
+    public boolean isQuiescent() {
+        synchronized (lock) {
+            return cursor >= n && inFlight == 0 && catchUpQueue.isEmpty();
+        }
+    }
+
+    /** First uncaught worker failure (usually an InvariantViolation), or null. */
+    public Throwable workerError() {
+        return firstWorkerError;
+    }
+
+    public boolean isConverged() {
+        synchronized (lock) {
+            return converged;
+        }
+    }
+
+    public int gen() {
+        synchronized (lock) {
+            return gen;
+        }
+    }
+
+    public int epoch() {
+        synchronized (lock) {
+            return epoch;
+        }
+    }
+
+    /** Test view: entry served in the current epoch (baked or caught up). */
+    public boolean isServed(final int e) {
+        synchronized (lock) {
+            return served(e);
+        }
+    }
+
+    /** Test view: entry currently inside the eligibility ring. */
+    public boolean isEligible(final int e) {
+        synchronized (lock) {
+            return computeEligibility()[e];
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/Raycast.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/Raycast.java
new file mode 100644 (file)
index 0000000..d8ff476
--- /dev/null
@@ -0,0 +1,50 @@
+package eu.svjatoslav.aukio.e3d2.systems.gi;
+
+import eu.svjatoslav.aukio.e3d2.kernel.BvhStore;
+import eu.svjatoslav.aukio.e3d2.kernel.MeshStore;
+
+/**
+ * Ray queries against the world mesh. Thin facade over
+ * {@link BvhStore} (the substrate index, rebuilt by version check);
+ * the triangle-intersection math lives there as the single copy.
+ *
+ * The brute-force implementation was replaced by BVH traversal in
+ * slice 2 — callers unchanged in shape, an order-of-magnitude
+ * faster on real scenes.
+ */
+public final class Raycast {
+
+    private Raycast() {
+    }
+
+    /** Nearest hit with barycentric uv at the hit point (for lightmap
+     *  sampling of the gathered surface). uvOut receives {u, v} when a hit
+     *  is returned. */
+    public static int nearest(final MeshStore mesh, final BvhStore bvh,
+                              final int self,
+                              final double ox, final double oy, final double oz,
+                              final double dx, final double dy, final double dz,
+                              final double maxDist, final double[] uvOut) {
+        return bvh.nearest(mesh, self, ox, oy, oz, dx, dy, dz, maxDist, uvOut);
+    }
+
+    /** Nearest hit: triangle index of the closest intersection along the
+     *  ray, or -1. */
+    public static int nearest(final MeshStore mesh, final BvhStore bvh,
+                              final int self,
+                              final double ox, final double oy, final double oz,
+                              final double dx, final double dy, final double dz,
+                              final double maxDist) {
+        return bvh.nearest(mesh, self, ox, oy, oz, dx, dy, dz, maxDist, null);
+    }
+
+    /** True if the ray hits any triangle except {@code self} with
+     *  0 &lt; t &lt; maxDist. */
+    public static boolean occluded(final MeshStore mesh, final BvhStore bvh,
+                                   final int self,
+                                   final double ox, final double oy, final double oz,
+                                   final double dx, final double dy, final double dz,
+                                   final double maxDist) {
+        return bvh.occluded(mesh, self, ox, oy, oz, dx, dy, dz, maxDist);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/DiagnosticsHudPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/DiagnosticsHudPass.java
new file mode 100644 (file)
index 0000000..d13678e
--- /dev/null
@@ -0,0 +1,54 @@
+package eu.svjatoslav.aukio.e3d2.systems.gui;
+
+import eu.svjatoslav.aukio.e3d2.kernel.GuiStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * Diagnostics HUD: writes engine stats into a GUI label as data —
+ * the F3-style debug readout. Stats arrive as substrate data (the app
+ * shell records frame times); the pass formats them. Order 249, just
+ * before gui-render.
+ */
+public final class DiagnosticsHudPass implements Pass {
+
+    private GuiStore.Label hud;
+    private long frameCount;
+    private long windowStart = System.nanoTime();
+    private double fps;
+
+    @Override
+    public int order() {
+        return 249;
+    }
+
+    @Override
+    public String name() {
+        return "diag-hud";
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        if (hud == null) {
+            hud = new GuiStore.Label(8, data.image.height - 10, 0.2,
+                    0xFF9AB0C0, "");
+            data.gui.labels.add(hud);
+        }
+        frameCount++;
+        final long now = System.nanoTime();
+        if (now - windowStart > 1_000_000_000L) {
+            fps = frameCount * 1e9 / (now - windowStart);
+            frameCount = 0;
+            windowStart = now;
+        }
+        long live = 0;
+        for (final var c : data.chunks.all()) {
+            live += c.triCount;
+        }
+        hud.text = String.format("%.0f fps | tris %d (live %d) | chunks %d | "
+                        + "cam %.1f, %.1f, %.1f",
+                fps, data.triangles.triangleCount(), live,
+                data.chunks.liveCount(),
+                data.camera.x, data.camera.y, data.camera.z);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/GuiPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/GuiPass.java
new file mode 100644 (file)
index 0000000..8008bb8
--- /dev/null
@@ -0,0 +1,43 @@
+package eu.svjatoslav.aukio.e3d2.systems.gui;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * GUI logic: hit-test, hover, click publish. Reads InputStore mouse
+ * state, updates GuiStore button flags, publishes click ids on
+ * release-inside. Order 6 — after streaming/camera, before rendering.
+ */
+public final class GuiPass implements Pass {
+
+    @Override
+    public int order() {
+        return 6;
+    }
+
+    @Override
+    public String name() {
+        return "gui";
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        final int mx = data.input.mouseX();
+        final int my = data.input.mouseY();
+        final boolean down = data.input.isMouseDown(1);
+        for (final var b : data.gui.buttons) {
+            final boolean wasPressed = b.pressed;
+            b.hover = b.contains(mx, my);
+            if (b.hover && down && !wasPressed) {
+                b.pressed = true;
+            } else if (wasPressed && !down) {
+                b.pressed = false;
+                if (b.hover) {
+                    data.gui.clicks.add(b.id);
+                }
+            } else if (!b.hover && !down) {
+                b.pressed = false;
+            }
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/GuiRenderPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/GuiRenderPass.java
new file mode 100644 (file)
index 0000000..e0a788b
--- /dev/null
@@ -0,0 +1,154 @@
+package eu.svjatoslav.aukio.e3d2.systems.gui;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.SdfFontStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * GUI renderer: panels, buttons, and SDF text. Order 250 — after the
+ * world and fog (GUI is not fogged). Per-pixel text coverage comes
+ * from the distance field: alpha = 0.5 + signedScreenDistance, which
+ * keeps edges crisp at ANY scale — the entire point of SDF text.
+ */
+public final class GuiRenderPass implements Pass {
+
+    @Override
+    public int order() {
+        return 250;
+    }
+
+    @Override
+    public String name() {
+        return "gui-render";
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        for (final var p : data.gui.panels) {
+            fillRect(data.image.pixels, data.image.width, data.image.height,
+                    p.x, p.y, p.w, p.h, p.argb);
+        }
+        for (final var b : data.gui.buttons) {
+            fillRect(data.image.pixels, data.image.width, data.image.height,
+                    b.x, b.y, b.w, b.h, b.hover ? b.hoverArgb : b.argb);
+            if (data.guiFont != null) {
+                final double s = (b.h - 10.0) / SdfFontStore.CELL;
+                final int textW = textWidth(data.guiFont, b.text, s);
+                drawText(data, b.x + (b.w - textW) / 2,
+                        b.y + b.h / 2 + (int) (data.guiFont.baseline * s / 2),
+                        s, b.textArgb, b.text);
+            }
+        }
+        if (data.guiFont != null) {
+            for (final var l : data.gui.labels) {
+                drawText(data, l.x, l.y, l.scale, l.argb, l.text);
+            }
+        }
+    }
+
+    /** Pixel width of a string at scale s (atlas advances are in cell px). */
+    public static int textWidth(final SdfFontStore font, final String text,
+                                final double s) {
+        int w = 0;
+        for (int i = 0; i < text.length(); i++) {
+            final char c = text.charAt(i);
+            if (c < SdfFontStore.FIRST || c > SdfFontStore.LAST) {
+                continue;
+            }
+            w += (int) (font.advance[c - SdfFontStore.FIRST] * s);
+        }
+        return w;
+    }
+
+    private static int clamp(final int v, final int lo, final int hi) {
+        return v < lo ? lo : Math.min(v, hi);
+    }
+
+    /** Draw text with the baseline at (x, baseY), scale s = screen px per
+     *  atlas px. Coverage from the SDF: alpha = 0.5 + dist*s (clamped). */
+    public static void drawText(final Substrate data, final int x, final int baseY,
+                                final double s, final int argb, final String text) {
+        final SdfFontStore font = data.guiFont;
+        final int cell = SdfFontStore.CELL;
+        int penX = x;
+        for (int i = 0; i < text.length(); i++) {
+            final char c = text.charAt(i);
+            if (c < SdfFontStore.FIRST || c > SdfFontStore.LAST) {
+                // unknown glyph: advance like a space, draw nothing
+                penX += (int) (font.advance[' ' - SdfFontStore.FIRST] * s);
+                continue;
+            }
+            final int il = Math.max(0, font.inkLeft[c - SdfFontStore.FIRST] - 2);
+            final int ir = Math.min(cell - 1, font.inkRight[c - SdfFontStore.FIRST] + 2);
+            // 2px padding: the AA ramp lives just OUTSIDE the exact ink
+            // bounds; crop tighter and antialiasing is shaved off
+            if (ir < il) {   // no ink (space)
+                penX += (int) (font.advance[c - SdfFontStore.FIRST] * s);
+                continue;
+            }
+            final int quadW = (int) ((ir - il + 1) * s);
+            final int quadH = (int) (cell * s);
+            final int qx = penX;
+            final int qy = baseY - (int) (font.baseline * s);
+            for (int py = 0; py < quadH; py++) {
+                final int sy = qy + py;
+                if (sy < 0 || sy >= data.image.height) {
+                    continue;
+                }
+                for (int px = 0; px < quadW; px++) {
+                    final int sx = qx + px;
+                    if (sx < 0 || sx >= data.image.width) {
+                        continue;
+                    }
+                    // bilinear SDF sample at the pixel CENTER in atlas
+                    // space — interpolation is what makes the coverage
+                    // continuous (crisp AA) instead of blocky
+                    final double fx = il + (px + 0.5) / s - 0.5;
+                    final double fy = (py + 0.5) / s - 0.5;
+                    final int x0 = clamp((int) Math.floor(fx), 0, cell - 1);
+                    final int y0 = clamp((int) Math.floor(fy), 0, cell - 1);
+                    final int x1 = Math.min(x0 + 1, cell - 1);
+                    final int y1 = Math.min(y0 + 1, cell - 1);
+                    final double tx = Math.min(1.0, Math.max(0.0, fx - x0));
+                    final double ty = Math.min(1.0, Math.max(0.0, fy - y0));
+                    final double d00 = font.distAt(c, x0, y0);
+                    final double d10 = font.distAt(c, x1, y0);
+                    final double d01 = font.distAt(c, x0, y1);
+                    final double d11 = font.distAt(c, x1, y1);
+                    final double d = (d00 * (1 - tx) + d10 * tx) * (1 - ty)
+                            + (d01 * (1 - tx) + d11 * tx) * ty;
+                    // signed distance in screen px
+                    final double sd = (d - 128) / 255.0 * 2.0
+                            * SdfFontStore.SPREAD * s;
+                    final double a = Math.max(0.0, Math.min(1.0, 0.5 + sd));
+                    if (a > 0) {
+                        blend(data.image.pixels, sy * data.image.width + sx, argb, a);
+                    }
+                }
+            }
+            penX += (int) (font.advance[c - SdfFontStore.FIRST] * s);
+        }
+    }
+
+    private static void fillRect(final int[] pixels, final int width, final int height,
+                                 final int x, final int y, final int w, final int h,
+                                 final int argb) {
+        final int alpha = (argb >>> 24) & 0xFF;
+        final double a = alpha / 255.0;
+        for (int py = Math.max(0, y); py < Math.min(height, y + h); py++) {
+            for (int px = Math.max(0, x); px < Math.min(width, x + w); px++) {
+                blend(pixels, py * width + px, argb, a);
+            }
+        }
+    }
+
+    /** dst = src*a + dst*(1-a), per channel. */
+    private static void blend(final int[] pixels, final int i, final int argb,
+                              final double a) {
+        final int dst = pixels[i];
+        final int r = (int) (((argb >> 16) & 0xFF) * a + ((dst >> 16) & 0xFF) * (1 - a));
+        final int g = (int) (((argb >> 8) & 0xFF) * a + ((dst >> 8) & 0xFF) * (1 - a));
+        final int b = (int) ((argb & 0xFF) * a + (dst & 0xFF) * (1 - a));
+        pixels[i] = 0xFF000000 | (r << 16) | (g << 8) | b;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfFont.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfFont.java
new file mode 100644 (file)
index 0000000..6b6d364
--- /dev/null
@@ -0,0 +1,115 @@
+package eu.svjatoslav.aukio.e3d2.systems.gui;
+
+import eu.svjatoslav.aukio.e3d2.kernel.SdfFontStore;
+
+import java.awt.Font;
+import java.awt.FontMetrics;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+
+/**
+ * Builds an SdfFontStore: renders each printable ASCII glyph with AWT
+ * (allowed HERE — this is tooling, not a pass), then runs a two-pass
+ * chamfer distance transform to get signed distance per pixel.
+ *
+ * Inside/outside sign: pixels covered by the glyph are inside
+ * (distance positive); the field is min(distToEdge), signed.
+ */
+public final class SdfFont {
+
+    private SdfFont() {
+    }
+
+    public static SdfFontStore build(final int fontSize) {
+        final SdfFontStore store = new SdfFontStore();
+        store.baseSize = fontSize;
+        final Font font = new Font(Font.SANS_SERIF, Font.PLAIN, fontSize);
+        for (int g = SdfFontStore.FIRST; g <= SdfFontStore.LAST; g++) {
+            buildGlyph(store, font, g);
+        }
+        return store;
+    }
+
+    private static void buildGlyph(final SdfFontStore store, final Font font,
+                                   final int glyph) {
+        final int cell = SdfFontStore.CELL;
+        final BufferedImage img = new BufferedImage(cell, cell, BufferedImage.TYPE_INT_ARGB);
+        final Graphics2D g2 = img.createGraphics();
+        g2.setFont(font);
+        final FontMetrics fm = g2.getFontMetrics();
+        final String s = String.valueOf((char) glyph);
+        final int adv = fm.charWidth((char) glyph);
+        g2.dispose();
+        // draw centered
+        final Graphics2D g = img.createGraphics();
+        g.setFont(font);
+        g.setColor(java.awt.Color.WHITE);
+        final int x = Math.max(0, (cell - adv) / 2);
+        final int y = (cell + fm.getAscent() - fm.getDescent()) / 2;
+        g.drawString(s, x, y);
+        g.dispose();
+        store.baseline = y;
+        store.advance[glyph - SdfFontStore.FIRST] = adv;
+
+        // binary mask: alpha > 0 = inside; record ink bounds
+        final boolean[] inside = new boolean[cell * cell];
+        int left = cell - 1, right = 0;
+        for (int py = 0; py < cell; py++) {
+            for (int px = 0; px < cell; px++) {
+                final boolean in = (img.getRGB(px, py) >>> 24) > 0;
+                inside[py * cell + px] = in;
+                if (in) {
+                    left = Math.min(left, px);
+                    right = Math.max(right, px);
+                }
+            }
+        }
+        store.inkLeft[glyph - SdfFontStore.FIRST] = left;
+        store.inkRight[glyph - SdfFontStore.FIRST] = right;
+        // chamfer distance transform, two passes, separately for inside
+        // and outside; signed field = distOut - distIn
+        final float[] dIn = chamfer(inside, true, cell);
+        final float[] dOut = chamfer(inside, false, cell);
+        // signed field: + inside the glyph, - outside (dIn = distance
+        // from an inside pixel to the exterior, dOut = the reverse)
+        final int base = (glyph - SdfFontStore.FIRST) * cell * cell;
+        for (int i = 0; i < cell * cell; i++) {
+            final float d = dIn[i] - dOut[i];   // + inside, - outside
+            final float clamped = Math.max(-SdfFontStore.SPREAD,
+                    Math.min(SdfFontStore.SPREAD, d));
+            store.dist[base + i] = 128 + Math.round(
+                    255.0f * clamped / (2.0f * SdfFontStore.SPREAD));
+        }
+    }
+
+    /** Two-pass chamfer distance to the nearest pixel of the OPPOSITE
+     *  class. queryInside=true: distance from inside pixels to outside. */
+    private static float[] chamfer(final boolean[] inside,
+                                   final boolean queryInside, final int cell) {
+        final float inf = 1e9f;
+        final float[] d = new float[cell * cell];
+        for (int i = 0; i < d.length; i++) {
+            final boolean in = inside[i];
+            d[i] = (in == queryInside) ? inf : 0;
+        }
+        for (int y = 0; y < cell; y++) {
+            for (int x = 0; x < cell; x++) {
+                final int i = y * cell + x;
+                if (x > 0) d[i] = Math.min(d[i], d[i - 1] + 1);
+                if (y > 0) d[i] = Math.min(d[i], d[i - cell] + 1);
+                if (x > 0 && y > 0) d[i] = Math.min(d[i], d[i - cell - 1] + 1.4142f);
+                if (x < cell - 1 && y > 0) d[i] = Math.min(d[i], d[i - cell + 1] + 1.4142f);
+            }
+        }
+        for (int y = cell - 1; y >= 0; y--) {
+            for (int x = cell - 1; x >= 0; x--) {
+                final int i = y * cell + x;
+                if (x < cell - 1) d[i] = Math.min(d[i], d[i + 1] + 1);
+                if (y < cell - 1) d[i] = Math.min(d[i], d[i + cell] + 1);
+                if (x < cell - 1 && y < cell - 1) d[i] = Math.min(d[i], d[i + cell + 1] + 1.4142f);
+                if (x > 0 && y < cell - 1) d[i] = Math.min(d[i], d[i + cell - 1] + 1.4142f);
+            }
+        }
+        return d;
+    }
+}
diff --git a/src/main/resources/META-INF/services/eu.svjatoslav.aukio.e3d2.kernel.Pass b/src/main/resources/META-INF/services/eu.svjatoslav.aukio.e3d2.kernel.Pass
new file mode 100644 (file)
index 0000000..4447bf9
--- /dev/null
@@ -0,0 +1,7 @@
+# Pass registry. Run order = line order. A new feature is a new class
+# plus ONE new line here — registration, not a code edit.
+eu.svjatoslav.aukio.e3d2.systems.TransformPass
+eu.svjatoslav.aukio.e3d2.systems.CullPass
+eu.svjatoslav.aukio.e3d2.systems.RasterPass
+eu.svjatoslav.aukio.e3d2.systems.FogPass
+eu.svjatoslav.aukio.e3d2.systems.SpaceMousePass
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/DiscoverOrderTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/DiscoverOrderTest.java
new file mode 100644 (file)
index 0000000..351e348
--- /dev/null
@@ -0,0 +1,27 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Plugin ordering: discovery runs passes in Pass.order(), not file order. */
+class DiscoverOrderTest {
+
+    @Test
+    void discoverRunsKernelBeforeFeatureBeforeApp() {
+        final Kernel kernel = Kernel.discover();
+        assertEquals(5, kernel.passCount());
+        for (int i = 0; i < kernel.passCount(); i++) {
+            final String n = kernel.passName(i);
+            assertTrue(switch (i) {
+                case 0 -> n.contains("pacemouse");
+                case 1 -> n.contains("ransform");
+                case 2 -> n.contains("ull");
+                case 3 -> n.contains("aster");
+                default -> n.contains("og");
+            }, n);
+        }
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/RasterPipelineTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/RasterPipelineTest.java
new file mode 100644 (file)
index 0000000..18b45d1
--- /dev/null
@@ -0,0 +1,96 @@
+package eu.svjatoslav.aukio.e3d2;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class RasterPipelineTest {
+
+    private static final int BG = 0xFF101010;
+    private static final int RED = 0xFFFF0000;
+    private static final int BLUE = 0xFF0000FF;
+
+    private static Substrate render(final Substrate data, final int w, final int h) {
+        data.image = new ImageStore(w, h);
+        data.image.clear(BG);
+        data.camera.set(0, 0, 0, 0, 0, 100);
+        final Kernel kernel = new Kernel();
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        kernel.runOnce(data);
+        return data;
+    }
+
+    @Test
+    void frontFacingTriangleRendersAtCenter() {
+        final Substrate data = new Substrate();
+        // 4x4 square straight ahead at z=10, CCW as seen by the camera
+        data.triangles.addTriangle(-2, 2, 10, -2, -2, 10, 2, -2, 10, RED);
+        render(data, 64, 64);
+        assertEquals(RED, data.image.pixels[32 * 64 + 32], "center pixel is the triangle");
+        assertNotEquals(RED, data.image.pixels[2 * 64 + 2], "corner stays background");
+    }
+
+    @Test
+    void backFacingTriangleIsCulled() {
+        final Substrate data = new Substrate();
+        // same square, reversed winding
+        data.triangles.addTriangle(-2, 2, 10, 2, -2, 10, -2, -2, 10, RED);
+        render(data, 64, 64);
+        for (final int p : data.image.pixels) {
+            assertEquals(BG, p, "backface renders nothing");
+        }
+    }
+
+    @Test
+    void nearerTriangleOccludes() {
+        final Substrate data = new Substrate();
+        data.triangles.addTriangle(-2, 2, 20, -2, -2, 20, 2, -2, 20, BLUE);  // far
+        data.triangles.addTriangle(-2, 2, 10, -2, -2, 10, 2, -2, 10, RED);   // near
+        render(data, 64, 64);
+        assertEquals(RED, data.image.pixels[32 * 64 + 32], "nearer wins regardless of order");
+    }
+
+    @Test
+    void pitchedCameraCentersAimPoint() {
+        // regression: pitch rotation sign was inverted (caught by Cornell demo)
+        final Substrate data = new Substrate();
+        data.triangles.addTriangle(-3, 0.1, 7, -1, 0.1, 7, -2, 0.1, 9, RED); // at the aim point
+        data.image = new ImageStore(64, 64);
+        data.image.clear(BG);
+        final double fx = -2 - 10, fy = 0 - 10, fz = 8 - (-8);
+        final double fl = Math.sqrt(fx * fx + fy * fy + fz * fz);
+        data.camera.set(10, 10, -8, Math.atan2(fx, fz), Math.asin(fy / fl), 100);
+        final Kernel kernel = new Kernel();
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        kernel.runOnce(data);
+        assertEquals(RED, data.image.pixels[32 * 64 + 32],
+                "aimed-at point lands at screen center");
+    }
+
+    @Test
+    void renderIsDeterministic() {
+        final Substrate a = RenderMain.buildScene();
+        final Substrate b = RenderMain.buildScene();
+        RenderMain.render(a, RenderMain.SKY);
+        RenderMain.render(b, RenderMain.SKY);
+        assertArrayEquals(a.image.pixels, b.image.pixels,
+                "same scene renders bit-identically");
+        int nonSky = 0;
+        for (final int p : a.image.pixels) {
+            if (p != RenderMain.SKY) {
+                nonSky++;
+            }
+        }
+        assertTrue(nonSky > 10_000, "temple scene actually covers pixels, got " + nonSky);
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/app/AwtShellTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/app/AwtShellTest.java
new file mode 100644 (file)
index 0000000..87833ab
--- /dev/null
@@ -0,0 +1,313 @@
+package eu.svjatoslav.aukio.e3d2.app;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.SpaceMousePass;
+import org.junit.jupiter.api.Test;
+
+import java.awt.event.KeyEvent;
+import java.awt.event.MouseEvent;
+import java.awt.image.BufferedImage;
+import java.awt.Graphics2D;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Shell seams: AWT events become InputStore data; paint composites
+ *  the ImageStore; render-on-demand signature tracks real change. */
+class AwtShellTest {
+
+    private static Substrate data() {
+        final Substrate d = new Substrate();
+        d.image = new ImageStore(64, 48);
+        d.image.clear(0xFF102030);
+        return d;
+    }
+
+    @Test
+    void keyEventsReachInputStore() {
+        final Substrate d = data();
+        final AwtShell shell = new AwtShell(d, new Kernel(), "t");
+        final KeyEvent press = new KeyEvent(new java.awt.Panel(), 0, 0, 0,
+                KeyEvent.VK_W, 'w');
+        shell.keyListener.keyPressed(press);
+        assertTrue(d.input.isDown(KeyEvent.VK_W), "key down recorded");
+        shell.keyListener.keyReleased(press);
+        assertTrue(!d.input.isDown(KeyEvent.VK_W), "key up recorded");
+    }
+
+    @Test
+    void hoverTracksPositionOnlyDragAccumulatesDelta() {
+        final Substrate d = data();
+        final AwtShell shell = new AwtShell(d, new Kernel(), "t");
+        final java.awt.Panel src = new java.awt.Panel();
+        // hover: position tracked, NO camera delta (legacy InputManager rule)
+        shell.mouseMotionListener.mouseMoved(
+                new MouseEvent(src, 0, 0, 0, 10, 10, 0, false));
+        shell.mouseMotionListener.mouseMoved(
+                new MouseEvent(src, 0, 0, 0, 17, 13, 0, false));
+        assertEquals(17, d.input.mouseX());
+        assertEquals(13, d.input.mouseY());
+        assertEquals(0.0, d.input.consumeMouseDelta()[0], 1e-9,
+                "hover must not move the camera");
+        // drag: deltas accumulate; release resets the anchor
+        shell.mouseMotionListener.mouseDragged(
+                new MouseEvent(src, 0, 0, 0, 20, 20, 0, false));
+        shell.mouseMotionListener.mouseDragged(
+                new MouseEvent(src, 0, 0, 0, 27, 23, 0, false));
+        final double[] delta = d.input.consumeMouseDelta();
+        assertEquals(7.0, delta[0], 1e-9);
+        assertEquals(3.0, delta[1], 1e-9);
+        shell.mouseListener.mouseReleased(
+                new MouseEvent(src, 0, 0, 0, 27, 23, 1, false));
+        shell.mouseMotionListener.mouseDragged(
+                new MouseEvent(src, 0, 0, 0, 90, 90, 0, false));
+        assertEquals(0.0, d.input.consumeMouseDelta()[0], 1e-9,
+                "first drag event after release starts a fresh anchor (no jump)");
+    }
+
+    @Test
+    void paintCompositesImageStore() {
+        final Substrate d = data();
+        d.image.pixels[0] = 0xFFFF0000;
+        final AwtShell shell = new AwtShell(d, new Kernel(), "t");
+        final BufferedImage target = new BufferedImage(64, 48, BufferedImage.TYPE_INT_ARGB);
+        final Graphics2D g = target.createGraphics();
+        shell.blit(g);
+        g.dispose();
+        assertEquals(0xFFFF0000, target.getRGB(0, 0), "pixel 0 composited");
+        assertEquals(0xFF102030, target.getRGB(10, 10), "background composited");
+    }
+
+    @Test
+    void wheelEventReachesInputStore() {
+        final Substrate d = data();
+        final AwtShell shell = new AwtShell(d, new Kernel(), "t");
+        shell.mouseWheelListener.mouseWheelMoved(new java.awt.event.MouseWheelEvent(
+                new java.awt.Panel(), 0, 0, 0, 10, 10, 0, false,
+                java.awt.event.MouseWheelEvent.WHEEL_UNIT_SCROLL, 1, -1));
+        assertEquals(-1, d.input.consumeWheel(), "scroll-up notch recorded");
+    }
+
+    @Test
+    void blitPaintsOnlyCompleteFrames() {
+        final Substrate d = data();
+        final Kernel kernel = new Kernel();
+        kernel.add(new eu.svjatoslav.aukio.e3d2.kernel.Pass() {
+            @Override public String name() { return "painter"; }
+            @Override public void apply(final Substrate data) {
+                data.image.pixels[0] = 0xFF00FF00;
+            }
+        });
+        final AwtShell shell = new AwtShell(d, kernel, "t");
+        shell.loopTick();                    // renders + publishes
+        // mid-render state: the back buffer was just cleared, nothing
+        // painted yet — a blit must NOT see this (the resize flicker)
+        d.image.clear(0xFF000000);
+        final BufferedImage target = new BufferedImage(64, 48, BufferedImage.TYPE_INT_ARGB);
+        final Graphics2D g = target.createGraphics();
+        shell.blit(g);
+        g.dispose();
+        assertEquals(0xFF00FF00, target.getRGB(0, 0),
+                "blit shows the last COMPLETE frame, not the mid-render back buffer");
+    }
+
+    @Test
+    void blitSurvivesImageResize() {
+        final Substrate d = data();
+        final AwtShell shell = new AwtShell(d, new Kernel(), "t");
+        // simulate the shell's resize listener swapping in a BIGGER store
+        d.image = new eu.svjatoslav.aukio.e3d2.kernel.ImageStore(320, 200);
+        d.image.clear(0xFF102030);
+        d.image.pixels[319 + 199 * 320] = 0xFF00FF00;
+        final BufferedImage target = new BufferedImage(320, 200, BufferedImage.TYPE_INT_ARGB);
+        final Graphics2D g = target.createGraphics();
+        shell.blit(g);   // must not throw (was the resize freeze)
+        g.dispose();
+        assertEquals(0xFF00FF00, target.getRGB(319, 199), "resized image composited");
+    }
+
+    @Test
+    void renderOnDemandSignatureTracksChange() {
+        final Substrate d = data();
+        final String s0 = AwtShell.signature(d);
+        assertEquals(s0, AwtShell.signature(d), "stable when nothing changes");
+        d.camera.x += 1;
+        assertNotEquals(s0, AwtShell.signature(d), "camera move changes it");
+        final String s1 = AwtShell.signature(d);
+        d.triangles.addTriangle(0, 0, 5, 1, 0, 5, 0, 1, 5);
+        assertNotEquals(s1, AwtShell.signature(d), "world version changes it");
+    }
+
+    @Test
+    void pendingInputForcesFrameButNotSignatureChange() {
+        final Substrate d = data();
+        final java.util.concurrent.atomic.AtomicInteger runs =
+                new java.util.concurrent.atomic.AtomicInteger();
+        final Kernel kernel = new Kernel();
+        kernel.add(new eu.svjatoslav.aukio.e3d2.kernel.Pass() {
+            @Override public String name() { return "counter"; }
+            @Override public void apply(final Substrate data) { runs.incrementAndGet(); }
+        });
+        final AwtShell shell = new AwtShell(d, kernel, "t");
+        shell.loopTick();
+        assertEquals(1, runs.get(), "initial render");
+        shell.loopTick();
+        assertEquals(1, runs.get(), "no render when idle and unchanged");
+        // held key: signature unchanged, but the loop MUST render so
+        // CameraPass can consume the input (the starvation condition)
+        d.input.keyDown(KeyEvent.VK_W);
+        shell.loopTick();
+        assertEquals(2, runs.get(), "held key forces a frame");
+        d.input.keyUp(KeyEvent.VK_W);
+        shell.loopTick();
+        assertEquals(2, runs.get(), "idle again after release");
+        // accumulated mouse motion likewise
+        d.input.addMouseDelta(5, 3);
+        shell.loopTick();
+        assertEquals(3, runs.get(), "pending deltas force a frame");
+        d.input.consumeMouseDelta();
+        shell.loopTick();
+        assertEquals(3, runs.get(), "idle after consumption");
+    }
+
+    @Test
+    void giProgressForcesRepaint() {
+        // The raster reads the GI field, so GI progress is frame INPUT even
+        // when camera and mesh are untouched. Without it a HouseDemo opens
+        // black (lightmap texels start at zero) and stays black until the
+        // user moves — the loop never sees the bake land.
+        final Substrate d = data();
+        final java.util.concurrent.atomic.AtomicInteger runs =
+                new java.util.concurrent.atomic.AtomicInteger();
+        final Kernel kernel = new Kernel();
+        kernel.add(new eu.svjatoslav.aukio.e3d2.kernel.Pass() {
+            @Override public String name() { return "counter"; }
+            @Override public void apply(final Substrate data) { runs.incrementAndGet(); }
+        });
+        final AwtShell shell = new AwtShell(d, kernel, "t");
+        shell.loopTick();
+        assertEquals(1, runs.get(), "initial render");
+        shell.loopTick();
+        assertEquals(1, runs.get(), "static scene idles");
+        d.giLight = new eu.svjatoslav.aukio.e3d2.kernel.GiStore(1);
+        shell.loopTick();
+        assertEquals(2, runs.get(), "a GI store appearing invalidates the frame");
+        d.giLight.flip();
+        shell.loopTick();
+        assertEquals(3, runs.get(), "a GI generation flip invalidates the frame");
+        d.giLightmaps = new eu.svjatoslav.aukio.e3d2.kernel.LightmapStore();
+        shell.loopTick();
+        assertEquals(4, runs.get(), "a new lightmap epoch invalidates the frame");
+    }
+
+    @Test
+    void frameRateDefaultsCappedAndSettable() {
+        final AwtShell shell = new AwtShell(data(), new Kernel(), "t");
+        assertEquals(60, shell.getTargetFPS(), "legacy default cap");
+        shell.setFrameRate(0);
+        assertEquals(0, shell.getTargetFPS(), "0 = unlimited (benchmark mode)");
+        shell.setFrameRate(60);
+        assertEquals(60, shell.getTargetFPS());
+    }
+
+    @Test
+    void unlimitedFpsRendersEveryTick() {
+        // legacy ViewPanel rule: benchmark mode renders continuously
+        // even when the scene reports no changes
+        final Substrate d = data();
+        final java.util.concurrent.atomic.AtomicInteger runs =
+                new java.util.concurrent.atomic.AtomicInteger();
+        final Kernel kernel = new Kernel();
+        kernel.add(new eu.svjatoslav.aukio.e3d2.kernel.Pass() {
+            @Override public String name() { return "counter"; }
+            @Override public void apply(final Substrate data) { runs.incrementAndGet(); }
+        });
+        final AwtShell shell = new AwtShell(d, kernel, "t");
+        shell.loopTick();
+        shell.loopTick();
+        assertEquals(1, runs.get(), "capped: static scene renders once");
+        shell.setFrameRate(0);
+        shell.loopTick();
+        shell.loopTick();
+        assertEquals(3, runs.get(), "unlimited: every tick renders");
+    }
+
+    @Test
+    void measuredFpsCountsBlittedFrames() {
+        // the FPS number is the SCREEN rate (legacy noteFrameBlitted):
+        // it counts blits, not renders
+        final Substrate d = data();
+        final Kernel kernel = new Kernel();
+        kernel.add(new eu.svjatoslav.aukio.e3d2.kernel.Pass() {
+            @Override public String name() { return "noop"; }
+            @Override public void apply(final Substrate data) { }
+        });
+        final AwtShell shell = new AwtShell(d, kernel, "t");
+        assertEquals(0.0, shell.getMeasuredFPS(), "no frames yet");
+        assertEquals(0L, shell.getLastPublishNanos(), "never blitted");
+        shell.loopTick(); // publishes one front
+        final BufferedImage target = new BufferedImage(64, 48, BufferedImage.TYPE_INT_ARGB);
+        final Graphics2D g = target.createGraphics();
+        final long deadline = System.nanoTime() + 700_000_000L;
+        while (System.nanoTime() < deadline) {
+            shell.blit(g);
+        }
+        g.dispose();
+        assertTrue(shell.getMeasuredFPS() > 0,
+                "700ms of continuous blitting produces a measured rate");
+        assertTrue(shell.getLastPublishNanos() > 0,
+                "blit timestamp tracked (the window's idle detector)");
+    }
+
+    @Test
+    void f12IsAShellKeyNotInput() {
+        final Substrate d = data();
+        final AwtShell shell = new AwtShell(d, new Kernel(), "t");
+        final KeyEvent f12 = new KeyEvent(new java.awt.Panel(), 0, 0, 0,
+                KeyEvent.VK_F12, '\0');
+        shell.keyListener.keyPressed(f12);
+        shell.keyListener.keyReleased(f12);
+        assertTrue(!d.input.isDown(KeyEvent.VK_F12),
+                "F12 toggles developer tools; it never enters InputStore");
+        assertTrue(!d.input.hasActivity(),
+                "F12 produces no pending camera input");
+    }
+
+    @Test
+    void autoWiresSpaceMousePassWhenMissing() {
+        // the shell owns its input source (SpaceMouseManager, started
+        // in open()) so it also owns the consuming pass: any app that
+        // opens an AwtShell flies with the 3D mouse with zero wiring
+        // (legacy ViewPanel behavior)
+        final Substrate d = data();
+        final Kernel kernel = new Kernel();
+        kernel.add(new eu.svjatoslav.aukio.e3d2.kernel.Pass() {
+            @Override public String name() { return "app"; }
+            @Override public void apply(final Substrate data) { }
+        });
+        final AwtShell shell = new AwtShell(d, kernel, "t");
+        assertEquals(2, kernel.passCount());
+        assertEquals("spacemouse", kernel.passName(0),
+                "auto-wired pass runs at its order (7), before app passes");
+        assertEquals("app", kernel.passName(1));
+    }
+
+    @Test
+    void keepsAppRegisteredSpaceMousePass() {
+        // an app that wired its own SpaceMousePass keeps it — no duplicate
+        final Substrate d = data();
+        final Kernel kernel = new Kernel();
+        kernel.add(new SpaceMousePass());
+        kernel.add(new eu.svjatoslav.aukio.e3d2.kernel.Pass() {
+            @Override public String name() { return "app"; }
+            @Override public void apply(final Substrate data) { }
+        });
+        final AwtShell shell = new AwtShell(d, kernel, "t");
+        assertEquals(2, kernel.passCount());
+        assertEquals("spacemouse", kernel.passName(0));
+        assertEquals("app", kernel.passName(1));
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/app/BugReportTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/app/BugReportTest.java
new file mode 100644 (file)
index 0000000..82f09f6
--- /dev/null
@@ -0,0 +1,105 @@
+package eu.svjatoslav.aukio.e3d2.app;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import javax.imageio.ImageIO;
+import java.io.File;
+import java.nio.file.Files;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * BugReport: the report directory carries description, info (camera
+ * pose, versions), a screenshot of the frame buffer and a thread dump.
+ */
+class BugReportTest {
+
+    @TempDir
+    File tempDir;
+
+    @Test
+    void createsCompleteReportDirectory() throws Exception {
+        System.setProperty("e3d2.bugreport.dir", tempDir.getAbsolutePath());
+        try {
+            final Substrate data = new Substrate();
+            data.image = new ImageStore(32, 24);
+            data.image.clear(0xFF102030);
+            data.camera.set(1, 2, 3, 0.4, -0.5, 20);
+
+            final File dir = BugReport.create(data, null, "it broke");
+
+            assertEquals("it broke", Files.readString(
+                    new File(dir, "description.txt").toPath()));
+
+            final String info = Files.readString(
+                    new File(dir, "info.txt").toPath());
+            assertTrue(info.contains("camera = (1.00, 2.00, 3.00, 0.40, -0.50)"),
+                    "info carries the five-field pose:\n" + info);
+            assertTrue(info.contains("view.size = 32x24"));
+            assertTrue(info.contains("java.version"));
+            assertTrue(info.contains("engine = aukio-3d-2"));
+
+            final File screenshot = new File(dir, "screenshot.png");
+            assertTrue(screenshot.isFile());
+            final java.awt.image.BufferedImage img =
+                    ImageIO.read(screenshot);
+            assertEquals(32, img.getWidth());
+            assertEquals(24, img.getHeight());
+            assertEquals(0x102030, img.getRGB(10, 10) & 0xFFFFFF);
+
+            final String threads = Files.readString(
+                    new File(dir, "threads.txt").toPath());
+            assertTrue(threads.contains("main"), "thread dump non-empty");
+            // histogram: either the real thing or the explicit
+            // unavailable note — never silently missing
+            assertTrue(new File(dir, "histogram.txt").isFile()
+                    || new File(dir, "histogram-unavailable.txt").isFile());
+        } finally {
+            System.clearProperty("e3d2.bugreport.dir");
+        }
+    }
+
+    @Test
+    void noFrameWritesScreenshotMissingNote() throws Exception {
+        System.setProperty("e3d2.bugreport.dir", tempDir.getAbsolutePath());
+        try {
+            final Substrate data = new Substrate();
+            final File dir = BugReport.create(data, null, "");
+            assertTrue(new File(dir, "screenshot-missing.txt").isFile());
+        } finally {
+            System.clearProperty("e3d2.bugreport.dir");
+        }
+    }
+
+    @Test
+    void reportIncludesPersistentLog() throws Exception {
+        final File logDir = new File(tempDir, "logs");
+        System.setProperty("e3d2.bugreport.dir", tempDir.getAbsolutePath());
+        System.setProperty("e3d2.log.dir", logDir.getAbsolutePath());
+        try {
+            PersistentLog.install();
+            final String marker = "bugreport-test-marker "
+                    + System.nanoTime();
+            System.out.println(marker);
+
+            final Substrate data = new Substrate();
+            final File dir = BugReport.create(data, null, "log pin");
+
+            final File copied = new File(dir, "e3d2.log");
+            assertTrue(copied.isFile(),
+                    "report carries the persistent log");
+            assertTrue(Files.readString(copied.toPath()).contains(marker),
+                    "copied log contains this session's output");
+            assertTrue(Files.readString(
+                    new File(dir, "info.txt").toPath())
+                    .contains("persistent.log = "));
+        } finally {
+            System.clearProperty("e3d2.bugreport.dir");
+            System.clearProperty("e3d2.log.dir");
+        }
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/app/DeveloperToolsWindowTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/app/DeveloperToolsWindowTest.java
new file mode 100644 (file)
index 0000000..ecc465a
--- /dev/null
@@ -0,0 +1,51 @@
+package eu.svjatoslav.aukio.e3d2.app;
+
+import eu.svjatoslav.aukio.e3d2.kernel.CameraStore;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/** Camera pose copy/paste format: five strict decimals, round-trip. */
+class DeveloperToolsWindowTest {
+
+    @Test
+    void formatIsLegacyMinusRoll() {
+        final CameraStore camera = new CameraStore();
+        camera.set(-405.73, 407.15, -713.60, 0.5170, -0.4609, 300);
+        assertEquals("-405.73, 407.15, -713.60, 0.52, -0.46",
+                DeveloperToolsWindow.formatPose(camera));
+    }
+
+    @Test
+    void parseRoundTripsFormat() {
+        final double[] pose = DeveloperToolsWindow.parsePose(
+                "-405.73, 407.15, -713.60, 0.52, -0.46");
+        assertEquals(-405.73, pose[0], 1e-9);
+        assertEquals(407.15, pose[1], 1e-9);
+        assertEquals(-713.60, pose[2], 1e-9);
+        assertEquals(0.52, pose[3], 1e-9);
+        assertEquals(-0.46, pose[4], 1e-9);
+    }
+
+    @Test
+    void parseAcceptsStrictDecimalsOnly() {
+        assertEquals(5, DeveloperToolsWindow.parsePose(
+                "1, -2.5, .25, 1e3, +0.5").length);
+        // legacy rejects these too (Double.parseDouble would accept):
+        assertNull(DeveloperToolsWindow.parsePose("1, 2, 3, 4, NaN"));
+        assertNull(DeveloperToolsWindow.parsePose("1, 2, 3, 4, Infinity"));
+        assertNull(DeveloperToolsWindow.parsePose("1, 2, 3, 4, 0x1p3"));
+    }
+
+    @Test
+    void parseRejectsWrongFieldCounts() {
+        // six fields (a LEGACY pose with roll) is rejected: legacy
+        // coordinates are -Y-up and meaningless in e3d2 verbatim
+        assertNull(DeveloperToolsWindow.parsePose("1, 2, 3, 4, 5, 6"));
+        assertNull(DeveloperToolsWindow.parsePose("1, 2, 3, 4"));
+        assertNull(DeveloperToolsWindow.parsePose(""));
+        assertNull(DeveloperToolsWindow.parsePose(null));
+        assertNull(DeveloperToolsWindow.parsePose("hello"));
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/app/DragLookLoopTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/app/DragLookLoopTest.java
new file mode 100644 (file)
index 0000000..ec34fb5
--- /dev/null
@@ -0,0 +1,55 @@
+package eu.svjatoslav.aukio.e3d2.app;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CameraPass;
+import org.junit.jupiter.api.Test;
+
+import java.awt.event.MouseEvent;
+
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+/**
+ * The REAL drag-look integration: drag events through the shell's real
+ * listeners + the real loop tick + the real CameraPass. If the camera
+ * does not turn tick over tick, the render-on-demand logic is broken
+ * (reproduces "drag rotates nothing until resize").
+ */
+class DragLookLoopTest {
+
+    @Test
+    void dragTurnsCameraEveryTick() {
+        final Substrate d = new Substrate();
+        d.image = new ImageStore(64, 48);
+        d.camera.set(0, 100, -300, 0, -0.3, 21.3);
+        final Kernel kernel = new Kernel();
+        kernel.add(new CameraPass());
+        final AwtShell shell = new AwtShell(d, kernel, "t");
+        final java.awt.Panel src = new java.awt.Panel();
+
+        // first tick: initial render (lastSignature null)
+        shell.loopTick();
+
+        // press inside the window
+        shell.mouseListener.mousePressed(
+                new MouseEvent(src, 0, 0, 0, 30, 24, 1, false));
+        double lastYaw = d.camera.yaw;
+        int renders = 0;
+        for (int i = 1; i <= 6; i++) {
+            shell.mouseMotionListener.mouseDragged(
+                    new MouseEvent(src, 0, 0, 0, 30 + i * 4, 24, 0, false));
+            shell.loopTick();
+            if (d.camera.yaw != lastYaw) {
+                renders++;
+                lastYaw = d.camera.yaw;
+            }
+        }
+        assertNotEquals(0, renders,
+                "camera must turn on drag ticks (loop starvation bug)");
+        // the FIRST drag event only sets the anchor (no delta), so tick
+        // 1 renders without turning; ticks 2-6 must ALL turn
+        org.junit.jupiter.api.Assertions.assertEquals(5, renders,
+                "every drag tick after the anchor must turn the camera");
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/app/SpaceNavigatorHidTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/app/SpaceNavigatorHidTest.java
new file mode 100644 (file)
index 0000000..1583427
--- /dev/null
@@ -0,0 +1,125 @@
+package eu.svjatoslav.aukio.e3d2.app;
+
+import eu.svjatoslav.aukio.e3d2.kernel.InputStore;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Wire-protocol parsing for both supported 3Dconnexion models, fed
+ * synthetic reports (byte offsets breaking silently is the failure
+ * mode — the constants come from libspnav docs and the device's own
+ * HID descriptor, both live-verified in legacy aukio-3d).
+ */
+class SpaceNavigatorHidTest {
+
+    private static byte[] report(final int... bytes) {
+        final byte[] b = new byte[bytes.length];
+        for (int i = 0; i < bytes.length; i++) {
+            b[i] = (byte) bytes[i];
+        }
+        return b;
+    }
+
+    @Test
+    void usbTranslationReport() {
+        final InputStore input = new InputStore();
+        final SpaceNavigatorHid hid = new SpaceNavigatorHid(input,
+                SpaceNavigatorHid.Model.SPACENAVIGATOR_USB);
+        // id 1: tx=100, ty=-100, tz=350 (int16 LE)
+        hid.parseReport(report(1, 100, 0, 0x9C, 0xFF, 0x5E, 0x01), 7);
+        assertEquals(100, input.capTx());
+        assertEquals(-100, input.capTy());
+        assertEquals(350, input.capTz());
+    }
+
+    @Test
+    void usbRotationReportMergesWithTranslation() {
+        // USB sends translation and rotation as SEPARATE reports; one
+        // report must not zero the other triple
+        final InputStore input = new InputStore();
+        final SpaceNavigatorHid hid = new SpaceNavigatorHid(input,
+                SpaceNavigatorHid.Model.SPACENAVIGATOR_USB);
+        hid.parseReport(report(1, 10, 0, 20, 0, 30, 0), 7);
+        hid.parseReport(report(2, 1, 0, 2, 0, 3, 0), 7);
+        assertEquals(10, input.capTx());
+        assertEquals(20, input.capTy());
+        assertEquals(30, input.capTz());
+        assertEquals(1, input.capRx());
+        assertEquals(2, input.capRy());
+        assertEquals(3, input.capRz());
+    }
+
+    @Test
+    void usbButtons() {
+        final InputStore input = new InputStore();
+        final SpaceNavigatorHid hid = new SpaceNavigatorHid(input,
+                SpaceNavigatorHid.Model.SPACENAVIGATOR_USB);
+        hid.parseReport(report(3, 0x01, 0, 0, 0, 0, 0), 7);
+        assertEquals(1, input.capButtons());
+        hid.parseReport(report(3, 0x02, 0, 0, 0, 0, 0), 7);
+        assertEquals(2, input.capButtons());
+    }
+
+    @Test
+    void usbShortReportIgnored() {
+        final InputStore input = new InputStore();
+        final SpaceNavigatorHid hid = new SpaceNavigatorHid(input,
+                SpaceNavigatorHid.Model.SPACENAVIGATOR_USB);
+        hid.parseReport(report(1, 100, 0), 3);
+        assertEquals(0, input.capTx());
+    }
+
+    @Test
+    void btCombinedMotionReport() {
+        final InputStore input = new InputStore();
+        final SpaceNavigatorHid hid = new SpaceNavigatorHid(input,
+                SpaceNavigatorHid.Model.SPACEMOUSE_WIRELESS_BT);
+        // id 1, 13 bytes: x=1, y=-2, z=3, rx=4, ry=5, rz=-6
+        hid.parseReport(report(1, 1, 0, 0xFE, 0xFF, 3, 0, 4, 0, 5, 0,
+                0xFA, 0xFF), 13);
+        assertEquals(1, input.capTx());
+        assertEquals(-2, input.capTy());
+        assertEquals(3, input.capTz());
+        assertEquals(4, input.capRx());
+        assertEquals(5, input.capRy());
+        assertEquals(-6, input.capRz());
+    }
+
+    @Test
+    void btShortButtonReportNotDropped() {
+        // the BT button report is 3 bytes — a shared n < 7 guard would
+        // drop it (legacy comment pins this)
+        final InputStore input = new InputStore();
+        final SpaceNavigatorHid hid = new SpaceNavigatorHid(input,
+                SpaceNavigatorHid.Model.SPACEMOUSE_WIRELESS_BT);
+        hid.parseReport(report(3, 0x01, 0), 3);
+        assertEquals(1, input.capButtons());
+    }
+
+    @Test
+    void btBatteryReport() {
+        final InputStore input = new InputStore();
+        final SpaceNavigatorHid hid = new SpaceNavigatorHid(input,
+                SpaceNavigatorHid.Model.SPACEMOUSE_WIRELESS_BT);
+        assertEquals(-1, hid.getBatteryPercent());
+        assertFalse(hid.isCharging());
+        hid.parseReport(report(0x17, 73, 0x01), 3);
+        assertEquals(73, hid.getBatteryPercent());
+        assertTrue(hid.isCharging());
+    }
+
+    @Test
+    void modelMatching() {
+        assertEquals(SpaceNavigatorHid.Model.SPACENAVIGATOR_USB,
+                SpaceNavigatorHid.Model.match(
+                        "HID_ID=0003:0000046D:0000C626\n"));
+        assertEquals(SpaceNavigatorHid.Model.SPACEMOUSE_WIRELESS_BT,
+                SpaceNavigatorHid.Model.match(
+                        "HID_ID=0005:0000256F:0000C63A\n"));
+        assertEquals(null, SpaceNavigatorHid.Model.match(
+                "HID_ID=0003:0000046D:0000C548\n"));
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/BvhRaceStressTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/BvhRaceStressTest.java
new file mode 100644 (file)
index 0000000..e054e27
--- /dev/null
@@ -0,0 +1,92 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The BVH snapshot race: a query must see ONE consistent tree version.
+ * Reader threads probe rays with known-correct answers while a builder
+ * thread keeps mutating the mesh and rebuilding. Any mixed-version
+ * traversal shows up as a wrong answer.
+ *
+ * Before the RCU fix this FAILS within seconds; after it, silent.
+ */
+class BvhRaceStressTest {
+
+    private static final int SLOTS = 64;
+
+    @Test
+    void queriesSeeOneConsistentVersionDuringRebuilds() throws Exception {
+        final MeshStore mesh = new MeshStore();
+        // SLOTS triangles, each owning an x-slot at z=10
+        for (int i = 0; i < SLOTS; i++) {
+            mesh.addTriangle(i * 2.0, -1, 10, i * 2.0 + 1.5, -1, 10, i * 2.0, 1, 10);
+        }
+        final BvhStore bvh = new BvhStore();
+        bvh.ensure(mesh);
+
+        // expected[i] = the triangle a probe ray through slot i must hit
+        final int[] expected = new int[SLOTS];
+        for (int i = 0; i < SLOTS; i++) {
+            expected[i] = bvh.nearest(mesh, -1, i * 2.0 + 0.5, 0, 0, 0, 0, 1, 1e9, null);
+        }
+
+        final AtomicLong mismatches = new AtomicLong();
+        final AtomicLong queries = new AtomicLong();
+        final AtomicLong rebuilds = new AtomicLong();
+        final AtomicLong crashes = new AtomicLong();
+        final java.util.concurrent.atomic.AtomicBoolean stopFlag =
+                new java.util.concurrent.atomic.AtomicBoolean();
+
+        final Runnable reader = () -> {
+            long n = 0, bad = 0;
+            try {
+                while (!stopFlag.get()) {
+                    for (int i = 0; i < SLOTS; i++) {
+                        final int hit = bvh.nearest(mesh, -1,
+                                i * 2.0 + 0.5, 0, 0, 0, 0, 1, 1e9, null);
+                        n++;
+                        if (hit != expected[i]) {
+                            bad++;
+                        }
+                    }
+                }
+            } catch (final Throwable t) {
+                crashes.incrementAndGet();
+            }
+            queries.addAndGet(n);
+            mismatches.addAndGet(bad);
+        };
+        final Thread[] readers = new Thread[4];
+        for (int r = 0; r < readers.length; r++) {
+            readers[r] = new Thread(reader);
+            readers[r].setDaemon(true);
+            readers[r].start();
+        }
+
+        // builder: add a far-away triangle (never affects probe answers),
+        // forcing a full BVH rebuild under the readers, repeatedly
+        final long end = System.currentTimeMillis() + 3000;
+        int extra = 0;
+        while (System.currentTimeMillis() < end) {
+            mesh.addTriangle(-500, -500, -1000 - extra,
+                    -499, -500, -1000 - extra, -500, -499, -1000 - extra);
+            extra++;
+            bvh.ensure(mesh);
+            rebuilds.incrementAndGet();
+        }
+        stopFlag.set(true);
+        for (final Thread t : readers) {
+            t.join();
+        }
+        System.out.println("queries=" + queries.get() + " rebuilds=" + rebuilds.get()
+                + " mismatches=" + mismatches.get() + " crashes=" + crashes.get());
+        assertEquals(0, crashes.get(), "reader threads crashed mid-traversal");
+        assertEquals(0, mismatches.get(),
+                "mixed-version traversals observed (queries=" + queries.get()
+                        + ", rebuilds=" + rebuilds.get() + ")");
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/BvhStoreTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/BvhStoreTest.java
new file mode 100644 (file)
index 0000000..c784630
--- /dev/null
@@ -0,0 +1,178 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Random;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * BVH vs brute force: the brute-force reference lives HERE (test-owned),
+ * so the production code keeps a single copy of the intersection math.
+ */
+class BvhStoreTest {
+
+    /** Reference: exhaustive scan using the public single-hit query. */
+    private static int bruteNearest(final MeshStore mesh, final int self,
+                                    final double ox, final double oy, final double oz,
+                                    final double dx, final double dy, final double dz,
+                                    final double maxDist) {
+        int hit = -1;
+        double best = maxDist;
+        for (int tri = 0; tri < mesh.triangleCount(); tri++) {
+            if (tri == self) {
+                continue;
+            }
+            final double t = BvhStore.intersectTuv(mesh, tri, ox, oy, oz,
+                    dx, dy, dz, best, null);
+            if (t > 0 && t < best) {
+                best = t;
+                hit = tri;
+            }
+        }
+        return hit;
+    }
+
+    private static MeshStore randomMesh(final Random rnd, final int n) {
+        final MeshStore mesh = new MeshStore();
+        for (int i = 0; i < n; i++) {
+            final double cx = rnd.nextDouble() * 100 - 50;
+            final double cy = rnd.nextDouble() * 100 - 50;
+            final double cz = rnd.nextDouble() * 100 - 50;
+            final double s = 1 + rnd.nextDouble() * 4;
+            mesh.addTriangle(cx, cy, cz,
+                    cx + s * rnd.nextDouble(), cy + s * rnd.nextDouble(), cz,
+                    cx, cy + s * rnd.nextDouble(), cz + s * rnd.nextDouble());
+        }
+        return mesh;
+    }
+
+    /** Ray either aimed at a random triangle's centroid (with jitter) or
+     *  fully random (10%), so the suite exercises hits AND misses. */
+    private static double[] ray(final Random rnd, final MeshStore mesh) {
+        final double ox = rnd.nextDouble() * 120 - 60;
+        final double oy = rnd.nextDouble() * 120 - 60;
+        final double oz = rnd.nextDouble() * 120 - 60;
+        if (rnd.nextInt(10) == 0) {
+            return new double[]{ox, oy, oz,
+                    rnd.nextDouble() * 2 - 1, rnd.nextDouble() * 2 - 1,
+                    rnd.nextDouble() * 2 - 1};
+        }
+        final int target = rnd.nextInt(mesh.triangleCount());
+        double tx = 0, ty = 0, tz = 0;
+        for (int v = 0; v < 3; v++) {
+            tx += mesh.at(target, v, 0); ty += mesh.at(target, v, 1); tz += mesh.at(target, v, 2);
+        }
+        double dx = tx / 3 - ox + (rnd.nextDouble() - 0.5) * 3;
+        double dy = ty / 3 - oy + (rnd.nextDouble() - 0.5) * 3;
+        double dz = tz / 3 - oz + (rnd.nextDouble() - 0.5) * 3;
+        final double len = Math.sqrt(dx * dx + dy * dy + dz * dz);
+        return new double[]{ox, oy, oz, dx / len, dy / len, dz / len};
+    }
+
+    @Test
+    void nearestMatchesBruteForceOnRandomRays() {
+        final Random rnd = new Random(42);
+        final MeshStore mesh = randomMesh(rnd, 300);
+        final BvhStore bvh = new BvhStore();
+        bvh.ensure(mesh);
+        int hits = 0;
+        for (int r = 0; r < 2000; r++) {
+            final double[] q = ray(rnd, mesh);
+            final int self = rnd.nextInt(mesh.triangleCount());
+            final int brute = bruteNearest(mesh, self, q[0], q[1], q[2], q[3], q[4], q[5], 1e9);
+            final int fast = bvh.nearest(mesh, self, q[0], q[1], q[2], q[3], q[4], q[5], 1e9, null);
+            assertEquals(brute, fast, "ray " + r);
+            if (brute >= 0) {
+                hits++;
+            }
+        }
+        assertTrue(hits > 200, "test must exercise real hits, got " + hits);
+    }
+
+    @Test
+    void occludedMatchesBruteForceOnRandomRays() {
+        final Random rnd = new Random(7);
+        final MeshStore mesh = randomMesh(rnd, 300);
+        final BvhStore bvh = new BvhStore();
+        bvh.ensure(mesh);
+        int occl = 0;
+        for (int r = 0; r < 2000; r++) {
+            final double[] q = ray(rnd, mesh);
+            final int self = rnd.nextInt(mesh.triangleCount());
+            final double maxDist = 5 + rnd.nextDouble() * 200;
+            final boolean brute = bruteNearest(mesh, self, q[0], q[1], q[2], q[3], q[4], q[5], maxDist) >= 0;
+            assertEquals(brute, bvh.occluded(mesh, self, q[0], q[1], q[2], q[3], q[4], q[5], maxDist),
+                    "ray " + r);
+            if (brute) {
+                occl++;
+            }
+        }
+        assertTrue(occl > 100, "test must exercise occlusions, got " + occl);
+    }
+
+    @Test
+    void uvOutMatchesBruteForce() {
+        final Random rnd = new Random(13);
+        final MeshStore mesh = randomMesh(rnd, 100);
+        final BvhStore bvh = new BvhStore();
+        bvh.ensure(mesh);
+        final double[] uvBvh = new double[2];
+        final double[] uvBrute = new double[2];
+        int checked = 0;
+        for (int r = 0; r < 500; r++) {
+            final double[] q = ray(rnd, mesh);
+            final int hit = bvh.nearest(mesh, -1, q[0], q[1], q[2], q[3], q[4], q[5], 1e9, uvBvh);
+            // reference with uv
+            int brute = -1;
+            double best = 1e9;
+            for (int tri = 0; tri < mesh.triangleCount(); tri++) {
+                final double t = BvhStore.intersectTuv(mesh, tri, q[0], q[1], q[2],
+                        q[3], q[4], q[5], best, uvBrute);
+                if (t > 0 && t < best) {
+                    best = t;
+                    brute = tri;
+                }
+            }
+            assertEquals(brute, hit, "ray " + r);
+            if (hit >= 0) {
+                assertEquals(uvBrute[0], uvBvh[0], 1e-9, "u ray " + r);
+                assertEquals(uvBrute[1], uvBvh[1], 1e-9, "v ray " + r);
+                checked++;
+            }
+        }
+        assertTrue(checked > 50, "uv check exercised, got " + checked);
+    }
+
+    @Test
+    void emptyAndSingleTriangleMeshes() {
+        final BvhStore bvh = new BvhStore();
+        final MeshStore empty = new MeshStore();
+        bvh.ensure(empty);
+        assertEquals(-1, bvh.nearest(empty, -1, 0, 0, 0, 0, 0, 1, 1e9, null));
+
+        final MeshStore one = new MeshStore();
+        one.addTriangle(-1, -1, 5, 1, -1, 5, 0, 1, 5);
+        bvh.ensure(one);
+        assertEquals(0, bvh.nearest(one, -1, 0, 0, 0, 0, 0, 1, 1e9, null));
+        assertEquals(-1, bvh.nearest(one, 0, 0, 0, 0, 0, 0, 1, 1e9, null),
+                "self excluded");
+        assertEquals(-1, bvh.nearest(one, -1, 0, 0, 5, 0, 0, -1, 1e9, null),
+                "backwards ray misses");
+    }
+
+    @Test
+    void ensureIsVersionCheckedAndIdempotent() {
+        final MeshStore mesh = new MeshStore();
+        mesh.addTriangle(-1, -1, 5, 1, -1, 5, 0, 1, 5);
+        final BvhStore bvh = new BvhStore();
+        bvh.ensure(mesh);
+        assertEquals(0, bvh.nearest(mesh, -1, 0, 0, 0, 0, 0, 1, 1e9, null));
+        // add geometry BEHIND the first tri: index must see it after mutation
+        mesh.addTriangle(-2, -2, 8, 2, -2, 8, 0, 2, 8);
+        bvh.ensure(mesh);
+        assertEquals(0, bvh.nearest(mesh, -1, 0, 0, 0, 0, 0, 1, 1e9, null),
+                "nearer tri still wins after rebuild");
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/KernelOrderingTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/KernelOrderingTest.java
new file mode 100644 (file)
index 0000000..b778072
--- /dev/null
@@ -0,0 +1,55 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class KernelOrderingTest {
+
+    private static Pass pass(final String name, final int order) {
+        return new Pass() {
+            @Override
+            public String name() {
+                return name;
+            }
+
+            @Override
+            public void apply(final Substrate data) {
+            }
+
+            @Override
+            public int order() {
+                return order;
+            }
+        };
+    }
+
+    @Test
+    void discoverySortsByOrderNotRegistration() {
+        // a "third-party plugin" (order 250) registered BEFORE kernel passes
+        final List<Pass> found = List.of(
+                pass("plugin", 250),
+                pass("raster", 30),
+                pass("transform", 10),
+                pass("feature", 100));
+        final List<Pass> sorted = Kernel.sortedByOrder(found);
+        assertEquals(List.of("transform", "raster", "feature", "plugin"),
+                sorted.stream().map(Pass::name).toList(),
+                "plugins declare their own priority; no central file");
+    }
+
+    @Test
+    void builtInPipelineOrderIsDeterministic() {
+        final Kernel kernel = Kernel.discover();
+        // built-ins must come out spacemouse -> transform -> cull ->
+        // raster -> fog regardless of ServiceLoader's load order
+        assertTrue(kernel.passCount() >= 5, "at least the 5 built-ins discovered");
+        assertEquals("spacemouse", kernel.passName(0));
+        assertEquals("transform", kernel.passName(1));
+        assertEquals("cull", kernel.passName(2));
+        assertEquals("raster", kernel.passName(3));
+        assertEquals("fog", kernel.passName(4));
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/KernelSortTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/KernelSortTest.java
new file mode 100644 (file)
index 0000000..253b760
--- /dev/null
@@ -0,0 +1,59 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/** Stable ascending sort by Pass.order(). */
+class KernelSortTest {
+
+    private static Pass stub(final String name, final int order) {
+        return new Pass() {
+            @Override public String name() { return name; }
+            @Override public void apply(final Substrate data) { }
+            @Override public int order() { return order; }
+        };
+    }
+
+    @Test
+    void ascendingWithStableTies() {
+        final Pass a = stub("a", 100);
+        final Pass b = stub("b", 100);
+        final Pass c = stub("c", 10);
+        final List<Pass> sorted = Kernel.sortedByOrder(List.of(a, b, c));
+        assertEquals("c", sorted.get(0).name());
+        assertEquals("a", sorted.get(1).name());
+        assertEquals("b", sorted.get(2).name());
+    }
+
+    @Test
+    void addInsertsByOrder() {
+        // explicit kernels get the discover() ordering guarantee:
+        // registration sequence must not change run order
+        final Kernel kernel = new Kernel();
+        kernel.add(stub("late", 30));
+        kernel.add(stub("early", 10));
+        kernel.add(stub("mid", 20));
+        kernel.add(stub("tie", 20)); // stable: after the first order-20
+        assertEquals("early", kernel.passName(0));
+        assertEquals("mid", kernel.passName(1));
+        assertEquals("tie", kernel.passName(2));
+        assertEquals("late", kernel.passName(3));
+    }
+
+    @Test
+    void hasFindsExactClassOnly() {
+        final Kernel kernel = new Kernel();
+        assertEquals(false, kernel.has(CameraPassProbe.class));
+        kernel.add(new CameraPassProbe());
+        assertEquals(true, kernel.has(CameraPassProbe.class));
+    }
+
+    private static final class CameraPassProbe implements Pass {
+        @Override public String name() { return "probe"; }
+        @Override public void apply(final Substrate data) { }
+        @Override public int order() { return 999; }
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/LightmapStoreVersionTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/LightmapStoreVersionTest.java
new file mode 100644 (file)
index 0000000..646e90e
--- /dev/null
@@ -0,0 +1,68 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The lightmap field is a frame input: every publish that changes what the
+ * raster samples must be observable as a version bump, or render-on-demand
+ * keeps showing the pre-bake (black) frame.
+ */
+class LightmapStoreVersionTest {
+
+    private static Substrate oneTriangle() {
+        final Substrate d = new Substrate();
+        d.image = new ImageStore(8, 8);
+        d.triangles.addTriangle(0, 0, 5, 1, 0, 5, 0, 1, 5);
+        return d;
+    }
+
+    @Test
+    void versionAdvancesOnEveryFieldPublish() {
+        final Substrate d = oneTriangle();
+        final LightmapStore lm = new LightmapStore();
+        lm.allocate(d, 0, 1.0);
+        final int allocated = lm.version();
+        lm.flip();
+        final int flipped = lm.version();
+        assertNotEquals(allocated, flipped, "a generation flip changes the field");
+        lm.publishEntry(0);
+        assertNotEquals(flipped, lm.version(), "a catch-up publish changes the field");
+    }
+
+    @Test
+    void versionsAreUniqueAcrossStores() {
+        // A rebuild allocates a NEW store; if versions restarted at 0 the
+        // signature could not tell the new epoch from the old one.
+        final int a = new LightmapStore().version();
+        final int b = new LightmapStore().version();
+        assertTrue(a != b, "distinct stores must carry distinct versions");
+    }
+
+    @Test
+    void unbakedSurfaceShowsAlbedoNotBlack() {
+        // v1 parity: before the first bake flip the light field reads FULL
+        // irradiance, so the display composite is the albedo. A zero field
+        // made every lightmapped surface black until the user moved.
+        final Substrate d = oneTriangle();   // default triangle colour 0xFF808080
+        final LightmapStore lm = new LightmapStore();
+        lm.allocate(d, 0, 1.0);
+        assertEquals(0xFFFFFF, lm.texelAt(0, 0, 0), "placeholder light is full-bright");
+        final double u = 0.5 / lm.width(0), v = 0.5 / lm.height(0);
+        assertEquals(0xFF808080, lm.compositeAt(0, u, v), "display is albedo, not black");
+    }
+
+    @Test
+    void giStoreVersionsTrackPublish() {
+        final GiStore gi = new GiStore(1);
+        final int created = gi.version();
+        gi.flip();
+        assertNotEquals(created, gi.version(), "flip changes the displayed field");
+        final int flipped = gi.version();
+        gi.publishEntry(0);
+        assertNotEquals(flipped, gi.version(), "publishEntry changes the displayed field");
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/MeshPublishStressTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/MeshPublishStressTest.java
new file mode 100644 (file)
index 0000000..7176a8f
--- /dev/null
@@ -0,0 +1,59 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The MeshPublish protocol under real concurrency: appender and
+ * builder(ensure) on SEPARATE threads (BvhRaceStressTest appends and
+ * ensures from one thread, so it cannot see the version-vs-count
+ * ordering race). A version-before-count ordering can tag a tree with
+ * a version whose last triangle it does not contain — a PERMANENT miss,
+ * because ensure() then sees equal versions and never rebuilds.
+ * Protocol verified by Model/MeshPublish.tla; this pins the Java side.
+ */
+class MeshPublishStressTest {
+
+    @Test
+    void noPermanentIndexMissUnderConcurrentAppendAndEnsure() throws Exception {
+        final int n = 2000;
+        final MeshStore mesh = new MeshStore();
+        final BvhStore bvh = new BvhStore();
+        final AtomicBoolean appenderDone = new AtomicBoolean();
+        final AtomicLong crashes = new AtomicLong();
+
+        final Thread builder = new Thread(() -> {
+            try {
+                while (!appenderDone.get()) {
+                    bvh.ensure(mesh);
+                }
+                bvh.ensure(mesh); // final rebuild
+            } catch (final Throwable t) {
+                crashes.incrementAndGet();
+            }
+        });
+        builder.setDaemon(true);
+        builder.start();
+
+        for (int i = 0; i < n; i++) {
+            mesh.addTriangle(i * 2.0, -1, 10, i * 2.0 + 1.5, -1, 10, i * 2.0, 1, 10);
+        }
+        appenderDone.set(true);
+        builder.join();
+
+        assertEquals(0, crashes.get(), "builder crashed");
+        // every triangle must be findable: no permanent miss at any version
+        int misses = 0;
+        for (int i = 0; i < n; i++) {
+            final int hit = bvh.nearest(mesh, -1, i * 2.0 + 0.5, 0, 0, 0, 0, 1, 1e9, null);
+            if (hit != i) {
+                misses++;
+            }
+        }
+        assertEquals(0, misses, "permanent index misses after final ensure");
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/MeshStoreBlocksTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/kernel/MeshStoreBlocksTest.java
new file mode 100644 (file)
index 0000000..27e08e2
--- /dev/null
@@ -0,0 +1,50 @@
+package eu.svjatoslav.aukio.e3d2.kernel;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/** Blocked storage layout: correctness across block boundaries and
+ *  growth without whole-store reallocation. */
+class MeshStoreBlocksTest {
+
+    @Test
+    void trianglesSurviveBlockBoundaries() {
+        final MeshStore mesh = new MeshStore();
+        final int n = 8192 * 2 + 100; // crosses two block boundaries
+        for (int i = 0; i < n; i++) {
+            mesh.addTriangle(i, i + 1, i + 2,
+                    i + 3, i + 4, i + 5,
+                    i + 6, i + 7, i + 8, 0xFF000000 | i,
+                    i + 0.1, i + 0.2, i + 0.3, i + 0.4, i + 0.5, i + 0.6,
+                    i * 2);
+        }
+        assertEquals(n, mesh.triangleCount());
+        assertEquals(n, mesh.version());
+        // spot-check first, boundary, and last triangles
+        final int[] probes = {0, 8191, 8192, 8193, 16383, 16384, n - 1};
+        for (final int i : probes) {
+            assertEquals(i, mesh.at(i, 0, 0), "x0 at " + i);
+            assertEquals(i + 8, mesh.at(i, 2, 2), "z2 at " + i);
+            assertEquals(0xFF000000 | i, mesh.color(i), "color at " + i);
+            assertEquals(i * 2, mesh.textureId(i), "texId at " + i);
+            assertEquals(i + 0.5, mesh.uvAt(i, 2, 0), 1e-9, "u2 at " + i);
+            assertEquals(i + 0.6, mesh.uvAt(i, 2, 1), 1e-9, "v2 at " + i);
+        }
+    }
+
+    @Test
+    void bvhStillIndexesAcrossBlocks() {
+        final MeshStore mesh = new MeshStore();
+        final int n = 9000;
+        for (int i = 0; i < n; i++) {
+            mesh.addTriangle(i * 2.0, -1, 10, i * 2.0 + 1.5, -1, 10, i * 2.0, 1, 10);
+        }
+        final BvhStore bvh = new BvhStore();
+        bvh.ensure(mesh);
+        for (final int i : new int[]{0, 8191, 8192, n - 1}) {
+            assertEquals(i, bvh.nearest(mesh, -1, i * 2.0 + 0.5, 0, 0, 0, 0, 1, 1e9, null),
+                    "probe " + i);
+        }
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/shapes/ShapesTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/shapes/ShapesTest.java
new file mode 100644 (file)
index 0000000..4813f38
--- /dev/null
@@ -0,0 +1,191 @@
+package eu.svjatoslav.aukio.e3d2.shapes;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Shapes gallery: triangle counts, WINDING NORMALS, and visibility.
+ *
+ * The e3d2 cull convention (measured, CullPass javadoc): the KEPT
+ * winding has its normal pointing AWAY from the viewer — so a solid
+ * must be wound with normals pointing INTO it. The render-from-six-
+ * directions check alone CANNOT see an inside-out solid (flat albedo
+ * makes interior faces indistinguishable; user bugreport
+ * 20261014-214114 — sofa cube hollow under GI — shipped past this
+ * test), so every solid additionally gets a geometric pin: each
+ * triangle's winding normal must point toward the solid's interior
+ * reference point.
+ */
+class ShapesTest {
+
+    private static final int W = 64;
+    private static final int H = 64;
+    private static final int BG = 0xFF102030;
+
+    private static Kernel kernel() {
+        final Kernel kernel = new Kernel();
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        return kernel;
+    }
+
+    /** Renders the scene looking at the origin from (px,py,pz);
+     *  returns the center pixel. */
+    private static int centerPixelFrom(final Substrate data,
+                                       final double px, final double py,
+                                       final double pz) {
+        data.image = new ImageStore(W, H);
+        data.image.clear(BG);
+        final double len = Math.sqrt(px * px + py * py + pz * pz);
+        final double yaw = Math.atan2(-px, -pz);
+        final double pitch = Math.asin(-py / len);
+        data.camera.set(px, py, pz, yaw, pitch, 40);
+        kernel().runOnce(data);
+        return data.image.pixels[(H / 2) * W + W / 2];
+    }
+
+    /** Six axis directions. */
+    private static void visibleFromAllSides(final Substrate data,
+                                            final String shape) {
+        final double[][] poses = {
+                {0, 0, -5}, {0, 0, 5}, {5, 0, 0}, {-5, 0, 0},
+                {0, 5, -0.01}, // almost straight above (yaw needs a z)
+                {0, -5, -0.01},
+        };
+        for (final double[] p : poses) {
+            assertNotEquals(BG, centerPixelFrom(data, p[0], p[1], p[2]),
+                    shape + " must show a face from ("
+                            + p[0] + "," + p[1] + "," + p[2] + ")");
+        }
+    }
+
+    /**
+     * The geometric winding pin: every triangle's winding normal must
+     * point TOWARD the interior reference point (CullPass keeps the
+     * normal-away winding, so normals must point INTO the solid).
+     * Degenerate (zero-area) triangles — sphere pole rows — are skipped.
+     */
+    private static void assertWindingIntoSolid(final Substrate data,
+                                               final double rx,
+                                               final double ry,
+                                               final double rz,
+                                               final String shape) {
+        final int n = data.triangles.triangleCount();
+        for (int tri = 0; tri < n; tri++) {
+            final double ax = data.triangles.at(tri, 0, 0);
+            final double ay = data.triangles.at(tri, 0, 1);
+            final double az = data.triangles.at(tri, 0, 2);
+            final double bx = data.triangles.at(tri, 1, 0);
+            final double by = data.triangles.at(tri, 1, 1);
+            final double bz = data.triangles.at(tri, 1, 2);
+            final double cx = data.triangles.at(tri, 2, 0);
+            final double cy = data.triangles.at(tri, 2, 1);
+            final double cz = data.triangles.at(tri, 2, 2);
+            final double e1x = bx - ax, e1y = by - ay, e1z = bz - az;
+            final double e2x = cx - ax, e2y = cy - ay, e2z = cz - az;
+            final double nx = e1y * e2z - e1z * e2y;
+            final double ny = e1z * e2x - e1x * e2z;
+            final double nz = e1x * e2y - e1y * e2x;
+            if (nx * nx + ny * ny + nz * nz < 1e-12) {
+                continue; // degenerate (pole rows): no winding to pin
+            }
+            final double mx = (ax + bx + cx) / 3, my = (ay + by + cy) / 3,
+                    mz = (az + bz + cz) / 3;
+            final double dot = nx * (rx - mx) + ny * (ry - my)
+                    + nz * (rz - mz);
+            assertTrue(dot > 0, shape + " tri " + tri
+                    + " winding normal must point INTO the solid"
+                    + " (CullPass keeps the normal-away winding);"
+                    + " dot=" + dot);
+        }
+    }
+
+    @Test
+    void cubeCountsAndVisibility() {
+        final Substrate data = new Substrate();
+        Shapes.cube(data, 0, 0, 0, 2, 0xFF00FF00);
+        assertEquals(12, data.triangles.triangleCount());
+        assertWindingIntoSolid(data, 0, 0, 0, "cube");
+        visibleFromAllSides(data, "cube");
+    }
+
+    @Test
+    void cylinderCountsAndVisibility() {
+        final Substrate data = new Substrate();
+        Shapes.cylinder(data, 0, -1, 0, 0, 2, 0, 0.8, 8, 0xFF00FF00);
+        assertEquals(8 * 4, data.triangles.triangleCount());
+        assertWindingIntoSolid(data, 0, 0, 0, "cylinder");
+        visibleFromAllSides(data, "cylinder");
+    }
+
+    @Test
+    void coneCountsAndVisibility() {
+        final Substrate data = new Substrate();
+        Shapes.cone(data, 0, -1, 0, 0, 2, 0, 1, 8, 0xFF00FF00);
+        assertEquals(8 * 2, data.triangles.triangleCount());
+        assertWindingIntoSolid(data, 0, 0, 0, "cone");
+        visibleFromAllSides(data, "cone");
+    }
+
+    @Test
+    void arrowCountsAndVisibility() {
+        final Substrate data = new Substrate();
+        // vertical arrow, body radius 0.15 -> tip length 0.75 of 2
+        Shapes.arrow(data, 0, -1, 0, 0, 2, 0, 0.15, 0xFF00FF00);
+        assertEquals(8 * 4 + 8 * 2, data.triangles.triangleCount());
+        visibleFromAllSides(data, "arrow");
+    }
+
+    @Test
+    void horizontalArrowVisibleFromFrontAndBack() {
+        final Substrate data = new Substrate();
+        Shapes.arrow(data, -1, 0, 0, 2, 0, 0, 0.15, 0xFF00FF00);
+        visibleFromAllSides(data, "horizontal arrow");
+    }
+
+    @Test
+    void pyramidCountsAndVisibility() {
+        final Substrate data = new Substrate();
+        Shapes.pyramid(data, 0, -1, 0, 0.8, 2, 0xFF00FF00);
+        assertEquals(8, data.triangles.triangleCount());
+        assertWindingIntoSolid(data, 0, -1.0 / 3, 0, "pyramid");
+        visibleFromAllSides(data, "pyramid");
+    }
+
+    @Test
+    void sphereCountsAndVisibility() {
+        final Substrate data = new Substrate();
+        Shapes.sphere(data, 0, 0, 0, 1.2, 8, 6, 0xFF00FF00);
+        assertEquals(8 * 6 * 2, data.triangles.triangleCount());
+        assertWindingIntoSolid(data, 0, 0, 0, "sphere");
+        visibleFromAllSides(data, "sphere");
+    }
+
+    @Test
+    void gridLineCount() {
+        final Substrate data = new Substrate();
+        Shapes.grid(data, -300, -300, 300, 300, 0, 50, 0xFF505064, 1);
+        // 13 lines each way (legacy Grid3D -300..300 step 50)
+        assertEquals(26, data.lines.count);
+    }
+
+    @Test
+    void tiltedCylinderVisibleFromAllSides() {
+        final Substrate data = new Substrate();
+        // diagonal axis exercises the arbitrary-basis path
+        Shapes.cylinder(data, -0.7, -0.7, -0.7, 1.4, 1.4, 1.4, 0.5, 8,
+                0xFF00FF00);
+        assertWindingIntoSolid(data, 0, 0, 0, "tilted cylinder");
+        visibleFromAllSides(data, "tilted cylinder");
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/CameraPhysicsTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/CameraPhysicsTest.java
new file mode 100644 (file)
index 0000000..e257055
--- /dev/null
@@ -0,0 +1,192 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Camera physics via the deterministic tick(). */
+class CameraPhysicsTest {
+
+    private static Substrate atOrigin() {
+        final Substrate data = new Substrate();
+        data.camera.set(0, 0, 0, 0, 0, 300);
+        return data;
+    }
+
+    @Test
+    void forwardKeyMovesAlongYawDirection() {
+        final Substrate data = atOrigin();
+        final CameraPass cam = new CameraPass();
+        data.input.keyDown(CameraPass.KEY_W);
+        for (int i = 0; i < 120; i++) {
+            cam.tick(data, 1.0 / 60);
+        }
+        assertTrue(data.camera.z > 5, "yaw=0 forward is +Z, got z=" + data.camera.z);
+        assertEquals(0.0, data.camera.x, 1e-9, "no sideways drift");
+    }
+
+    @Test
+    void yawChangesForwardDirection() {
+        final Substrate data = atOrigin();
+        final CameraPass cam = new CameraPass();
+        data.input.addMouseDelta(40, 0);        // turn right 0.8 rad
+        cam.tick(data, 1.0 / 60);
+        final double yaw = data.camera.yaw;
+        assertTrue(yaw > 0.5, "mouse dx must turn yaw, got " + yaw);
+        assertEquals(0.8, yaw, 1e-9, "legacy rate: 40px / 50 = 0.8 rad");
+        data.input.keyDown(CameraPass.KEY_W);
+        for (int i = 0; i < 120; i++) {
+            cam.tick(data, 1.0 / 60);
+        }
+        // forward = (sin yaw, cos yaw): must have moved in +X as well
+        assertTrue(data.camera.x > 1, "turned right: x grows, got " + data.camera.x);
+        assertTrue(data.camera.z > 1, "still +Z component, got " + data.camera.z);
+        final double ratio = data.camera.x / data.camera.z;
+        assertEquals(Math.tan(yaw), ratio, 0.05,
+                "motion direction matches yaw");
+    }
+
+    @Test
+    void dampingStopsMotionAfterKeyRelease() {
+        final Substrate data = atOrigin();
+        final CameraPass cam = new CameraPass();
+        data.input.keyDown(CameraPass.KEY_W);
+        for (int i = 0; i < 60; i++) {
+            cam.tick(data, 1.0 / 60);
+        }
+        data.input.keyUp(CameraPass.KEY_W);
+        final double zAtRelease = data.camera.z;
+        for (int i = 0; i < 300; i++) {
+            cam.tick(data, 1.0 / 60);
+        }
+        final double drift = data.camera.z - zAtRelease;
+        assertTrue(drift > 0, "coasts a bit after release");
+        // legacy-matched damping (5/s) from the 600 u/s cap coasts ~120 u
+        assertTrue(drift < 150, "but stops quickly, drift=" + drift);
+    }
+
+    @Test
+    void speedIsCapped() {
+        final Substrate data = atOrigin();
+        final CameraPass cam = new CameraPass();
+        data.input.keyDown(CameraPass.KEY_W);
+        double prevZ = 0;
+        for (int i = 0; i < 600; i++) {
+            cam.tick(data, 1.0 / 60);
+            prevZ = data.camera.z;
+        }
+        cam.tick(data, 1.0);
+        assertTrue(data.camera.z - prevZ <= 600.0 + 1e-6,
+                "terminal speed at most MAX_SPEED");
+    }
+
+    @Test
+    void dragUpLooksUpLikeLegacy() {
+        // legacy InputManager: cameraPitch -= mouseDelta.y — dragging the
+        // mouse UP (negative screen dy) increases pitch (looks up)
+        final Substrate data = atOrigin();
+        final CameraPass cam = new CameraPass();
+        data.input.addMouseDelta(0, -25);
+        cam.tick(data, 1.0 / 60);
+        assertEquals(0.5, data.camera.pitch, 1e-9,
+                "drag up 25px = +0.5 rad (legacy /50 rate)");
+        data.input.addMouseDelta(0, 50);
+        cam.tick(data, 1.0 / 60);
+        assertEquals(-0.5, data.camera.pitch, 1e-9,
+                "drag down must look down");
+    }
+
+    @Test
+    void pitchClampsAtLimit() {
+        final Substrate data = atOrigin();
+        final CameraPass cam = new CameraPass();
+        data.input.addMouseDelta(0, -100000);   // drag up: look up
+        cam.tick(data, 1.0 / 60);
+        assertEquals(1.5, data.camera.pitch, 1e-9, "pitch clamps at +limit");
+        data.input.addMouseDelta(0, 200000);    // drag down: look down
+        cam.tick(data, 1.0 / 60);
+        assertEquals(-1.5, data.camera.pitch, 1e-9, "pitch clamps at -limit");
+    }
+
+    @Test
+    void arrowKeysMirrorWasdLikeLegacy() {
+        // legacy WorldNavigationUserInputTracker: UP=forward, RIGHT=strafe right
+        final Substrate data = atOrigin();
+        final CameraPass cam = new CameraPass();
+        data.input.keyDown(CameraPass.KEY_UP);
+        data.input.keyDown(CameraPass.KEY_RIGHT);
+        for (int i = 0; i < 120; i++) {
+            cam.tick(data, 1.0 / 60);
+        }
+        assertTrue(data.camera.z > 5, "UP moves forward, z=" + data.camera.z);
+        assertTrue(data.camera.x > 5, "RIGHT strafes right, x=" + data.camera.x);
+        final Substrate back = atOrigin();
+        final CameraPass cam2 = new CameraPass();
+        back.input.keyDown(CameraPass.KEY_DOWN);
+        for (int i = 0; i < 120; i++) {
+            cam2.tick(back, 1.0 / 60);
+        }
+        assertTrue(back.camera.z < -5, "DOWN moves back, z=" + back.camera.z);
+    }
+
+    @Test
+    void dvorakClusterNavigates() {
+        // physical WASD positions on Dvorak: ',' = forward, A = left,
+        // O = back, E = right; physical Q/E = quote/period = down/up
+        final Substrate fwd = atOrigin();
+        final CameraPass cam = new CameraPass();
+        fwd.input.keyDown(CameraPass.KEY_COMMA);
+        for (int i = 0; i < 60; i++) {
+            cam.tick(fwd, 1.0 / 60);
+        }
+        assertTrue(fwd.camera.z > 5, "Dvorak ',' moves forward, z=" + fwd.camera.z);
+        final Substrate side = atOrigin();
+        final CameraPass cam2 = new CameraPass();
+        side.input.keyDown(CameraPass.KEY_E);
+        for (int i = 0; i < 60; i++) {
+            cam2.tick(side, 1.0 / 60);
+        }
+        assertTrue(side.camera.x > 5, "Dvorak E strafes right, x=" + side.camera.x);
+        assertEquals(0.0, side.camera.y, 1e-9, "E no longer flies up");
+        final Substrate vert = atOrigin();
+        final CameraPass cam3 = new CameraPass();
+        vert.input.keyDown(CameraPass.KEY_PERIOD);
+        for (int i = 0; i < 60; i++) {
+            cam3.tick(vert, 1.0 / 60);
+        }
+        assertTrue(vert.camera.y > 5, "Dvorak '.' flies up, y=" + vert.camera.y);
+    }
+
+    @Test
+    void wheelScrollsUpAndDownLikeLegacy() {
+        // legacy handleMouseScrolling: scroll up = fly up, one notch is
+        // a velocity kick that damping then bleeds off
+        final Substrate data = atOrigin();
+        final CameraPass cam = new CameraPass();
+        data.input.addWheel(-1);   // scroll up
+        for (int i = 0; i < 30; i++) {
+            cam.tick(data, 1.0 / 60);
+        }
+        assertTrue(data.camera.y > 1, "scroll up climbs, y=" + data.camera.y);
+        final double peak = data.camera.y;
+        data.input.addWheel(2);    // scroll down, twice as hard
+        for (int i = 0; i < 60; i++) {
+            cam.tick(data, 1.0 / 60);
+        }
+        assertTrue(data.camera.y < peak, "scroll down descends, y=" + data.camera.y);
+    }
+
+    @Test
+    void verticalKeysMoveStraightUpDown() {
+        final Substrate data = atOrigin();
+        final CameraPass cam = new CameraPass();
+        data.input.keyDown(CameraPass.KEY_R);
+        for (int i = 0; i < 120; i++) {
+            cam.tick(data, 1.0 / 60);
+        }
+        assertTrue(data.camera.y > 5, "R climbs, got " + data.camera.y);
+        assertEquals(0.0, data.camera.z, 1e-9);
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/ClipTextureTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/ClipTextureTest.java
new file mode 100644 (file)
index 0000000..3032a19
--- /dev/null
@@ -0,0 +1,115 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Slice 1: near-plane clipping + textured triangles.
+ */
+class ClipTextureTest {
+
+    private static Substrate render(final int w, final int h) {
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(w, h);
+        data.image.clear(0xFF000000);
+        return data;
+    }
+
+    private static Kernel kernel() {
+        final Kernel kernel = new Kernel();
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        return kernel;
+    }
+
+    private static int countNonBackground(final Substrate data) {
+        int count = 0;
+        for (final int p : data.image.pixels) {
+            if (p != 0xFF000000) {
+                count++;
+            }
+        }
+        return count;
+    }
+
+    @Test
+    void straddlingNearPlaneClipsNotDrops() {
+        // one vertex at z = 0.05 (behind NEAR = 0.1), two in front:
+        // v1 behavior culled this whole triangle; the clip must render
+        // the visible part as a 4-vertex fan
+        final Substrate data = render(64, 64);
+        data.camera.set(0, 0, 0, 0, 0, 20);
+        data.triangles.addTriangle(
+                -0.5, -0.5, 0.05,
+                0.5, -0.5, 2,
+                0, 0.5, 2, 0xFF00FF00);
+        kernel().runOnce(data);
+        assertEquals(4, data.screen.count(0), "straddler clipped to a quad loop");
+        assertFalse(data.screen.isCulled(0), "clipped triangle must survive culling");
+        assertTrue(countNonBackground(data) > 50,
+                "visible part of the straddler is rasterized");
+    }
+
+    @Test
+    void fullyBehindNearPlaneIsCulled() {
+        final Substrate data = render(64, 64);
+        data.camera.set(0, 0, 0, 0, 0, 20);
+        data.triangles.addTriangle(
+                -1, -1, 0.01, 1, -1, 0.02, 0, 1, 0.05, 0xFF00FF00);
+        kernel().runOnce(data);
+        assertEquals(0, data.screen.count(0));
+        assertEquals(0, countNonBackground(data), "nothing behind the camera renders");
+    }
+
+    @Test
+    void textureQuadrantsLandInExpectedRegions() {
+        // 2x2 texture: (0,0)=red (1,0)=green (0,1)=blue (1,1)=white
+        final int tex = 0; // added below into the SAME substrate
+        final Substrate data = render(200, 200);
+        final int[] texPixels = {
+                0xFFFF0000, 0xFF00FF00,
+                0xFF0000FF, 0xFFFFFFFF};
+        final int texId = data.textures.add(2, 2, texPixels);
+        assertEquals(tex, texId);
+        data.camera.set(0, 0, 0, 0, 0, 50);
+        // quad [-2,2]^2 at z=5, CCW from the camera
+        data.triangles.addTriangle(
+                -2, -2, 5, 2, -2, 5, 2, 2, 5, 0xFFFFFFFF,
+                0, 0, 1, 0, 1, 1, texId);
+        data.triangles.addTriangle(
+                -2, -2, 5, 2, 2, 5, -2, 2, 5, 0xFFFFFFFF,
+                0, 0, 1, 1, 0, 1, texId);
+        kernel().runOnce(data);
+
+        // quad projects to 4/5*50 = 40 px total, centered on (100,100):
+        // spans pixels 80..120 — probe at ±15, safely inside each quadrant
+        final int[] p = data.image.pixels;
+        final int bl = p[(100 + 15) * 200 + (100 - 15)]; // bottom-left region
+        final int br = p[(100 + 15) * 200 + (100 + 15)];
+        final int tl = p[(100 - 15) * 200 + (100 - 15)];
+        final int tr = p[(100 - 15) * 200 + (100 + 15)];
+        assertEquals(0xFFFF0000, bl, "uv (0,0) red at bottom-left");
+        assertEquals(0xFF00FF00, br, "uv (1,0) green at bottom-right");
+        assertEquals(0xFF0000FF, tl, "uv (0,1) blue at top-left");
+        assertEquals(0xFFFFFFFF, tr, "uv (1,1) white at top-right");
+    }
+
+    @Test
+    void texturedStraddlerStillRenders() {
+        final Substrate data = render(64, 64);
+        data.camera.set(0, 0, 0, 0, 0, 20);
+        final int[] texPixels = {0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF};
+        final int texId = data.textures.add(2, 2, texPixels);
+        data.triangles.addTriangle(
+                -0.5, -0.5, 0.05, 0.5, -0.5, 2, 0, 0.5, 2, 0xFFFFFFFF,
+                0, 0, 1, 0, 0.5, 1, texId);
+        kernel().runOnce(data);
+        assertTrue(countNonBackground(data) > 50,
+                "textured straddler renders (uv lerped at the clip)");
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/FogPassTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/FogPassTest.java
new file mode 100644 (file)
index 0000000..7d78939
--- /dev/null
@@ -0,0 +1,39 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class FogPassTest {
+
+    @Test
+    void fogBlendsByDepthAndSkipsSky() {
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(4, 1);
+        // near (z=1 -> w=1), far (z=30 -> w=1/30), sky (w=0), very far (z=300)
+        data.image.depth[0] = 1.0f;
+        data.image.depth[1] = 1.0f / 30.0f;
+        data.image.depth[2] = 0f;
+        data.image.depth[3] = 1.0f / 300.0f;
+        for (int i = 0; i < 4; i++) {
+            data.image.pixels[i] = 0xFF808080;
+        }
+
+        new FogPass().apply(data);
+
+        final int far = data.image.pixels[1] & 0xFFFFFF;
+        final int sky = data.image.pixels[2];
+        final int veryFar = data.image.pixels[3] & 0xFFFFFF;
+
+        final int nearR = (data.image.pixels[0] >> 16) & 0xFF;
+        assertTrue(Math.abs(nearR - 0x80) <= 3, "near pixel ~untouched, got " + nearR);
+        assertTrue(far != 0x808080, "far pixel tinted");
+        assertEquals(0xFF808080, sky, "sky untouched");
+        assertEquals(0x303844, veryFar, "very far = full fog color");
+        // monotonic: farther = closer to fog color
+        final int farR = (far >> 16) & 0xFF;
+        assertTrue(farR < 0x80 && farR > 0x30, "far is a blend, got " + farR);
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/LinePassTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/LinePassTest.java
new file mode 100644 (file)
index 0000000..3b1a692
--- /dev/null
@@ -0,0 +1,152 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * LinePass: legacy Line semantics — glow falloff on thick lines, alpha
+ * fade on thin ones, depth-test-no-write, near-plane clip.
+ *
+ * Projection reference (camera at origin, yaw/pitch 0, scale 100,
+ * image 200x200): screen x = 100 + (worldX/z)*100, y likewise.
+ */
+class LinePassTest {
+
+    private static final int W = 200;
+    private static final int H = 200;
+
+    private static Substrate scene() {
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(W, H);
+        data.image.clear(0); // transparent black: blend must preserve alpha
+        // camera at origin looking down +Z, scale 100
+        data.camera.set(0, 0, 0, 0, 0, 100);
+        return data;
+    }
+
+    /** A horizontal thin line paints a blended run at screen center. */
+    @Test
+    void thinLinePaintsBlendedRun() {
+        final Substrate data = scene();
+        // world x +/-50 at z=100 -> screen x 50..150, y=100.
+        // projected radius: 200*0.2*1/100 = 0.4 < 1 -> thin path,
+        // alpha = 255*0.4 = 102
+        data.lines.add(-50, 0, 100, 50, 0, 100, 0xFFFFFFFF, 1);
+        new LinePass().apply(data);
+        final int center = data.image.pixels[100 * W + 100];
+        assertEquals(0, (center >>> 24) & 0xFF, "dest alpha preserved");
+        final int blended = center & 0xFF;
+        // over black: (0*153 + 255*102)>>8 = 101
+        assertTrue(blended > 90 && blended < 112,
+                "thin line blends at faded alpha, got " + blended);
+        assertTrue((data.image.pixels[100 * W + 60] & 0xFF) > 0,
+                "line reaches left of center");
+        assertTrue((data.image.pixels[100 * W + 140] & 0xFF) > 0,
+                "line reaches right of center");
+    }
+
+    /** Far-away lines fade toward invisible instead of shimmering. */
+    @Test
+    void farThinLineFadesWithDistance() {
+        final Substrate data = scene();
+        // radius 200*0.2*1/10000 = 0.004 -> alpha 255*0.004 = 1 -> < 2,
+        // legacy skips painting entirely
+        data.lines.add(-100, 0, 10000, 100, 0, 10000, 0xFFFFFFFF, 1);
+        new LinePass().apply(data);
+        assertEquals(0, data.image.pixels[100 * W + 100] & 0xFFFFFF);
+    }
+
+    /** Thick lines fall off toward the edges: the center of the stroke
+     *  is brighter than its fringe (the glow). */
+    @Test
+    void thickLineGlowsBrighterAtCenter() {
+        final Substrate data = scene();
+        // world x +/-0.5 at z=10 -> screen x 95..105;
+        // radius 200*0.2*1/10 = 4 px -> thick path
+        data.lines.add(-0.5, 0, 10, 0.5, 0, 10, 0xFFFFFFFF, 1);
+        new LinePass().apply(data);
+        final int center = data.image.pixels[100 * W + 100] & 0xFF;
+        int fringe = 0;
+        for (int y = 100; y < 110; y++) {
+            final int v = data.image.pixels[y * W + 100] & 0xFF;
+            if (v > 0) {
+                fringe = v; // outermost painted pixel below center
+            }
+        }
+        assertTrue(center > 200, "center nearly opaque, got " + center);
+        assertTrue(fringe > 0 && fringe < center / 2,
+                "fringe fainter than center: fringe=" + fringe
+                        + " center=" + center);
+    }
+
+    /** Lines depth-TEST: opaque geometry closer to the camera hides
+     *  the part of a line behind it. */
+    @Test
+    void solidGeometryOccludesLines() {
+        final Substrate data = scene();
+        data.lines.add(-50, 0, 100, 50, 0, 100, 0xFFFFFFFF, 1);
+        // wall at z=50 across screen x 50..100 (the line's left half)
+        for (int y = 90; y <= 110; y++) {
+            for (int x = 50; x <= 100; x++) {
+                data.image.depth[y * W + x] = 1.0f / 50;
+            }
+        }
+        new LinePass().apply(data);
+        assertEquals(0, data.image.pixels[100 * W + 75] & 0xFFFFFF,
+                "behind the wall: hidden");
+        assertTrue((data.image.pixels[100 * W + 130] & 0xFF) > 0,
+                "in front of the wall: visible");
+    }
+
+    /** Lines never depth-WRITE: geometry rasterized after a line must
+     *  not see it in the buffer. */
+    @Test
+    void linesNeverWriteDepth() {
+        final Substrate data = scene();
+        data.lines.add(-0.5, 0, 10, 0.5, 0, 10, 0xFFFFFFFF, 1);
+        new LinePass().apply(data);
+        assertEquals(0.0, data.image.depth[100 * W + 100]);
+    }
+
+    /** Regression (2026-10-13): a thin line whose screen direction
+     *  points LEFT (dx < 0) must follow the true segment — an extra
+     *  sign multiplication used to mirror the minor coordinate. */
+    @Test
+    void leftPointingThinLineFollowsTrueSegment() {
+        final Substrate data = scene();
+        // world (50, -20, 100) -> (-50, 20, 100) projects to
+        // screen (150, 120) -> (50, 80): dx = -100, dy = -40.
+        // True line at screen x=60 has y = 120 + (60-150)*0.4 = 84.
+        data.lines.add(50, -20, 100, -50, 20, 100, 0xFFFFFFFF, 1);
+        new LinePass().apply(data);
+        // rasterizer truncates 83.6 -> pixel row 83; accept 83..85
+        boolean painted = false;
+        for (int y = 83; y <= 85; y++) {
+            painted |= (data.image.pixels[y * W + 60] & 0xFF) > 0;
+        }
+        assertTrue(painted, "true segment painted");
+        assertEquals(0, data.image.pixels[156 * W + 60] & 0xFFFFFF,
+                "mirrored ghost position stays empty");
+    }
+
+    /** A line straddling the near plane is clipped: the mirrored ghost
+     *  of the behind-camera endpoint must NOT paint. */
+    @Test
+    void nearPlaneClipsStraddlingLine() {
+        final Substrate data = scene();
+        // from (-0.5, 0, -5) behind the camera to (0.5, 0, 50) in front.
+        // Clipped start: t=(1+5)/55=0.109 -> (-0.391, 0, 1) -> screen 61;
+        // front end -> screen 101. Unclipped, the behind endpoint would
+        // project MIRRORED to screen x=110 (divide by negative z).
+        data.lines.add(-0.5, 0, -5, 0.5, 0, 50, 0xFFFFFFFF, 1);
+        new LinePass().apply(data);
+        assertTrue((data.image.pixels[100 * W + 75] & 0xFF) > 0,
+                "front half survives the clip");
+        assertEquals(0, data.image.pixels[100 * W + 108] & 0xFFFFFF,
+                "no mirrored ghost from the behind-camera endpoint");
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/RasterDebugTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/RasterDebugTest.java
new file mode 100644 (file)
index 0000000..738bd66
--- /dev/null
@@ -0,0 +1,87 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * DebugStore rendering flags: alternate-segments overdraw debug and
+ * segment-boundary overlay (legacy DeveloperTools ports).
+ */
+class RasterDebugTest {
+
+    // 500 wide = an EVEN number of tile columns at any tile size, so
+    // linear-index parity would produce stripes here; the checkerboard
+    // must not. Tile size is chosen per frame from the frame size, so the
+    // tests derive coordinates from it instead of hard-coding 128.
+    private static final int W = 500;
+    private static final int H = 300;
+    private static final int T = RasterPass.tileFor(W, H);
+    /** Centre of tile (tx, ty). */
+    private static int at(final int tx, final int ty) {
+        return (ty * T + T / 2) * W + tx * T + T / 2;
+    }
+    private static final int BG = 0xFF102030;
+
+    private static Substrate scene() {
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(W, H);
+        data.image.clear(BG);
+        data.camera.set(0, 0, 0, 0, 0, 20);
+        // full-frame quad
+        data.triangles.addTriangle(-30, -30, 2, 30, -30, 2, 30, 30, 2, 0xFF00FF00);
+        data.triangles.addTriangle(-30, -30, 2, 30, 30, 2, -30, 30, 2, 0xFF00FF00);
+        return data;
+    }
+
+    private static Kernel kernel() {
+        final Kernel kernel = new Kernel();
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        return kernel;
+    }
+
+    @Test
+    void alternateSegmentsIsACheckerboard() {
+        final Substrate data = scene();
+        data.debug.alternateSegments = true;
+        kernel().runOnce(data);
+        // painted where (tx+ty) is even
+        assertEquals(0xFF00FF00, data.image.pixels[at(0, 0)], "tx0 ty0");
+        assertEquals(BG, data.image.pixels[at(1, 0)],          "tx1 ty0");
+        assertEquals(0xFF00FF00, data.image.pixels[at(2, 0)],  "tx2 ty0");
+        assertEquals(BG, data.image.pixels[at(0, 1)],          "tx0 ty1 — stripes would paint this");
+        assertEquals(0xFF00FF00, data.image.pixels[at(1, 1)],  "tx1 ty1");
+        assertEquals(BG, data.image.pixels[at(2, 1)],          "tx2 ty1 — stripes would paint this");
+    }
+
+    @Test
+    void alternateSegmentsOffPaintsEverything() {
+        final Substrate data = scene();
+        kernel().runOnce(data);
+        assertEquals(0xFF00FF00, data.image.pixels[at(1, 0)]);
+    }
+
+    @Test
+    void segmentBoundariesDrawRedLines() {
+        final Substrate data = scene();
+        data.debug.showBoundaries = true;
+        kernel().runOnce(data);
+        // vertical boundary column x=T and horizontal boundary row y=T
+        assertEquals(0xFFFF0000, data.image.pixels[(T / 2) * W + T], "column x=T");
+        assertEquals(0xFFFF0000, data.image.pixels[T * W + T / 2], "row y=T");
+        // tile interiors untouched
+        assertEquals(0xFF00FF00, data.image.pixels[(T / 2) * W + T / 2], "interior");
+    }
+
+    @Test
+    void boundariesOffByDefault() {
+        final Substrate data = scene();
+        kernel().runOnce(data);
+        assertEquals(0xFF00FF00, data.image.pixels[at(0, 0)]);
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/RasterParallelTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/RasterParallelTest.java
new file mode 100644 (file)
index 0000000..bd854e3
--- /dev/null
@@ -0,0 +1,51 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+/** Tile-parallel raster must be byte-identical run to run: pixels are
+ *  owned by exactly one tile and see triangles in submission order. */
+class RasterParallelTest {
+
+    private static Substrate scene() {
+        final Substrate data = new Substrate();
+        // overlapping triangles so depth ordering is exercised per pixel
+        for (int i = 0; i < 200; i++) {
+            final double x = (i % 20) - 10, y = (i % 7) * 0.3, z = 5 + (i % 5);
+            data.triangles.addTriangle(x, y, z, x + 3, y, z, x, y + 3, z,
+                    0xFF000000 | (i * 7919) & 0xFFFFFF);
+        }
+        data.camera.set(0, 1, -6, 0, 0.1, 300);
+        data.image = new ImageStore(640, 480);
+        return data;
+    }
+
+    private static int[] render() {
+        final Substrate data = scene();
+        final Kernel kernel = new Kernel();
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        data.image.clear(0xFF102030);
+        kernel.runOnce(data);
+        return data.image.pixels;
+    }
+
+    @Test
+    void parallelRasterIsDeterministic() {
+        final int[] a = render();
+        final int[] b = render();
+        assertArrayEquals(a, b, "same scene rendered twice must match byte for byte");
+    }
+
+    @Test
+    void parallelRasterMatchesIndependentThirdRender() {
+        final int[] a = render();
+        final int[] c = render();
+        assertArrayEquals(a, c);
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/RasterTranslucencyTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/RasterTranslucencyTest.java
new file mode 100644 (file)
index 0000000..abbee21
--- /dev/null
@@ -0,0 +1,142 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * RasterPass translucency: legacy pass-2 semantics. Alpha 1..254
+ * blends over the destination, depth-TESTS against the opaque
+ * z-buffer, never depth-WRITES; alpha 255 stays the untouched opaque
+ * path.
+ */
+class RasterTranslucencyTest {
+
+    private static final int BG = 0xFF102030;
+
+    private static Kernel kernel() {
+        final Kernel kernel = new Kernel();
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        return kernel;
+    }
+
+    private static Substrate scene() {
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(64, 64);
+        data.image.clear(BG);
+        data.camera.set(0, 0, 0, 0, 0, 20);
+        return data;
+    }
+
+    /** Big quad covering the frame center at the given z. */
+    private static void quad(final Substrate data, final double z,
+                             final int argb) {
+        data.triangles.addTriangle(-2, -2, z, 2, -2, z, 2, 2, z, argb);
+        data.triangles.addTriangle(-2, -2, z, 2, 2, z, -2, 2, z, argb);
+    }
+
+    @Test
+    void translucentBlendsOverBackground() {
+        final Substrate data = scene();
+        quad(data, 2, 0xB4FF0000); // legacy arrow alpha: 180
+        kernel().runOnce(data);
+        final int p = data.image.pixels[32 * 64 + 32];
+        // (0x10*75 + 0xFF*180)>>8 = 183 ; green: (0x20*75)>>8 = 9
+        assertEquals(0xFF, (p >>> 24) & 0xFF, "frame stays opaque");
+        final int r = (p >> 16) & 0xFF;
+        final int g = (p >> 8) & 0xFF;
+        final int b = p & 0xFF;
+        assertTrue(Math.abs(r - 183) <= 2, "red blended, got " + r);
+        assertTrue(Math.abs(g - 9) <= 2, "green from background, got " + g);
+        assertTrue(Math.abs(b - 14) <= 2, "blue from background, got " + b);
+        assertEquals(0.0, data.image.depth[32 * 64 + 32],
+                "translucent paint never writes depth");
+    }
+
+    /** Painter's algorithm: the NEARER translucent always blends over
+     *  the farther one, in BOTH emission orders (2026-10-13 user
+     *  bugreport: blue Z arrow over nearer red X arrow). */
+    @Test
+    void nearerTranslucentWinsRegardlessOfEmissionOrder() {
+        for (int order = 0; order < 2; order++) {
+            final Substrate data = scene();
+            if (order == 0) {
+                quad(data, 1, 0x80FF0000);   // red, near, emitted first
+                quad(data, 2, 0x800000FF);   // blue, far
+            } else {
+                quad(data, 2, 0x800000FF);   // blue, far, emitted first
+                quad(data, 1, 0x80FF0000);   // red, near
+            }
+            kernel().runOnce(data);
+            final int p = data.image.pixels[32 * 64 + 32];
+            // painter: blue over bg = (7, 15, 150); red over that:
+            // r=(7*127+255*128)>>8=131, g=7, b=(150*127)>>8=74
+            final int r = (p >> 16) & 0xFF;
+            final int b = p & 0xFF;
+            assertTrue(Math.abs(r - 131) <= 4,
+                    "order " + order + ": near red wins, got r=" + r);
+            assertTrue(Math.abs(b - 74) <= 4,
+                    "order " + order + ": far blue underneath, got b=" + b);
+        }
+    }
+
+    @Test
+    void translucentNeverWritesDepth() {
+        final Substrate data = scene();
+        // translucent NEAR quad added first; opaque MID quad second.
+        // The opaque loop runs before the translucent one within a
+        // tile, so the opaque quad draws (depth unwritten at that
+        // point) and the translucent then blends over it: legacy
+        // pass-2 result is the blend. What this pins: the translucent
+        // must not have rejected the opaque one.
+        quad(data, 1, 0xB4FF0000);
+        quad(data, 3, 0xFF00FF00);
+        kernel().runOnce(data);
+        final int p = data.image.pixels[32 * 64 + 32];
+        // red 180 over green: r=(0*75+255*180)>>8=179, g=(255*75)>>8=74
+        final int r = (p >> 16) & 0xFF;
+        final int g = (p >> 8) & 0xFF;
+        assertTrue(Math.abs(r - 179) <= 2, "translucent over far opaque, r=" + r);
+        assertTrue(Math.abs(g - 74) <= 2, "g=" + g);
+        assertEquals(1.0 / 3, data.image.depth[32 * 64 + 32], 1e-6,
+                "depth holds the opaque quad's w, not the translucent");
+    }
+
+    @Test
+    void nearerOpaqueOccludesTranslucent() {
+        final Substrate data = scene();
+        quad(data, 1, 0xFF00FF00);   // opaque wall, near
+        quad(data, 3, 0xB4FF0000);   // translucent behind it
+        kernel().runOnce(data);
+        assertEquals(0xFF00FF00, data.image.pixels[32 * 64 + 32],
+                "translucent behind solid contributes nothing");
+    }
+
+    @Test
+    void translucentDrawsOverOpaqueBackgroundGeometry() {
+        final Substrate data = scene();
+        quad(data, 3, 0xFFFFFFFF);   // opaque white, far
+        quad(data, 1, 0x80FF0000);   // translucent red, near
+        kernel().runOnce(data);
+        final int p = data.image.pixels[32 * 64 + 32];
+        // (255*127 + 255*128)>>8 = 254 red; green (255*127)>>8 = 126
+        final int r = (p >> 16) & 0xFF;
+        final int g = (p >> 8) & 0xFF;
+        assertTrue(Math.abs(r - 254) <= 2, "got " + r);
+        assertTrue(Math.abs(g - 126) <= 2, "got " + g);
+    }
+
+    @Test
+    void alphaZeroPaintsNothing() {
+        final Substrate data = scene();
+        quad(data, 1, 0x00FF0000);
+        kernel().runOnce(data);
+        assertEquals(BG, data.image.pixels[32 * 64 + 32]);
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/SpaceMousePassTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/SpaceMousePassTest.java
new file mode 100644 (file)
index 0000000..6779856
--- /dev/null
@@ -0,0 +1,200 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Pins the SpaceMouse cap -> camera mapping against the legacy
+ * SpaceMouseController behavior (signs live-verified there against
+ * the real device): push right = strafe right, push away = forward,
+ * pull up = up, twist clockwise = turn right (PoC +yaw), tilt top
+ * away = look up; quadratic response with deadband; direct drive
+ * (release = instant stop); left button brakes keyboard velocity.
+ */
+class SpaceMousePassTest {
+
+    private static final double FULL_STEP_PER_S = 5.0 * 600.0 * 5.0; // 15000 u/s
+
+    private static Substrate scene() {
+        final Substrate data = new Substrate();
+        data.camera.set(0, 0, 0, 0, 0, 300);
+        return data;
+    }
+
+    @Test
+    void pushAwayFliesForward() {
+        final Substrate data = scene();
+        data.input.setCapAxes(0, -350, 0, 0, 0, 0); // push cap away
+        new SpaceMousePass().tick(data, 1.0);
+        assertEquals(0, data.camera.x, 1e-9);
+        assertEquals(0, data.camera.y, 1e-9);
+        assertEquals(FULL_STEP_PER_S, data.camera.z, 1e-6); // yaw 0 looks +Z
+    }
+
+    @Test
+    void pullTowardFliesBackward() {
+        final Substrate data = scene();
+        data.input.setCapAxes(0, 350, 0, 0, 0, 0); // pull cap toward you
+        new SpaceMousePass().tick(data, 1.0);
+        assertEquals(-FULL_STEP_PER_S, data.camera.z, 1e-6);
+    }
+
+    @Test
+    void pushRightStrafesRight() {
+        final Substrate data = scene();
+        data.input.setCapAxes(350, 0, 0, 0, 0, 0);
+        new SpaceMousePass().tick(data, 1.0);
+        assertEquals(FULL_STEP_PER_S, data.camera.x, 1e-6); // right = +X at yaw 0
+        assertEquals(0, data.camera.z, 1e-9);
+    }
+
+    @Test
+    void pullUpFliesUp() {
+        // pull cap up reads NEGATIVE tz on the device (probe-verified
+        // 2026-10-13: -419); the pass maps it to +y (PoC is +Y-up)
+        final Substrate data = scene();
+        data.input.setCapAxes(0, 0, -350, 0, 0, 0); // pull cap up
+        new SpaceMousePass().tick(data, 1.0);
+        assertEquals(FULL_STEP_PER_S, data.camera.y, 1e-6); // world-up
+        assertEquals(0, data.camera.x, 1e-9);
+        assertEquals(0, data.camera.z, 1e-9);
+    }
+
+    @Test
+    void pressDownFliesDown() {
+        // press cap down reads POSITIVE tz (probe-verified: +444)
+        final Substrate data = scene();
+        data.input.setCapAxes(0, 0, 350, 0, 0, 0); // press cap down
+        new SpaceMousePass().tick(data, 1.0);
+        assertEquals(-FULL_STEP_PER_S, data.camera.y, 1e-6);
+    }
+
+    @Test
+    void forwardFollowsPitch() {
+        // legacy: forward/strafe use the full rotation matrix (fly
+        // where you look); only the vertical axis is world-up
+        final Substrate data = scene();
+        data.camera.pitch = 0.5;
+        data.input.setCapAxes(0, -350, 0, 0, 0, 0);
+        new SpaceMousePass().tick(data, 1.0);
+        assertEquals(Math.sin(0.5) * FULL_STEP_PER_S, data.camera.y, 1e-4);
+        assertEquals(Math.cos(0.5) * FULL_STEP_PER_S, data.camera.z, 1e-4);
+    }
+
+    @Test
+    void strafeFollowsYaw() {
+        final Substrate data = scene();
+        data.camera.yaw = Math.PI / 2; // looking +X; right = -Z
+        data.input.setCapAxes(350, 0, 0, 0, 0, 0);
+        new SpaceMousePass().tick(data, 1.0);
+        assertEquals(0, data.camera.x, 1e-6);
+        assertEquals(-FULL_STEP_PER_S, data.camera.z, 1e-6);
+    }
+
+    @Test
+    void twistClockwiseTurnsRight() {
+        // PoC convention: +yaw = turn right (legacy was +yaw = LEFT,
+        // so the legacy YAW_SIGN -1 flips to +1 here)
+        final Substrate data = scene();
+        data.input.setCapAxes(0, 0, 0, 0, 0, 350);
+        new SpaceMousePass().tick(data, 1.0);
+        // 3 deg/frame at 60fps baseline = 180 deg/s x sensitivity 5
+        assertEquals(Math.toRadians(900), data.camera.yaw, 1e-6);
+    }
+
+    @Test
+    void tiltTopAwayLooksUp() {
+        final Substrate data = scene();
+        data.input.setCapAxes(0, 0, 0, 350, 0, 0);
+        new SpaceMousePass().tick(data, 0.01);
+        assertTrue(data.camera.pitch > 0, "pitch must increase (look up)");
+    }
+
+    @Test
+    void pitchClamps() {
+        final Substrate data = scene();
+        data.input.setCapAxes(0, 0, 0, 350, 0, 0);
+        final SpaceMousePass pass = new SpaceMousePass();
+        pass.tick(data, 1.0); // would be 450 deg without the clamp
+        assertEquals(1.5, data.camera.pitch, 1e-12);
+    }
+
+    @Test
+    void deadbandAndQuadratic() {
+        // |raw| <= 1 is hands-off
+        final Substrate data = scene();
+        data.input.setCapAxes(0, 1, 0, 0, 0, 0);
+        new SpaceMousePass().tick(data, 1.0);
+        assertEquals(0, data.camera.z, 1e-12);
+        // half deflection -> quarter speed (quadratic response)
+        final Substrate half = scene();
+        half.input.setCapAxes(0, -175, 0, 0, 0, 0);
+        new SpaceMousePass().tick(half, 1.0);
+        final double expected = Math.pow(174.0 / 349.0, 2) * FULL_STEP_PER_S;
+        assertEquals(expected, half.camera.z, 1e-3);
+    }
+
+    @Test
+    void releaseStopsInstantly() {
+        // direct drive: zero axes = zero motion, no coasting
+        final Substrate data = scene();
+        final SpaceMousePass pass = new SpaceMousePass();
+        data.input.setCapAxes(0, -350, 0, 0, 0, 0);
+        pass.tick(data, 0.016);
+        final double moved = data.camera.z;
+        assertTrue(moved > 0);
+        data.input.setCapAxes(0, 0, 0, 0, 0, 0); // released
+        pass.tick(data, 0.016);
+        assertEquals(moved, data.camera.z, 1e-12);
+    }
+
+    @Test
+    void rollIsIgnored() {
+        final Substrate data = scene();
+        data.input.setCapAxes(0, 0, 0, 0, 350, 0); // RY = roll
+        new SpaceMousePass().tick(data, 1.0);
+        assertEquals(0, data.camera.x, 1e-12);
+        assertEquals(0, data.camera.y, 1e-12);
+        assertEquals(0, data.camera.z, 1e-12);
+        assertEquals(0, data.camera.yaw, 1e-12);
+        assertEquals(0, data.camera.pitch, 1e-12);
+    }
+
+    @Test
+    void leftButtonBrakesKeyboardVelocity() {
+        // the keyboard velocity lives in CameraPass; the brake edge
+        // zeroes it (legacy SpaceMouseController left-button rule)
+        final Substrate data = scene();
+        final CameraPass camera = new CameraPass();
+        data.input.keyDown(CameraPass.KEY_W);
+        for (int i = 0; i < 60; i++) {
+            camera.tick(data, 0.016);
+        }
+        data.input.keyUp(CameraPass.KEY_W);
+        assertTrue(data.camera.z > 0, "must be moving before the brake");
+        data.input.setCapButtons(1); // left cap button down
+        final double before = data.camera.z;
+        camera.tick(data, 0.016);
+        assertEquals(before, data.camera.z, 1e-12,
+                "brake edge must zero the movement vector");
+        // holding the button does not re-brake: new input accelerates again
+        data.input.keyDown(CameraPass.KEY_W);
+        camera.tick(data, 0.016);
+        assertTrue(data.camera.z > before, "held brake is not a parking brake");
+    }
+
+    @Test
+    void capCountsAsActivity() {
+        // render-on-demand must wake for cap input (level state, so it
+        // stays true while deflected — continuous reports)
+        final Substrate data = scene();
+        assertTrue(!data.input.hasActivity());
+        data.input.setCapAxes(0, -40, 0, 0, 0, 0);
+        assertTrue(data.input.hasActivity());
+        data.input.setCapAxes(0, 0, 0, 0, 0, 0);
+        assertTrue(!data.input.hasActivity());
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/StereoTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/StereoTest.java
new file mode 100644 (file)
index 0000000..a7f891b
--- /dev/null
@@ -0,0 +1,79 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Stereo: parallax falls with depth; anaglyph channels map eyes. */
+class StereoTest {
+
+    private static double centroidX(final ImageStore img, final int argbMask) {
+        double sum = 0;
+        int n = 0;
+        for (int y = 0; y < img.height; y++) {
+            for (int x = 0; x < img.width; x++) {
+                if ((img.pixels[y * img.width + x] & 0xFFFFFF) == (argbMask & 0xFFFFFF)) {
+                    sum += x;
+                    n++;
+                }
+            }
+        }
+        return n == 0 ? Double.NaN : sum / n;
+    }
+
+    private static Substrate scene() {
+        final Substrate data = new Substrate();
+        // near marker at z=4 (red), far marker at z=20 (green, off-axis
+        // so the near one cannot occlude it)
+        data.triangles.addTriangle(-1, -1, 4, 1, -1, 4, 0, 1, 4, 0xFFFF0000);
+        data.triangles.addTriangle(4, -1, 20, 6, -1, 20, 5, 1, 20, 0xFF00FF00);
+        data.imageLeft = new ImageStore(320, 240);
+        data.imageRight = new ImageStore(320, 240);
+        return data;
+    }
+
+    private static void renderEye(final Substrate data, final ImageStore img,
+                                  final double eyeOffset) {
+        data.camera.set(0, 0, 0, 0, 0, 160);
+        eu.svjatoslav.aukio.e3d2.StereoDemoMain.eyePose(data.camera, eyeOffset);
+        data.image = img;
+        img.clear(0xFF000000);
+        final Kernel pipe = new Kernel();
+        pipe.add(new TransformPass());
+        pipe.add(new CullPass());
+        pipe.add(new RasterPass());
+        pipe.runOnce(data);
+    }
+
+    @Test
+    void parallaxFallsWithDepth() {
+        final Substrate data = scene();
+        renderEye(data, data.imageLeft, -0.2);
+        renderEye(data, data.imageRight, 0.2);
+        final double nearShift = Math.abs(
+                centroidX(data.imageRight, 0xFFFF0000) - centroidX(data.imageLeft, 0xFFFF0000));
+        final double farShift = Math.abs(
+                centroidX(data.imageRight, 0xFF00FF00) - centroidX(data.imageLeft, 0xFF00FF00));
+        assertTrue(nearShift > 0, "near marker shifts between eyes");
+        assertTrue(farShift > 0, "far marker shifts between eyes");
+        assertTrue(nearShift > farShift * 2,
+                "parallax falls with depth: near=" + nearShift + " far=" + farShift);
+    }
+
+    @Test
+    void anaglyphChannelsMapEyes() {
+        final Substrate data = scene();
+        data.imageLeft.clear(0xFF123456);
+        data.imageRight.clear(0xFFABCDEF);
+        data.image = new ImageStore(320, 240);
+        new StereoMergePass(StereoMergePass.Mode.ANAGLYPH).apply(data);
+        final int px = data.image.pixels[100];
+        assertEquals(0x12, (px >> 16) & 0xFF, "red channel from LEFT eye");
+        assertEquals(0xCD, (px >> 8) & 0xFF, "green channel from RIGHT eye");
+        assertEquals(0xEF, px & 0xFF, "blue channel from RIGHT eye");
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/StreamRaceStressTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/StreamRaceStressTest.java
new file mode 100644 (file)
index 0000000..23bf696
--- /dev/null
@@ -0,0 +1,125 @@
+package eu.svjatoslav.aukio.e3d2.systems;
+
+import eu.svjatoslav.aukio.e3d2.ProceduralTerrainSource;
+import eu.svjatoslav.aukio.e3d2.kernel.BvhStore;
+import eu.svjatoslav.aukio.e3d2.kernel.ChunkStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Streaming vs readers. Two pinned-contract proofs plus a chaos run:
+ * (1) a published Tree keeps serving the PRE-EVICT world (pinning);
+ * (2) after ensure(), the new tree reflects the eviction exactly;
+ * (3) concurrent streamer + reader threads: no crashes, hits always
+ * reference real (pinned) triangle data.
+ */
+class StreamRaceStressTest {
+
+    private static Substrate worldAt(final double camX, final double camZ) {
+        final Substrate data = new Substrate();
+        data.camera.set(camX, 9, camZ, 0, 0, 300);
+        new WorldStreamer(new ProceduralTerrainSource(), 32, 1).apply(data);
+        return data;
+    }
+
+    /** Center of chunk (0,0)'s first triangle region: probe ray down. */
+    private static int probe(final Substrate data, final BvhStore bvh,
+                             final double x, final double z) {
+        return bvh.nearest(data.triangles, -1, x + 0.5, 50, z + 0.5,
+                0, -1, 0, 1e9, null);
+    }
+
+    @Test
+    void publishedTreeSurvivesEvictionThenRefreshes() {
+        final Substrate data = worldAt(16, 16);
+        final BvhStore bvh = new BvhStore();
+        bvh.ensure(data.triangles);
+        final int before = probe(data, bvh, 0, 0);
+        assertTrue(before >= 0, "chunk (0,0) triangle must be hit");
+
+        // evict chunk (0,0) by moving the camera two chunks away
+        data.camera.set(16 + 96, 9, 16, 0, 0, 300);
+        new WorldStreamer(new ProceduralTerrainSource(), 32, 1).apply(data);
+        assertEquals(null, data.chunks.get(0, 0), "chunk evicted from registry");
+
+        // WITHOUT ensure: the published tree still answers with the
+        // pinned pre-evict world — no NPE despite the live mesh having
+        // dropped the blocks
+        assertEquals(before, probe(data, bvh, 0, 0),
+                "pinned tree must keep serving the pre-evict world");
+
+        // after ensure: the new world has no chunk (0,0)
+        bvh.ensure(data.triangles);
+        assertEquals(-1, probe(data, bvh, 0, 0),
+                "fresh tree must NOT see the evicted chunk");
+        // but nearby live chunks still resolve
+        assertNotEquals(-1, probe(data, bvh, 96, 0),
+                "live chunks still indexed");
+    }
+
+    @Test
+    void streamerAndReadersChaos() throws Exception {
+        final Substrate data = worldAt(16, 16);
+        final BvhStore bvh = new BvhStore();
+        bvh.ensure(data.triangles);
+        final AtomicBoolean stop = new AtomicBoolean();
+        final AtomicLong crashes = new AtomicLong();
+        final AtomicLong queries = new AtomicLong();
+
+        final Runnable reader = () -> {
+            long n = 0;
+            try {
+                final java.util.Random rnd = new java.util.Random();
+                while (!stop.get()) {
+                    final double x = rnd.nextDouble() * 160 - 32;
+                    final double z = rnd.nextDouble() * 160 - 32;
+                    bvh.nearest(data.triangles, -1, x, 50, z, 0, -1, 0, 1e9, null);
+                    n++;
+                }
+            } catch (final Throwable t) {
+                crashes.incrementAndGet();
+            }
+            queries.addAndGet(n);
+        };
+        final Thread[] readers = new Thread[4];
+        for (int i = 0; i < readers.length; i++) {
+            readers[i] = new Thread(reader);
+            readers[i].setDaemon(true);
+            readers[i].start();
+        }
+
+        // streamer thread: teleport the camera around, forcing loads and
+        // evictions, for 2 seconds. ensure() runs HERE — serialized with
+        // evictions, honoring the frame barrier (builds are readers too).
+        final WorldStreamer streamer = new WorldStreamer(
+                new ProceduralTerrainSource(), 32, 1);
+        final long end = System.currentTimeMillis() + 2000;
+        final java.util.Random rnd = new java.util.Random(7);
+        int transitions = 0;
+        while (System.currentTimeMillis() < end) {
+            data.camera.set(rnd.nextInt(8) * 32.0 + 16, 9,
+                    rnd.nextInt(8) * 32.0 + 16, 0, 0, 300);
+            streamer.apply(data);
+            bvh.ensure(data.triangles);
+            transitions++;
+        }
+        stop.set(true);
+        for (final Thread t : readers) {
+            t.join();
+        }
+        System.out.println("queries=" + queries.get() + " transitions=" + transitions
+                + " crashes=" + crashes.get());
+        assertEquals(0, crashes.get(), "readers crashed under streaming");
+        assertTrue(queries.get() > 1000, "readers actually ran");
+        // registry must be coherent afterwards
+        final ChunkStore chunks = data.chunks;
+        assertTrue(chunks.liveCount() <= 9, "radius-1 live set bounded");
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiBounceTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiBounceTest.java
new file mode 100644 (file)
index 0000000..6333e85
--- /dev/null
@@ -0,0 +1,78 @@
+package eu.svjatoslav.aukio.e3d2.systems.gi;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Stage-2 proof: color bleeding. A red wall sprays red indirect light
+ * onto the nearby floor; the far floor tri is the control. Also pins
+ * determinism (same scene bakes identically twice).
+ */
+class GiBounceTest {
+
+    /** White floor split left/right, red wall at x=-5 facing +X, sun right-up. */
+    private static Substrate cornerScene() {
+        final Substrate data = new Substrate();
+        final int white = 0xFFC0C0C0;
+        data.triangles.addTriangle(-10, 0, 0, 0, 0, 20, 0, 0, 0, white);     // left floor
+        data.triangles.addTriangle(0, 0, 0, 10, 0, 20, 10, 0, 0, white);    // right floor
+        final int red = 0xFFC03030;
+        data.triangles.addTriangle(-5, 0, 2, -5, 12, 2, -5, 12, 16, red);   // wall, normal +X
+        data.triangles.addTriangle(-5, 0, 2, -5, 12, 16, -5, 0, 16, red);
+        data.lights.add(5, 20, 10);
+        return data;
+    }
+
+    private static GiPass bake(final Substrate data) {
+        final GiPass gi = new GiPass(2, 1e9);
+        gi.apply(data);
+        final long deadline = System.currentTimeMillis() + 15_000;
+        while (!gi.isQuiescent()) {
+            if (System.currentTimeMillis() > deadline) {
+                gi.stop();
+                fail("GI bake did not quiesce");
+            }
+            try {
+                Thread.sleep(5);
+            } catch (final InterruptedException ie) {
+                Thread.currentThread().interrupt();
+            }
+        }
+        return gi;
+    }
+
+    @Test
+    void redWallBleedsOntoNearFloor() {
+        final Substrate data = cornerScene();
+        final GiPass gi = bake(data);
+        try {
+            assertTrue(gi.isConverged(), "bounce series converges within the cap");
+            final int leftRB = data.giLight.shadeR(0) - data.giLight.shadeB(0);
+            final int rightRB = data.giLight.shadeR(1) - data.giLight.shadeB(1);
+            assertTrue(leftRB > rightRB + 4,
+                    "red bleed stronger near the wall: left(R-B)=" + leftRB
+                            + " right(R-B)=" + rightRB);
+        } finally {
+            gi.stop();
+        }
+    }
+
+    @Test
+    void bakeIsDeterministic() {
+        final Substrate a = cornerScene();
+        final Substrate b = cornerScene();
+        final GiPass gia = bake(a);
+        final GiPass gib = bake(b);
+        try {
+            for (int e = 0; e < 4; e++) {
+                assertEquals(a.giLight.shade(e), b.giLight.shade(e),
+                        "entry " + e + " identical across runs");
+            }
+        } finally {
+            gia.stop();
+            gib.stop();
+        }
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiLitSideTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiLitSideTest.java
new file mode 100644 (file)
index 0000000..b479086
--- /dev/null
@@ -0,0 +1,145 @@
+package eu.svjatoslav.aukio.e3d2.systems.gi;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Two behavior pins landed with the HouseDemo port (2026-10-14):
+ *
+ * 1. Colored direct light: a red light tints the shade per channel
+ *    (shade_c = AMBIENT + (light_c − AMBIENT)·dot), not grayscale.
+ * 2. Lit-side bounce: geometry wound for RENDERING (winding normal pointing
+ *    away from the viewer — the CullPass convention) still gathers bounce
+ *    from the LIT side. Without the gen-0 litSide vote, a downward-wound
+ *    floor would gather only sky and show no wall bleed.
+ */
+class GiLitSideTest {
+
+    /** White floor, red wall at x=-5 (normal +X), sun up-right. The floor
+     *  is wound DOWNWARD (−Y) — the render-visible winding for a camera
+     *  above (CullPass keeps normal-away). */
+    private static Substrate renderWoundCornerScene() {
+        final Substrate data = new Substrate();
+        final int white = 0xFFC0C0C0;
+        data.triangles.addTriangle(-10, 0, 0, 0, 0, 0, 0, 0, 20, white);   // left floor, n=-Y
+        data.triangles.addTriangle(0, 0, 0, 10, 0, 0, 10, 0, 20, white);   // right floor, n=-Y
+        final int red = 0xFFC03030;
+        data.triangles.addTriangle(-5, 0, 2, -5, 12, 2, -5, 12, 16, red);  // wall, n=+X
+        data.triangles.addTriangle(-5, 0, 2, -5, 12, 16, -5, 0, 16, red);
+        data.lights.add(5, 20, 10);
+        return data;
+    }
+
+    private static GiPass bake(final Substrate data) {
+        final GiPass gi = new GiPass(2, 1e9);
+        gi.apply(data);
+        final long deadline = System.currentTimeMillis() + 30_000;
+        while (!gi.isQuiescent()) {
+            if (System.currentTimeMillis() > deadline) {
+                gi.stop();
+                fail("GI bake did not quiesce");
+            }
+            try {
+                Thread.sleep(5);
+            } catch (final InterruptedException ie) {
+                Thread.currentThread().interrupt();
+            }
+        }
+        return gi;
+    }
+
+    @Test
+    void coloredLightTintsDirectPerChannel() {
+        final Substrate data = new Substrate();
+        data.triangles.addTriangle(-10, 0, 0, 10, 0, 0, 0, 0, 10, 0xFF808080);
+        data.lights.add(0, 20, 5, 255, 40, 40);   // red lamp
+        final GiPass gi = bake(data);
+        try {
+            final int r = data.giLight.shadeR(0);
+            final int b = data.giLight.shadeB(0);
+            assertTrue(r > b + 40,
+                    "red light leaves R >> B on a gray floor: R=" + r + " B=" + b);
+        } finally {
+            gi.stop();
+        }
+    }
+
+    @Test
+    void renderWoundFloorGathersFromLitSide() {
+        final Substrate data = renderWoundCornerScene();
+        final GiPass gi = bake(data);
+        try {
+            assertTrue(gi.isConverged(), "bounce converges within the cap");
+            // left floor spans x -10..0, wall at x=-5: near vs far halves,
+            // sampled in the floor's lightmap (the texel the rasterizer
+            // actually displays).
+            final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lm = data.giLightmaps;
+            long nearSum = 0, nearCnt = 0, farSum = 0, farCnt = 0;
+            final int w = lm.width(0), h = lm.height(0);
+            for (int ty = 0; ty < h; ty++) {
+                for (int tx = 0; tx < w; tx++) {
+                    final double u = (tx + 0.5) / w, v = (ty + 0.5) / h;
+                    final double x = lm.worldX(0, u, v, 0);
+                    final int p = lm.texelAt(0, tx, ty);
+                    final int rb = ((p >> 16) & 0xFF) - (p & 0xFF);
+                    if (x >= -10 && x < -4) {
+                        nearSum += rb; nearCnt++;
+                    } else if (x >= -4 && x < 0.1) {
+                        farSum += rb; farCnt++;
+                    }
+                }
+            }
+            final double near = (double) nearSum / nearCnt;
+            final double far = (double) farSum / farCnt;
+            assertTrue(near > far + 3,
+                    "red wall bleeds into RENDER-wound floor texels near it: "
+                            + "near(R-B)=" + near + " far(R-B)=" + far
+                            + " (litSide flip missing — bounce gathered sky "
+                            + "behind the floor instead)");
+        } finally {
+            gi.stop();
+        }
+    }
+
+    /** Regression pin for user bugreport 20261014-221955 ("why is this
+     *  polygon dark"): a floor tri FULLY SHADOWED at its centroid (occluder
+     *  hovers between centroid and the only lamp) must still vote litSide
+     *  GEOMETRICALLY — the lamp is above, so the bounce hemisphere faces
+     *  up into the room. With a visibility-gated vote the tri fell back to
+     *  the winding-normal side, gathered from the void below, and baked
+     *  nearly black. Pin: the shadowed floor's centroid shade is lifted
+     *  above bare ambient by bounce gathered from the lit room (a bright
+     *  wall the upward hemisphere sees). */
+    @Test
+    void fullyShadowedCentroidStillVotesGeometricLitSide() {
+        final Substrate data = new Substrate();
+        final int white = 0xFFC0C0C0;
+        // floor wound DOWNWARD (−Y) — render-visible from above
+        data.triangles.addTriangle(-10, 0, 0, 0, 0, 0, 0, 0, 20, white);
+        data.triangles.addTriangle(0, 0, 0, 10, 0, 0, 10, 0, 20, white);
+        // hovers y≈5..6 spanning the whole floor: shadows every centroid
+        // (and nearly every texel) from the lamp above
+        final int gray = 0xFF606060;
+        data.triangles.addTriangle(-12, 5.5, -2, 12, 5.5, -2, 12, 5.5, 22, gray);
+        data.triangles.addTriangle(-12, 5.5, -2, 12, 5.5, 22, -12, 5.5, 22, gray);
+        // bright wall OUTSIDE the occluder's shadow cone in +x: lit by the
+        // lamp, visible from the floor's UP hemisphere
+        data.triangles.addTriangle(14, 0, 2, 14, 12, 2, 14, 12, 16, white); // n=-X faces the floor
+        data.triangles.addTriangle(14, 0, 2, 14, 0, 16, 14, 12, 16, white);
+        data.lights.add(0, 20, 10);
+        final GiPass gi = bake(data);
+        try {
+            // centroid shade of floor tri 0: ambient alone would be ~40/255
+            // per channel; the upward bounce off the lit +x wall lifts it
+            final int shade = data.giLight.shadeR(0);
+            assertTrue(shade > 42,
+                    "fully-shadowed floor gathers bounce from the room above: "
+                            + "shadeR=" + shade + " (geometric litSide vote missing — "
+                            + "fell back to the winding-normal side and gathered the void)");
+        } finally {
+            gi.stop();
+        }
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiPassTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiPassTest.java
new file mode 100644 (file)
index 0000000..090b2e9
--- /dev/null
@@ -0,0 +1,72 @@
+package eu.svjatoslav.aukio.e3d2.systems.gi;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class GiPassTest {
+
+    /** Floor at y=0, one blocker quad above the left half, sun overhead. */
+    private static Substrate shadowScene() {
+        final Substrate data = new Substrate();
+        final int floorColor = 0xFF808080;
+        data.triangles.addTriangle(-10, 0, 0, 0, 0, 0, 0, 0, 10, floorColor);   // left (shadowed)
+        data.triangles.addTriangle(0, 0, 0, 10, 0, 0, 10, 0, 10, floorColor);  // right (lit)
+        final int blockerColor = 0xFF404040;
+        data.triangles.addTriangle(-6, 5, 2, -1, 5, 2, -1, 5, 8, blockerColor);  // blocker over left
+        data.triangles.addTriangle(-6, 5, 2, -1, 5, 8, -6, 5, 8, blockerColor);
+        data.lights.add(0, 20, 5);   // sun straight up
+        return data;
+    }
+
+    private static GiPass bake(final Substrate data) {
+        final GiPass gi = new GiPass(2, 1e9);
+        gi.apply(data);
+        final long deadline = System.currentTimeMillis() + 10_000;
+        while (!gi.isQuiescent()) {
+            if (System.currentTimeMillis() > deadline) {
+                gi.stop();
+                fail("GI bake did not quiesce");
+            }
+            try {
+                Thread.sleep(5);
+            } catch (final InterruptedException ie) {
+                Thread.currentThread().interrupt();
+            }
+        }
+        return gi;
+    }
+
+    @Test
+    void occludedTriangleIsDarkerThanLit() {
+        final Substrate data = shadowScene();
+        final GiPass gi = bake(data);
+        try {
+            final int shadowed = data.giLight.shadeR(0);
+            final int lit = data.giLight.shadeR(1);
+            assertTrue(shadowed < lit,
+                    "occluded=" + shadowed + " must be darker than lit=" + lit);
+            assertTrue(shadowed >= 40, "shadow floor = ambient, got " + shadowed);
+            assertTrue(shadowed > 40, "stage 2: bounce lifts the shadow, got " + shadowed);
+            assertTrue(lit > 200, "lit ~full, got " + lit);
+            assertTrue(gi.isConverged());
+        } finally {
+            gi.stop();
+        }
+    }
+
+    @Test
+    void shadeSurvivesRebuildUnchanged() {
+        final Substrate data = shadowScene();
+        final GiPass gi = bake(data);
+        try {
+            final int before = data.giLight.shade(0);
+            gi.apply(data);   // no-op while running
+            assertEquals(before, data.giLight.shade(0),
+                    "shade stable while no world change");
+        } finally {
+            gi.stop();
+        }
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiSchedulerTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiSchedulerTest.java
new file mode 100644 (file)
index 0000000..dc549c6
--- /dev/null
@@ -0,0 +1,141 @@
+package eu.svjatoslav.aukio.e3d2.systems.gi;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import java.util.Random;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Runtime twin of Model/GiScheduler5.tla: the TLC invariants are audited
+ * inside GiScheduler (InvariantViolation on breach); these tests drive
+ * camera moves and world rebuilds against the live bake and demand
+ * quiescence with every eligible entry served.
+ */
+class GiSchedulerTest {
+
+    /** Triangles strung along +X, one per 10 units. */
+    private static Substrate scene(final int count) {
+        final Substrate data = new Substrate();
+        for (int i = 0; i < count; i++) {
+            final double x = i * 10.0;
+            data.triangles.addTriangle(x, 0, 0, x + 1, 0, 0, x, 1, 0);
+        }
+        return data;
+    }
+
+    private static void awaitQuiescent(final GiScheduler gi, final long timeoutMs) {
+        final long deadline = System.currentTimeMillis() + timeoutMs;
+        while (!gi.isQuiescent()) {
+            if (gi.workerError() != null) {
+                fail("worker died: " + gi.workerError());
+            }
+            if (System.currentTimeMillis() > deadline) {
+                fail("scheduler did not quiesce within " + timeoutMs + " ms"
+                        + (gi.workerError() != null ? " (worker error: " + gi.workerError() + ")" : ""));
+            }
+            try {
+                Thread.sleep(5);
+            } catch (final InterruptedException ie) {
+                Thread.currentThread().interrupt();
+                fail("interrupted");
+            }
+        }
+        if (gi.workerError() != null) {
+            fail("worker died: " + gi.workerError());
+        }
+    }
+
+    @Test
+    void convergesAndServesAll() {
+        final GiScheduler gi = new GiScheduler(scene(8), 2, 1000.0, 6);
+        gi.start();
+        try {
+            awaitQuiescent(gi, 10_000);
+            assertTrue(gi.isConverged(), "bake should converge with everything in ring");
+            for (int e = 0; e < 8; e++) {
+                assertTrue(gi.isServed(e), "entry " + e + " served");
+            }
+        } finally {
+            gi.stop();
+        }
+    }
+
+    @Test
+    void catchUpAfterCameraMoveKeepsBakeFrozen() {
+        final GiScheduler gi = new GiScheduler(scene(8), 2, 15.0, 6);
+        gi.start();
+        try {
+            awaitQuiescent(gi, 10_000);
+            assertTrue(gi.isConverged());
+            final int convergedGen = gi.gen();
+            assertTrue(gi.isServed(0) && gi.isServed(1));
+            assertFalse(gi.isServed(4));
+
+            gi.moveCamera(50, 0, 0);   // ring now covers entries 4, 5, 6
+            awaitQuiescent(gi, 10_000);
+
+            assertEquals(convergedGen, gi.gen(),
+                    "catch-up must never start a generation (ConvFrozen)");
+            assertTrue(gi.isServed(4) && gi.isServed(5) && gi.isServed(6),
+                    "late entrants served via catch-up");
+            assertFalse(gi.isServed(2), "entry 2 outside the ring stays unserved");
+        } finally {
+            gi.stop();
+        }
+    }
+
+    @Test
+    void rebuildRestartsEpochAndReconverges() {
+        final GiScheduler gi = new GiScheduler(scene(6), 2, 1000.0, 6);
+        gi.start();
+        try {
+            awaitQuiescent(gi, 10_000);
+            assertTrue(gi.isConverged());
+            final int oldEpoch = gi.epoch();
+
+            gi.rebuildWorld();
+            awaitQuiescent(gi, 10_000);
+
+            assertEquals(oldEpoch + 1, gi.epoch(), "rebuild bumps epoch");
+            assertTrue(gi.isConverged(), "re-converges after rebuild");
+        } finally {
+            gi.stop();
+        }
+    }
+
+    @Test
+    void stressCameraAndRebuilds() {
+        for (int seed = 0; seed < 3; seed++) {
+            final Random rnd = new Random(seed);
+            final GiScheduler gi = new GiScheduler(scene(12), 2, 25.0, 6);
+            gi.start();
+            try {
+                for (int step = 0; step < 40; step++) {
+                    if (rnd.nextInt(10) == 0) {
+                        gi.rebuildWorld();
+                    } else {
+                        gi.moveCamera(rnd.nextInt(12) * 10.0, 0, 0);
+                    }
+                    Thread.sleep(rnd.nextInt(3));
+                }
+                // environment quiets; a final rebuild resets the generation cap
+                gi.rebuildWorld();
+                awaitQuiescent(gi, 20_000);
+                assertTrue(gi.isConverged(), "seed " + seed + ": converges once quiet");
+                for (int e = 0; e < 12; e++) {
+                    if (gi.isEligible(e)) {
+                        assertTrue(gi.isServed(e),
+                                "seed " + seed + ": eligible entry " + e + " served");
+                    }
+                }
+            } catch (final InterruptedException ie) {
+                Thread.currentThread().interrupt();
+                fail("interrupted");
+            } finally {
+                gi.stop();
+            }
+        }
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiStressTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiStressTest.java
new file mode 100644 (file)
index 0000000..0d40bc5
--- /dev/null
@@ -0,0 +1,85 @@
+package eu.svjatoslav.aukio.e3d2.systems.gi;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import java.util.Random;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Bake-under-stress: real radiometry (shadow + bounce rays) while a hammer
+ * thread fires camera moves and world rebuilds into the live bake. The
+ * scheduler audits (inFlight, barrier integrity, frozen-after-convergence)
+ * must stay silent, and the bake must converge once the environment quiets.
+ */
+class GiStressTest {
+
+    /** 4x4 floor grid + one tall blocker, sun overhead-right, small ring so
+     *  camera moves actually change eligibility. */
+    private static Substrate scene() {
+        final Substrate data = new Substrate();
+        final int gray = 0xFF909090;
+        for (int gx = 0; gx < 4; gx++) {
+            for (int gz = 0; gz < 4; gz++) {
+                data.triangles.addTriangle(gx * 10, 0, gz * 10,
+                        gx * 10 + 10, 0, gz * 10 + 10, gx * 10 + 10, 0, gz * 10, gray);
+            }
+        }
+        final int dark = 0xFF504030;
+        data.triangles.addTriangle(5, 0, 5, 5, 15, 5, 15, 15, 5, dark);   // blocker wall
+        data.triangles.addTriangle(5, 0, 5, 15, 15, 5, 15, 0, 5, dark);
+        data.lights.add(25, 30, 25);
+        return data;
+    }
+
+    @Test
+    void bakeSurvivesHammering() throws Exception {
+        for (int seed = 0; seed < 3; seed++) {
+            final Substrate data = scene();
+            final GiPass gi = new GiPass(2, 25.0);
+            gi.apply(data);
+            final GiScheduler sched = gi.scheduler();
+
+            final Random rnd = new Random(seed);
+            final Thread hammer = new Thread(() -> {
+                for (int i = 0; i < 60; i++) {
+                    if (rnd.nextInt(8) == 0) {
+                        sched.rebuildWorld();
+                    } else {
+                        sched.moveCamera(rnd.nextInt(5) * 10.0, 0, rnd.nextInt(5) * 10.0);
+                    }
+                    try {
+                        Thread.sleep(rnd.nextInt(4));
+                    } catch (final InterruptedException ie) {
+                        return;
+                    }
+                }
+            }, "gi-stress-hammer");
+            hammer.start();
+
+            final long deadline = System.currentTimeMillis() + 30_000;
+            while (!gi.isQuiescent()) {
+                if (sched.workerError() != null || System.currentTimeMillis() > deadline) {
+                    break;
+                }
+                Thread.sleep(10);
+            }
+            hammer.join(2000);
+            // environment quiet now: the bake must settle
+            final long settle = System.currentTimeMillis() + 30_000;
+            while (!gi.isQuiescent() && sched.workerError() == null
+                    && System.currentTimeMillis() < settle) {
+                Thread.sleep(10);
+            }
+            try {
+                assertNull(sched.workerError(),
+                        "seed " + seed + ": audit fired under stress: " + sched.workerError());
+                assertTrue(gi.isQuiescent(), "seed " + seed + ": quiesces after hammering");
+                assertTrue(gi.isConverged(), "seed " + seed + ": converged after hammering");
+            } finally {
+                gi.stop();
+            }
+        }
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/LightmapBounceTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/LightmapBounceTest.java
new file mode 100644 (file)
index 0000000..850079b
--- /dev/null
@@ -0,0 +1,108 @@
+package eu.svjatoslav.aukio.e3d2.systems.gi;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Stage-4 proof: bounce lands in the LIGHTMAP, per texel — the red wall's
+ * bleed tints floor texels near it, and shadowed texels rise above ambient.
+ */
+class LightmapBounceTest {
+
+    private static Substrate cornerScene() {
+        final Substrate data = new Substrate();
+        final int white = 0xFFC0C0C0;
+        data.triangles.addTriangle(-10, 0, 0, 0, 0, 20, 0, 0, 0, white);     // left floor
+        data.triangles.addTriangle(0, 0, 0, 10, 0, 20, 10, 0, 0, white);    // right floor
+        final int red = 0xFFC03030;
+        data.triangles.addTriangle(-5, 0, 2, -5, 12, 2, -5, 12, 16, red);   // wall, +X
+        data.triangles.addTriangle(-5, 0, 2, -5, 12, 16, -5, 0, 16, red);
+        data.lights.add(5, 20, 10);
+        return data;
+    }
+
+    private static GiPass bake(final Substrate data) {
+        final GiPass gi = new GiPass(2, 1e9);
+        gi.apply(data);
+        final long deadline = System.currentTimeMillis() + 30_000;
+        while (!gi.isQuiescent()) {
+            if (System.currentTimeMillis() > deadline) {
+                gi.stop();
+                fail("GI bake did not quiesce");
+            }
+            try {
+                Thread.sleep(5);
+            } catch (final InterruptedException ie) {
+                Thread.currentThread().interrupt();
+            }
+        }
+        return gi;
+    }
+
+    /** Mean (R−B) over texels of a triangle with world x in [minX, maxX]. */
+    private static double meanRedMinusBlue(final Substrate data, final int tri,
+                                           final double minX, final double maxX) {
+        final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lm = data.giLightmaps;
+        long sum = 0;
+        int cnt = 0;
+        final int w = lm.width(tri), h = lm.height(tri);
+        for (int ty = 0; ty < h; ty++) {
+            for (int tx = 0; tx < w; tx++) {
+                final double u = (tx + 0.5) / w, v = (ty + 0.5) / h;
+                final double x = lm.worldX(tri, u, v, 0);
+                if (x < minX || x >= maxX) {
+                    continue;
+                }
+                final int p = lm.texelAt(tri, tx, ty);
+                sum += ((p >> 16) & 0xFF) - (p & 0xFF);
+                cnt++;
+            }
+        }
+        return cnt == 0 ? 0.0 : (double) sum / cnt;
+    }
+
+    @Test
+    void wallBleedsIntoFloorTexels() {
+        final Substrate data = cornerScene();
+        final GiPass gi = bake(data);
+        try {
+            assertTrue(gi.isConverged(), "texel bounce converges within the cap");
+            // left floor spans x -10..0, wall at x=-5: near vs far halves
+            final double near = meanRedMinusBlue(data, 0, -10, -4);
+            final double far = meanRedMinusBlue(data, 0, -4, 0.1);
+            // RELATIVE margin: the absolute one (>3) was calibrated against
+            // a build where border texels were never baked and sat at
+            // bounce-only light, which exaggerated the far half. Since the
+            // border extension (bugreport-20261022-085712) both halves are
+            // properly lit, so the bleed must be judged as a ratio.
+            assertTrue(near > 2 && near > 1.5 * far,
+                    "texel bleed near wall: near(R-B)=" + near + " far(R-B)=" + far);
+        } finally {
+            gi.stop();
+        }
+    }
+
+    @Test
+    void texelBakeIsDeterministic() {
+        final Substrate a = cornerScene();
+        final Substrate b = cornerScene();
+        final GiPass gia = bake(a);
+        final GiPass gib = bake(b);
+        try {
+            final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore la = a.giLightmaps;
+            final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lb = b.giLightmaps;
+            final int w = la.width(0), h = la.height(0);
+            for (int ty = 0; ty < h; ty++) {
+                for (int tx = 0; tx < w; tx++) {
+                    assertEquals(la.texelAt(0, tx, ty), lb.texelAt(0, tx, ty),
+                            "texel (" + tx + "," + ty + ") identical across bakes");
+                }
+            }
+        } finally {
+            gia.stop();
+            gib.stop();
+        }
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/LightmapTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/LightmapTest.java
new file mode 100644 (file)
index 0000000..b72de30
--- /dev/null
@@ -0,0 +1,91 @@
+package eu.svjatoslav.aukio.e3d2.systems.gi;
+
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Lightmap proof: a blocker over one end of a floor triangle must produce
+ * a per-TEXEL shadow gradient (a per-triangle shade cannot express this),
+ * and the bake must be bit-deterministic.
+ */
+class LightmapTest {
+
+    private static Substrate shadowScene() {
+        final Substrate data = new Substrate();
+        final int floorColor = 0xFF808080;
+        data.triangles.addTriangle(-10, 0, 0, 0, 0, 0, 0, 0, 10, floorColor);   // left (shadowed end)
+        data.triangles.addTriangle(0, 0, 0, 10, 0, 0, 10, 0, 10, floorColor);  // right (lit)
+        final int blockerColor = 0xFF404040;
+        data.triangles.addTriangle(-6, 5, 2, -1, 5, 2, -1, 5, 8, blockerColor);  // blocker over left
+        data.triangles.addTriangle(-6, 5, 2, -1, 5, 8, -6, 5, 8, blockerColor);
+        data.lights.add(0, 20, 5);
+        return data;
+    }
+
+    private static GiPass bake(final Substrate data) {
+        final GiPass gi = new GiPass(2, 1e9);
+        gi.apply(data);
+        final long deadline = System.currentTimeMillis() + 15_000;
+        while (!gi.isQuiescent()) {
+            if (System.currentTimeMillis() > deadline) {
+                gi.stop();
+                fail("GI bake did not quiesce");
+            }
+            try {
+                Thread.sleep(5);
+            } catch (final InterruptedException ie) {
+                Thread.currentThread().interrupt();
+            }
+        }
+        return gi;
+    }
+
+    private static int[] texelRow(final Substrate data, final int tri) {
+        final eu.svjatoslav.aukio.e3d2.kernel.LightmapStore lm = data.giLightmaps;
+        final int w = lm.width(tri), h = lm.height(tri);
+        final int[] out = new int[w * h];
+        for (int ty = 0; ty < h; ty++) {
+            for (int tx = 0; tx < w; tx++) {
+                out[ty * w + tx] = lm.texelAt(tri, tx, ty) & 0xFF;
+            }
+        }
+        return out;
+    }
+
+    @Test
+    void blockerCastsTexelGradient() {
+        final Substrate data = shadowScene();
+        final GiPass gi = bake(data);
+        try {
+            final int[] shades = texelRow(data, 0);
+            int min = 255, max = 0;
+            for (final int s : shades) {
+                min = Math.min(min, s);
+                max = Math.max(max, s);
+            }
+            assertTrue(max > 200, "open end texels lit, got max=" + max);
+            assertTrue(min < max - 60,
+                    "per-texel shadow gradient: min=" + min + " max=" + max
+                            + " — a per-triangle shade cannot express this");
+        } finally {
+            gi.stop();
+        }
+    }
+
+    @Test
+    void texelsAreDeterministic() {
+        final Substrate a = shadowScene();
+        final Substrate b = shadowScene();
+        final GiPass gia = bake(a);
+        final GiPass gib = bake(b);
+        try {
+            assertArrayEquals(texelRow(a, 0), texelRow(b, 0),
+                    "identical scenes bake identical lightmaps");
+        } finally {
+            gia.stop();
+            gib.stop();
+        }
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfGuiTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfGuiTest.java
new file mode 100644 (file)
index 0000000..32640d9
--- /dev/null
@@ -0,0 +1,106 @@
+package eu.svjatoslav.aukio.e3d2.systems.gui;
+
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.SdfFontStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** SDF font + GUI logic + SDF text rendering. */
+class SdfGuiTest {
+
+    private static SdfFontStore font() {
+        return SdfFont.build(48);
+    }
+
+    @Test
+    void atlasHasSignedField() {
+        final SdfFontStore font = font();
+        int inside = 0, outside = 0, edge = 0;
+        for (int y = 0; y < SdfFontStore.CELL; y++) {
+            for (int x = 0; x < SdfFontStore.CELL; x++) {
+                final int d = font.distAt('A', x, y);
+                if (d > 150) inside++;
+                else if (d < 105) outside++;
+                else edge++;
+            }
+        }
+        assertTrue(inside > 50, "glyph interior exists, got " + inside);
+        assertTrue(outside > 1000, "exterior exists, got " + outside);
+        assertTrue(edge > 50, "edge band exists, got " + edge);
+        assertTrue(font.advance['A' - SdfFontStore.FIRST] > 10, "advance measured");
+    }
+
+    @Test
+    void buttonHitTestAndClickPublish() {
+        final Substrate data = new Substrate();
+        data.gui.buttons.add(new eu.svjatoslav.aukio.e3d2.kernel.GuiStore.Button(
+                10, 10, 100, 30, "quit", "Quit"));
+        final GuiPass gui = new GuiPass();
+        // hover without click
+        data.input.setMousePos(50, 20);
+        gui.apply(data);
+        assertTrue(data.gui.buttons.get(0).hover, "hover detected");
+        assertEquals(0, data.gui.clicks.size());
+        // press inside, release inside -> click published
+        data.input.setMouseButton(1, true);
+        gui.apply(data);
+        data.input.setMouseButton(1, false);
+        gui.apply(data);
+        assertEquals(1, data.gui.clicks.size());
+        assertEquals("quit", data.gui.clicks.get(0));
+        // press inside, release OUTSIDE -> no click
+        data.input.setMouseButton(1, true);
+        gui.apply(data);
+        data.input.setMousePos(500, 300);
+        data.input.setMouseButton(1, false);
+        gui.apply(data);
+        assertEquals(1, data.gui.clicks.size(), "no click on release outside");
+    }
+
+    @Test
+    void sdfTextRendersWithAntialiasedEdges() {
+        final Substrate data = new Substrate();
+        data.guiFont = font();
+        data.image = new ImageStore(400, 200);
+        data.image.clear(0xFF000000);
+        // UPSCALE is where SDF promises crisp AA: the coverage ramp spans
+        // multiple screen pixels, so partial blends must be plentiful
+        GuiRenderPass.drawText(data, 20, 140, 2.0, 0xFFFFFFFF, "HI");
+        int solid = 0, partial = 0;
+        for (final int px : data.image.pixels) {
+            final int r = (px >> 16) & 0xFF;
+            if (r > 200) solid++;
+            else if (r > 20) partial++;
+        }
+        assertTrue(solid > 200, "glyph cores rendered, got " + solid);
+        // outline arithmetic: "HI" has ~70px of screen outline at 2.0,
+        // of which the interpolated band catches ~40% — 20+ partials
+        // proves the ramp exists and is not a binary edge
+        assertTrue(partial > 20, "AA ramp pixels exist on upscale, got " + partial);
+    }
+
+    @Test
+    void sdfTextScalesWithoutLosingCoverage() {
+        final Substrate data = new Substrate();
+        data.guiFont = font();
+        // same text at 0.25x and 2.0x — SDF must cover at both extremes
+        final double[][] cases = {{0.25, 50}, {2.0, 1000}};
+        for (final double[] c : cases) {
+            final double s = c[0];
+            data.image = new ImageStore(600, 300);
+            data.image.clear(0xFF000000);
+            GuiRenderPass.drawText(data, 10, (int) (120 * s), s, 0xFFFFFFFF, "SDF");
+            int covered = 0;
+            for (final int px : data.image.pixels) {
+                if (((px >> 16) & 0xFF) > 40) {
+                    covered++;
+                }
+            }
+            assertTrue(covered > (int) c[1],
+                    "coverage at scale " + s + ", got " + covered);
+        }
+    }
+}
diff --git a/states/26-10-11-19-54-26.124/MeshPublish-0.st b/states/26-10-11-19-54-26.124/MeshPublish-0.st
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/states/26-10-11-19-54-26.124/MeshPublish-1.st b/states/26-10-11-19-54-26.124/MeshPublish-1.st
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/states/26-10-11-19-54-26.124/MeshPublish-2.st b/states/26-10-11-19-54-26.124/MeshPublish-2.st
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/states/26-10-11-19-54-26.124/MeshPublish-3.st b/states/26-10-11-19-54-26.124/MeshPublish-3.st
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/states/26-10-11-19-54-26.124/MeshPublish.st b/states/26-10-11-19-54-26.124/MeshPublish.st
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/states/26-10-11-19-54-26.124/MeshPublish_0.fp b/states/26-10-11-19-54-26.124/MeshPublish_0.fp
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/states/26-10-11-19-54-26.124/MeshPublish_1.fp b/states/26-10-11-19-54-26.124/MeshPublish_1.fp
new file mode 100644 (file)
index 0000000..e69de29