From 4ff9d7fc0bc86c92afde73c82053def7b122486d Mon Sep 17 00:00:00 2001 From: Svjatoslav Agejenko Date: Sat, 24 Oct 2026 13:40:16 +0300 Subject: [PATCH 1/1] initial commit --- .gitignore | 7 + AGENTS.org | 37 + Documentation/index.org | 2435 +++++++++++++++++ Model/BvhSnapshot.tla | 82 + Model/BvhSnapshot_buggy.cfg | 10 + Model/BvhSnapshot_fix.cfg | 10 + Model/GiScheduler.cfg | 11 + Model/GiScheduler.tla | 125 + Model/GiScheduler2.cfg | 14 + Model/GiScheduler2.tla | 211 ++ Model/GiScheduler3.cfg | 15 + Model/GiScheduler3.tla | 289 ++ Model/GiScheduler4.cfg | 16 + Model/GiScheduler4.tla | 398 +++ Model/GiScheduler5.tla | 455 +++ Model/GiScheduler5_p1.cfg | 16 + Model/GiScheduler5_p1_gate.cfg | 20 + Model/GiScheduler5_p2.cfg | 16 + Model/GiScheduler5_p2_gate.cfg | 20 + Model/MeshPublish.tla | 101 + Model/MeshPublish_buggy.cfg | 9 + Model/MeshPublish_fix.cfg | 9 + Model/SlotLifecycle.tla | 151 + Model/SlotLifecycle_buggy.cfg | 12 + Model/SlotLifecycle_fix.cfg | 12 + oracle/GiOracle.java | 160 ++ oracle/OracleTemple.java | 110 + oracle/compare.sh | 20 + oracle/gi-compare.sh | 19 + pom.xml | 128 + .../svjatoslav/aukio/e3d2/BvhBenchMain.java | 41 + .../aukio/e3d2/CornellDemoMain.java | 72 + .../eu/svjatoslav/aukio/e3d2/Fo4ExamMain.java | 82 + .../eu/svjatoslav/aukio/e3d2/GuiDemoMain.java | 48 + .../aukio/e3d2/LightmapDemoMain.java | 64 + .../java/eu/svjatoslav/aukio/e3d2/Main.java | 21 + .../aukio/e3d2/ProceduralTerrainSource.java | 45 + .../aukio/e3d2/RasterBenchMain.java | 56 + .../eu/svjatoslav/aukio/e3d2/RenderMain.java | 107 + .../svjatoslav/aukio/e3d2/StereoDemoMain.java | 92 + .../aukio/e3d2/StreamingDemoMain.java | 59 + .../aukio/e3d2/StreamingFlightMain.java | 60 + .../aukio/e3d2/SyntheticCitySource.java | 124 + .../aukio/e3d2/TexturedDemoMain.java | 59 + .../svjatoslav/aukio/e3d2/app/AwtShell.java | 777 ++++++ .../svjatoslav/aukio/e3d2/app/BugReport.java | 244 ++ .../aukio/e3d2/app/DeveloperToolsWindow.java | 434 +++ .../aukio/e3d2/app/PersistentLog.java | 150 + .../aukio/e3d2/app/SpaceMouseManager.java | 107 + .../aukio/e3d2/app/SpaceNavigatorHid.java | 323 +++ .../aukio/e3d2/kernel/BvhStore.java | 344 +++ .../aukio/e3d2/kernel/CameraStore.java | 20 + .../aukio/e3d2/kernel/ChunkStore.java | 89 + .../aukio/e3d2/kernel/DebugStore.java | 22 + .../aukio/e3d2/kernel/FramePipeline.java | 177 ++ .../aukio/e3d2/kernel/FrameStream.java | 235 ++ .../aukio/e3d2/kernel/FrameView.java | 60 + .../svjatoslav/aukio/e3d2/kernel/GiStore.java | 104 + .../aukio/e3d2/kernel/GuiStore.java | 64 + .../aukio/e3d2/kernel/ImageStore.java | 52 + .../aukio/e3d2/kernel/InputStore.java | 129 + .../svjatoslav/aukio/e3d2/kernel/Kernel.java | 92 + .../aukio/e3d2/kernel/LightStore.java | 39 + .../aukio/e3d2/kernel/LightmapStore.java | 584 ++++ .../aukio/e3d2/kernel/LineStore.java | 53 + .../aukio/e3d2/kernel/MeshStore.java | 178 ++ .../aukio/e3d2/kernel/Parallel.java | 122 + .../eu/svjatoslav/aukio/e3d2/kernel/Pass.java | 56 + .../aukio/e3d2/kernel/ScreenStore.java | 66 + .../aukio/e3d2/kernel/SdfFontStore.java | 39 + .../aukio/e3d2/kernel/StreamedPass.java | 39 + .../aukio/e3d2/kernel/Substrate.java | 85 + .../aukio/e3d2/kernel/TextureStore.java | 49 + .../svjatoslav/aukio/e3d2/shapes/Shapes.java | 343 +++ .../aukio/e3d2/systems/CameraPass.java | 142 + .../e3d2/systems/ChunkFrustumCullPass.java | 78 + .../aukio/e3d2/systems/CullPass.java | 126 + .../aukio/e3d2/systems/DebugStatsPass.java | 29 + .../aukio/e3d2/systems/FogPass.java | 48 + .../aukio/e3d2/systems/LinePass.java | 286 ++ .../aukio/e3d2/systems/RasterPass.java | 701 +++++ .../aukio/e3d2/systems/SpaceMousePass.java | 154 ++ .../aukio/e3d2/systems/StereoMergePass.java | 56 + .../aukio/e3d2/systems/TransformPass.java | 245 ++ .../aukio/e3d2/systems/WorldStreamer.java | 97 + .../aukio/e3d2/systems/gi/EntryWork.java | 14 + .../aukio/e3d2/systems/gi/GiPass.java | 463 ++++ .../aukio/e3d2/systems/gi/GiScheduler.java | 429 +++ .../aukio/e3d2/systems/gi/Raycast.java | 50 + .../e3d2/systems/gui/DiagnosticsHudPass.java | 54 + .../aukio/e3d2/systems/gui/GuiPass.java | 43 + .../aukio/e3d2/systems/gui/GuiRenderPass.java | 154 ++ .../aukio/e3d2/systems/gui/SdfFont.java | 115 + .../eu.svjatoslav.aukio.e3d2.kernel.Pass | 7 + .../aukio/e3d2/DiscoverOrderTest.java | 27 + .../aukio/e3d2/RasterPipelineTest.java | 96 + .../aukio/e3d2/app/AwtShellTest.java | 313 +++ .../aukio/e3d2/app/BugReportTest.java | 105 + .../e3d2/app/DeveloperToolsWindowTest.java | 51 + .../aukio/e3d2/app/DragLookLoopTest.java | 55 + .../aukio/e3d2/app/SpaceNavigatorHidTest.java | 125 + .../aukio/e3d2/kernel/BvhRaceStressTest.java | 92 + .../aukio/e3d2/kernel/BvhStoreTest.java | 178 ++ .../aukio/e3d2/kernel/KernelOrderingTest.java | 55 + .../aukio/e3d2/kernel/KernelSortTest.java | 59 + .../e3d2/kernel/LightmapStoreVersionTest.java | 68 + .../e3d2/kernel/MeshPublishStressTest.java | 59 + .../e3d2/kernel/MeshStoreBlocksTest.java | 50 + .../aukio/e3d2/shapes/ShapesTest.java | 191 ++ .../aukio/e3d2/systems/CameraPhysicsTest.java | 192 ++ .../aukio/e3d2/systems/ClipTextureTest.java | 115 + .../aukio/e3d2/systems/FogPassTest.java | 39 + .../aukio/e3d2/systems/LinePassTest.java | 152 + .../aukio/e3d2/systems/RasterDebugTest.java | 87 + .../e3d2/systems/RasterParallelTest.java | 51 + .../e3d2/systems/RasterTranslucencyTest.java | 142 + .../e3d2/systems/SpaceMousePassTest.java | 200 ++ .../aukio/e3d2/systems/StereoTest.java | 79 + .../e3d2/systems/StreamRaceStressTest.java | 125 + .../aukio/e3d2/systems/gi/GiBounceTest.java | 78 + .../aukio/e3d2/systems/gi/GiLitSideTest.java | 145 + .../aukio/e3d2/systems/gi/GiPassTest.java | 72 + .../e3d2/systems/gi/GiSchedulerTest.java | 141 + .../aukio/e3d2/systems/gi/GiStressTest.java | 85 + .../e3d2/systems/gi/LightmapBounceTest.java | 108 + .../aukio/e3d2/systems/gi/LightmapTest.java | 91 + .../aukio/e3d2/systems/gui/SdfGuiTest.java | 106 + states/26-10-11-19-54-26.124/MeshPublish-0.st | 0 states/26-10-11-19-54-26.124/MeshPublish-1.st | 0 states/26-10-11-19-54-26.124/MeshPublish-2.st | 0 states/26-10-11-19-54-26.124/MeshPublish-3.st | 0 states/26-10-11-19-54-26.124/MeshPublish.st | 0 states/26-10-11-19-54-26.124/MeshPublish_0.fp | 0 states/26-10-11-19-54-26.124/MeshPublish_1.fp | 0 134 files changed, 17747 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.org create mode 100644 Documentation/index.org create mode 100644 Model/BvhSnapshot.tla create mode 100644 Model/BvhSnapshot_buggy.cfg create mode 100644 Model/BvhSnapshot_fix.cfg create mode 100644 Model/GiScheduler.cfg create mode 100644 Model/GiScheduler.tla create mode 100644 Model/GiScheduler2.cfg create mode 100644 Model/GiScheduler2.tla create mode 100644 Model/GiScheduler3.cfg create mode 100644 Model/GiScheduler3.tla create mode 100644 Model/GiScheduler4.cfg create mode 100644 Model/GiScheduler4.tla create mode 100644 Model/GiScheduler5.tla create mode 100644 Model/GiScheduler5_p1.cfg create mode 100644 Model/GiScheduler5_p1_gate.cfg create mode 100644 Model/GiScheduler5_p2.cfg create mode 100644 Model/GiScheduler5_p2_gate.cfg create mode 100644 Model/MeshPublish.tla create mode 100644 Model/MeshPublish_buggy.cfg create mode 100644 Model/MeshPublish_fix.cfg create mode 100644 Model/SlotLifecycle.tla create mode 100644 Model/SlotLifecycle_buggy.cfg create mode 100644 Model/SlotLifecycle_fix.cfg create mode 100644 oracle/GiOracle.java create mode 100644 oracle/OracleTemple.java create mode 100755 oracle/compare.sh create mode 100755 oracle/gi-compare.sh create mode 100644 pom.xml create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/BvhBenchMain.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/CornellDemoMain.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/Fo4ExamMain.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/GuiDemoMain.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/LightmapDemoMain.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/Main.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/ProceduralTerrainSource.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/RasterBenchMain.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/RenderMain.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/StereoDemoMain.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/StreamingDemoMain.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/StreamingFlightMain.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/SyntheticCitySource.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/TexturedDemoMain.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/app/AwtShell.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/app/BugReport.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/app/DeveloperToolsWindow.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/app/PersistentLog.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/app/SpaceMouseManager.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/app/SpaceNavigatorHid.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/BvhStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/CameraStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/ChunkStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/DebugStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FramePipeline.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FrameStream.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FrameView.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/GiStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/GuiStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/ImageStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/InputStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Kernel.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/LightStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/LightmapStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/LineStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/MeshStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Parallel.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Pass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/ScreenStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/SdfFontStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/StreamedPass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/Substrate.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/kernel/TextureStore.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/shapes/Shapes.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/CameraPass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/ChunkFrustumCullPass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/CullPass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/DebugStatsPass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/FogPass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/LinePass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/RasterPass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/SpaceMousePass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/StereoMergePass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/TransformPass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/WorldStreamer.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/EntryWork.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiPass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiScheduler.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/gi/Raycast.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/DiagnosticsHudPass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/GuiPass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/GuiRenderPass.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfFont.java create mode 100644 src/main/resources/META-INF/services/eu.svjatoslav.aukio.e3d2.kernel.Pass create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/DiscoverOrderTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/RasterPipelineTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/app/AwtShellTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/app/BugReportTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/app/DeveloperToolsWindowTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/app/DragLookLoopTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/app/SpaceNavigatorHidTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/kernel/BvhRaceStressTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/kernel/BvhStoreTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/kernel/KernelOrderingTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/kernel/KernelSortTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/kernel/LightmapStoreVersionTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/kernel/MeshPublishStressTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/kernel/MeshStoreBlocksTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/shapes/ShapesTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/CameraPhysicsTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/ClipTextureTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/FogPassTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/LinePassTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/RasterDebugTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/RasterParallelTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/RasterTranslucencyTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/SpaceMousePassTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/StereoTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/StreamRaceStressTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiBounceTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiLitSideTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiPassTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiSchedulerTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/GiStressTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/LightmapBounceTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/gi/LightmapTest.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfGuiTest.java create mode 100644 states/26-10-11-19-54-26.124/MeshPublish-0.st create mode 100644 states/26-10-11-19-54-26.124/MeshPublish-1.st create mode 100644 states/26-10-11-19-54-26.124/MeshPublish-2.st create mode 100644 states/26-10-11-19-54-26.124/MeshPublish-3.st create mode 100644 states/26-10-11-19-54-26.124/MeshPublish.st create mode 100644 states/26-10-11-19-54-26.124/MeshPublish_0.fp create mode 100644 states/26-10-11-19-54-26.124/MeshPublish_1.fp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..460f2ab --- /dev/null +++ b/.gitignore @@ -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 index 0000000..ce7eb86 --- /dev/null +++ b/AGENTS.org @@ -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 index 0000000..6fdcc01 --- /dev/null +++ b/Documentation/index.org @@ -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 index 0000000..d70fdc6 --- /dev/null +++ b/Model/BvhSnapshot.tla @@ -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 == <> + +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 <> + ELSE /\ published = v + /\ published' = 1 - v + /\ UNCHANGED <> + +\* ---- 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 <> + +QueryReset(q) == + /\ Len(reads[q]) = F + /\ reads' = [reads EXCEPT ![q] = << >>] + /\ UNCHANGED <> + +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 index 0000000..1b896f9 --- /dev/null +++ b/Model/BvhSnapshot_buggy.cfg @@ -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 index 0000000..ddc0114 --- /dev/null +++ b/Model/BvhSnapshot_fix.cfg @@ -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 index 0000000..3ef898c --- /dev/null +++ b/Model/GiScheduler.cfg @@ -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 index 0000000..79d9b26 --- /dev/null +++ b/Model/GiScheduler.tla @@ -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 index 0000000..55e7967 --- /dev/null +++ b/Model/GiScheduler2.cfg @@ -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 index 0000000..8dc094a --- /dev/null +++ b/Model/GiScheduler2.tla @@ -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 index 0000000..9a67689 --- /dev/null +++ b/Model/GiScheduler3.cfg @@ -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 index 0000000..16dcf59 --- /dev/null +++ b/Model/GiScheduler3.tla @@ -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 <> + 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 index 0000000..b888543 --- /dev/null +++ b/Model/GiScheduler4.cfg @@ -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 index 0000000..f373370 --- /dev/null +++ b/Model/GiScheduler4.tla @@ -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 <> + 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 <> + 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 index 0000000..450fc82 --- /dev/null +++ b/Model/GiScheduler5.tla @@ -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 <> + 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 <> + 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 index 0000000..480b835 --- /dev/null +++ b/Model/GiScheduler5_p1.cfg @@ -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 index 0000000..01c36dd --- /dev/null +++ b/Model/GiScheduler5_p1_gate.cfg @@ -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 index 0000000..48ceaf6 --- /dev/null +++ b/Model/GiScheduler5_p2.cfg @@ -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 index 0000000..42d033a --- /dev/null +++ b/Model/GiScheduler5_p2_gate.cfg @@ -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 index 0000000..6e86e76 --- /dev/null +++ b/Model/MeshPublish.tla @@ -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 <> + +vars == <> + +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 <> + +AppFinish == + /\ appPhase = "mid" + /\ appPhase' = "idle" + /\ version' = version + 1 + /\ UNCHANGED <> + +\* ---- 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 <> + +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, <>) + /\ bldPhase' = "idle" + /\ UNCHANGED <> + +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 index 0000000..9645e68 --- /dev/null +++ b/Model/MeshPublish_buggy.cfg @@ -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 index 0000000..6467f51 --- /dev/null +++ b/Model/MeshPublish_fix.cfg @@ -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 index 0000000..6591669 --- /dev/null +++ b/Model/SlotLifecycle.tla @@ -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 == <> + +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 <> + +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 <> + +\* ---- 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 <> + /\ hist' = Append(hist, LiveMapOf([state EXCEPT ![s] = "empty"])) + /\ UNCHANGED <> + +\* ---- 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 <> + +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 <> + +ReaderRelease(r) == + /\ rCap[r] > 0 + /\ \A s \in Slots : rSeen[r][s] # UNREAD + /\ rCap' = [rCap EXCEPT ![r] = 0] + /\ UNCHANGED <> + +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 index 0000000..941df99 --- /dev/null +++ b/Model/SlotLifecycle_buggy.cfg @@ -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 index 0000000..ce54bdf --- /dev/null +++ b/Model/SlotLifecycle_fix.cfg @@ -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 index 0000000..ebb6503 --- /dev/null +++ b/oracle/GiOracle.java @@ -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 index 0000000..72310f3 --- /dev/null +++ b/oracle/OracleTemple.java @@ -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 index 0000000..b3f19cc --- /dev/null +++ b/oracle/compare.sh @@ -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 index 0000000..ed634d0 --- /dev/null +++ b/oracle/gi-compare.sh @@ -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 index 0000000..086f585 --- /dev/null +++ b/pom.xml @@ -0,0 +1,128 @@ + + + 4.0.0 + + eu.svjatoslav + aukio-3d-2 + 2.0.0-SNAPSHOT + jar + + + 21 + UTF-8 + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + + + + + net.java.dev.jna + jna + 5.14.0 + + + org.junit.jupiter + junit-jupiter + 5.11.4 + test + + + + + + + tla-check + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + tla-bvh-snapshot + verify + exec + + java + ${project.basedir}/Model + + -cptla2tools.jar + tlc2.TLC + -workers4 + -configBvhSnapshot_fix.cfg + BvhSnapshot.tla + + + + + tla-mesh-publish + verify + exec + + java + ${project.basedir}/Model + + -cptla2tools.jar + tlc2.TLC + -workers4 + -deadlock + -configMeshPublish_fix.cfg + MeshPublish.tla + + + + + tla-scheduler-p1 + verify + exec + + java + ${project.basedir}/Model + + -cptla2tools.jar + tlc2.TLC + -workers4 + -configGiScheduler5_p1_gate.cfg + GiScheduler5.tla + + + + + tla-scheduler-p2 + verify + exec + + java + ${project.basedir}/Model + + -cptla2tools.jar + tlc2.TLC + -workers4 + -configGiScheduler5_p2_gate.cfg + GiScheduler5.tla + + + + + + + + + + + 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 index 0000000..54bc5e4 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/BvhBenchMain.java @@ -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 index 0000000..28699bd --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/CornellDemoMain.java @@ -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 index 0000000..bce4ed5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/Fo4ExamMain.java @@ -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 index 0000000..9877ebd --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/GuiDemoMain.java @@ -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 index 0000000..f184d71 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/LightmapDemoMain.java @@ -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 index 0000000..604634d --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/Main.java @@ -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 index 0000000..fa4397f --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/ProceduralTerrainSource.java @@ -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 index 0000000..f96db1f --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/RasterBenchMain.java @@ -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 index 0000000..8fa05a3 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/RenderMain.java @@ -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 index 0000000..d3e02d2 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/StereoDemoMain.java @@ -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 index 0000000..1be5d77 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/StreamingDemoMain.java @@ -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 index 0000000..29dd689 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/StreamingFlightMain.java @@ -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 index 0000000..ca26115 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/SyntheticCitySource.java @@ -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 index 0000000..89a8899 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/TexturedDemoMain.java @@ -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 index 0000000..fb1fb7c --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/app/AwtShell.java @@ -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 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 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 index 0000000..2ed949f --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/app/BugReport.java @@ -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-} 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: + *
    + *
  • {@code description.txt} — what the user wrote in the popup
  • + *
  • {@code info.txt} — camera pose (five fields; e3d2 has no + * roll), view/screen resolution, measured fps, heap, java/os + * versions, launch command
  • + *
  • {@code screenshot.png} — a copy of the current frame buffer
  • + *
  • {@code histogram.txt} — live-object histogram from + * {@code jcmd GC.class_histogram} (skipped when jcmd is + * unavailable or hangs)
  • + *
  • {@code threads.txt} — full thread dump (hang diagnosis)
  • + *
  • {@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
  • + *
+ * 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 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 index 0000000..2633b5f --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/app/DeveloperToolsWindow.java @@ -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 index 0000000..6823f2a --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/app/PersistentLog.java @@ -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. + * + *

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.

+ * + *

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.

+ */ +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 index 0000000..a3ae150 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/app/SpaceMouseManager.java @@ -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 index 0000000..aecdc32 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/app/SpaceNavigatorHid.java @@ -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//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 index 0000000..c2f0dee --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/BvhStore.java @@ -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 < t < 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 < t < + * 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 index 0000000..3940ef5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/CameraStore.java @@ -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 index 0000000..f22ca97 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/ChunkStore.java @@ -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 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 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 index 0000000..5b7c526 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/DebugStore.java @@ -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 index 0000000..8bc18b2 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FramePipeline.java @@ -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. + * + *

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.

+ * + *

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).

+ * + *

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.

+ */ +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 acquireImage; + private final Consumer publish; + + private final List geometryPasses = new ArrayList<>(); + private final List 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 completed = new TreeMap<>(); + private long nextSeq; + private long nextPublish; + + public FramePipeline(final Substrate data, final Kernel kernel, + final Supplier acquireImage, + final Consumer 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 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 index 0000000..243b445 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FrameStream.java @@ -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. + * + *

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.

+ * + *

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.

+ * + *

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.

+ */ +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 passes = new ArrayList<>(); + private final Supplier acquireImage; + private final Consumer 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 completed = new TreeMap<>(); + private long nextSeq; + private long nextPublish; + + public FrameStream(final Substrate data, final Kernel kernel, + final Supplier acquireImage, + final Consumer publish) { + this.data = data; + this.acquireImage = acquireImage; + this.publish = publish; + final List ps = new ArrayList<>(); + final List 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 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 index 0000000..5087dc5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/FrameView.java @@ -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: + * + *
    + *
  • {@link #slot} — which {@link ScreenStore} slot this frame owns;
  • + *
  • {@link #screen} — that slot (transform output, raster input);
  • + *
  • {@link #image} — the buffer this frame paints into;
  • + *
  • {@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;
  • + *
  • the camera pose this frame renders ({@code x..scale}) — sampled + * once when the frame is created, because the live camera moves on.
  • + *
+ * + *

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.

+ */ +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 index 0000000..085584e --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/GiStore.java @@ -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 >= 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 index 0000000..88d3388 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/kernel/GuiStore.java @@ -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 panels = new ArrayList<>(); + public final List