feat: virtual 3D workspace with captured apps, terminals and editors
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 20 Sep 2026 14:27:46 +0000 (17:27 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 20 Sep 2026 14:27:46 +0000 (17:27 +0300)
The Aukio desktop: ordinary Linux GUI applications captured live over
X11, PTY terminals and rich text editors placed as interactive objects
in a flyable 3D world built on the aukio-3d software rasterizer.
Keyboard, mouse and scroll input is routed into the captured apps
(including texture-space click forwarding); panels are placed, focused
and managed from the 3D view. Includes launcher tooling and user
documentation.

26 files changed:
.gitignore [new file with mode: 0644]
COPYING [new file with mode: 0644]
Documentation/index.org [new file with mode: 0644]
TODO.org [new file with mode: 0644]
Tools/Open with IntelliJ IDEA [new file with mode: 0755]
Tools/Update web site [new file with mode: 0755]
pom.xml [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/bridge/pty/PtySession.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/bridge/pty/ScreenBuffer.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/bridge/pty/UnixPty.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/bridge/pty/Vt100Emulator.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/bridge/x11/AwtKeysyms.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/bridge/x11/GuiAppSession.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/bridge/x11/X11Native.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/bridge/x11/XvfbServer.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/core/Main.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/env/EnvironmentContext.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/env/EnvironmentProvider.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/env/EnvironmentRegistry.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/env/GridEnvironmentProvider.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/workspace/FirefoxPanel.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/workspace/GuiAppPanel.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/workspace/TerminalPanel.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/workspace/VmwarePanel.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/workspace/Workspace.java [new file with mode: 0644]
start.sh [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..096db16
--- /dev/null
@@ -0,0 +1,11 @@
+/.idea/
+/.classpath
+/.project
+/test.byar
+/target/
+/bin
+/src/main/resources/rebel.xml
+/aukio.iml
+/.settings/
+/*.iml
+*.html
\ No newline at end of file
diff --git a/COPYING b/COPYING
new file mode 100644 (file)
index 0000000..0e259d4
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+    HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+  i. the right to reproduce, adapt, distribute, perform, display,
+     communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+     likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+     subject to the limitations in paragraph 4(a), below;
+  v. rights protecting the extraction, dissemination, use and reuse of data
+     in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+     European Parliament and of the Council of 11 March 1996 on the legal
+     protection of databases, and under any national implementation
+     thereof, including any amended or successor version of such
+     directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+     world based on applicable law or treaty, and any national
+     implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+    surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+    warranties of any kind concerning the Work, express, implied,
+    statutory or otherwise, including without limitation warranties of
+    title, merchantability, fitness for a particular purpose, non
+    infringement, or the absence of latent or other defects, accuracy, or
+    the present or absence of errors, whether or not discoverable, all to
+    the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+    that may apply to the Work or any use thereof, including without
+    limitation any person's Copyright and Related Rights in the Work.
+    Further, Affirmer disclaims responsibility for obtaining any necessary
+    consents, permissions or other rights required for any use of the
+    Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+    party to this document and has no duty or obligation with respect to
+    this CC0 or use of the Work.
diff --git a/Documentation/index.org b/Documentation/index.org
new file mode 100644 (file)
index 0000000..5611539
--- /dev/null
@@ -0,0 +1,200 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Aukio — System for Data Storage, Computation, Exploration and Interaction
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+* Vision
+  :PROPERTIES:
+  :CUSTOM_ID: vision
+  :ID:       1f4e1c17-d25f-4d92-aa9b-5785f1d86f4f
+  :END:
+
+: A tool to amplify human ability
+
+The goal is a /bicycle for the mind/ — a powerful, extensible, hackable,
+general-purpose computing environment for working with knowledge.
+
+The system is built around the following priorities:
+
++ *Knowledge-first.* Data and insights should be easy to discover,
+  understand, manipulate, transform and visualize.
+
++ *Intuitive, visual, real-time, 3D-first interface.*
+
+  #+BEGIN_QUOTE
+  "Virtual reality holds the key to the evolution of the human mind."
+  — Dr. Lawrence Angelo, /The Lawnmower Man/ (1992)
+  #+END_QUOTE
+
+
+
+** Extensible, Programmable Computing Environment — An Example
+   :PROPERTIES:
+   :CUSTOM_ID: extensible-programmable-computing-environment
+   :ID:       c19c5a3b-dfb0-4f7f-961c-a387b925669f
+   :END:
+
+[[https://www.johndcook.com/blog/2008/04/27/one-program-to-rule-them-all/][GNU Emacs]] is perhaps the best existing example of this philosophy. At
+its core, Emacs is a text editor built on top of a Lisp runtime. Data
+storage and computation can be expressed in [[https://www.defmacro.org/ramblings/lisp.html][Lisp]], which is itself a
+programmable programming language — new paradigms and domain-specific
+languages can be added dynamically. Text buffers serve as building
+blocks for arbitrary user interfaces. The result is an environment
+that can be adapted and extended to fit virtually any problem domain.
+
+** Architecture and Components
+   :PROPERTIES:
+   :CUSTOM_ID: architecture-and-components
+   :ID:       52dbbf4c-2ef4-42a6-8331-ad006b6a52ae
+   :END:
+
++ [[https://www3.svjatoslav.eu/projects/aukio/][Aukio]] — Parent project and the product: a spatial computing
+  environment (3D virtual workspace).
+  + [[https://www3.svjatoslav.eu/projects/aukio-data/][Aukio Data]] — Data storage and computation engine. (Very early
+    stage, nothing to see yet)
+  + [[https://www3.svjatoslav.eu/projects/aukio-3d/][Aukio 3D]] — Real-time 3D engine for user interface and data
+    visualization. (Working and usable, fast evolving)
+    + [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/][Aukio 3D Demos]] — Demonstration scenes showcasing the
+      capabilities of the Aukio 3D engine. (Many demos, in good shape)
+
+The system is far from complete — the scope is large and available
+time is limited.
+
+** Roles and boundaries
+
++ *Aukio 3D* is a reusable library. Anyone can take it to build games
+  or visualizations. It keeps only generic mechanisms: the renderer,
+  scene graph, camera, input primitives, and the widget layer
+  (~GuiComponent~, ~TextCanvas~, ~TextEditComponent~) that games can
+  also use (in-game consoles, interactive screens).
++ *Aukio* is the concrete product built on that library: the virtual
+  workspace you actually work in. Application-integration bridges
+  (PTY/terminal, future VNC client, window capture) live here.
+  Exception to the "no native code in the engine" rule: XR glasses
+  head tracking (RayNeo IMU over hidraw, ~eu.svjatoslav.aukio.e3d.gui.headtrack~)
+  lives in the ENGINE, so every app and demo gets look-around head
+  tracking automatically when glasses are plugged in — including
+  hot-plug at runtime.
++ *Aukio 3D Demos* remains the capability gallery and regression
+  harness. Workspace features are prototyped as demos, but the real
+  thing lives in Aukio.
+
+** Virtual workspace
+
+Aukio is a spatial computing environment: a persistent 3D world where
+every program you use — text editor, terminal, browser, virtual
+machine, data view — is a virtual object you place, walk to, and work
+at. It replaces window-switching with spatial memory: related tools
+live near each other, and your body remembers where things are.
+
+Roadmap for external applications, from cheapest to most general:
+
++ *Virtual machines* — talk VNC/SPICE to the guest display instead of
+  scraping a window; the protocol provides both framebuffer updates
+  and input injection.
++ *Arbitrary Linux applications* (browser, etc.) — run the app on a
+  dedicated ~Xvfb~ display, capture pixels via ~XShmGetImage~, inject
+  input via XTEST; the app lives only inside the 3D world.
+
+* Ideas
+:PROPERTIES:
+:CUSTOM_ID: ideas
+:END:
+
+** Molecular dynamics integration
+:PROPERTIES:
+:CUSTOM_ID: molecular-dynamics-integration
+:END:
+
+Integrate with [[https://www.gromacs.org/][GROMACS]] or a similar molecular dynamics simulator.
+
+Users could place atoms in molecular dynamics models and manipulate
+them directly with hand gestures (in VR) or mouse drag-and-drop
+(desktop), seeing immediate force-based reactions. in 3D, the
+simulation becomes a tangible object you "hold" and adjust.
+
+Teams in VR could jointly manipulate a protein model while viewing
+real-time simulation data, with each member's avatar contributing to
+the workspace.
+
+** Emacs client integration
+:PROPERTIES:
+:CUSTOM_ID: emacs-client-integration
+:END:
+
+Allow Aukio to act as an Emacs client, connecting to a running Emacs
+instance. Text editors could then be spawned as objects in 3D space,
+each backed by the live Emacs process. This would make all existing
+Emacs plugins and functionality available inside the Aukio environment.
+
+** Document represented as geometry
+
+Text editors would be replaced by floating 3D "cubes" where code
+structure is visualized spatially - functions as connected nodes,
+dependencies as glowing wires. Moving a function node would
+automatically rewire related components, making refactoring a spatial
+act. In 2D, this requires mental mapping; in 3D, the structure is
+visible and manipulable.
+
+Ideas in Obsidian-like systems could be arranged in 3D mind maps where
+proximity indicates relevance - clicking a node pulls connected
+documents into view, creating a navigable "knowledge landscape".
+
+** Electronics circuit simulation integration
+:PROPERTIES:
+:CUSTOM_ID: electronics-circuit-simulation-integration
+:END:
+
+Integrate with [[https://ngspice.sourceforge.io/][ngspice]], [[http://www.bwsst.com/][SPICE]], or a similar electronics circuit
+simulator, enabling interactive circuit design and simulation in 3D.
+
+A circuit designer using ngspice could place resistors/capacitors in
+3D space, connect them with virtual wires, and simultaneously
+visualize current flow as animated particles while overlaying
+Pandas-generated performance metrics. This eliminates
+context-switching between tools - data, simulation, and visualization
+coexist in one spatial workflow.
+
+This would provide contextual continuity. Related tools (e.g., a
+simulation and its data analysis) can be spatially adjacent in space,
+avoiding the "window-switching" friction of 2D desktops.
+
+** 3D visualization of statistical data
+
+Dataframes from Pandas would render as 3D grids where rows/columns
+occupy spatial axes. Dragging a region of the grid would instantly
+filter related data. Unlike 2D tables where multidimensional
+relationships are flattened, 3D preserves natural spatial
+relationships- e.g., high-value data points "tower" above low-value
+ones, creating intuitive topographical insights.
+
+A 3D scatter plot shows all variables simultaneously; rotating it
+reveals patterns invisible in 2D projections.
+
+Students could "walk through" a dataset experiencing trends as spatial
+terrain rather than abstract graphs.
+
+* Source code
+:PROPERTIES:
+:CUSTOM_ID: source-code
+:END:
+
+*This program is free software: released under Creative Commons Zero
+(CC0) license*
+
+*Program author:*
+- Svjatoslav Agejenko
+- Homepage: https://svjatoslav.eu
+- Email: mailto://svjatoslav@svjatoslav.eu
+- See also: [[https://www.svjatoslav.eu/projects/][Other software projects hosted at svjatoslav.eu]]
+
+*Getting the source code:*
+- [[https://www2.svjatoslav.eu/gitweb/?p=aukio.git;a=snapshot;h=HEAD;sf=tgz][Download latest snapshot in TAR GZ format]]
+- [[https://www2.svjatoslav.eu/gitweb/?p=aukio.git;a=summary][Browse Git repository online]]
+- Clone Git repository using command:
+  : git clone https://www2.svjatoslav.eu/git/aukio.git
diff --git a/TODO.org b/TODO.org
new file mode 100644 (file)
index 0000000..590c157
--- /dev/null
+++ b/TODO.org
@@ -0,0 +1,239 @@
+* Features
+** Put agents in 3D world
+
+- Agent has body that resembles human
+  - TODO: find good design for humanoid
+
+- Agent has ability to move around cyberspace
+
+- Agent has ability to see (project image from agent perspective)
+
+- Agent has ability to write and execute programs
+
+- Agent has ability to read and write documents in cyberspace
+
+- Agent can hear your voice (STT, whisper)
+
+- Agent can speak (TTS, Piper)
+
+- Local LLM as well as cloud based LLM can be used
+  - OpenRouter free LLMs
+  - Kimi K3
+
+
+
+** World is built around intent
+
+- you start with the world and you name intent for it
+
+- AI is monitoring what you are doing and is always ready to help
+  - rename space to better capture intent
+
+- you create new (optionally linked) spaces with (sub intents)
+  - you can inherit copy of existing apps into new space or star with empty space
+
+
+* 3D rendering
+
+Roadmap and measurement notes from the 2026-09-13 perf study
+(Fallout 4 environment, downtown Boston spawn — camera
+"7146.48,-7989.42,-5598.31,-2.49,-0.83,0.00", 3840x2091). Resume
+work here.
+
+** Where we are — measured baseline
+
+Single-threaded frame at the Boston spawn (ProfProbe harness):
+transform 120 ms (21%) + painter sort 40 ms (7%) + paint 411 ms
+(72%) = 571 ms/frame. Paint decomposes (linear fit across
+resolutions): per-triangle setup 51%, per-span 25%, pixel fill 24%.
+SETUP dominates, not fill.
+
+Triangle funnel: 1.27M loaded -> 894k reach paint. Of those:
+- 46% backfacing (painted, then covered)
+- 26% die at the vertical clamp AFTER full per-tri setup
+- 20% tinier than 2x2 px at 4K (44% at 960x540!)
+- overdraw 3.28x (every pixel written 3.28 times)
+- frustum culling: ZERO composites in the FO4 scene — the engine's
+  AABB frustum culling never engages (shapes added flat to root)
+
+Live app was at 0.7 fps (bugreport-20260913-030135) vs ~1.75 fps
+single-thread probe: heap histogram shows 3.6 GB texture int[] +
+1.4 GB G1 filler arrays in 12.4/16 GB heap -> GC pressure is a
+separate live-session problem.
+
+Nanite viability floor (NanoTriBench, 24-core tiny): pixel-sized
+triangles rasterize at 26 Mtri/s single-thread, 126 Mtri/s on 22
+threads (8 ns/tri); texture fetch free at that size. Java CAN feed
+a micro-triangle pipeline. Modelled Nanite frame at 4K: ~110 ms
+(~9 fps) without occlusion culling, ~60 ms (~16 fps) with HZB —
+and that number is FLAT with world size.
+
+NOTE (2026-09-13 evening): fo4 default farRadius is now 2, not 4
+(bugreport-20260913-165044 — the terrain-only FAR doughnut rings
+2-4 read as "empty near, detailed far"; BTO object LOD now starts
+at ring 3). Baselines above were probed with farRadius=4 (81
+streamed cells at the Boston spawn); reruns now see 25 cells and a
+different tri mix — re-baseline ProfProbe before comparing.
+
+** Road to Nanite — evolutionary stages
+
+Each stage ships independently and keeps its value; nothing is
+thrown away later. The seam of the whole plan is Stage 1 (touches
+every rasterizer); after that everything is additive.
+
+*** TODO Stage 0a: per-cell composite frustum culling
+Wrap each streamed cell's shapes in a CompositeShape with an AABB
+so the engine's existing frustum test engages. Est -20-30% frame.
+Already the top item in aukio-environment-fo4/AGENTS.org.
+
+*** TODO Stage 0b: per-material backface culling
+MEASURED -11% paint (-De3d.backface=true exists in the engine,
+global, default off). Production version must key off the NIF
+two-sided shader flag (cutout trees/fences are double-sided), so
+wire a flag from NifFile shader properties into TexturedTriangle.
+
+*** TODO Stage 1: z-buffer (w-buffer) + two-pass rendering
+Opaque pass: depth test+write. Transparent pass (glass, GUI, text):
+back-to-front painter, test but NEVER write depth (avoids the
+"insert between merged layers" problem — see Design decisions).
+Cutout alpha (fences, foliage) joins the opaque pass with texel
+test. Store 1/z (w-buffer): our perspective path already
+interpolates 1/z per pixel, depth is a byproduct we throw away;
+hyperbolic precision suits the 130k-unit horizon.
+Seam: every span writer in the engine must learn depth (same class
+of sweep as the stereo X-clipping change — grep all
+drawHorizontalLine variants). Needs demo app + golden re-render
+with VISION review (z intentionally changes pixels where painter
+was wrong — road mottling, depthBias hack, dithered _lod statics,
+patchy distant buildings all die in this commit).
+
+*** TODO Stage 2: opaque near-to-far (early reject)
+Flip the comparator for the opaque pass. Est -12% frame from
+killing the 3.28x overdraw. Hours once Stage 1 exists.
+
+*** TODO Stage 3: HZB occlusion culling
+Downsample depth into a coarse mip pyramid per frame; test
+cell/object AABBs against it before streaming/drawing; cull whole
+cells — skips transform+sort+setup+fill. Nanite's two-phase trick
+applies: reuse LAST frame's pyramid, draw last frame's visible set
+first, then test the rest. View-dependent: moderate at the aerial
+spawn, large at street level.
+
+*** TODO Stage 4: tiny-tri stamp fast path
+At paint entry: screen bbox < 2x2 px -> stamp one average-colored
+quad (nearest mip texel at centroid) instead of full setup. 20% of
+triangles at 4K; est -7-10% paint. This is the SEED of the
+micro-rasterizer grown inside the existing one. Object-level
+variant: drop whole placements under ~2 px bbox at transform time
+(with hysteresis against pop-in).
+
+*** TODO Stage 5: cluster DAG remeshing (Nanite proper)
+The pipeline can't tell where triangles came from — a cluster DAG
+is just another shape source, so build it mesh-by-mesh, no flag
+day. Offline tool: cluster ~128-tri groups, simplification DAG
+with boundary locking; emit clusters; render through the same
+scene graph. Prototype on one kit (Red Rocket shell), measure,
+then batch. Naive vertex-clustering decimation = days; good
+quadric-error version = the multi-week meshoptimizer-class nut,
+but isolated offline. FO4's own _lod.nif levels are a quality
+reference. End state: ~15 fps at 4K FLAT with world size; all
+hand/baked LOD (_lod.nif, BTR, BTO, near ring) replaced uniformly;
+distant patchiness gone.
+
+*** Optional fork: visibility buffer
+Write triangle-ID + depth, resolve materials once per visible
+pixel in a second pass. Worth it when shading dominates; borderline
+at 4K already. Cheap to try once Stage 1 exists (ID write rides
+with the depth write).
+
+** Other optimization proposals (not on the Nanite road)
+
+- Heap/GC diet (live-session 0.7 fps cause): 3.6 GB texture int[]
+  (mip chains) + G1 humongous fragmentation. Options: bigger heap,
+  drop deepest mips for distant-only textures, 16-bit pixel
+  storage. Own work item, independent of rendering changes.
+- Temporal coherence: cache per-cell visibility/culling decisions
+  while the camera stays in a cell; re-evaluate on cell change or
+  large rotation.
+- Coverage mask (painter-era occlusion, NO z-buffer): rasterize
+  nearest ~10-20% opaque tris into a coarse coverage bitmap
+  front-first, skip far objects fully covered. Exact under painter
+  rules (only nearer+opaque sets bits). SUPERSEDED by Stage 3 if
+  the z-buffer lands — kept here in case we stay painter-only.
+- Terrain horizon culling: heightfield horizon-angle test; cells
+  behind hills culled nearly free. Big in hilly country, minor in
+  flat Boston.
+- BSP exact ordering: engine already has BSP-ranked shapes (skip
+  Z-sort). Correctness + sort time, not occlusion. Pairs with
+  everything above.
+- Resolution honesty: ~900k tris at 4K on CPU won't hit 60 fps by
+  any of these. Internal-render-at-lower-res + upscale is the
+  blunt instrument (540p -> 4K saves ~40% paint, looks soft).
+
+** Exotic techniques survey (verdicts from the discussion)
+
+All complexity-independent techniques share one idea: query a
+prebuilt spatial index per pixel instead of drawing per triangle;
+cost = pixels x log(world).
+
+- BVH ray tracing: THE complexity-independent endpoint. Subsumes
+  occlusion/frustum/backface/overdraw/painter-sort by never
+  touching invisible geometry. Static world = ideal (build once).
+  Java CPU: ~10-50M rays/s -> a few fps at 1080p, ~1 at 4K, flat
+  as world grows; shadows/GI nearly free per extra ray. Hybrid
+  variant: rasterize near ring, ray-trace past it. Aligned with
+  user's taste (chose real RT over lightmap upscaling for GI).
+- Surfel/point-cloud (QSplat/Potree): the 4px-blob idea
+  industrialized. Octree of representative points, screen-error
+  LOD walk, hard POINT BUDGET per frame -> world-size independent.
+  Most painter-friendly; cheapest exotic to try.
+- Voxel octree raymarching (SVO, Teardown; Euclideon was the
+  marketing version): geometry forgotten after voxelization.
+  Niche for us: voxelize the coarse BTR/BTO horizon blocks.
+- 3D Gaussian splatting: differentiable painter's algorithm;
+  beautiful but a CAPTURE representation (from photos), wrong
+  input format for game meshes.
+- SDF ray marching: procedural content only; converting FO4's
+  detailed meshes to distance fields is lossy. Bad fit.
+
+** Design decisions (resolved discussions, don't re-litigate)
+
+- Z-buffer speedup mechanics: z alone gives CORRECTNESS, not
+  speed; speed comes from flipping opaque order near-to-far so
+  behind-fragments become cheap depth-test rejects. Per-pixel
+  reject ~1/10 of a shaded write.
+- Transparent polygons under z: two passes (opaque test+write;
+  transparent back-to-front test-only). One depth slot per pixel
+  cannot represent a stack of see-through layers.
+- REJECTED: single-pass alpha-accumulation (framebuffer color +
+  per-pixel alpha + depth, compose on arrival). Math is valid
+  (front-to-back "over" is associative, A' = A + a(1-A)) but
+  requires strict per-pixel near-to-far arrival; per-triangle
+  centroid sorting violates it routinely (existing road mottling
+  proves the error rate), and once layers merge to alpha=1 a later
+  fragment landing between them gets weight 0 -> OPAQUE geometry
+  can vanish. Two-pass fails cosmetically (wrong tint between
+  glasses, rare); one-pass fails structurally (missing geometry,
+  common). Real descendants if ever needed: depth peeling,
+  weighted blended OIT.
+- User preference noted: evolutionary stages with each step
+  shippable, over big-bang rewrites.
+
+** Harnesses and flags (for resuming work)
+
+- ProfProbe.java — per-stage frame profiler at the bugreport pose
+  (world load like Fo4Shot, times transform/sort/paint, prints
+  counters). NanoTriBench.java — micro-triangle floor bench.
+  Both in /tmp/hermes-verify-redrocket/ — VOLATILE (tmpfs);
+  recreate or move into the repo before reboot.
+- Engine instrumentation (UNCOMMITTED in aukio-3d): -De3d.prof=true
+  enables TexturedTriangle counters (tris/backface/offscreenY/
+  tiny/spans/pixels, profReset()); -De3d.backface=true sets the
+  backface-culling default. Probe classpath: prepend
+  aukio-3d/target/classes so the instrumented engine wins over the
+  installed jar.
+- FO4 goldens: Fo4Shot <out.png> spawn|<pose> [w h]; spawn pose
+  lives in ~/.config/aukio/config.properties (fo4.spawnAt).
+- Project docs: aukio-environment-fo4/AGENTS.org (streaming/LOD
+  details), skills n0-aukio-environment-fo4, n0-aukio-3d-engine.
+
diff --git a/Tools/Open with IntelliJ IDEA b/Tools/Open with IntelliJ IDEA
new file mode 100755 (executable)
index 0000000..de9bae5
--- /dev/null
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+#
+# This is a helper bash script that starts IntelliJ with the current project.
+# Script is written is such a way that you can simply click on it in file
+# navigator to run it.
+#
+#
+# Script assumes:
+#
+#    + GNU operating system
+#    + IntelliJ is installed and commandline launcher "idea" is enabled.
+#
+
+cd "${0%/*}"
+cd ..
+
+setsid idea . &>/dev/null
diff --git a/Tools/Update web site b/Tools/Update web site
new file mode 100755 (executable)
index 0000000..028c6e4
--- /dev/null
@@ -0,0 +1,76 @@
+#!/bin/bash
+cd "${0%/*}"; if [ "$1" != "T" ]; then gnome-terminal -e "'$0' T"; exit; fi;
+
+cd ..
+
+export_org_to_html() {
+    local org_file=$1
+    local dir=$(dirname "$org_file")
+    local base=$(basename "$org_file" .org)
+    (
+        cd "$dir" || return 1
+        local html_file="${base}.html"
+        if [ -f "$html_file" ]; then
+            rm -f "$html_file"
+        fi
+        echo "Exporting: $org_file → $dir/$html_file"
+        emacs --batch -l ~/.emacs --visit="${base}.org" --funcall=org-html-export-to-html --kill
+        if [ $? -eq 0 ]; then
+            echo "✓ Successfully exported $org_file"
+        else
+            echo "✗ Failed to export $org_file"
+            return 1
+        fi
+    )
+}
+
+export_org_files_to_html() {
+    echo "🔍 Searching for .org files in Documentation/ ..."
+    echo "======================================="
+
+    mapfile -t ORG_FILES < <(find Documentation -type f -name "*.org" | sort)
+
+    if [ ${#ORG_FILES[@]} -eq 0 ]; then
+        echo "❌ No .org files found!"
+        return 1
+    fi
+
+    echo "Found ${#ORG_FILES[@]} .org file(s):"
+    printf '%s\n' "${ORG_FILES[@]}"
+    echo "======================================="
+
+    SUCCESS_COUNT=0
+    FAILED_COUNT=0
+
+    for org_file in "${ORG_FILES[@]}"; do
+        export_org_to_html "$org_file"
+        if [ $? -eq 0 ]; then
+            ((SUCCESS_COUNT++))
+        else
+            ((FAILED_COUNT++))
+        fi
+    done
+
+    echo "======================================="
+    echo "📊 SUMMARY:"
+    echo "   ✓ Successful: $SUCCESS_COUNT"
+    echo "   ✗ Failed:     $FAILED_COUNT"
+    echo "   Total:        $((SUCCESS_COUNT + FAILED_COUNT))"
+    echo ""
+}
+
+export_org_files_to_html
+
+echo "📤 Uploading to server..."
+rsync -avz --delete -e 'ssh -p 10006' Documentation/ \
+      n0@www3.svjatoslav.eu:/mnt/big/projects/aukio/
+
+if [ $? -eq 0 ]; then
+    echo "✓ Upload completed successfully!"
+else
+    echo "✗ Upload failed!"
+fi
+
+echo ""
+echo "Press ENTER to close this window."
+read
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
new file mode 100644 (file)
index 0000000..94aba4d
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,125 @@
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>eu.svjatoslav</groupId>
+    <artifactId>aukio</artifactId>
+    <version>1.0.0-SNAPSHOT</version>
+    <name>Aukio</name>
+    <description>Spatial computing environment</description>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <maven.compiler.source>21</maven.compiler.source>
+        <maven.compiler.target>21</maven.compiler.target>
+    </properties>
+
+
+    <organization>
+        <name>svjatoslav.eu</name>
+        <url>http://svjatoslav.eu</url>
+    </organization>
+
+    <dependencies>
+
+        <dependency>
+            <groupId>eu.svjatoslav</groupId>
+            <artifactId>aukio-3d</artifactId>
+            <version>1.0.0-SNAPSHOT</version>
+        </dependency>
+
+        <dependency>
+            <groupId>net.java.dev.jna</groupId>
+            <artifactId>jna</artifactId>
+            <version>5.14.0</version>
+        </dependency>
+
+        <dependency>
+            <groupId>eu.svjatoslav</groupId>
+            <artifactId>svjatoslavcommons</artifactId>
+            <version>1.8</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.13.0</version>
+                <configuration>
+                    <source>21</source>
+                    <target>21</target>
+                    <encoding>UTF-8</encoding>
+                </configuration>
+            </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-source-plugin</artifactId>
+                <version>2.2.1</version>
+                <executions>
+                    <execution>
+                        <id>attach-sources</id>
+                        <goals>
+                            <goal>jar</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-javadoc-plugin</artifactId>
+                <version>2.9</version>
+                <executions>
+                    <execution>
+                        <id>attach-javadocs</id>
+                        <goals>
+                            <goal>jar</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+
+        </plugins>
+
+        <extensions>
+            <extension>
+                <groupId>org.apache.maven.wagon</groupId>
+                <artifactId>wagon-ssh-external</artifactId>
+                <version>2.6</version>
+            </extension>
+        </extensions>
+    </build>
+
+
+    <distributionManagement>
+        <snapshotRepository>
+            <id>svjatoslav.eu</id>
+            <name>svjatoslav.eu</name>
+            <url>scpexe://svjatoslav.eu:10006/srv/maven</url>
+        </snapshotRepository>
+        <repository>
+            <id>svjatoslav.eu</id>
+            <name>svjatoslav.eu</name>
+            <url>scpexe://svjatoslav.eu:10006/srv/maven</url>
+        </repository>
+    </distributionManagement>
+
+    <repositories>
+        <repository>
+            <id>svjatoslav.eu</id>
+            <name>Svjatoslav repository</name>
+            <url>https://www3.svjatoslav.eu/maven/</url>
+        </repository>
+    </repositories>
+
+    <scm>
+        <connection>scm:git:ssh://n0@svjatoslav.eu:10006/home/n0/git/aukio.git</connection>
+        <developerConnection>scm:git:ssh://n0@svjatoslav.eu:10006/home/n0/git/aukio.git</developerConnection>
+        <tag>HEAD</tag>
+    </scm>
+
+
+</project>
diff --git a/src/main/java/eu/svjatoslav/aukio/bridge/pty/PtySession.java b/src/main/java/eu/svjatoslav/aukio/bridge/pty/PtySession.java
new file mode 100644 (file)
index 0000000..a96d794
--- /dev/null
@@ -0,0 +1,167 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.bridge.pty;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A shell process running on a real pseudo-terminal, feeding a
+ * {@link ScreenBuffer} through a {@link Vt100Emulator}.
+ *
+ * <p>Engine-independent: this class knows nothing about the 3D world.
+ * Rendering and keyboard delivery are the caller's job (see
+ * {@code workspace.TerminalPanel}).</p>
+ *
+ * <p>Output path: a daemon reader thread pumps decoded characters into the
+ * emulator and invokes the registered {@link ContentListener} after every
+ * batch, so the renderer can repaint. Input path: {@link #send(String)}
+ * writes bytes to the PTY master.</p>
+ */
+public class PtySession {
+
+    /**
+     * Called on the reader thread after terminal content changed.
+     */
+    public interface ContentListener {
+        void contentChanged();
+    }
+
+    private final UnixPty pty;
+    private final ScreenBuffer screenBuffer;
+    private final Vt100Emulator emulator;
+    private final Thread readerThread;
+
+    private volatile ContentListener contentListener;
+    private volatile boolean running = true;
+
+    /**
+     * Spawns an interactive bash on a new PTY.
+     *
+     * @param columns terminal width in characters
+     * @param rows    terminal height in characters
+     */
+    public PtySession(final int columns, final int rows) {
+        screenBuffer = new ScreenBuffer(columns, rows);
+        emulator = new Vt100Emulator(screenBuffer);
+
+        final Map<String, String> environment = new HashMap<>(
+                System.getenv());
+        environment.put("TERM", "xterm-256color");
+        environment.put("COLORTERM", "truecolor");
+
+        // Do not inherit an "active conda environment" from the parent
+        // process: a fresh terminal session must re-activate through
+        // bashrc like any desktop terminal. Inheriting CONDA_DEFAULT_ENV
+        // without CONDA_PREFIX crashes conda's activator, which then
+        // blocks shell startup with an interactive error-report question.
+        environment.remove("CONDA_DEFAULT_ENV");
+        environment.remove("CONDA_SHLVL");
+        environment.remove("CONDA_PROMPT_MODIFIER");
+        environment.remove("CONDA_PREFIX");
+        // never block shell startup on conda's interactive error upload
+        environment.put("CONDA_REPORT_ERRORS", "false");
+
+        pty = new UnixPty(new String[]{"/bin/bash"},
+                System.getProperty("user.dir"), environment, columns, rows);
+
+        readerThread = new Thread(this::readLoop, "pty-reader");
+        readerThread.setDaemon(true);
+        readerThread.start();
+    }
+
+    private InputStream ptyInputStream() {
+        return new InputStream() {
+            private final byte[] chunk = new byte[8192];
+
+            @Override
+            public int read() {
+                final byte[] one = new byte[1];
+                return read(one, 0, 1) < 0 ? -1 : (one[0] & 0xFF);
+            }
+
+            @Override
+            public int read(final byte[] buffer, final int offset,
+                            final int length) {
+                final int count = pty.read(chunk);
+                if (count < 0)
+                    return -1;
+                final int amount = Math.min(count, length);
+                System.arraycopy(chunk, 0, buffer, offset, amount);
+                return amount;
+            }
+        };
+    }
+
+    private void readLoop() {
+        final char[] chunk = new char[8192];
+        try (final InputStreamReader reader = new InputStreamReader(
+                ptyInputStream(), StandardCharsets.UTF_8)) {
+            while (running) {
+                final int count = reader.read(chunk);
+                if (count < 0)
+                    break;
+                emulator.accept(chunk, count);
+                final ContentListener listener = contentListener;
+                if (listener != null)
+                    try {
+                        listener.contentChanged();
+                    } catch (final Throwable t) {
+                        // The renderer can briefly reallocate canvas
+                        // internals during a window resize, racing this
+                        // writer thread. A listener failure must never
+                        // kill the reader loop — the next output batch
+                        // repaints anyway.
+                        t.printStackTrace();
+                    }
+            }
+        } catch (final IOException e) {
+            // PTY closed: shell exited or session stopped
+        }
+        running = false;
+    }
+
+    /**
+     * Sends text to the shell as if typed (UTF-8 encoded).
+     */
+    public void send(final String text) {
+        final byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
+        pty.write(bytes, 0, bytes.length);
+    }
+
+    public ScreenBuffer getScreenBuffer() {
+        return screenBuffer;
+    }
+
+    public void setContentListener(final ContentListener listener) {
+        this.contentListener = listener;
+    }
+
+    public boolean isRunning() {
+        return running && pty.isChildAlive();
+    }
+
+    /**
+     * Kills the shell and stops the reader thread.
+     *
+     * <p>Order matters: kill the child first so the reader's blocking
+     * read() returns, join the reader, and only then let the JVM move on
+     * to exit — a daemon thread mid-JNA-call during JVM teardown can
+     * crash the process with native heap errors.</p>
+     */
+    public void stop() {
+        running = false;
+        pty.destroy();
+        try {
+            readerThread.join(2000);
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/bridge/pty/ScreenBuffer.java b/src/main/java/eu/svjatoslav/aukio/bridge/pty/ScreenBuffer.java
new file mode 100644 (file)
index 0000000..34ace80
--- /dev/null
@@ -0,0 +1,574 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.bridge.pty;
+
+/**
+ * Character cell grid backing a terminal screen.
+ *
+ * <p>Pure model: no rendering, no engine dependencies. Colors are stored as
+ * ANSI palette indices ({@code -1} = terminal default); mapping to actual
+ * RGB values is the renderer's job.</p>
+ *
+ * <p>All public methods must be called while holding {@code synchronized}
+ * on this instance (the emulator and the renderer both access it).</p>
+ */
+public class ScreenBuffer {
+
+    /**
+     * Default color marker: use the terminal's default foreground/background.
+     */
+    public static final int DEFAULT_COLOR = -1;
+
+    /**
+     * One character cell: glyph plus attributes.
+     */
+    public static final class Cell {
+        public char ch = ' ';
+        public int fg = DEFAULT_COLOR;
+        public int bg = DEFAULT_COLOR;
+        public boolean bold = false;
+        public boolean reverse = false;
+
+        void set(final char c, final int fg, final int bg,
+                 final boolean bold, final boolean reverse) {
+            this.ch = c;
+            this.fg = fg;
+            this.bg = bg;
+            this.bold = bold;
+            this.reverse = reverse;
+        }
+
+        void clear(final int bg) {
+            set(' ', DEFAULT_COLOR, bg, false, false);
+        }
+    }
+
+    private final int columns;
+    private final int rows;
+
+    private Cell[][] screen;
+    private Cell[][] alternateScreen;
+
+    public int cursorX = 0;
+    public int cursorY = 0;
+    public boolean cursorVisible = true;
+
+    private int savedCursorX = 0;
+    private int savedCursorY = 0;
+
+    private int scrollTop = 0;
+    private int scrollBottom;
+
+    private int currentFg = DEFAULT_COLOR;
+    private int currentBg = DEFAULT_COLOR;
+    private boolean currentBold = false;
+    private boolean currentReverse = false;
+
+    boolean autoWrap = true;
+    boolean insertMode = false;
+    boolean lineDrawing = false;
+
+    /**
+     * DECCKM (?1h): when set, cursor keys must be reported as SS3
+     * (ESC O A) instead of CSI (ESC [ A). Curses applications enable
+     * this; input translation reads it.
+     */
+    public boolean applicationCursorKeys = false;
+
+    private boolean pendingWrap = false;
+
+    public ScreenBuffer(final int columns, final int rows) {
+        this.columns = columns;
+        this.rows = rows;
+        this.scrollBottom = rows - 1;
+        screen = newGrid();
+    }
+
+    private Cell[][] newGrid() {
+        final Cell[][] grid = new Cell[rows][columns];
+        for (int row = 0; row < rows; row++)
+            for (int column = 0; column < columns; column++) {
+                grid[row][column] = new Cell();
+                grid[row][column].clear(DEFAULT_COLOR);
+            }
+        return grid;
+    }
+
+    public int getColumns() {
+        return columns;
+    }
+
+    public int getRows() {
+        return rows;
+    }
+
+    public Cell getCell(final int row, final int column) {
+        return screen[row][column];
+    }
+
+    // ------------------------------------------------------------------
+    // printing
+    // ------------------------------------------------------------------
+
+    public void putChar(final char c) {
+        if (pendingWrap) {
+            pendingWrap = false;
+            carriageReturn();
+            lineFeed();
+        }
+        if (insertMode)
+            insertChars(1);
+        screen[cursorY][cursorX].set(mapLineDrawing(c), currentFg, currentBg,
+                currentBold, currentReverse);
+        if (cursorX == columns - 1) {
+            if (autoWrap)
+                pendingWrap = true;
+        } else
+            cursorX++;
+    }
+
+    private char mapLineDrawing(final char c) {
+        if (!lineDrawing)
+            return c;
+        // DEC special graphics character set (approximate)
+        return switch (c) {
+            case 'j' -> '┘';
+            case 'k' -> '┐';
+            case 'l' -> '┌';
+            case 'm' -> '└';
+            case 'n' -> '┼';
+            case 'q' -> '─';
+            case 't' -> '├';
+            case 'u' -> '┤';
+            case 'v' -> '┴';
+            case 'w' -> '┬';
+            case 'x' -> '│';
+            case 'a' -> '▒';
+            default -> c;
+        };
+    }
+
+    // ------------------------------------------------------------------
+    // control characters
+    // ------------------------------------------------------------------
+
+    public void carriageReturn() {
+        cursorX = 0;
+        pendingWrap = false;
+    }
+
+    public void lineFeed() {
+        pendingWrap = false;
+        if (cursorY == scrollBottom)
+            scrollUp(1);
+        else if (cursorY < rows - 1)
+            cursorY++;
+    }
+
+    /**
+     * Reverse index: cursor up, scrolling the region down at the top edge.
+     */
+    public void reverseIndex() {
+        pendingWrap = false;
+        if (cursorY == scrollTop)
+            scrollDown(1);
+        else if (cursorY > 0)
+            cursorY--;
+    }
+
+    public void backspace() {
+        pendingWrap = false;
+        if (cursorX > 0)
+            cursorX--;
+    }
+
+    public void tab() {
+        pendingWrap = false;
+        cursorX = Math.min(columns - 1, (cursorX + 8) & ~7);
+    }
+
+    // ------------------------------------------------------------------
+    // cursor movement (CSI)
+    // ------------------------------------------------------------------
+
+    public void cursorUp(final int n) {
+        pendingWrap = false;
+        cursorY = Math.max(scrollTop, cursorY - Math.max(1, n));
+    }
+
+    public void cursorDown(final int n) {
+        pendingWrap = false;
+        cursorY = Math.min(scrollBottom, cursorY + Math.max(1, n));
+    }
+
+    public void cursorForward(final int n) {
+        pendingWrap = false;
+        cursorX = Math.min(columns - 1, cursorX + Math.max(1, n));
+    }
+
+    public void cursorBack(final int n) {
+        pendingWrap = false;
+        cursorX = Math.max(0, cursorX - Math.max(1, n));
+    }
+
+    /**
+     * 1-based absolute position, as sent by CSI H / CSI f.
+     */
+    public void setCursorPosition(final int row1based, final int column1based) {
+        pendingWrap = false;
+        cursorY = clamp(row1based - 1, 0, rows - 1);
+        cursorX = clamp(column1based - 1, 0, columns - 1);
+    }
+
+    public void setCursorColumn(final int column1based) {
+        pendingWrap = false;
+        cursorX = clamp(column1based - 1, 0, columns - 1);
+    }
+
+    public void setCursorRow(final int row1based) {
+        pendingWrap = false;
+        cursorY = clamp(row1based - 1, 0, rows - 1);
+    }
+
+    public void saveCursor() {
+        savedCursorX = cursorX;
+        savedCursorY = cursorY;
+    }
+
+    public void restoreCursor() {
+        pendingWrap = false;
+        cursorX = savedCursorX;
+        cursorY = savedCursorY;
+    }
+
+    private static int clamp(final int v, final int min, final int max) {
+        return Math.max(min, Math.min(max, v));
+    }
+
+    // ------------------------------------------------------------------
+    // erasing
+    // ------------------------------------------------------------------
+
+    private void clearRange(final int row, final int fromColumn,
+                            final int toColumn) {
+        for (int column = fromColumn; column <= toColumn; column++)
+            screen[row][column].clear(currentBg);
+    }
+
+    /**
+     * CSI J: 0 = cursor to end, 1 = start to cursor, 2 = whole screen.
+     */
+    public void eraseInDisplay(final int mode) {
+        pendingWrap = false;
+        switch (mode) {
+            case 0 -> {
+                clearRange(cursorY, cursorX, columns - 1);
+                for (int row = cursorY + 1; row < rows; row++)
+                    clearRange(row, 0, columns - 1);
+            }
+            case 1 -> {
+                for (int row = 0; row < cursorY; row++)
+                    clearRange(row, 0, columns - 1);
+                clearRange(cursorY, 0, cursorX);
+            }
+            case 2, 3 -> {
+                for (int row = 0; row < rows; row++)
+                    clearRange(row, 0, columns - 1);
+            }
+            default -> {
+            }
+        }
+    }
+
+    /**
+     * CSI K: 0 = cursor to end of line, 1 = start to cursor, 2 = whole line.
+     */
+    public void eraseInLine(final int mode) {
+        pendingWrap = false;
+        switch (mode) {
+            case 0 -> clearRange(cursorY, cursorX, columns - 1);
+            case 1 -> clearRange(cursorY, 0, cursorX);
+            case 2 -> clearRange(cursorY, 0, columns - 1);
+            default -> {
+            }
+        }
+    }
+
+    /**
+     * CSI X: erase n characters from cursor without moving it.
+     */
+    public void eraseChars(final int n) {
+        pendingWrap = false;
+        final int count = Math.max(1, n);
+        clearRange(cursorY, cursorX,
+                Math.min(columns - 1, cursorX + count - 1));
+    }
+
+    // ------------------------------------------------------------------
+    // inserting / deleting / scrolling
+    // ------------------------------------------------------------------
+
+    /**
+     * CSI P: delete n characters at cursor, shifting the rest of the line left.
+     */
+    public void deleteChars(final int n) {
+        pendingWrap = false;
+        final int count = Math.max(1, n);
+        for (int column = cursorX; column < columns; column++) {
+            if (column + count < columns)
+                screen[cursorY][column].set(
+                        screen[cursorY][column + count].ch,
+                        screen[cursorY][column + count].fg,
+                        screen[cursorY][column + count].bg,
+                        screen[cursorY][column + count].bold,
+                        screen[cursorY][column + count].reverse);
+            else
+                screen[cursorY][column].clear(currentBg);
+        }
+    }
+
+    /**
+     * CSI @: insert n blank characters at cursor, shifting line right.
+     */
+    public void insertChars(final int n) {
+        final int count = Math.max(1, n);
+        for (int column = columns - 1; column >= cursorX; column--) {
+            if (column - count >= cursorX)
+                screen[cursorY][column].set(
+                        screen[cursorY][column - count].ch,
+                        screen[cursorY][column - count].fg,
+                        screen[cursorY][column - count].bg,
+                        screen[cursorY][column - count].bold,
+                        screen[cursorY][column - count].reverse);
+            else
+                screen[cursorY][column].clear(currentBg);
+        }
+    }
+
+    /**
+     * CSI L: insert n blank lines at cursor row (within scroll region).
+     */
+    public void insertLines(final int n) {
+        pendingWrap = false;
+        if (cursorY < scrollTop || cursorY > scrollBottom)
+            return;
+        final int count = Math.min(Math.max(1, n), scrollBottom - cursorY + 1);
+        for (int row = scrollBottom; row >= cursorY; row--) {
+            if (row - count >= cursorY)
+                copyRow(row - count, row);
+            else
+                clearRange(row, 0, columns - 1);
+        }
+    }
+
+    /**
+     * CSI M: delete n lines at cursor row (within scroll region).
+     */
+    public void deleteLines(final int n) {
+        pendingWrap = false;
+        if (cursorY < scrollTop || cursorY > scrollBottom)
+            return;
+        final int count = Math.min(Math.max(1, n), scrollBottom - cursorY + 1);
+        for (int row = cursorY; row <= scrollBottom; row++) {
+            if (row + count <= scrollBottom)
+                copyRow(row + count, row);
+            else
+                clearRange(row, 0, columns - 1);
+        }
+    }
+
+    private void copyRow(final int fromRow, final int toRow) {
+        for (int column = 0; column < columns; column++) {
+            final Cell from = screen[fromRow][column];
+            screen[toRow][column].set(from.ch, from.fg, from.bg, from.bold,
+                    from.reverse);
+        }
+    }
+
+    /**
+     * Scroll the scroll region up by n lines (content moves up, blanks at
+     * bottom).
+     */
+    public void scrollUp(final int n) {
+        final int count = Math.min(Math.max(1, n),
+                scrollBottom - scrollTop + 1);
+        for (int row = scrollTop; row <= scrollBottom; row++) {
+            if (row + count <= scrollBottom)
+                copyRow(row + count, row);
+            else
+                clearRange(row, 0, columns - 1);
+        }
+    }
+
+    /**
+     * Scroll the scroll region down by n lines (content moves down, blanks at
+     * top).
+     */
+    public void scrollDown(final int n) {
+        final int count = Math.min(Math.max(1, n),
+                scrollBottom - scrollTop + 1);
+        for (int row = scrollBottom; row >= scrollTop; row--) {
+            if (row - count >= scrollTop)
+                copyRow(row - count, row);
+            else
+                clearRange(row, 0, columns - 1);
+        }
+    }
+
+    /**
+     * CSI r: set scroll region, 1-based inclusive rows. Also homes the cursor
+     * per xterm behavior.
+     */
+    public void setScrollRegion(final int top1based, final int bottom1based) {
+        final int top = clamp(top1based - 1, 0, rows - 1);
+        final int bottom = clamp(bottom1based - 1, 0, rows - 1);
+        if (top < bottom) {
+            scrollTop = top;
+            scrollBottom = bottom;
+        }
+        setCursorPosition(1, 1);
+    }
+
+    // ------------------------------------------------------------------
+    // attributes / modes
+    // ------------------------------------------------------------------
+
+    /**
+     * CSI m: select graphic rendition. {@code params[i] == -1} marks an
+     * omitted parameter (treated as 0).
+     */
+    public void sgr(final int[] params) {
+        if (params.length == 0) {
+            resetAttributes();
+            return;
+        }
+        for (int i = 0; i < params.length; i++) {
+            final int p = params[i] < 0 ? 0 : params[i];
+            if (p == 0)
+                resetAttributes();
+            else if (p == 1)
+                currentBold = true;
+            else if (p == 22)
+                currentBold = false;
+            else if (p == 7)
+                currentReverse = true;
+            else if (p == 27)
+                currentReverse = false;
+            else if (p >= 30 && p <= 37)
+                currentFg = p - 30;
+            else if (p == 39)
+                currentFg = DEFAULT_COLOR;
+            else if (p >= 40 && p <= 47)
+                currentBg = p - 40;
+            else if (p == 49)
+                currentBg = DEFAULT_COLOR;
+            else if (p >= 90 && p <= 97)
+                currentFg = p - 90 + 8;
+            else if (p >= 100 && p <= 107)
+                currentBg = p - 100 + 8;
+            else if ((p == 38 || p == 48) && i + 2 < params.length
+                    && params[i + 1] == 5) {
+                // 256-color palette index; renderer maps it
+                if (p == 38)
+                    currentFg = 16 + (params[i + 2] & 0xFF);
+                else
+                    currentBg = 16 + (params[i + 2] & 0xFF);
+                i += 2;
+            }
+            // other attributes (underline, blink, ...) accepted but not
+            // visualized
+        }
+    }
+
+    private void resetAttributes() {
+        currentFg = DEFAULT_COLOR;
+        currentBg = DEFAULT_COLOR;
+        currentBold = false;
+        currentReverse = false;
+    }
+
+    /**
+     * DEC private mode set/reset (CSI ? ... h / l).
+     */
+    public void setDecMode(final int mode, final boolean enabled) {
+        switch (mode) {
+            case 1 -> applicationCursorKeys = enabled;
+            case 7 -> autoWrap = enabled;
+            case 25 -> cursorVisible = enabled;
+            case 47, 1047 -> setAlternateScreen(enabled, false);
+            case 1048 -> {
+                if (enabled)
+                    saveCursor();
+                else
+                    restoreCursor();
+            }
+            case 1049 -> setAlternateScreen(enabled, true);
+            default -> {
+                // other private modes (bracketed paste, application
+                // keypad, mouse reporting, ...) accepted but ignored
+            }
+        }
+    }
+
+    /**
+     * ANSI mode set/reset (CSI h / l without '?').
+     */
+    public void setAnsiMode(final int mode, final boolean enabled) {
+        if (mode == 4)
+            insertMode = enabled;
+    }
+
+    private void setAlternateScreen(final boolean enabled,
+                                    final boolean saveCursor) {
+        if (enabled) {
+            if (saveCursor)
+                saveCursor();
+            alternateScreen = screen;
+            screen = newGrid();
+            setCursorPosition(1, 1);
+        } else {
+            if (alternateScreen != null)
+                screen = alternateScreen;
+            alternateScreen = null;
+            if (saveCursor)
+                restoreCursor();
+        }
+    }
+
+    public void fullReset() {
+        resetAttributes();
+        autoWrap = true;
+        insertMode = false;
+        lineDrawing = false;
+        applicationCursorKeys = false;
+        cursorVisible = true;
+        pendingWrap = false;
+        scrollTop = 0;
+        scrollBottom = rows - 1;
+        alternateScreen = null;
+        screen = newGrid();
+        cursorX = 0;
+        cursorY = 0;
+    }
+
+    // ------------------------------------------------------------------
+    // debugging / testing
+    // ------------------------------------------------------------------
+
+    /**
+     * Renders the visible screen as plain text (attributes stripped).
+     */
+    public String dumpText() {
+        final StringBuilder result = new StringBuilder();
+        for (int row = 0; row < rows; row++) {
+            final StringBuilder line = new StringBuilder();
+            for (int column = 0; column < columns; column++)
+                line.append(screen[row][column].ch);
+            result.append(line.toString().replaceAll("\\s+$", ""))
+                    .append('\n');
+        }
+        return result.toString();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/bridge/pty/UnixPty.java b/src/main/java/eu/svjatoslav/aukio/bridge/pty/UnixPty.java
new file mode 100644 (file)
index 0000000..1c2b082
--- /dev/null
@@ -0,0 +1,243 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.bridge.pty;
+
+import com.sun.jna.Library;
+import com.sun.jna.Memory;
+import com.sun.jna.Native;
+import com.sun.jna.Pointer;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Minimal Linux PTY implementation via JNA direct libc calls.
+ *
+ * <p>Replaces the pty4j dependency: pty4j 0.12.x requires purejavacomm
+ * (jtermios classes), which is no longer published on any reachable Maven
+ * repository. All we need is {@code posix_openpt} + {@code fork} +
+ * {@code execve}, which is a handful of libc calls.</p>
+ *
+ * <p>Linux-only by design; the workspace targets Linux.</p>
+ */
+final class UnixPty {
+
+    private static final int O_RDWR = 0x02;
+    private static final int O_NOCTTY = 0x100;
+    private static final int TIOCSWINSZ = 0x5414;
+    private static final int POSIX_SPAWN_SETSID = 0x80; // glibc >= 2.26
+    private static final int SIGKILL = 9;
+
+    private interface CLib extends Library {
+        CLib INSTANCE = Native.load("c", CLib.class);
+
+        int posix_openpt(int flags);
+
+        int grantpt(int fd);
+
+        int unlockpt(int fd);
+
+        String ptsname(int fd);
+
+        int ioctl(int fd, int request, Pointer arg);
+
+        int posix_spawn(Memory pid, String path, Pointer fileActions,
+                        Pointer attributes, Pointer argv, Pointer envp);
+
+        int posix_spawn_file_actions_init(Pointer fileActions);
+
+        int posix_spawn_file_actions_addopen(Pointer fileActions, int fd,
+                                             String path, int oflag,
+                                             int mode);
+
+        int posix_spawn_file_actions_adddup2(Pointer fileActions, int fd,
+                                             int newFd);
+
+        int posix_spawn_file_actions_addclose(Pointer fileActions, int fd);
+
+        int posix_spawnattr_init(Pointer attributes);
+
+        int posix_spawnattr_setflags(Pointer attributes, short flags);
+
+        int posix_spawn_file_actions_destroy(Pointer fileActions);
+
+        int posix_spawnattr_destroy(Pointer attributes);
+
+        int close(int fd);
+
+        int read(int fd, Pointer buffer, int count);
+
+        int write(int fd, Pointer buffer, int count);
+
+        int waitpid(int pid, int[] status, int options);
+
+        int kill(int pid, int signal);
+    }
+
+    final int masterFd;
+    final int childPid;
+
+    /**
+     * Single reused native buffer for reads: per-call Memory allocation
+     * risks the JNA cleaner freeing a buffer while a thread is blocked in
+     * libc read() on it during JVM shutdown.
+     */
+    private final Memory readMemory = new Memory(65536);
+
+    /**
+     * Creates a PTY and spawns the command on the slave side via
+     * {@code posix_spawn}: a new session whose first opened terminal
+     * becomes its controlling terminal.
+     *
+     * <p>posix_spawn is used instead of fork+exec because fork inside a
+     * live JVM child deadlocks on JVM internal locks (futex) before it can
+     * exec — observed empirically: the forked child hung as a second JVM
+     * and bash never started.</p>
+     *
+     * @param command     argv (absolute path first)
+     * @param workingDir  child working directory (applied by the caller's
+     *                    command, e.g. bash is started with the dir as cwd
+     *                    via a chdir wrapper below)
+     * @param environment full environment for the child
+     * @param columns     terminal width in characters
+     * @param rows        terminal height in characters
+     */
+    UnixPty(final String[] command, final String workingDir,
+            final Map<String, String> environment,
+            final int columns, final int rows) {
+
+        masterFd = CLib.INSTANCE.posix_openpt(O_RDWR | O_NOCTTY);
+        if (masterFd < 0)
+            throw new IllegalStateException("posix_openpt failed: "
+                    + Native.getLastError());
+        if (CLib.INSTANCE.grantpt(masterFd) != 0
+                || CLib.INSTANCE.unlockpt(masterFd) != 0)
+            throw new IllegalStateException("grantpt/unlockpt failed: "
+                    + Native.getLastError());
+
+        final String slaveName = CLib.INSTANCE.ptsname(masterFd);
+
+        // window size must be set before spawn so the child inherits it
+        final Memory winsize = new Memory(8);
+        winsize.setShort(0, (short) rows);
+        winsize.setShort(2, (short) columns);
+        winsize.setShort(4, (short) 0);
+        winsize.setShort(6, (short) 0);
+        CLib.INSTANCE.ioctl(masterFd, TIOCSWINSZ, winsize);
+
+        // child file actions: slave PTY onto stdin/stdout/stderr, drop
+        // the master. POSIX_SPAWN_SETSID makes the child a session leader;
+        // glibc applies it BEFORE file actions, so the addopen below (no
+        // O_NOCTTY) also acquires the slave as the controlling terminal.
+        // glibc's posix_spawnattr_t is 336 bytes on amd64; allocate a
+        // padded block for both structs rather than relying on ABI sizes.
+        final Memory fileActions = new Memory(512);
+        final Memory attributes = new Memory(512);
+        CLib.INSTANCE.posix_spawn_file_actions_init(fileActions);
+        CLib.INSTANCE.posix_spawnattr_init(attributes);
+        CLib.INSTANCE.posix_spawnattr_setflags(attributes,
+                (short) POSIX_SPAWN_SETSID);
+        CLib.INSTANCE.posix_spawn_file_actions_addopen(fileActions, 0,
+                slaveName, O_RDWR, 0);
+        CLib.INSTANCE.posix_spawn_file_actions_adddup2(fileActions, 0, 1);
+        CLib.INSTANCE.posix_spawn_file_actions_adddup2(fileActions, 0, 2);
+        CLib.INSTANCE.posix_spawn_file_actions_addclose(fileActions,
+                masterFd);
+
+        final List<String> envStrings = new ArrayList<>();
+        environment.forEach((key, value) -> envStrings.add(key + "=" + value));
+        final Pointer envp = pointerArray(
+                envStrings.toArray(new String[0]));
+
+        // working directory: posix_spawn has no chdir file action (glibc
+        // added addchdir only in 2.29 as a GNU extension), so spawn a
+        // helper shell that chdirs and execs the real command with its
+        // arguments.
+        final StringBuilder execLine = new StringBuilder(
+                "cd \"$1\" && shift && exec");
+        for (final String arg : command)
+            execLine.append(" \"").append(arg.replace("\"", "\\\""))
+                    .append('"');
+        final String[] shellCommand = {"/bin/sh", "-c",
+                execLine.toString(), "sh", workingDir};
+        final Pointer shellArgv = pointerArray(shellCommand);
+
+        final Memory pidResult = new Memory(4);
+        final int rc = CLib.INSTANCE.posix_spawn(pidResult, "/bin/sh",
+                fileActions, attributes, shellArgv, envp);
+        CLib.INSTANCE.posix_spawn_file_actions_destroy(fileActions);
+        CLib.INSTANCE.posix_spawnattr_destroy(attributes);
+        if (rc != 0)
+            throw new IllegalStateException("posix_spawn failed: " + rc);
+        childPid = pidResult.getInt(0);
+    }
+
+    /**
+     * Builds a NULL-terminated char** from Java strings.
+     *
+     * <p>The backing native memory is deliberately kept reachable for the
+     * JVM lifetime (a few dozen small strings per PTY): the child execs
+     * asynchronously after {@code fork}, so the memory must outlive this
+     * method call.</p>
+     */
+    private static final List<Memory> NATIVE_STRING_STORAGE =
+            new ArrayList<>();
+
+    private static synchronized Pointer pointerArray(
+            final String[] strings) {
+        final Memory array = new Memory(
+                (long) (strings.length + 1) * Native.POINTER_SIZE);
+        NATIVE_STRING_STORAGE.add(array);
+        for (int i = 0; i < strings.length; i++) {
+            final byte[] bytes = strings[i].getBytes(StandardCharsets.UTF_8);
+            final Memory entry = new Memory(bytes.length + 1);
+            entry.write(0, bytes, 0, bytes.length);
+            entry.setByte(bytes.length, (byte) 0);
+            NATIVE_STRING_STORAGE.add(entry);
+            array.setPointer((long) i * Native.POINTER_SIZE, entry);
+        }
+        array.setPointer((long) strings.length * Native.POINTER_SIZE, null);
+        return array;
+    }
+
+    /**
+     * Blocking read from the PTY master.
+     *
+     * @return number of bytes read, or -1 when the child side closed
+     */
+    int read(final byte[] buffer) {
+        final int amount = Math.min(buffer.length, (int) readMemory.size());
+        final int count = CLib.INSTANCE.read(masterFd, readMemory, amount);
+        if (count <= 0)
+            return -1;
+        readMemory.read(0, buffer, 0, count);
+        return count;
+    }
+
+    void write(final byte[] buffer, final int offset, final int length) {
+        final Memory memory = new Memory(length);
+        memory.write(0, buffer, offset, length);
+        int written = 0;
+        while (written < length) {
+            final int count = CLib.INSTANCE.write(masterFd,
+                    memory.share(written), length - written);
+            if (count <= 0)
+                return;
+            written += count;
+        }
+    }
+
+    boolean isChildAlive() {
+        return CLib.INSTANCE.kill(childPid, 0) == 0;
+    }
+
+    void destroy() {
+        CLib.INSTANCE.kill(childPid, SIGKILL);
+        CLib.INSTANCE.waitpid(childPid, new int[1], 0);
+        CLib.INSTANCE.close(masterFd);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/bridge/pty/Vt100Emulator.java b/src/main/java/eu/svjatoslav/aukio/bridge/pty/Vt100Emulator.java
new file mode 100644 (file)
index 0000000..2e0f86d
--- /dev/null
@@ -0,0 +1,255 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.bridge.pty;
+
+/**
+ * Minimal VT100/xterm escape sequence interpreter feeding a
+ * {@link ScreenBuffer}.
+ *
+ * <p>Covers what an interactive shell and everyday tools actually emit:
+ * printable text (UTF-8 decoded by the caller), C0 controls, CSI cursor
+ * movement / erasing / insert-delete / scroll region / SGR colors, DEC
+ * private modes (autowrap, cursor visibility, alternate screen), OSC
+ * (ignored), and the DEC line-drawing charset.</p>
+ *
+ * <p>Not a full xterm: no scrollback, no mouse reporting, no 8-bit C1.
+ * Unknown sequences are consumed and ignored rather than mishandled.</p>
+ *
+ * <p>Threading: all calls arrive on the PTY reader thread; the emulator
+ * synchronizes on the buffer for every operation.</p>
+ */
+public class Vt100Emulator {
+
+    private enum State {
+        GROUND, ESCAPE, CSI, OSC, OSC_ESC, CHARSET, IGNORE_ONE
+    }
+
+    private final ScreenBuffer buffer;
+
+    private State state = State.GROUND;
+
+    private final StringBuilder params = new StringBuilder();
+    private boolean csiPrivate = false;
+
+    public Vt100Emulator(final ScreenBuffer buffer) {
+        this.buffer = buffer;
+    }
+
+    public void accept(final char[] chars, final int length) {
+        for (int i = 0; i < length; i++)
+            accept(chars[i]);
+    }
+
+    public void accept(final char c) {
+        switch (state) {
+            case GROUND -> ground(c);
+            case ESCAPE -> escape(c);
+            case CSI -> csi(c);
+            case OSC -> osc(c);
+            case OSC_ESC -> {
+                state = State.GROUND; // consume ST's backslash
+            }
+            case CHARSET -> charset(c);
+            case IGNORE_ONE -> state = State.GROUND;
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // GROUND
+    // ------------------------------------------------------------------
+
+    private void ground(final char c) {
+        if (c == 0x1B) {
+            state = State.ESCAPE;
+            return;
+        }
+        if (c < 0x20) {
+            control(c);
+            return;
+        }
+        if (c == 0x7F)
+            return; // DEL: ignored on input path
+        synchronized (buffer) {
+            buffer.putChar(c);
+        }
+    }
+
+    private void control(final char c) {
+        synchronized (buffer) {
+            switch (c) {
+                case '\b' -> buffer.backspace();
+                case '\t' -> buffer.tab();
+                case '\n', 0x0B, 0x0C -> buffer.lineFeed();
+                case '\r' -> buffer.carriageReturn();
+                default -> {
+                    // BEL and the rest: no visual effect
+                }
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // ESC
+    // ------------------------------------------------------------------
+
+    private void escape(final char c) {
+        switch (c) {
+            case '[' -> {
+                params.setLength(0);
+                csiPrivate = false;
+                state = State.CSI;
+            }
+            case ']' -> state = State.OSC;
+            case '(' -> state = State.CHARSET;
+            case ')' -> state = State.IGNORE_ONE; // G1 charset: unused
+            case 'O' -> state = State.IGNORE_ONE; // SS3: single-char control
+            case '7' -> {
+                synchronized (buffer) {
+                    buffer.saveCursor();
+                }
+                state = State.GROUND;
+            }
+            case '8' -> {
+                synchronized (buffer) {
+                    buffer.restoreCursor();
+                }
+                state = State.GROUND;
+            }
+            case 'D' -> {
+                synchronized (buffer) {
+                    buffer.lineFeed();
+                }
+                state = State.GROUND;
+            }
+            case 'M' -> {
+                synchronized (buffer) {
+                    buffer.reverseIndex();
+                }
+                state = State.GROUND;
+            }
+            case 'E' -> {
+                synchronized (buffer) {
+                    buffer.carriageReturn();
+                    buffer.lineFeed();
+                }
+                state = State.GROUND;
+            }
+            case 'c' -> {
+                synchronized (buffer) {
+                    buffer.fullReset();
+                }
+                state = State.GROUND;
+            }
+            default -> state = State.GROUND; // =, > and unknowns: ignore
+        }
+    }
+
+    private void charset(final char c) {
+        synchronized (buffer) {
+            buffer.lineDrawing = (c == '0');
+        }
+        state = State.GROUND;
+    }
+
+    private void osc(final char c) {
+        if (c == 0x07)
+            state = State.GROUND; // BEL terminates
+        else if (c == 0x1B)
+            state = State.OSC_ESC; // expect ST ("\")
+    }
+
+    // ------------------------------------------------------------------
+    // CSI
+    // ------------------------------------------------------------------
+
+    private void csi(final char c) {
+        if (c == '?') {
+            csiPrivate = true;
+            return;
+        }
+        if ((c >= '0' && c <= '9') || c == ';') {
+            params.append(c);
+            return;
+        }
+        if (c >= 0x20 && c <= 0x2F)
+            return; // intermediate bytes: ignored (e.g. space in "CSI 4 SP q")
+        if (c < 0x40 || c > 0x7E) {
+            state = State.GROUND;
+            return;
+        }
+        dispatchCsi(c);
+        state = State.GROUND;
+    }
+
+    private int[] parsedParams() {
+        if (params.length() == 0)
+            return new int[0];
+        final String[] parts = params.toString().split(";", -1);
+        final int[] result = new int[parts.length];
+        for (int i = 0; i < parts.length; i++)
+            result[i] = parts[i].isEmpty() ? -1 : Integer.parseInt(parts[i]);
+        return result;
+    }
+
+    private int param(final int[] p, final int index, final int defaultValue) {
+        if (index >= p.length || p[index] <= 0)
+            return defaultValue;
+        return p[index];
+    }
+
+    private void dispatchCsi(final char command) {
+        final int[] p = parsedParams();
+        synchronized (buffer) {
+            switch (command) {
+                case 'A' -> buffer.cursorUp(param(p, 0, 1));
+                case 'B' -> buffer.cursorDown(param(p, 0, 1));
+                case 'C' -> buffer.cursorForward(param(p, 0, 1));
+                case 'D' -> buffer.cursorBack(param(p, 0, 1));
+                case 'E' -> {
+                    buffer.cursorDown(param(p, 0, 1));
+                    buffer.carriageReturn();
+                }
+                case 'F' -> {
+                    buffer.cursorUp(param(p, 0, 1));
+                    buffer.carriageReturn();
+                }
+                case 'G', '`' -> buffer.setCursorColumn(param(p, 0, 1));
+                case 'd' -> buffer.setCursorRow(param(p, 0, 1));
+                case 'H', 'f' -> buffer.setCursorPosition(param(p, 0, 1),
+                        param(p, 1, 1));
+                case 'J' -> buffer.eraseInDisplay(param(p, 0, 0));
+                case 'K' -> buffer.eraseInLine(param(p, 0, 0));
+                case 'L' -> buffer.insertLines(param(p, 0, 1));
+                case 'M' -> buffer.deleteLines(param(p, 0, 1));
+                case 'P' -> buffer.deleteChars(param(p, 0, 1));
+                case '@' -> buffer.insertChars(param(p, 0, 1));
+                case 'S' -> buffer.scrollUp(param(p, 0, 1));
+                case 'T' -> buffer.scrollDown(param(p, 0, 1));
+                case 'X' -> buffer.eraseChars(param(p, 0, 1));
+                case 'm' -> buffer.sgr(p);
+                case 'r' -> buffer.setScrollRegion(param(p, 0, 1),
+                        param(p, 1, buffer.getRows()));
+                case 's' -> buffer.saveCursor();
+                case 'u' -> buffer.restoreCursor();
+                case 'h' -> setModes(p, true);
+                case 'l' -> setModes(p, false);
+                default -> {
+                    // unknown CSI: ignored
+                }
+            }
+        }
+    }
+
+    private void setModes(final int[] p, final boolean enabled) {
+        for (final int mode : p) {
+            if (mode < 0)
+                continue;
+            if (csiPrivate)
+                buffer.setDecMode(mode, enabled);
+            else
+                buffer.setAnsiMode(mode, enabled);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/bridge/x11/AwtKeysyms.java b/src/main/java/eu/svjatoslav/aukio/bridge/x11/AwtKeysyms.java
new file mode 100644 (file)
index 0000000..c1511a6
--- /dev/null
@@ -0,0 +1,159 @@
+/* Aukio spatial computing environment. Author: Svjatoslav Agejenko. This project is released under Creative Commons Zero (CC0) license. */
+package eu.svjatoslav.aukio.bridge.x11;
+
+import java.awt.event.KeyEvent;
+
+/**
+ * Translates AWT key events to X11 keysyms for injection into the virtual
+ * display via the X TEST extension.
+ *
+ * <p>Mapping is by {@code keyCode}, not {@code keyChar}: letters map to
+ * their unshifted keysyms and uppercase is produced by the real Shift key
+ * events, which are forwarded like any other key. This mirrors how a
+ * physical keyboard works and assumes the virtual server's keymap is a
+ * US-style layout (Xvfb default).</p>
+ *
+ * <p>Keys with no obvious mapping fall back to the Unicode keysym scheme
+ * ({@code 0x01000000 | codepoint}) when the event carries a character —
+ * this covers characters like ä and ö when they exist in the keymap.</p>
+ */
+final class AwtKeysyms {
+
+    // X11 keysym values (from keysymdef.h)
+    static final long XK_BACK_SPACE = 0xFF08;
+    static final long XK_TAB = 0xFF09;
+    static final long XK_RETURN = 0xFF0D;
+    static final long XK_ESCAPE = 0xFF1B;
+    static final long XK_HOME = 0xFF50;
+    static final long XK_LEFT = 0xFF51;
+    static final long XK_UP = 0xFF52;
+    static final long XK_RIGHT = 0xFF53;
+    static final long XK_DOWN = 0xFF54;
+    static final long XK_PAGE_UP = 0xFF55;
+    static final long XK_PAGE_DOWN = 0xFF56;
+    static final long XK_END = 0xFF57;
+    static final long XK_INSERT = 0xFF63;
+    static final long XK_F1 = 0xFFBE;
+    static final long XK_KP_MULTIPLY = 0xFFAA;
+    static final long XK_KP_ADD = 0xFFAB;
+    static final long XK_KP_SUBTRACT = 0xFFAD;
+    static final long XK_KP_DECIMAL = 0xFFAE;
+    static final long XK_KP_DIVIDE = 0xFFAF;
+    static final long XK_KP_0 = 0xFFB0;
+    static final long XK_SHIFT_L = 0xFFE1;
+    static final long XK_CONTROL_L = 0xFFE3;
+    static final long XK_CAPS_LOCK = 0xFFE5;
+    static final long XK_ALT_L = 0xFFE9;
+    static final long XK_DELETE = 0xFFFF;
+
+    private AwtKeysyms() {
+    }
+
+    /**
+     * Maps an AWT key event to an X keysym.
+     *
+     * @return the keysym, or {@code -1} when the key cannot be represented
+     */
+    static long keysymFor(final KeyEvent event) {
+        final int code = event.getKeyCode();
+
+        // letters: unshifted keysyms; shift state comes from real Shift
+        // key events
+        if (code >= KeyEvent.VK_A && code <= KeyEvent.VK_Z)
+            return 'a' + (code - KeyEvent.VK_A);
+        if (code >= KeyEvent.VK_0 && code <= KeyEvent.VK_9)
+            return '0' + (code - KeyEvent.VK_0);
+        if (code >= KeyEvent.VK_F1 && code <= KeyEvent.VK_F12)
+            return XK_F1 + (code - KeyEvent.VK_F1);
+        if (code >= KeyEvent.VK_NUMPAD0 && code <= KeyEvent.VK_NUMPAD9)
+            return XK_KP_0 + (code - KeyEvent.VK_NUMPAD0);
+
+        switch (code) {
+            case KeyEvent.VK_BACK_SPACE:
+                return XK_BACK_SPACE;
+            case KeyEvent.VK_TAB:
+                return XK_TAB;
+            case KeyEvent.VK_ENTER:
+                return XK_RETURN;
+            case KeyEvent.VK_ESCAPE:
+                return XK_ESCAPE;
+            case KeyEvent.VK_INSERT:
+                return XK_INSERT;
+            case KeyEvent.VK_DELETE:
+                return XK_DELETE;
+            case KeyEvent.VK_HOME:
+                return XK_HOME;
+            case KeyEvent.VK_END:
+                return XK_END;
+            case KeyEvent.VK_PAGE_UP:
+                return XK_PAGE_UP;
+            case KeyEvent.VK_PAGE_DOWN:
+                return XK_PAGE_DOWN;
+            case KeyEvent.VK_LEFT:
+                return XK_LEFT;
+            case KeyEvent.VK_UP:
+                return XK_UP;
+            case KeyEvent.VK_RIGHT:
+                return XK_RIGHT;
+            case KeyEvent.VK_DOWN:
+                return XK_DOWN;
+            case KeyEvent.VK_SHIFT:
+                return XK_SHIFT_L;
+            case KeyEvent.VK_CONTROL:
+                return XK_CONTROL_L;
+            case KeyEvent.VK_ALT:
+                return XK_ALT_L;
+            case KeyEvent.VK_CAPS_LOCK:
+                return XK_CAPS_LOCK;
+            case KeyEvent.VK_MULTIPLY:
+                return XK_KP_MULTIPLY;
+            case KeyEvent.VK_ADD:
+                return XK_KP_ADD;
+            case KeyEvent.VK_SUBTRACT:
+                return XK_KP_SUBTRACT;
+            case KeyEvent.VK_DECIMAL:
+                return XK_KP_DECIMAL;
+            case KeyEvent.VK_DIVIDE:
+                return XK_KP_DIVIDE;
+            case KeyEvent.VK_SPACE:
+                return ' ';
+            case KeyEvent.VK_COMMA:
+                return ',';
+            case KeyEvent.VK_PERIOD:
+                return '.';
+            case KeyEvent.VK_SLASH:
+                return '/';
+            case KeyEvent.VK_BACK_SLASH:
+                return '\\';
+            case KeyEvent.VK_SEMICOLON:
+                return ';';
+            case KeyEvent.VK_QUOTE:
+                return '\'';
+            case KeyEvent.VK_OPEN_BRACKET:
+                return '[';
+            case KeyEvent.VK_CLOSE_BRACKET:
+                return ']';
+            case KeyEvent.VK_BACK_QUOTE:
+                return '`';
+            case KeyEvent.VK_MINUS:
+                return '-';
+            case KeyEvent.VK_EQUALS:
+                return '=';
+            default:
+                return unicodeFallback(event.getKeyChar());
+        }
+    }
+
+    /**
+     * Unicode keysym fallback ({@code 0x01000000 | codepoint}) for
+     * characters outside the keyCode-based map, e.g. ä, ö, é.
+     */
+    private static long unicodeFallback(final char keyChar) {
+        if (keyChar == KeyEvent.CHAR_UNDEFINED || keyChar < 0x20)
+            return -1;
+        if (keyChar < 0x7F)
+            // printable ASCII not covered above
+            return keyChar;
+        return 0x01000000L | keyChar;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/bridge/x11/GuiAppSession.java b/src/main/java/eu/svjatoslav/aukio/bridge/x11/GuiAppSession.java
new file mode 100644 (file)
index 0000000..d1d16d4
--- /dev/null
@@ -0,0 +1,418 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.bridge.x11;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A GUI application running on a private Xvfb display, with its screen
+ * continuously captured into a caller-owned ARGB pixel buffer.
+ *
+ * <p>The session owns three things: the Xvfb server, the application
+ * process, and a capture thread that grabs the root window at a fixed
+ * rate. The capture thread writes directly into the destination array
+ * (typically a texture's primary bitmap pixels) and invokes the frame
+ * listener only when the image actually changed, so a static screen costs
+ * no repaints.</p>
+ */
+public final class GuiAppSession implements AutoCloseable {
+
+    /**
+     * Called on the capture thread after the destination buffer was
+     * updated with a changed frame.
+     */
+    public interface FrameListener {
+        void frameCaptured();
+    }
+
+    private static final long CAPTURE_INTERVAL_MS = 100;
+
+    private final int width;
+    private final int height;
+    private final List<String> command;
+    private final Map<String, String> extraEnvironment;
+    private final boolean privateDbusSession;
+    private final String windowTitleFilter;
+    private final File logFile;
+
+    private XvfbServer server;
+    private Process application;
+    private X11Native.Connection connection;
+    private Thread captureThread;
+    private Thread fitterThread;
+    private volatile boolean running;
+
+    public GuiAppSession(final int width, final int height,
+                         final List<String> command,
+                         final Map<String, String> extraEnvironment) {
+        this(width, height, command, extraEnvironment, false, null);
+    }
+
+    /**
+     * @param privateDbusSession wrap the app in {@code dbus-run-session}
+     *                           so it gets a private session bus. Needed
+     *                           because merely unsetting
+     *                           DBUS_SESSION_BUS_ADDRESS is not enough:
+     *                           GIO then falls back to the systemd user
+     *                           bus socket (/run/user/$UID/bus), where the
+     *                           app's desktop instance can still be found
+     *                           and command-line handling forwarded to it.
+     */
+    public GuiAppSession(final int width, final int height,
+                         final List<String> command,
+                         final Map<String, String> extraEnvironment,
+                         final boolean privateDbusSession) {
+        this(width, height, command, extraEnvironment, privateDbusSession,
+                null);
+    }
+
+    /**
+     * @param windowTitleFilter when non-null, a background thread
+     *                          periodically moves/resizes every top-level
+     *                          window whose name contains this string to
+     *                          fill the whole virtual screen. For
+     *                          applications that cannot be sized on the
+     *                          command line (VMware Workstation).
+     */
+    public GuiAppSession(final int width, final int height,
+                         final List<String> command,
+                         final Map<String, String> extraEnvironment,
+                         final boolean privateDbusSession,
+                         final String windowTitleFilter) {
+        this.width = width;
+        this.height = height;
+        this.command = command;
+        this.extraEnvironment = extraEnvironment;
+        this.privateDbusSession = privateDbusSession;
+        this.windowTitleFilter = windowTitleFilter;
+        // per-application log file: several sessions run side by side
+        // and a shared file would be truncated by each launch
+        final String logName = command.isEmpty() ? "app"
+                : command.get(0).replaceAll("[^A-Za-z0-9_.-]", "_");
+        logFile = new File("/tmp/aukio-xapp-" + logName + ".log");
+    }
+
+    /**
+     * Starts Xvfb, launches the application onto it, and begins capturing.
+     *
+     * @param destination ARGB pixel buffer of exactly
+     *                    {@code width * height} ints; every captured frame
+     *                    is written into this array
+     * @param listener    notified (on the capture thread) whenever a frame
+     *                    changed the buffer
+     */
+    public void start(final int[] destination, final FrameListener listener)
+            throws IOException {
+        if (destination.length != width * height)
+            throw new IllegalArgumentException("destination buffer holds "
+                    + destination.length + " pixels, expected "
+                    + (width * height));
+
+        server = XvfbServer.start(width, height);
+        connection = connectWithRetry(server.getDisplayName());
+
+        final List<String> effectiveCommand = privateDbusSession
+                ? java.util.stream.Stream.concat(
+                        java.util.stream.Stream.of("dbus-run-session", "--"),
+                        command.stream()).toList()
+                : command;
+        final ProcessBuilder builder = new ProcessBuilder(effectiveCommand);
+        final Map<String, String> environment = builder.environment();
+        environment.put("DISPLAY", server.getDisplayName());
+        // Firefox/GTK apps locate an already-running instance through the
+        // SESSION D-Bus (GApplication), ignoring --no-remote and DISPLAY:
+        // the window then pops up on the user's desktop while our Xvfb
+        // screen stays black. Hide the session bus so the app is forced
+        // to be its own primary instance on the private display.
+        environment.remove("DBUS_SESSION_BUS_ADDRESS");
+        environment.put("MOZ_NO_REMOTE", "1");
+        // On Wayland desktops (GNOME/Mutter) Firefox ignores DISPLAY
+        // entirely and renders natively into the user's compositor —
+        // the window pops up on the real screen while the Xvfb display
+        // stays windowless. Force the X11 backend onto our display.
+        environment.remove("WAYLAND_DISPLAY");
+        environment.put("MOZ_ENABLE_WAYLAND", "0");
+        // GTK (Firefox, VMware UI) tries its Wayland backend FIRST and
+        // then passes the X11 DISPLAY string to wl_display_connect,
+        // which resolves it as $XDG_RUNTIME_DIR/<display> — that socket
+        // does not exist, and GTK gives up without ever trying X11
+        // ("Error: cannot open display: :9x", zero X11-unix connects in
+        // strace). Pin the x11 backend so the attempt order cannot
+        // regress; the private display is X11-only by design.
+        environment.put("GDK_BACKEND", "x11");
+        environment.putAll(extraEnvironment);
+        builder.redirectOutput(logFile);
+        builder.redirectError(logFile);
+        application = builder.start();
+
+        running = true;
+        captureThread = new Thread(
+                () -> captureLoop(destination, listener), "x11-capture");
+        captureThread.setDaemon(true);
+        captureThread.start();
+
+        if (windowTitleFilter != null) {
+            fitterThread = new Thread(this::windowFitterLoop,
+                    "x11-window-fitter");
+            fitterThread.setDaemon(true);
+            fitterThread.start();
+        }
+    }
+
+    /**
+     * Periodically fits windows matching {@link #windowTitleFilter} to
+     * fill the virtual screen. Runs for the session's lifetime: the
+     * application may (re)position its windows long after startup, and
+     * one pass per second is cheap.
+     */
+    private void windowFitterLoop() {
+        while (running) {
+            try {
+                X11Native.fitNamedWindows(connection, windowTitleFilter,
+                        width, height);
+            } catch (final Throwable throwable) {
+                // window set churn (map/unmap races) must never kill the
+                // fitter
+                if (Boolean.getBoolean("aukio.xcapture.debug"))
+                    throwable.printStackTrace();
+            }
+            try {
+                Thread.sleep(1000);
+            } catch (final InterruptedException e) {
+                return;
+            }
+        }
+    }
+
+    /**
+     * Opens the session's X connection, retrying briefly. There is a
+     * real race at Xvfb birth: the readiness probe inside
+     * {@link XvfbServer#start} connects and disconnects, and the server
+     * resets when its last client goes away — a connect issued in the
+     * next few milliseconds can be refused, which used to surface as a
+     * fatal {@code IllegalStateException: cannot open X display} right
+     * after a successful probe (observed twice in one day on the
+     * low-power-profile box, once on the user's desktop run).
+     */
+    private static X11Native.Connection connectWithRetry(
+            final String displayName) throws IOException {
+        final long deadline = System.currentTimeMillis() + 10_000;
+        Throwable lastFailure = null;
+        while (System.currentTimeMillis() < deadline) {
+            try {
+                return new X11Native.Connection(displayName);
+            } catch (final Throwable failure) {
+                lastFailure = failure;
+                try {
+                    Thread.sleep(100);
+                } catch (final InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    break;
+                }
+            }
+        }
+        throw new IOException("cannot open X display " + displayName
+                + " after 10 s of retries", lastFailure);
+    }
+
+    private void captureLoop(final int[] destination,
+                             final FrameListener listener) {
+        final boolean debug = Boolean.getBoolean("aukio.xcapture.debug");
+        int iterations = 0;
+        while (running) {
+            try {
+                final boolean changed = X11Native.captureRoot(connection,
+                        width, height, destination);
+                if (debug && iterations++ % 10 == 0) {
+                    final var distinct = new java.util.HashSet<Integer>();
+                    for (int i = 0; i < destination.length; i += 97)
+                        distinct.add(destination[i] & 0xFFFFFF);
+                    System.out.println("XCAPTURE changed=" + changed
+                            + " distinct=" + distinct.size()
+                            + " appAlive=" + isApplicationRunning());
+                }
+                if (changed)
+                    listener.frameCaptured();
+            } catch (final Throwable throwable) {
+                // capture failures (window closing, renderer racing the
+                // buffer) must never kill the capture thread
+                if (debug)
+                    throwable.printStackTrace();
+            }
+            try {
+                Thread.sleep(CAPTURE_INTERVAL_MS);
+            } catch (final InterruptedException e) {
+                return;
+            }
+        }
+    }
+
+    /**
+     * Forwards a keyboard event (press or release) into the virtual
+     * display. Modifier keys are forwarded as ordinary keys, so the
+     * server-side modifier state tracks the user's real keyboard.
+     *
+     * @param event   the AWT key event
+     * @param pressed {@code true} for key press, {@code false} for release
+     */
+    public void sendKeyEvent(final java.awt.event.KeyEvent event,
+                             final boolean pressed) {
+        if (connection == null || !isApplicationRunning())
+            return;
+        final long keysym = AwtKeysyms.keysymFor(event);
+        if (keysym < 0)
+            return;
+        if (Boolean.getBoolean("aukio.xcapture.debug"))
+            System.out.println("XINPUT key keysym=0x"
+                    + Long.toHexString(keysym) + (pressed ? " press" : " release")
+                    + " on " + server.getDisplayName());
+        X11Native.sendKeyEvent(connection, keysym, pressed);
+    }
+
+    /**
+     * Releases Shift/Control/Alt server-side. Called when keyboard focus
+     * is lost mid-modifier (e.g. Shift+ESC pops focus while Shift is
+     * held), so the browser is not left with stuck modifiers.
+     */
+    public void releaseModifiers() {
+        if (connection == null || !isApplicationRunning())
+            return;
+        X11Native.sendKeyEvent(connection, AwtKeysyms.XK_SHIFT_L, false);
+        X11Native.sendKeyEvent(connection, AwtKeysyms.XK_CONTROL_L, false);
+        X11Native.sendKeyEvent(connection, AwtKeysyms.XK_ALT_L, false);
+    }
+
+    /**
+     * Forwards scroll wheel input into the virtual display at the given
+     * screen coordinates (typically the current pointer hover position on
+     * the panel).
+     *
+     * @param verticalUnits   positive = scroll down, negative = scroll up
+     * @param horizontalUnits positive = scroll right, negative = left
+     */
+    public void sendScroll(final int verticalUnits, final int horizontalUnits,
+                           final int x, final int y) {
+        if (connection == null || !isApplicationRunning())
+            return;
+        final int clampedX = Math.max(0, Math.min(x, width - 1));
+        final int clampedY = Math.max(0, Math.min(y, height - 1));
+        X11Native.sendScroll(connection, clampedX, clampedY, verticalUnits,
+                horizontalUnits);
+    }
+
+    /**
+     * Moves the pointer inside the virtual display without pressing
+     * buttons (hover forwarding).
+     */
+    public void sendMouseMove(final int x, final int y) {
+        if (connection == null || !isApplicationRunning())
+            return;
+        X11Native.sendMouseMove(connection,
+                Math.max(0, Math.min(x, width - 1)),
+                Math.max(0, Math.min(y, height - 1)));
+    }
+
+    public boolean isApplicationRunning() {
+        return application != null && application.isAlive();
+    }
+
+    /**
+     * The private X display name (e.g. ":91"), or null before
+     * {@link #start} — useful for inspecting the session with standard
+     * tools (xwininfo, import).
+     */
+    public String getDisplayName() {
+        return server == null ? null : server.getDisplayName();
+    }
+
+    /**
+     * Forwards a mouse click into the virtual display at screen
+     * coordinates (x, y). The captured texture IS the virtual screen, so
+     * texture pixels map 1:1 onto these coordinates. The click is
+     * synthesized with the X TEST extension, which applications treat as
+     * real device input.
+     *
+     * @param x      screen X coordinate (clamped into the screen)
+     * @param y      screen Y coordinate (clamped into the screen)
+     * @param button X button number: 1 = left, 3 = right
+     */
+    public void sendMouseClick(final int x, final int y, final int button) {
+        if (connection == null || !isApplicationRunning())
+            return;
+        final int clampedX = Math.max(0, Math.min(x, width - 1));
+        final int clampedY = Math.max(0, Math.min(y, height - 1));
+        if (Boolean.getBoolean("aukio.xcapture.debug"))
+            System.out.println("XINPUT click at " + clampedX + "," + clampedY
+                    + " button " + button + " on "
+                    + server.getDisplayName());
+        X11Native.sendMouseClick(connection, clampedX, clampedY, button);
+    }
+
+    @Override
+    public void close() {
+        running = false;
+        if (captureThread != null)
+            captureThread.interrupt();
+        if (fitterThread != null)
+            fitterThread.interrupt();
+        if (application != null)
+            application.destroyForcibly();
+        if (connection != null)
+            connection.close();
+        if (server != null)
+            server.close();
+    }
+
+    /**
+     * Builds a session that runs Firefox on a fresh throwaway profile,
+     * sized to fill the whole virtual screen.
+     */
+    public static GuiAppSession firefox(final int width, final int height,
+                                        final String url)
+            throws IOException {
+        final File profile = Files.createTempDirectory(
+                "aukio-firefox-profile").toFile();
+
+        // keep first-run noise out of the captured screen
+        final String userJs = """
+                user_pref("browser.shell.checkDefaultBrowser", false);
+                user_pref("browser.aboutwelcome.enabled", false);
+                user_pref("datareporting.policy.dataSubmissionEnabled", false);
+                user_pref("datareporting.policy.firstRunURL", "");
+                user_pref("trailhead.firstrun.didSeeAboutWelcome", true);
+                // stop Debian's system-wide extensions (Web eID) from
+                // hijacking the first-run session with an active tab
+                user_pref("extensions.autoDisableScopes", 15);
+                user_pref("extensions.shownSelectionUI", true);
+                """;
+        Files.writeString(new File(profile, "user.js").toPath(), userJs);
+
+        return new GuiAppSession(width, height,
+                List.of("firefox", "--no-remote", "--new-instance",
+                        "--profile", profile.getAbsolutePath(),
+                        "--width", String.valueOf(width),
+                        "--height", String.valueOf(height),
+                        url),
+                Map.of(), true);
+    }
+
+    /**
+     * Builds a session that runs VMware Workstation sized to fill the
+     * whole virtual screen. VMware has no command-line size flags, so a
+     * background thread continuously moves/resizes windows titled
+     * "VMware" onto the full screen.
+     */
+    public static GuiAppSession vmware(final int width, final int height) {
+        // Qt must use the X11 backend even when the desktop session is
+        // Wayland; the private display is X11-only (GuiAppSession also
+        // removes WAYLAND_DISPLAY for every app)
+        return new GuiAppSession(width, height, List.of("vmware"),
+                Map.of("QT_QPA_PLATFORM", "xcb"), false, "VMware");
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/bridge/x11/X11Native.java b/src/main/java/eu/svjatoslav/aukio/bridge/x11/X11Native.java
new file mode 100644 (file)
index 0000000..3e61819
--- /dev/null
@@ -0,0 +1,433 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.bridge.x11;
+
+import com.sun.jna.Function;
+import com.sun.jna.Library;
+import com.sun.jna.Native;
+import com.sun.jna.NativeLong;
+import com.sun.jna.Pointer;
+import com.sun.jna.ptr.IntByReference;
+import com.sun.jna.ptr.NativeLongByReference;
+import com.sun.jna.ptr.PointerByReference;
+
+/**
+ * Minimal libX11 bindings for screen capture via JNA.
+ *
+ * <p>Only what {@link X11Capture} needs: open/close a display, resolve the
+ * root window, and {@code XGetImage} a rectangular area into ARGB pixels.
+ * The XImage struct is read through manual field offsets (amd64 layout)
+ * instead of a JNA {@code Structure} — the struct embeds function pointers
+ * and exact alignment is easier to get right explicitly.</p>
+ *
+ * <p>Linux-only by design; the workspace targets Linux.</p>
+ */
+final class X11Native {
+
+    /** ZPixmap format constant for XGetImage. */
+    static final int Z_PIXMAP = 2;
+
+    /** XImage field offsets (amd64 / LP64 layout). */
+    private static final int IMAGE_WIDTH = 0;
+    private static final int IMAGE_HEIGHT = 4;
+    private static final int IMAGE_DATA = 16;
+    private static final int IMAGE_BYTE_ORDER = 24;
+    private static final int IMAGE_DEPTH = 40;
+    private static final int IMAGE_BYTES_PER_LINE = 44;
+    private static final int IMAGE_BITS_PER_PIXEL = 48;
+    private static final int IMAGE_RED_MASK = 56;
+    private static final int IMAGE_GREEN_MASK = 64;
+    private static final int IMAGE_BLUE_MASK = 72;
+    private static final int IMAGE_DESTROY_FUNCTION = 96;
+
+    private static final int LSB_FIRST = 0;
+
+    private interface X11Lib extends Library {
+        X11Lib INSTANCE = Native.load("X11", X11Lib.class);
+
+        Pointer XOpenDisplay(String displayName);
+
+        int XCloseDisplay(Pointer display);
+
+        int XDefaultScreen(Pointer display);
+
+        NativeLong XRootWindow(Pointer display, int screenNumber);
+
+        Pointer XGetImage(Pointer display, NativeLong drawable, int x, int y,
+                          int width, int height, NativeLong planeMask,
+                          int format);
+
+        int XFlush(Pointer display);
+
+        /**
+         * Converts a keysym to a keycode in the server's current keymap.
+         * Returns 0 when the keysym is not present in the keymap.
+         */
+        int XKeysymToKeycode(Pointer display, NativeLong keysym);
+
+        /**
+         * Lists the children of a window in stacking order (bottom first).
+         *
+         * @return 0 on failure; on success childrenReturn holds a malloc'd
+         * Window array that the caller must release with {@link #XFree}
+         */
+        int XQueryTree(Pointer display, NativeLong window,
+                       NativeLongByReference rootReturn,
+                       NativeLongByReference parentReturn,
+                       PointerByReference childrenReturn,
+                       IntByReference nchildrenReturn);
+
+        /**
+         * Reads a window's name (WM_NAME).
+         *
+         * @return 0 when the window has no name; on success nameReturn
+         * holds a malloc'd string that the caller must release with
+         * {@link #XFree}
+         */
+        int XFetchName(Pointer display, NativeLong window,
+                       PointerByReference nameReturn);
+
+        int XFree(Pointer data);
+
+        int XMoveResizeWindow(Pointer display, NativeLong window, int x,
+                              int y, int width, int height);
+
+        int XRaiseWindow(Pointer display, NativeLong window);
+    }
+
+    /**
+     * libXtst (X TEST extension) bindings for synthesizing input. Fake
+     * input events are indistinguishable from real device events, unlike
+     * XSendEvent which many toolkits (GTK included) treat as untrusted.
+     */
+    private interface XTestLib extends Library {
+        XTestLib INSTANCE = Native.load("Xtst", XTestLib.class);
+
+        int XTestFakeMotionEvent(Pointer display, int screenNumber,
+                                 int x, int y, NativeLong delay);
+
+        int XTestFakeButtonEvent(Pointer display, int button,
+                                 boolean isPress, NativeLong delay);
+
+        /**
+         * Synthesizes a key press or release.
+         *
+         * @return nonzero on success
+         */
+        int XTestFakeKeyEvent(Pointer display, int keycode, boolean isPress,
+                              NativeLong delay);
+    }
+
+    /**
+     * An open connection to an X server.
+     *
+     * <p>libX11 is not thread-safe unless XInitThreads was called; all
+     * requests on this connection are instead serialized through
+     * {@link #lock} (the capture loop and the input injector run on
+     * different threads).</p>
+     */
+    static final class Connection {
+        final Pointer display;
+        final NativeLong rootWindow;
+        final int screenNumber;
+        /** Serializes all X protocol traffic on this connection. */
+        final Object lock = new Object();
+
+        Connection(final String displayName) {
+            display = X11Lib.INSTANCE.XOpenDisplay(displayName);
+            if (display == null)
+                throw new IllegalStateException(
+                        "cannot open X display " + displayName);
+            screenNumber = X11Lib.INSTANCE.XDefaultScreen(display);
+            rootWindow = X11Lib.INSTANCE.XRootWindow(display, screenNumber);
+        }
+
+        void close() {
+            synchronized (lock) {
+                if (display != null)
+                    X11Lib.INSTANCE.XCloseDisplay(display);
+            }
+        }
+    }
+
+    /**
+     * Grabs the top-left rectangle of the root window and converts it to
+     * ARGB, honoring the server's pixel masks, bits-per-pixel and byte
+     * order. Writes directly into {@code destination} and reports whether
+     * any pixel changed.
+     *
+     * @return true when at least one written pixel differs from the
+     * previous buffer content
+     */
+    static boolean captureRoot(final Connection connection,
+                               final int width, final int height,
+                               final int[] destination) {
+        synchronized (connection.lock) {
+            final Pointer image = X11Lib.INSTANCE.XGetImage(connection.display,
+                    connection.rootWindow, 0, 0, width, height,
+                    new NativeLong(-1L), Z_PIXMAP);
+            if (image == null)
+                return false;
+            try {
+                return convert(image, width, height, destination);
+            } finally {
+                // XDestroyImage is a macro calling image->f.destroy_image
+                final Pointer destroyFunction = image.getPointer(
+                        IMAGE_DESTROY_FUNCTION);
+                Function.getFunction(destroyFunction).invokeInt(
+                        new Object[]{image});
+            }
+        }
+    }
+
+    /**
+     * Sends a single key press or release, identified by X keysym, to the
+     * virtual display. Modifier state is NOT synthesized here — real
+     * modifier key events (Shift/Ctrl/Alt) are forwarded like any other
+     * key, so the server-side modifier state tracks the user's keyboard.
+     *
+     * @param keysym the X keysym (see {@link AwtKeysyms})
+     * @param press  {@code true} for press, {@code false} for release
+     */
+    static void sendKeyEvent(final Connection connection, final long keysym,
+                             final boolean press) {
+        synchronized (connection.lock) {
+            final int keycode = X11Lib.INSTANCE.XKeysymToKeycode(
+                    connection.display, new NativeLong(keysym));
+            if (keycode == 0)
+                // keysym not present in the server keymap; nothing to send
+                return;
+            XTestLib.INSTANCE.XTestFakeKeyEvent(connection.display, keycode,
+                    press, new NativeLong(0));
+            X11Lib.INSTANCE.XFlush(connection.display);
+        }
+    }
+
+    /**
+     * Sends scroll wheel input at the given root-window coordinates: moves
+     * the pointer there, then presses/releases the X scroll buttons
+     * (4 = up, 5 = down, 6 = left, 7 = right), one click per notch.
+     *
+     * @param verticalUnits   positive = scroll down, negative = scroll up
+     * @param horizontalUnits positive = scroll right, negative = left
+     */
+    static void sendScroll(final Connection connection, final int x,
+                           final int y, final int verticalUnits,
+                           final int horizontalUnits) {
+        synchronized (connection.lock) {
+            XTestLib.INSTANCE.XTestFakeMotionEvent(connection.display,
+                    connection.screenNumber, x, y, new NativeLong(0));
+            final int verticalButton = verticalUnits < 0 ? 4 : 5;
+            for (int i = 0; i < Math.abs(verticalUnits); i++) {
+                XTestLib.INSTANCE.XTestFakeButtonEvent(connection.display,
+                        verticalButton, true, new NativeLong(0));
+                XTestLib.INSTANCE.XTestFakeButtonEvent(connection.display,
+                        verticalButton, false, new NativeLong(0));
+            }
+            final int horizontalButton = horizontalUnits < 0 ? 6 : 7;
+            for (int i = 0; i < Math.abs(horizontalUnits); i++) {
+                XTestLib.INSTANCE.XTestFakeButtonEvent(connection.display,
+                        horizontalButton, true, new NativeLong(0));
+                XTestLib.INSTANCE.XTestFakeButtonEvent(connection.display,
+                        horizontalButton, false, new NativeLong(0));
+            }
+            X11Lib.INSTANCE.XFlush(connection.display);
+        }
+    }
+
+    /**
+     * Moves the pointer to the given root-window coordinates without
+     * pressing any button (hover forwarding).
+     */
+    static void sendMouseMove(final Connection conn, final int x,
+                              final int y) {
+        synchronized (conn.lock) {
+            XTestLib.INSTANCE.XTestFakeMotionEvent(conn.display,
+                    conn.screenNumber, x, y, new NativeLong(0));
+            X11Lib.INSTANCE.XFlush(conn.display);
+        }
+    }
+
+    /**
+     * Synthesizes a full mouse click (move pointer, press, release) at the
+     * given root-window coordinates. The pointer is moved first because a
+     * button press acts at the current pointer location. The button is
+     * held briefly because Firefox does not synthesize a DOM click from a
+     * zero-duration press+release.
+     *
+     * @param x      root-window X coordinate
+     * @param y      root-window Y coordinate
+     * @param button X button number (1 = left, 3 = right)
+     */
+    static void sendMouseClick(final Connection connection, final int x,
+                               final int y, final int button) {
+        synchronized (connection.lock) {
+            XTestLib.INSTANCE.XTestFakeMotionEvent(connection.display,
+                    connection.screenNumber, x, y, new NativeLong(0));
+            XTestLib.INSTANCE.XTestFakeButtonEvent(connection.display, button,
+                    true, new NativeLong(0));
+            X11Lib.INSTANCE.XFlush(connection.display);
+            // Firefox (EventStateManager) does not synthesize a DOM click
+            // from a zero-duration press+release; hold the button briefly
+            try {
+                Thread.sleep(80);
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+            XTestLib.INSTANCE.XTestFakeButtonEvent(connection.display, button,
+                    false, new NativeLong(0));
+            X11Lib.INSTANCE.XFlush(connection.display);
+        }
+    }
+
+    /**
+     * Moves and resizes every top-level window whose name contains
+     * {@code titleFilter} to fill the whole virtual screen, raising it to
+     * the top of the stacking order. Used for applications that cannot be
+     * sized on the command line (e.g. VMware Workstation): a background
+     * thread calls this periodically so late-appearing windows get fitted
+     * too.
+     *
+     * @return the number of windows fitted in this pass
+     */
+    static int fitNamedWindows(final Connection connection,
+                               final String titleFilter,
+                               final int width, final int height) {
+        synchronized (connection.lock) {
+            final NativeLongByReference rootReturn = new NativeLongByReference();
+            final NativeLongByReference parentReturn = new NativeLongByReference();
+            final PointerByReference childrenReturn = new PointerByReference();
+            final IntByReference countReturn = new IntByReference();
+            if (X11Lib.INSTANCE.XQueryTree(connection.display,
+                    connection.rootWindow, rootReturn, parentReturn,
+                    childrenReturn, countReturn) == 0)
+                return 0;
+            final Pointer children = childrenReturn.getValue();
+            int fitted = 0;
+            try {
+                final int count = countReturn.getValue();
+                if (children == null || count <= 0)
+                    return 0;
+                for (int i = 0; i < count; i++) {
+                    final NativeLong window = new NativeLong(
+                            children.getNativeLong((long) i * NativeLong.SIZE)
+                                    .longValue());
+                    final PointerByReference nameReturn =
+                            new PointerByReference();
+                    if (X11Lib.INSTANCE.XFetchName(connection.display, window,
+                            nameReturn) == 0)
+                        continue; // unnamed window
+                    final Pointer name = nameReturn.getValue();
+                    if (name == null)
+                        continue;
+                    try {
+                        final String title = name.getString(0);
+                        if (title == null || !title.contains(titleFilter))
+                            continue;
+                        X11Lib.INSTANCE.XMoveResizeWindow(connection.display,
+                                window, 0, 0, width, height);
+                        X11Lib.INSTANCE.XRaiseWindow(connection.display,
+                                window);
+                        fitted++;
+                    } finally {
+                        X11Lib.INSTANCE.XFree(name);
+                    }
+                }
+            } finally {
+                X11Lib.INSTANCE.XFree(children);
+            }
+            if (fitted > 0)
+                X11Lib.INSTANCE.XFlush(connection.display);
+            return fitted;
+        }
+    }
+
+    private static boolean convert(final Pointer image, final int width,
+                                   final int height, final int[] destination) {
+        final int imageWidth = image.getInt(IMAGE_WIDTH);
+        final int imageHeight = image.getInt(IMAGE_HEIGHT);
+        final Pointer data = image.getPointer(IMAGE_DATA);
+        final int byteOrder = image.getInt(IMAGE_BYTE_ORDER);
+        final int bytesPerLine = image.getInt(IMAGE_BYTES_PER_LINE);
+        final int bitsPerPixel = image.getInt(IMAGE_BITS_PER_PIXEL);
+        final long redMask = image.getLong(IMAGE_RED_MASK);
+        final long greenMask = image.getLong(IMAGE_GREEN_MASK);
+        final long blueMask = image.getLong(IMAGE_BLUE_MASK);
+
+        final Channel red = new Channel(redMask);
+        final Channel green = new Channel(greenMask);
+        final Channel blue = new Channel(blueMask);
+
+        boolean changed = false;
+        final int copyWidth = Math.min(width, imageWidth);
+        final int copyHeight = Math.min(height, imageHeight);
+        for (int y = 0; y < copyHeight; y++) {
+            final int rowOffset = y * bytesPerLine;
+            final int destinationRow = y * width;
+            for (int x = 0; x < copyWidth; x++) {
+                final long pixelValue = readPixel(data, rowOffset, x,
+                        bitsPerPixel, byteOrder);
+                final int argb = 0xFF000000
+                        | (red.extract(pixelValue) << 16)
+                        | (green.extract(pixelValue) << 8)
+                        | blue.extract(pixelValue);
+                final int index = destinationRow + x;
+                if (destination[index] != argb) {
+                    destination[index] = argb;
+                    changed = true;
+                }
+            }
+        }
+        return changed;
+    }
+
+    private static long readPixel(final Pointer data, final int rowOffset,
+                                  final int x, final int bitsPerPixel,
+                                  final int byteOrder) {
+        if (bitsPerPixel == 32) {
+            final int value = data.getInt(rowOffset + (long) x * 4);
+            return Integer.toUnsignedLong(
+                    byteOrder == LSB_FIRST ? value : Integer.reverseBytes(value));
+        }
+        if (bitsPerPixel == 24) {
+            final long offset = rowOffset + (long) x * 3;
+            final int b0 = data.getByte(offset) & 0xFF;
+            final int b1 = data.getByte(offset + 1) & 0xFF;
+            final int b2 = data.getByte(offset + 2) & 0xFF;
+            return byteOrder == LSB_FIRST
+                    ? b0 | (b1 << 8) | (b2 << 16)
+                    : (b0 << 16) | (b1 << 8) | b2;
+        }
+        if (bitsPerPixel == 16) {
+            final short value = data.getShort(rowOffset + (long) x * 2);
+            return Short.toUnsignedLong(
+                    byteOrder == LSB_FIRST ? value : Short.reverseBytes(value));
+        }
+        throw new IllegalStateException(
+                "unsupported bits_per_pixel " + bitsPerPixel);
+    }
+
+    /**
+     * Extracts one 8-bit color channel from a raw pixel using the server
+     * pixel mask (any position and width, scaled to 0..255).
+     */
+    private static final class Channel {
+        private final long mask;
+        private final int shift;
+        private final long maxValue;
+
+        Channel(final long mask) {
+            this.mask = mask;
+            shift = mask == 0 ? 0 : Long.numberOfTrailingZeros(mask);
+            maxValue = mask == 0 ? 1 : (mask >>> shift);
+        }
+
+        int extract(final long pixelValue) {
+            if (mask == 0)
+                return 0;
+            return (int) (((pixelValue & mask) >>> shift) * 255 / maxValue);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/bridge/x11/XvfbServer.java b/src/main/java/eu/svjatoslav/aukio/bridge/x11/XvfbServer.java
new file mode 100644 (file)
index 0000000..73acb99
--- /dev/null
@@ -0,0 +1,116 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.bridge.x11;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Lifecycle of a private {@code Xvfb} virtual X server that one captured
+ * GUI application renders into.
+ *
+ * <p>Each server gets its own display number (scanned upward from 90,
+ * skipping displays with an existing lock file or socket), so multiple
+ * captured apps never share a screen. Readiness is detected by polling for
+ * the unix socket and then proving the display with a real
+ * {@code XOpenDisplay} — just waiting for the socket is not enough.</p>
+ */
+public final class XvfbServer implements AutoCloseable {
+
+    private static final int FIRST_DISPLAY = 90;
+    private static final int LAST_DISPLAY = 200;
+    private static final long START_TIMEOUT_MS = 10_000;
+
+    private final int displayNumber;
+    private final Process process;
+
+    private XvfbServer(final int displayNumber, final Process process) {
+        this.displayNumber = displayNumber;
+        this.process = process;
+    }
+
+    /**
+     * Starts an Xvfb server with a single screen of the given size and
+     * waits until the display actually accepts connections.
+     *
+     * @throws IOException if Xvfb fails to start within the timeout
+     */
+    public static XvfbServer start(final int width, final int height)
+            throws IOException {
+        final int displayNumber = findFreeDisplay();
+        final Process process = new ProcessBuilder("Xvfb",
+                ":" + displayNumber,
+                "-screen", "0", width + "x" + height + "x24",
+                "-nolisten", "tcp")
+                .redirectOutput(new File("/tmp/aukio-xvfb-"
+                        + displayNumber + ".log"))
+                .redirectError(new File("/tmp/aukio-xvfb-"
+                        + displayNumber + ".log"))
+                .start();
+
+        final XvfbServer server = new XvfbServer(displayNumber, process);
+        final long deadline = System.currentTimeMillis() + START_TIMEOUT_MS;
+        while (System.currentTimeMillis() < deadline) {
+            if (!process.isAlive())
+                break;
+            if (new File(server.socketPath()).exists()) {
+                try {
+                    final X11Native.Connection probe =
+                            new X11Native.Connection(server.getDisplayName());
+                    probe.close();
+                    return server;
+                } catch (final Throwable ignored) {
+                    // socket exists but server not accepting yet
+                }
+            }
+            try {
+                Thread.sleep(100);
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+                break;
+            }
+        }
+        process.destroyForcibly();
+        throw new IOException("Xvfb :" + displayNumber
+                + " did not become ready within " + START_TIMEOUT_MS + " ms");
+    }
+
+    private static int findFreeDisplay() throws IOException {
+        for (int number = FIRST_DISPLAY; number <= LAST_DISPLAY; number++)
+            if (!new File("/tmp/.X" + number + "-lock").exists()
+                    && !new File(socketPath(number)).exists())
+                return number;
+        throw new IOException("no free X display number between "
+                + FIRST_DISPLAY + " and " + LAST_DISPLAY);
+    }
+
+    private static String socketPath(final int displayNumber) {
+        return "/tmp/.X11-unix/X" + displayNumber;
+    }
+
+    private String socketPath() {
+        return socketPath(displayNumber);
+    }
+
+    /**
+     * Display name for the {@code DISPLAY} environment variable
+     * (e.g. {@code ":93"}).
+     */
+    public String getDisplayName() {
+        return ":" + displayNumber;
+    }
+
+    @Override
+    public void close() {
+        process.destroy();
+        try {
+            if (!process.waitFor(2, java.util.concurrent.TimeUnit.SECONDS))
+                process.destroyForcibly();
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+            process.destroyForcibly();
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/core/Main.java b/src/main/java/eu/svjatoslav/aukio/core/Main.java
new file mode 100644 (file)
index 0000000..04782c8
--- /dev/null
@@ -0,0 +1,955 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.core;
+
+import eu.svjatoslav.aukio.workspace.FirefoxPanel;
+import eu.svjatoslav.aukio.workspace.TerminalPanel;
+import eu.svjatoslav.aukio.workspace.Workspace;
+
+/**
+ * Entry point for the Aukio spatial computing environment.
+ *
+ * <p>Usage: {@code aukio [--selftest]}</p>
+ *
+ * <p>{@code --selftest} opens the workspace, types {@code ls} and an
+ * arithmetic echo into the terminal programmatically, verifies the expected
+ * output appears on the terminal screen, prints the screen contents, and
+ * exits with status 0 (pass) or 1 (fail). Intended to run under
+ * {@code xvfb-run} in CI-style verification.</p>
+ */
+public class Main {
+
+    private static final long SELFTEST_TIMEOUT_MS = 15_000;
+
+    public static void main(final String[] args) throws Exception {
+        // persistent log + telemetry FIRST, so all boot output is captured
+        eu.svjatoslav.aukio.e3d.diag.Diagnostics.install();
+
+        String environmentName = null;
+        for (final String arg : args)
+            if (arg.startsWith("--env="))
+                environmentName = arg.substring("--env=".length());
+
+        if (hasArg(args, "--clicktest"))
+            prepareClickTestPage();
+
+        if (hasArg(args, "--keytest"))
+            prepareKeyTestPage();
+
+        if (hasArg(args, "--scrolltest"))
+            prepareScrollTestPage();
+
+        if (hasArg(args, "--hovertest"))
+            prepareHoverTestPage();
+
+        final Workspace workspace = new Workspace();
+        try {
+            workspace.open();
+
+            if (environmentName != null)
+                openRequestedEnvironment(workspace, environmentName);
+        } catch (final Throwable throwable) {
+            // a failed startup (e.g. a GUI app's X display would not
+            // open) must not leave a half-open workspace rendering
+            // forever: main() dying alone does not stop the JVM — the
+            // view's render/present threads keep it alive as a zombie
+            // that holds its Xvfb displays and PTYs hostage
+            throwable.printStackTrace();
+            System.err.println("startup failed, exiting");
+            System.exit(1);
+        }
+
+        if (hasArg(args, "--selftest"))
+            System.exit(runSelfTest(workspace));
+
+        if (hasArg(args, "--guitest"))
+            System.exit(runGuiTest(workspace));
+
+        if (hasArg(args, "--clicktest"))
+            System.exit(runClickTest(workspace));
+
+        if (hasArg(args, "--keytest"))
+            System.exit(runKeyTest(workspace));
+
+        if (hasArg(args, "--scrolltest"))
+            System.exit(runScrollTest(workspace));
+
+        if (hasArg(args, "--hovertest"))
+            System.exit(runHoverTest(workspace));
+
+        if (hasArg(args, "--headtest"))
+            System.exit(runHeadTest(workspace));
+
+        if (hasArg(args, "--headdebug"))
+            startHeadDebugLogger(workspace);
+
+        Runtime.getRuntime().addShutdownHook(
+                new Thread(workspace::close));
+    }
+
+    /**
+     * True when the exact flag appears anywhere on the command line;
+     * test drivers must not be position-sensitive now that --env= can
+     * occupy args[0].
+     */
+    private static boolean hasArg(final String[] args, final String flag) {
+        for (final String arg : args)
+            if (flag.equals(arg))
+                return true;
+        return false;
+    }
+
+    /**
+     * Resolves the --env=<name> provider, checks availability, and opens
+     * it into the running workspace. Exits with status 1 when the name
+     * is unknown or the provider's data files are missing.
+     */
+    private static void openRequestedEnvironment(final Workspace workspace,
+                                                 final String name)
+            throws Exception {
+        final var provider
+                = eu.svjatoslav.aukio.env.EnvironmentRegistry.findByName(name);
+        if (provider == null) {
+            System.out.println("no environment named '" + name + "'");
+            eu.svjatoslav.aukio.env.EnvironmentRegistry
+                    .printAvailableEnvironments();
+            System.exit(1);
+        }
+        if (!provider.isAvailable()) {
+            System.out.println("environment '" + name + "' ("
+                    + provider.getDisplayName() + ") is not available on"
+                    + " this machine — its data files were not found");
+            System.exit(1);
+        }
+        System.out.println("opening environment: "
+                + provider.getDisplayName());
+        workspace.openEnvironment(provider);
+    }
+
+    /**
+     * Drives the terminal without AWT events: injects bytes straight into
+     * the PTY, exactly what a focused keystroke would send.
+     */
+    private static int runSelfTest(final Workspace workspace)
+            throws InterruptedException {
+        final TerminalPanel terminal = workspace.getTerminalPanel();
+        final var terminals = workspace.getTerminalPanels();
+        final int expectedTerminalCount = Workspace.TERMINAL_GRID_COLUMNS
+                * Workspace.TERMINAL_GRID_ROWS;
+        if (terminals.size() != expectedTerminalCount)
+            return fail(terminal, "expected " + expectedTerminalCount
+                    + " terminal panels, got " + terminals.size());
+        final long sessionCount = terminals.stream()
+                .map(TerminalPanel::getSession)
+                .distinct()
+                .count();
+        if (sessionCount != terminals.size())
+            return fail(terminal, "terminal panels share PTY sessions: "
+                    + sessionCount + " sessions for " + terminals.size()
+                    + " panels");
+        final long runningSessionCount = terminals.stream()
+                .filter(panel -> panel.getSession().isRunning())
+                .count();
+        if (runningSessionCount != terminals.size())
+            return fail(terminal, "only " + runningSessionCount + " of "
+                    + terminals.size() + " PTY sessions are running");
+
+        // wait for the real shell prompt (bashrc noise may contain a bare
+        // "$", so match the user@host prompt text instead)
+        if (!waitFor(terminal, expectedShellPrompt()))
+            return fail(terminal, "no shell prompt appeared");
+
+        terminal.typeText("ls\r");
+        if (!waitFor(terminal, "pom.xml"))
+            return fail(terminal, "'ls' output missing pom.xml");
+        if (!waitFor(terminal, "src"))
+            return fail(terminal, "'ls' output missing src");
+
+        terminal.typeText("echo $((6*7))\r");
+        if (!waitFor(terminal, "42"))
+            return fail(terminal, "arithmetic echo produced no 42");
+
+        // unique sentinel that exists ONLY on the main screen; mc/htop
+        // draw on the alternate screen, and mc shows both its own
+        // user@host command-line prompt AND file timestamps that can
+        // contain substrings like "42" — only a made-up string is a
+        // non-vacuous proof of being back at the shell
+        terminal.typeText("echo SENTINEL42XYZ\r");
+        if (!waitFor(terminal, "SENTINEL42XYZ"))
+            return fail(terminal, "sentinel echo failed");
+
+        // fullscreen curses programs: mc must open (and enable
+        // application cursor keys), quit on F10; htop must quit on 'q';
+        // mc must also quit on ESC 0 (proves ESC reaches programs)
+        terminal.typeText("mc\r");
+        if (!waitFor(terminal, "Name"))
+            return fail(terminal, "mc did not open");
+        if (!terminal.isApplicationCursorKeys())
+            return fail(terminal,
+                    "mc did not enable application cursor keys");
+        terminal.typeText("\u001B[21~"); // F10
+        if (!waitFor(terminal, "SENTINEL42XYZ"))
+            return fail(terminal, "mc did not quit on F10");
+
+        terminal.typeText("htop\r");
+        if (!waitFor(terminal, "Tasks"))
+            return fail(terminal, "htop did not open");
+        terminal.typeText("q");
+        if (!waitFor(terminal, "SENTINEL42XYZ"))
+            return fail(terminal, "htop did not quit on 'q'");
+
+        terminal.typeText("mc\r");
+        if (!waitFor(terminal, "Name"))
+            return fail(terminal, "mc did not reopen");
+        terminal.typeText("\u001B");
+        terminal.typeText("0"); // ESC 0 = F10 in mc
+        if (!waitFor(terminal, "SENTINEL42XYZ"))
+            return fail(terminal, "mc did not quit on ESC 0");
+
+        System.out.println("SELFTEST PASS — terminal screen:");
+        System.out.println(terminal.getScreenText());
+        workspace.close();
+        return 0;
+    }
+
+    /**
+     * Prints head tracker diagnostics every 2 seconds (frame flow, tick,
+     * dt, max gyro excursion, fused angles) so a live look-around test
+     * can be verified from the process log afterwards.
+     */
+    private static void startHeadDebugLogger(final Workspace workspace) {
+        final Thread logger = new Thread(() -> {
+            while (true) {
+                final var tracker = workspace.getHeadTracker();
+                System.out.println("HEADTRACK "
+                        + (tracker == null ? "no device"
+                                : tracker.getDebugString()));
+                try {
+                    Thread.sleep(2000);
+                } catch (final InterruptedException e) {
+                    return;
+                }
+            }
+        }, "head-debug-logger");
+        logger.setDaemon(true);
+        logger.start();
+    }
+
+    /**
+     * Prints head tracker and camera angles for 30 seconds so axis/sign
+     * conventions can be verified against real head movement. Exit 0 if
+     * the tracker produced data at all.
+     */
+    private static int runHeadTest(final Workspace workspace)
+            throws InterruptedException {
+        final var tracker = workspace.getHeadTracker();
+        if (tracker == null) {
+            System.out.println("HEADTEST FAIL: no glasses detected");
+            return 1;
+        }
+        // frame listeners do not run under xvfb (no render loop), so do
+        // the boot recenter here and compute what HeadLookController
+        // would apply, in-line
+        while (!tracker.isCalibrated())
+            Thread.sleep(50);
+        Thread.sleep(1000); // let the filter settle
+        tracker.recenter();
+        final var camera = workspace.getViewFrame().getViewPanel()
+                .getCamera();
+        final double[] baseAngles = camera.getTransform().getRotation()
+                .toAngles();
+        System.out.println("wait for calibration, then: turn head LEFT, "
+                + "watch yaw; nod DOWN, watch pitch (30s)");
+        final long deadline = System.currentTimeMillis() + 30_000;
+        boolean sawMovement = false;
+        while (System.currentTimeMillis() < deadline) {
+            final double headYaw = tracker.getLookYaw();
+            final double headPitch = tracker.getLookPitch();
+            // frame listeners do not run under xvfb (no render loop), so
+            // compute what HeadLookController would apply, in-line
+            final double camYaw = baseAngles[0] - headYaw;
+            final double camPitch = baseAngles[1] + headPitch;
+            if (Math.abs(headYaw) > 0.05 || Math.abs(headPitch) > 0.05)
+                sawMovement = true;
+            System.out.printf("head yaw %+7.1f pitch %+7.1f deg | "
+                            + "camera yaw %+7.3f pitch %+7.3f rad%n",
+                    Math.toDegrees(headYaw), Math.toDegrees(headPitch),
+                    camYaw, camPitch);
+            Thread.sleep(200);
+        }
+        workspace.close();
+        System.out.println(sawMovement ? "HEADTEST PASS (movement seen)"
+                : "HEADTEST FAIL (no head movement detected)");
+        return sawMovement ? 0 : 1;
+    }
+
+    /**
+     * Verifies the GUI-app bridge end to end without AWT events: waits
+     * for Firefox to paint into the panel texture, then checks the
+     * captured frame is a real image (not a flat fill). Exit 0 = pass.
+     */
+    private static int runGuiTest(final Workspace workspace)
+            throws InterruptedException {
+        final var panel = workspace.getFirefoxPanel();
+        final long deadline = System.currentTimeMillis() + 60_000;
+        while (System.currentTimeMillis() < deadline) {
+            if (panel.hasLiveFrame())
+                break;
+            Thread.sleep(500);
+        }
+        if (!panel.hasLiveFrame()) {
+            System.out.println("GUITEST FAIL: no live frame within 60s"
+                    + " (firefox running: "
+                    + panel.getSession().isApplicationRunning() + ")");
+            workspace.close();
+            return 1;
+        }
+
+        // a real browser screen has many distinct colors; a dead/black
+        // capture collapses to one or two
+        final int[] pixels = panel.getTexture().primaryBitmap.pixels;
+        final var distinctColors = new java.util.HashSet<Integer>();
+        long luminanceSum = 0;
+        int samples = 0;
+        for (int i = 0; i < pixels.length; i += 997) {
+            final int pixel = pixels[i];
+            distinctColors.add(pixel);
+            luminanceSum += ((pixel >> 16) & 0xFF) + ((pixel >> 8) & 0xFF)
+                    + (pixel & 0xFF);
+            samples++;
+        }
+        final double meanLuminance = (double) luminanceSum / samples / 3.0;
+        System.out.println("GUITEST stats: distinct sampled colors="
+                + distinctColors.size() + " mean luminance="
+                + String.format("%.1f", meanLuminance));
+        if (distinctColors.size() < 16 || meanLuminance < 5) {
+            System.out.println("GUITEST FAIL: captured frame looks flat");
+            workspace.close();
+            return 1;
+        }
+
+        System.out.println("GUITEST PASS — firefox is painting into the"
+                + " workspace panel");
+        workspace.close();
+        return 0;
+    }
+
+    /**
+     * Verifies mouse click forwarding into the virtual display: loads a
+     * test page that paints the whole screen red on click, focuses the
+     * Firefox panel, sends a left click through the panel's mouse
+     * interaction path, and requires a large pixel change. Also verifies
+     * that a middle click releases focus and is NOT forwarded. Exit 0 =
+     * pass.
+     */
+    private static int runClickTest(final Workspace workspace)
+            throws Exception {
+        final var panel = workspace.getFirefoxPanel();
+        final long deadline = System.currentTimeMillis() + 60_000;
+        while (System.currentTimeMillis() < deadline) {
+            if (panel.hasLiveFrame())
+                break;
+            Thread.sleep(500);
+        }
+        if (!panel.hasLiveFrame()) {
+            System.out.println("CLICKTEST FAIL: no live frame within 60s");
+            workspace.close();
+            return 1;
+        }
+        // let the page finish rendering after the first live frame
+        Thread.sleep(3000);
+
+        final int[] pixels = panel.getTexture().primaryBitmap.pixels;
+        final int[] before = pixels.clone();
+
+        // click while unfocused: grabs focus only, page must not change
+        panel.mouseClicked(
+                eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent.BUTTON_LEFT,
+                640, 480);
+        Thread.sleep(2000);
+        if (countChangedPixels(before, pixels) > 1000) {
+            System.out.println("CLICKTEST FAIL: focus-grab click leaked"
+                    + " into the page");
+            workspace.close();
+            return 1;
+        }
+        if (!panel.hasKeyboardFocus()) {
+            System.out.println("CLICKTEST FAIL: first click did not focus"
+                    + " the panel");
+            workspace.close();
+            return 1;
+        }
+
+        // left click while focused: forwarded, page turns red
+        System.out.println("CLICKTEST focus=" + panel.hasKeyboardFocus()
+                + " — sending forwarded click");
+        panel.mouseClicked(
+                eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent.BUTTON_LEFT,
+                640, 480);
+        final long clickDeadline = System.currentTimeMillis() + 10_000;
+        int changed = 0;
+        while (System.currentTimeMillis() < clickDeadline) {
+            Thread.sleep(500);
+            changed = countChangedPixels(before, pixels);
+            if (changed > 100_000)
+                break;
+        }
+        if (changed <= 100_000) {
+            saveTexturePng(panel, "/tmp/clicktest-texture.png");
+            System.out.println("CLICKTEST FAIL: forwarded click changed only "
+                    + changed + " pixels (texture dump: "
+                    + "/tmp/clicktest-texture.png)");
+            workspace.close();
+            return 1;
+        }
+
+        // mouse back button: navigates back to the gradient page
+        final int[] redPage = pixels.clone();
+        panel.mouseClicked(
+                eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent.BUTTON_BACK,
+                640, 480);
+        int backChanged = awaitPixelChange(pixels, redPage, 10_000);
+        if (backChanged <= 100_000) {
+            System.out.println("CLICKTEST FAIL: back button did not"
+                    + " navigate back (" + backChanged + " pixels)");
+            workspace.close();
+            return 1;
+        }
+
+        // mouse forward button: navigates forward to the red page
+        final int[] gradientPage = pixels.clone();
+        panel.mouseClicked(
+                eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent.BUTTON_FORWARD,
+                640, 480);
+        int forwardChanged = awaitPixelChange(pixels, gradientPage, 10_000);
+        if (forwardChanged <= 100_000) {
+            System.out.println("CLICKTEST FAIL: forward button did not"
+                    + " navigate forward (" + forwardChanged + " pixels)");
+            workspace.close();
+            return 1;
+        }
+
+        // middle click: releases focus, not forwarded
+        final int[] red = pixels.clone();
+        panel.mouseClicked(
+                eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent.BUTTON_MIDDLE,
+                640, 480);
+        Thread.sleep(2000);
+        if (panel.hasKeyboardFocus()) {
+            System.out.println("CLICKTEST FAIL: middle click did not"
+                    + " release focus");
+            workspace.close();
+            return 1;
+        }
+        if (countChangedPixels(red, pixels) > 1000) {
+            System.out.println("CLICKTEST FAIL: middle click leaked into"
+                    + " the page");
+            workspace.close();
+            return 1;
+        }
+
+        System.out.println("CLICKTEST PASS — focus grab, forwarded click ("
+                + changed + " pixels changed), back/forward navigation,"
+                + " middle-click release");
+        workspace.close();
+        return 0;
+    }
+
+    /**
+     * Writes the hover-target page and points the Firefox panel at it.
+     * The page turns orange on a mousemove event in its top-left quadrant
+     * (clientX &lt; 300 &amp;&amp; clientY &lt; 300). Must run BEFORE the
+     * workspace is constructed.
+     */
+    private static void prepareHoverTestPage() throws Exception {
+        final java.io.File page = java.io.File.createTempFile(
+                "aukio-hovertest", ".html");
+        page.deleteOnExit();
+        java.nio.file.Files.writeString(page.toPath(), """
+                <!doctype html><html><head><title>HOVERTEST</title></head>
+                <body style="margin:0">
+                <div style="height:960px;background:linear-gradient(
+                    to right,#e6194b,#f58231,#ffe119,#bfef45,#3cb44b,
+                    #42d4f4,#4363d8,#911eb4,#f032e6)"></div>
+                <script>
+                window.addEventListener('mousemove', e => {
+                    document.title = 'HOVER ' + e.clientX + ','
+                        + e.clientY;
+                    if (e.clientX < 300 && e.clientY < 300) {
+                        document.body.style.background = '#f80';
+                        document.body.innerHTML =
+                            '<h1 style="font-size:100px">HOVERED</h1>';
+                    }
+                }, {capture: true, passive: true});
+                </script></body></html>
+                """);
+        System.setProperty("aukio.firefox.url", page.toURI().toString());
+    }
+
+    /**
+     * Verifies hover forwarding: while unfocused, hovering the panel must
+     * NOT move the pointer inside Firefox; once focused, hovering the
+     * top-left area must deliver a mousemove there (page turns orange).
+     */
+    private static int runHoverTest(final Workspace workspace)
+            throws Exception {
+        final FirefoxPanel panel = workspace.getFirefoxPanel();
+        if (panel == null) {
+            System.out.println("HOVERTEST FAIL: no firefox panel");
+            workspace.close();
+            return 1;
+        }
+        final long deadline = System.currentTimeMillis() + 60_000;
+        while (System.currentTimeMillis() < deadline) {
+            if (panel.hasLiveFrame())
+                break;
+            Thread.sleep(500);
+        }
+        if (!panel.hasLiveFrame()) {
+            System.out.println("HOVERTEST FAIL: no live firefox capture");
+            workspace.close();
+            return 1;
+        }
+        Thread.sleep(2_000);
+        final int[] pixels = panel.getTexture().primaryBitmap.pixels;
+
+        // unfocused hover must NOT reach the page
+        final int[] beforeHover = pixels.clone();
+        panel.mouseHover(150, 200);
+        Thread.sleep(2_000);
+        if (countChangedPixels(beforeHover, pixels) > 100_000) {
+            System.out.println("HOVERTEST FAIL: unfocused hover leaked"
+                    + " into the page");
+            workspace.close();
+            return 1;
+        }
+
+        // focus (click at 640,480 — forwarded pointer lands outside the
+        // top-left quadrant, page stays unchanged)
+        panel.mouseClicked(
+                eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent.BUTTON_LEFT,
+                640, 480);
+        Thread.sleep(1_500);
+        if (!panel.hasKeyboardFocus()) {
+            System.out.println("HOVERTEST FAIL: focus was not acquired");
+            workspace.close();
+            return 1;
+        }
+
+        // focused hover into the top-left quadrant must turn it orange
+        final int[] focused = pixels.clone();
+        panel.mouseHover(150, 200);
+        final int changed = awaitPixelChange(pixels, focused, 10_000);
+        if (changed <= 100_000) {
+            System.out.println("HOVERTEST FAIL: focused hover changed only "
+                    + changed + " pixels");
+            workspace.close();
+            return 1;
+        }
+
+        System.out.println("HOVERTEST PASS — unfocused hover not forwarded,"
+                + " focused hover moved the pointer (" + changed
+                + " pixels changed)");
+        workspace.close();
+        return 0;
+    }
+
+    private static int awaitPixelChange(final int[] pixels,
+                                        final int[] reference,
+                                        final long timeoutMs)
+            throws InterruptedException {
+        final long deadline = System.currentTimeMillis() + timeoutMs;
+        int changed = 0;
+        while (System.currentTimeMillis() < deadline) {
+            Thread.sleep(500);
+            changed = countChangedPixels(reference, pixels);
+            if (changed > 100_000)
+                break;
+        }
+        return changed;
+    }
+
+    /**
+     * Verifies keyboard forwarding into the virtual display: the test page
+     * has an input field that turns the whole screen green once it
+     * receives the text "hello". The panel is focused, the input is
+     * clicked (focus it in the page), then "hello" is typed as AWT key
+     * events through the panel's keyboard path. Also verifies that
+     * Shift+ESC releases focus. Exit 0 = pass.
+     */
+    private static int runKeyTest(final Workspace workspace)
+            throws Exception {
+        final var panel = workspace.getFirefoxPanel();
+        final long deadline = System.currentTimeMillis() + 60_000;
+        while (System.currentTimeMillis() < deadline) {
+            if (panel.hasLiveFrame())
+                break;
+            Thread.sleep(500);
+        }
+        if (!panel.hasLiveFrame()) {
+            System.out.println("KEYTEST FAIL: no live frame within 60s");
+            workspace.close();
+            return 1;
+        }
+        Thread.sleep(3000);
+
+        final int[] pixels = panel.getTexture().primaryBitmap.pixels;
+
+        // focus the panel (first click grabs focus only)
+        panel.mouseClicked(
+                eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent.BUTTON_LEFT,
+                640, 480);
+        if (!panel.hasKeyboardFocus()) {
+            System.out.println("KEYTEST FAIL: first click did not focus"
+                    + " the panel");
+            workspace.close();
+            return 1;
+        }
+
+        // click the input field (page coords 600,280 + chrome offset
+        // lands it near texture 700,390) so typed text goes into it
+        panel.mouseClicked(
+                eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent.BUTTON_LEFT,
+                700, 390);
+        Thread.sleep(1500);
+
+        // type "hello" as raw key events through the panel keyboard path
+        final int[] before = pixels.clone();
+        final var viewPanel = workspace.getViewFrame().getViewPanel();
+        for (final char c : "hello".toCharArray()) {
+            final int keyCode = java.awt.event.KeyEvent.getExtendedKeyCodeForChar(c);
+            panel.keyPressed(new java.awt.event.KeyEvent(viewPanel,
+                    java.awt.event.KeyEvent.KEY_PRESSED,
+                    System.currentTimeMillis(), 0, keyCode, c), viewPanel);
+            panel.keyReleased(new java.awt.event.KeyEvent(viewPanel,
+                    java.awt.event.KeyEvent.KEY_RELEASED,
+                    System.currentTimeMillis(), 0, keyCode, c), viewPanel);
+            Thread.sleep(150);
+        }
+
+        // the page turns green once the input contains "hello"
+        final long typeDeadline = System.currentTimeMillis() + 15_000;
+        int changed = 0;
+        while (System.currentTimeMillis() < typeDeadline) {
+            Thread.sleep(500);
+            changed = countChangedPixels(before, pixels);
+            if (changed > 100_000)
+                break;
+        }
+        if (changed <= 100_000) {
+            saveTexturePng(panel, "/tmp/keytest-texture.png");
+            System.out.println("KEYTEST FAIL: typing changed only "
+                    + changed + " pixels (texture dump: "
+                    + "/tmp/keytest-texture.png)");
+            workspace.close();
+            return 1;
+        }
+
+        // Shift+ESC releases focus
+        final int shiftMods = java.awt.event.InputEvent.SHIFT_DOWN_MASK;
+        panel.keyPressed(new java.awt.event.KeyEvent(viewPanel,
+                java.awt.event.KeyEvent.KEY_PRESSED,
+                System.currentTimeMillis(), shiftMods,
+                java.awt.event.KeyEvent.VK_SHIFT,
+                java.awt.event.KeyEvent.CHAR_UNDEFINED), viewPanel);
+        panel.keyPressed(new java.awt.event.KeyEvent(viewPanel,
+                java.awt.event.KeyEvent.KEY_PRESSED,
+                System.currentTimeMillis(), shiftMods,
+                java.awt.event.KeyEvent.VK_ESCAPE, '\e'), viewPanel);
+        Thread.sleep(500);
+        if (panel.hasKeyboardFocus()) {
+            System.out.println("KEYTEST FAIL: Shift+ESC did not release"
+                    + " focus");
+            workspace.close();
+            return 1;
+        }
+
+        System.out.println("KEYTEST PASS — typed text reached the page ("
+                + changed + " pixels changed), Shift+ESC released focus");
+        workspace.close();
+        return 0;
+    }
+
+    /**
+     * Verifies scroll wheel forwarding on both axes:
+     * <ul>
+     *   <li>Firefox: the test page counts wheel events (deltaY and
+     *       deltaX) and turns blue once it has seen 2 notches down and
+     *       2 notches left.</li>
+     *   <li>Terminal: the wheel becomes arrow keys — wheel-up at a prompt
+     *       with a drafted line recalls history, wheel-left moves the
+     *       cursor inside the drafted line.</li>
+     * </ul>
+     * Exit 0 = pass.
+     */
+    private static int runScrollTest(final Workspace workspace)
+            throws Exception {
+        final var panel = workspace.getFirefoxPanel();
+        final long deadline = System.currentTimeMillis() + 60_000;
+        while (System.currentTimeMillis() < deadline) {
+            if (panel.hasLiveFrame())
+                break;
+            Thread.sleep(500);
+        }
+        if (!panel.hasLiveFrame()) {
+            System.out.println("SCROLLTEST FAIL: no live frame within 60s");
+            workspace.close();
+            return 1;
+        }
+        Thread.sleep(3000);
+
+        // --- firefox: both wheel axes reach the page ---
+        panel.mouseClicked(
+                eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent.BUTTON_LEFT,
+                640, 480);
+        if (!panel.hasKeyboardFocus()) {
+            System.out.println("SCROLLTEST FAIL: first click did not focus"
+                    + " the panel");
+            workspace.close();
+            return 1;
+        }
+
+        final int[] pixels = panel.getTexture().primaryBitmap.pixels;
+        final int[] before = pixels.clone();
+        panel.mouseWheelMoved(2, 0); // two notches down
+        Thread.sleep(300);
+        panel.mouseWheelMoved(0, -2); // two notches left
+        final long scrollDeadline = System.currentTimeMillis() + 15_000;
+        int changed = 0;
+        while (System.currentTimeMillis() < scrollDeadline) {
+            Thread.sleep(500);
+            changed = countChangedPixels(before, pixels);
+            if (changed > 100_000)
+                break;
+        }
+        if (changed <= 100_000) {
+            saveTexturePng(panel, "/tmp/scrolltest-texture.png");
+            System.out.println("SCROLLTEST FAIL: wheel changed only "
+                    + changed + " pixels (texture dump: "
+                    + "/tmp/scrolltest-texture.png)");
+            workspace.close();
+            return 1;
+        }
+
+        // --- terminal: wheel becomes arrow keys ---
+        final TerminalPanel terminal = workspace.getTerminalPanel();
+        if (!waitFor(terminal, expectedShellPrompt())) {
+            System.out.println("SCROLLTEST FAIL: no shell prompt appeared");
+            workspace.close();
+            return 1;
+        }
+        // controlled history: wheel-up is 3 arrow-ups per notch, so after
+        // A, B, C the recalled line is deterministically A
+        terminal.typeText("echo WHEEL_A\r");
+        if (!waitFor(terminal, "WHEEL_A")) {
+            System.out.println("SCROLLTEST FAIL: base command A failed");
+            workspace.close();
+            return 1;
+        }
+        terminal.typeText("echo WHEEL_B\r");
+        Thread.sleep(700);
+        terminal.typeText("echo WHEEL_C\r");
+        Thread.sleep(700);
+
+        // draft a partial command, then wheel up: bash history recall
+        // (3 up-arrows per notch) replaces the draft with WHEEL_A's line
+        terminal.typeText("echo DRAFT");
+        Thread.sleep(500);
+        terminal.mouseWheelMoved(-1, 0);
+        Thread.sleep(1000);
+        final String screenAfterUp = terminal.getScreenText().trim();
+        if (!screenAfterUp.endsWith("echo WHEEL_A")) {
+            System.out.println("SCROLLTEST FAIL: wheel-up did not recall"
+                    + " history; screen:\n" + screenAfterUp);
+            workspace.close();
+            return 1;
+        }
+
+        // wheel left: cursor moves into the recalled line, typed text
+        // lands in the middle of it: "echo WHEEL_A" (12 chars), 6
+        // cursor-lefts -> insert at position 6 -> "echo WQHEEL_A"
+        terminal.mouseWheelMoved(0, -2); // 6 cursor-lefts
+        Thread.sleep(500);
+        terminal.typeText("Q");
+        Thread.sleep(500);
+        if (!terminal.getScreenText().trim().endsWith("echo WQHEEL_A")) {
+            System.out.println("SCROLLTEST FAIL: horizontal wheel did not"
+                    + " move the cursor left; screen:\n"
+                    + terminal.getScreenText());
+            workspace.close();
+            return 1;
+        }
+
+        System.out.println("SCROLLTEST PASS — firefox wheel both axes ("
+                + changed + " pixels changed), terminal wheel-as-arrows");
+        workspace.close();
+        return 0;
+    }
+
+    /**
+     * Writes the scroll-target page and points the Firefox panel at it.
+     * Must run BEFORE the workspace is constructed, because the panel
+     * reads the URL system property in its constructor.
+     */
+    private static void prepareScrollTestPage() throws Exception {
+        final java.io.File page = java.io.File.createTempFile(
+                "aukio-scrolltest", ".html");
+        page.deleteOnExit();
+        java.nio.file.Files.writeString(page.toPath(), """
+                <!doctype html><html><head><title>SCROLL none</title></head>
+                <body style="margin:0">
+                <div style="height:200px;background:linear-gradient(
+                    to right,#e6194b,#f58231,#ffe119,#bfef45,#3cb44b,
+                    #42d4f4,#4363d8,#911eb4,#f032e6)"></div>
+                <div style="padding:20px;font-size:24px">
+                Scroll down twice and left twice.</div>
+                <script>
+                let v = 0, h = 0;
+                window.addEventListener('wheel', e => {
+                    v += Math.sign(e.deltaY);
+                    h += Math.sign(e.deltaX);
+                    document.title = 'SCROLL v=' + v + ' h=' + h;
+                    if (v >= 2 && h <= -2) {
+                        document.body.style.background = '#00f';
+                        document.body.innerHTML =
+                            '<h1 style="font-size:100px">SCROLLED</h1>';
+                    }
+                }, {capture: true, passive: true});
+                </script></body></html>
+                """);
+        System.setProperty("aukio.firefox.url", page.toURI().toString());
+    }
+
+    /**
+     * Writes the keyboard-target page and points the Firefox panel at it.
+     * Must run BEFORE the workspace is constructed, because the panel
+     * reads the URL system property in its constructor.
+     */
+    private static void prepareKeyTestPage() throws Exception {
+        final java.io.File page = java.io.File.createTempFile(
+                "aukio-keytest", ".html");
+        page.deleteOnExit();
+        java.nio.file.Files.writeString(page.toPath(), """
+                <!doctype html><html><head><title>KEYTEST none</title></head>
+                <body style="margin:0">
+                <div style="height:200px;background:linear-gradient(
+                    to right,#e6194b,#f58231,#ffe119,#bfef45,#3cb44b,
+                    #42d4f4,#4363d8,#911eb4,#f032e6)"></div>
+                <input id="i" style="position:absolute;left:600px;top:280px;
+                    width:200px;height:40px;font-size:30px">
+                <script>
+                const i = document.getElementById('i');
+                i.oninput = () => {
+                    document.title = 'KEYTEST ' + i.value;
+                    if (i.value === 'hello') {
+                        document.body.style.background = '#0f0';
+                        document.body.innerHTML =
+                            '<h1 style="font-size:100px">TYPED-OK</h1>';
+                    }
+                };
+                </script></body></html>
+                """);
+        System.setProperty("aukio.firefox.url", page.toURI().toString());
+    }
+
+    /**
+     * Writes the click-target page and points the Firefox panel at it.
+     * Must run BEFORE the workspace is constructed, because the panel
+     * reads the URL system property in its constructor.
+     */
+    private static void prepareClickTestPage() throws Exception {
+        // two pages linked by real navigation, so the mouse back/forward
+        // buttons have history to walk
+        final java.io.File page2 = java.io.File.createTempFile(
+                "aukio-clicktest-2", ".html");
+        page2.deleteOnExit();
+        java.nio.file.Files.writeString(page2.toPath(), """
+                <!doctype html><html><head><title>WITNESS page2</title></head>
+                <body style="margin:0;background:#f00">
+                <h1 style="font-size:100px">CLICKED</h1>
+                </body></html>
+                """);
+
+        final java.io.File page1 = java.io.File.createTempFile(
+                "aukio-clicktest-1", ".html");
+        page1.deleteOnExit();
+        java.nio.file.Files.writeString(page1.toPath(), """
+                <!doctype html><html><head><title>WITNESS none</title></head>
+                <body style="margin:0">
+                <div style="height:200px;background:linear-gradient(
+                    to right,#e6194b,#f58231,#ffe119,#bfef45,#3cb44b,
+                    #42d4f4,#4363d8,#911eb4,#f032e6)"></div>
+                <div style="padding:20px;font-size:24px">
+                Click anywhere to navigate to the red page.</div>
+                <script>
+                window.onmousedown = e => document.title =
+                    'WITNESS down ' + e.clientX + ',' + e.clientY;
+                window.addEventListener('click', e => {
+                    document.title = 'WITNESS winclick ' + e.clientX
+                        + ',' + e.clientY;
+                    location.href = 'PAGE2URL';
+                }, true);
+                </script></body></html>
+                """.replace("PAGE2URL", page2.toURI().toString()));
+        System.setProperty("aukio.firefox.url", page1.toURI().toString());
+    }
+
+    private static void saveTexturePng(final eu.svjatoslav.aukio.workspace.FirefoxPanel panel,
+                                       final String path) throws Exception {
+        final int w = eu.svjatoslav.aukio.workspace.FirefoxPanel.CAPTURE_WIDTH;
+        final int h = eu.svjatoslav.aukio.workspace.FirefoxPanel.CAPTURE_HEIGHT;
+        final var image = new java.awt.image.BufferedImage(w, h,
+                java.awt.image.BufferedImage.TYPE_INT_ARGB);
+        image.setRGB(0, 0, w, h, panel.getTexture().primaryBitmap.pixels, 0, w);
+        javax.imageio.ImageIO.write(image, "png", new java.io.File(path));
+    }
+
+    private static int countChangedPixels(final int[] a, final int[] b) {
+        int changed = 0;
+        for (int i = 0; i < a.length; i++)
+            if (a[i] != b[i])
+                changed++;
+        return changed;
+    }
+
+    /**
+     * Expected interactive shell prompt prefix ("user@host") for the
+     * account running the selftest. Derived at runtime so the test
+     * works on any machine, not just the author's.
+     */
+    private static String expectedShellPrompt() {
+        String host;
+        try {
+            host = java.net.InetAddress.getLocalHost().getHostName();
+            final int dot = host.indexOf('.');
+            if (dot > 0)
+                host = host.substring(0, dot); // bash \h is the short name
+        } catch (final Exception e) {
+            host = "localhost";
+        }
+        return System.getProperty("user.name") + "@" + host;
+    }
+
+    private static boolean waitFor(final TerminalPanel terminal,
+                                   final String needle)
+            throws InterruptedException {
+        final long deadline = System.currentTimeMillis()
+                + SELFTEST_TIMEOUT_MS;
+        while (System.currentTimeMillis() < deadline) {
+            if (terminal.getScreenText().contains(needle))
+                return true;
+            Thread.sleep(100);
+        }
+        return false;
+    }
+
+    private static int fail(final TerminalPanel terminal,
+                            final String reason) {
+        System.out.println("SELFTEST FAIL: " + reason);
+        System.out.println("terminal screen:");
+        System.out.println(terminal.getScreenText());
+        return 1;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/env/EnvironmentContext.java b/src/main/java/eu/svjatoslav/aukio/env/EnvironmentContext.java
new file mode 100644 (file)
index 0000000..b847353
--- /dev/null
@@ -0,0 +1,71 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.env;
+
+import eu.svjatoslav.aukio.e3d.geometry.Camera;
+import eu.svjatoslav.aukio.e3d.gui.FrameListener;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection;
+
+/**
+ * Host services handed to an {@link EnvironmentProvider} when it is
+ * opened. This is deliberately a narrow window into the workspace: the
+ * scene to add shapes to, the camera to place at the world's spawn
+ * point, and the per-frame tick for streaming worlds.
+ */
+public final class EnvironmentContext {
+
+    private final ViewPanel viewPanel;
+
+    public EnvironmentContext(final ViewPanel viewPanel) {
+        this.viewPanel = viewPanel;
+    }
+
+    /**
+     * The root scene graph. The environment adds its shapes here and
+     * keeps references to whatever it must later remove in close().
+     */
+    public ShapeCollection getScene() {
+        return viewPanel.getRootShapeCollection();
+    }
+
+    /**
+     * The workspace camera. An environment typically moves it to its
+     * spawn point in open(); afterwards the user owns it (fly controls,
+     * head tracking, SpaceNavigator).
+     */
+    public Camera getCamera() {
+        return viewPanel.getCamera();
+    }
+
+    /**
+     * Registers a per-frame callback — the streaming world's heartbeat:
+     * "recompute what should exist around the camera." Return true from
+     * the callback when the scene changed and needs a repaint.
+     */
+    public void addFrameListener(final FrameListener listener) {
+        viewPanel.addFrameListener(listener);
+    }
+
+    public void removeFrameListener(final FrameListener listener) {
+        viewPanel.removeFrameListener(listener);
+    }
+
+    /**
+     * Requests a repaint after scene changes made outside a frame
+     * callback (e.g. from a background loader thread).
+     */
+    public void requestRepaint() {
+        viewPanel.repaintDuringNextViewUpdate();
+    }
+
+    /**
+     * Escape hatch to the underlying view panel. Prefer the narrower
+     * methods above; this exists for things like reading viewport size.
+     */
+    public ViewPanel getViewPanel() {
+        return viewPanel;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/env/EnvironmentProvider.java b/src/main/java/eu/svjatoslav/aukio/env/EnvironmentProvider.java
new file mode 100644 (file)
index 0000000..a55880f
--- /dev/null
@@ -0,0 +1,77 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.env;
+
+/**
+ * A pluggable 3D environment ("world provider") for the Aukio workspace:
+ * a game world, a map, or a procedural scene that the workspace panels
+ * live inside of. Today the wallpaper is a Fallout 4 level, tomorrow a
+ * Quake map, the day after a 3D fractal — the workspace does not care,
+ * it only knows this interface.
+ *
+ * <p>Implementations are discovered in two ways:</p>
+ * <ul>
+ *   <li>built-in providers, registered directly in
+ *       {@link EnvironmentRegistry};</li>
+ *   <li>external providers on the classpath, discovered via
+ *       {@link java.util.ServiceLoader} — a provider jar declares itself in
+ *       {@code META-INF/services/eu.svjatoslav.aukio.env.EnvironmentProvider}.
+ *       This is how separate environment projects (e.g.
+ *       aukio-environment-fo4) plug in without Aukio depending on them.</li>
+ * </ul>
+ *
+ * <p>Selection: {@code aukio --env=<name>} picks a provider by
+ * {@link #getName()}.</p>
+ *
+ * <p>Lifecycle: {@link #open(EnvironmentContext)} is called once after the
+ * workspace window exists. The provider builds its initial scene into
+ * {@link EnvironmentContext#getScene()}, positions the camera via
+ * {@link EnvironmentContext#getCamera()}, and — for streaming worlds —
+ * registers a per-frame callback via
+ * {@link EnvironmentContext#addFrameListener} whose job is "recompute what
+ * should exist around the camera, add/remove shapes accordingly."
+ * {@link #close()} must unregister listeners and release resources.</p>
+ *
+ * <p>What an environment is NOT: it never touches the engine internals,
+ * never creates windows, and never interferes with workspace panels —
+ * it only adds shapes to the shared scene and moves nothing it does not
+ * own.</p>
+ */
+public interface EnvironmentProvider {
+
+    /**
+     * Short unique selector used on the command line, e.g. "fallout4",
+     * "quake1", "fractal". Lower case, no spaces.
+     */
+    String getName();
+
+    /**
+     * Human-readable name, e.g. "Fallout 4 — the Commonwealth".
+     */
+    String getDisplayName();
+
+    /**
+     * True when this provider can actually run on this machine — for
+     * game worlds this means the game data files were found. Providers
+     * that need no external data (procedural worlds) always return true.
+     */
+    boolean isAvailable();
+
+    /**
+     * Builds the initial scene and starts any background streaming.
+     * Called once, after the workspace window exists.
+     *
+     * @param context host services: the scene, the camera, frame ticks
+     * @throws Exception if the environment cannot be built (for game
+     *                   worlds: data files unreadable/corrupt)
+     */
+    void open(EnvironmentContext context) throws Exception;
+
+    /**
+     * Stops background work and removes the environment's shapes from
+     * the scene. Must tolerate being called after a failed open().
+     */
+    void close();
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/env/EnvironmentRegistry.java b/src/main/java/eu/svjatoslav/aukio/env/EnvironmentRegistry.java
new file mode 100644 (file)
index 0000000..454cc7c
--- /dev/null
@@ -0,0 +1,61 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.env;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.ServiceLoader;
+
+/**
+ * Finds the environments that can run on this machine: built-in
+ * providers plus any declared on the classpath via
+ * {@code META-INF/services/eu.svjatoslav.aukio.env.EnvironmentProvider}
+ * (the plug-in mechanism used by separate environment projects such as
+ * aukio-environment-fo4).
+ */
+public final class EnvironmentRegistry {
+
+    private EnvironmentRegistry() {
+    }
+
+    /**
+     * All discovered providers, available or not. Built-ins first, then
+     * classpath plug-ins; keyed by name, later duplicates lose.
+     */
+    public static List<EnvironmentProvider> discoverProviders() {
+        final Map<String, EnvironmentProvider> providers
+                = new LinkedHashMap<>();
+        providers.put("grid", new GridEnvironmentProvider());
+        for (final EnvironmentProvider provider
+                : ServiceLoader.load(EnvironmentProvider.class))
+            providers.putIfAbsent(provider.getName(), provider);
+        return new ArrayList<>(providers.values());
+    }
+
+    /**
+     * Looks up a provider by its command-line name; null when no
+     * provider with that name is on the classpath.
+     */
+    public static EnvironmentProvider findByName(final String name) {
+        for (final EnvironmentProvider provider : discoverProviders())
+            if (provider.getName().equals(name))
+                return provider;
+        return null;
+    }
+
+    /**
+     * Prints the known providers and their availability, for CLI help
+     * and for the error message when a requested provider is missing.
+     */
+    public static void printAvailableEnvironments() {
+        System.out.println("known environments:");
+        for (final EnvironmentProvider provider : discoverProviders())
+            System.out.println("  " + provider.getName()
+                    + (provider.isAvailable() ? "" : " (unavailable)")
+                    + " — " + provider.getDisplayName());
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/env/GridEnvironmentProvider.java b/src/main/java/eu/svjatoslav/aukio/env/GridEnvironmentProvider.java
new file mode 100644 (file)
index 0000000..4516d95
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.env;
+
+import eu.svjatoslav.aukio.e3d.geometry.Rectangle;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.Grid2D;
+
+import static eu.svjatoslav.aukio.e3d.renderer.raster.Color.hex;
+
+/**
+ * Built-in proof environment: a huge ground grid under the workspace.
+ * Exists to validate the provider contract (discovery, lifecycle, camera
+ * placement) without any external data — and as the reference
+ * implementation for how little a working provider can be.
+ */
+public class GridEnvironmentProvider implements EnvironmentProvider {
+
+    @Override
+    public String getName() {
+        return "grid";
+    }
+
+    @Override
+    public String getDisplayName() {
+        return "Ground grid (built-in test environment)";
+    }
+
+    @Override
+    public boolean isAvailable() {
+        return true;
+    }
+
+    @Override
+    public void open(final EnvironmentContext context) {
+        // ground plane at y=0, camera 150 units above it (negative Y is
+        // up in engine coordinates), looking slightly down
+        final Transform transform = Transform.fromAngles(0, 0, 0, 0,
+                Math.PI / 2, 0);
+        context.getScene().addShape(new Grid2D(transform,
+                new Rectangle(20000), 40, 40,
+                new LineAppearance(10, hex("5a5a3a"))));
+        context.getCamera().getTransform().set(0, -150, -300, 0, -0.4, 0);
+        context.requestRepaint();
+    }
+
+    @Override
+    public void close() {
+        // keeps no references and no background work — nothing to undo
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/workspace/FirefoxPanel.java b/src/main/java/eu/svjatoslav/aukio/workspace/FirefoxPanel.java
new file mode 100644 (file)
index 0000000..2e7692a
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.workspace;
+
+import eu.svjatoslav.aukio.bridge.x11.GuiAppSession;
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+
+import java.io.IOException;
+
+/**
+ * A live Firefox browser panel in 3D space. All capture and interaction
+ * machinery lives in {@link GuiAppPanel}; this class only defines the
+ * Firefox session.
+ */
+public class FirefoxPanel extends GuiAppPanel {
+
+    /**
+     * Virtual screen (and texture) resolution: the browser window fills
+     * the whole Xvfb screen, so this is also the captured window size.
+     */
+    public static final int CAPTURE_WIDTH = 1280;
+    public static final int CAPTURE_HEIGHT = 960;
+
+    /**
+     * Creates the panel. Call {@link #start()} to launch the browser and
+     * begin streaming its screen into the texture.
+     *
+     * @param transform              position in the world
+     * @param viewPanel              the view panel this component belongs to
+     * @param sizeInWorldCoordinates panel size in world units; the browser
+     *                               capture is stretched to fill it
+     */
+    public FirefoxPanel(final Transform transform, final ViewPanel viewPanel,
+                        final Point2D sizeInWorldCoordinates)
+            throws IOException {
+        super(transform, viewPanel, sizeInWorldCoordinates,
+                CAPTURE_WIDTH, CAPTURE_HEIGHT,
+                GuiAppSession.firefox(CAPTURE_WIDTH, CAPTURE_HEIGHT,
+                        System.getProperty("aukio.firefox.url", "about:home")));
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/workspace/GuiAppPanel.java b/src/main/java/eu/svjatoslav/aukio/workspace/GuiAppPanel.java
new file mode 100644 (file)
index 0000000..04f9926
--- /dev/null
@@ -0,0 +1,251 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.workspace;
+
+import eu.svjatoslav.aukio.bridge.x11.GuiAppSession;
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.gui.GuiComponent;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardFocusStack;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.TexturedRectangle;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
+
+/**
+ * Base class for live GUI application panels in 3D space.
+ *
+ * <p>The application runs on a private Xvfb display (via
+ * {@link GuiAppSession}); a capture thread copies the virtual screen into
+ * the texture of a {@link TexturedRectangle}, so the application window
+ * appears as an object in the world — including in stereo/VR rendering,
+ * like every other shape.</p>
+ *
+ * <p>Interaction: the first click focuses the panel; while focused,
+ * left/right clicks are forwarded into the application via the X TEST
+ * extension at the exact clicked position (the engine reports the
+ * perspective-correct texture coordinate of the hit), and all keyboard
+ * input is forwarded as X key events. Middle click or Shift+ESC releases
+ * focus and is never forwarded; plain ESC goes to the application. The
+ * capture thread writes the texture's primary bitmap directly and
+ * requests a repaint — the same benign writer/renderer race the terminal
+ * panel already accepts.</p>
+ */
+public abstract class GuiAppPanel extends GuiComponent {
+
+    protected final TexturedRectangle rectangle;
+    protected final GuiAppSession session;
+    protected final int captureWidth;
+    protected final int captureHeight;
+    private volatile boolean liveFrameSeen;
+    private double lastHoverU = Double.NaN;
+    private double lastHoverV = Double.NaN;
+    private int lastForwardedHoverX = -1;
+    private int lastForwardedHoverY = -1;
+
+    /**
+     * Creates the panel. Call {@link #start()} to launch the application
+     * and begin streaming its screen into the texture.
+     *
+     * @param transform              position in the world
+     * @param viewPanel              the view panel this component belongs to
+     * @param sizeInWorldCoordinates panel size in world units; the capture
+     *                               is stretched to fill it
+     * @param captureWidth           virtual screen (texture) width in pixels
+     * @param captureHeight          virtual screen (texture) height in pixels
+     * @param session                the application session to present
+     */
+    protected GuiAppPanel(final Transform transform, final ViewPanel viewPanel,
+                          final Point2D sizeInWorldCoordinates,
+                          final int captureWidth, final int captureHeight,
+                          final GuiAppSession session) {
+        super(transform, viewPanel, sizeInWorldCoordinates.to3D());
+        this.captureWidth = captureWidth;
+        this.captureHeight = captureHeight;
+
+        rectangle = new TexturedRectangle(new Transform(),
+                (int) sizeInWorldCoordinates.x,
+                (int) sizeInWorldCoordinates.y,
+                captureWidth, captureHeight, 1);
+        rectangle.setMouseInteractionController(this);
+        addShape(rectangle);
+
+        this.session = session;
+    }
+
+    /**
+     * Launches Xvfb + the application and starts the screen capture.
+     */
+    public void start() throws java.io.IOException {
+        session.start(rectangle.getTexture().primaryBitmap.pixels,
+                this::onFrameCaptured);
+    }
+
+    private void onFrameCaptured() {
+        try {
+            // captured pixels are already in the primary bitmap; drop the
+            // stale mipmaps so they regenerate lazily from the new frame
+            rectangle.getTexture().resetResampledBitmapCache();
+            if (!liveFrameSeen)
+                liveFrameSeen = looksLikeRealScreen(
+                        rectangle.getTexture().primaryBitmap.pixels);
+            viewPanel.repaintDuringNextViewUpdate();
+        } catch (final Throwable throwable) {
+            // renderer may briefly reallocate internals (e.g. on window
+            // resize); never let that kill the capture thread
+        }
+    }
+
+    /**
+     * A real application screen has many distinct colors; an empty Xvfb
+     * root (opaque black) collapses to a single one. The alpha channel
+     * is ignored: the very first capture of a black screen differs from
+     * the zero-initialized buffer in alpha only, and must not count.
+     */
+    static boolean looksLikeRealScreen(final int[] pixels) {
+        final var distinctColors = new java.util.HashSet<Integer>();
+        // dense enough to hit antialiased text and icons: a very coarse
+        // stride sees only the few flat background colors of a mostly
+        // empty page and reports a live application screen as "flat"
+        for (int i = 0; i < pixels.length; i += 997)
+            distinctColors.add(pixels[i] & 0xFFFFFF);
+        return distinctColors.size() >= 16;
+    }
+
+    /**
+     * Whether the application has painted a real screen into the texture
+     * (many distinct colors) — used by the automated GUI test. A black
+     * empty Xvfb root does not count.
+     */
+    public boolean hasLiveFrame() {
+        return liveFrameSeen;
+    }
+
+    /**
+     * The texture the captured application screen is written into.
+     */
+    public Texture getTexture() {
+        return rectangle.getTexture();
+    }
+
+    public GuiAppSession getSession() {
+        return session;
+    }
+
+    /**
+     * Focus and click-forwarding behavior:
+     * <ul>
+     *   <li>click while unfocused: take keyboard focus (first click is a
+     *       focus grab, not forwarded to the application)</li>
+     *   <li>left/right click while focused: forwarded into the application
+     *       at the clicked texture position (texture pixels map 1:1 onto
+     *       the virtual screen)</li>
+     *   <li>middle click: releases focus (workspace convention, like ESC)
+     *       and is never forwarded to the application</li>
+     * </ul>
+     */
+    @Override
+    public boolean mouseClicked(final int button, final double textureU,
+                                final double textureV) {
+        // Direct-call path (click-injection tests synthesize clicks through
+        // this overload); the live engine dispatch uses the 4-arg overload.
+        return mouseClicked(button, textureU, textureV,
+                viewPanel.getKeyboardFocusStack());
+    }
+
+    @Override
+    public boolean mouseClicked(final int button, final double textureU,
+                                final double textureV,
+                                final KeyboardFocusStack focusStack) {
+        if (!hasKeyboardFocus()
+                || button == eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent.BUTTON_MIDDLE
+                || Double.isNaN(textureU) || Double.isNaN(textureV))
+            return super.mouseClicked(button, textureU, textureV, focusStack);
+
+        // Mouse back/forward buttons are X buttons 8/9. AWT reports them
+        // as 6/7 on Linux and 4/5 on other platforms — accept both.
+        final int xButton = switch (button) {
+            case 4, 6 -> 8; // back
+            case 5, 7 -> 9; // forward
+            default -> button;
+        };
+        session.sendMouseClick((int) textureU, (int) textureV, xButton);
+        return true;
+    }
+
+    @Override
+    public boolean mouseHover(final double textureU, final double textureV) {
+        lastHoverU = textureU;
+        lastHoverV = textureV;
+        // while focused the app follows the mouse cursor without clicking
+        if (hasKeyboardFocus() && !Double.isNaN(textureU)) {
+            final int x = (int) textureU;
+            final int y = (int) textureV;
+            if (x != lastForwardedHoverX || y != lastForwardedHoverY) {
+                lastForwardedHoverX = x;
+                lastForwardedHoverY = y;
+                session.sendMouseMove(x, y);
+            }
+        }
+        return false; // no repaint needed
+    }
+
+    @Override
+    public boolean mouseWheelMoved(final int verticalUnits,
+                                   final int horizontalUnits) {
+        // scroll at the current pointer position so the element under the
+        // cursor scrolls, like on a real desktop
+        final int x = Double.isNaN(lastHoverU) ? captureWidth / 2
+                : (int) lastHoverU;
+        final int y = Double.isNaN(lastHoverV) ? captureHeight / 2
+                : (int) lastHoverV;
+        session.sendScroll(verticalUnits, horizontalUnits, x, y);
+        return true;
+    }
+
+    @Override
+    public boolean keyPressed(final java.awt.event.KeyEvent event,
+                              final ViewPanel viewPanel) {
+        if (isShiftEscape(event))
+            // focus-release shortcut, never forwarded to the application
+            return super.keyPressed(event, viewPanel);
+        session.sendKeyEvent(event, true);
+        return true;
+    }
+
+    @Override
+    public boolean keyReleased(final java.awt.event.KeyEvent event,
+                               final ViewPanel viewPanel) {
+        if (isShiftEscape(event))
+            // press side already consumed this shortcut; swallow release
+            return true;
+        session.sendKeyEvent(event, false);
+        return true;
+    }
+
+    /**
+     * Shift+ESC releases focus (same convention as the terminal panels).
+     * Plain ESC is forwarded to the application, which may need it itself.
+     */
+    private static boolean isShiftEscape(final java.awt.event.KeyEvent event) {
+        return event.getKeyCode() == java.awt.event.KeyEvent.VK_ESCAPE
+                && event.isShiftDown();
+    }
+
+    @Override
+    public boolean focusLost(final ViewPanel viewPanel) {
+        // Shift+ESC pops focus while Shift is still held; the Shift press
+        // was forwarded but its release will go to the next focus owner,
+        // so release modifiers server-side to avoid a stuck Shift
+        session.releaseModifiers();
+        return super.focusLost(viewPanel);
+    }
+
+    /**
+     * Stops the capture thread, kills the application and its Xvfb server.
+     */
+    public void stop() {
+        session.close();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/workspace/TerminalPanel.java b/src/main/java/eu/svjatoslav/aukio/workspace/TerminalPanel.java
new file mode 100644 (file)
index 0000000..95aacbe
--- /dev/null
@@ -0,0 +1,336 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.workspace;
+
+import eu.svjatoslav.aukio.bridge.pty.PtySession;
+import eu.svjatoslav.aukio.bridge.pty.ScreenBuffer;
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.gui.GuiComponent;
+import eu.svjatoslav.aukio.e3d.gui.TextPointer;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas;
+
+import java.awt.event.KeyEvent;
+
+/**
+ * A live terminal panel in 3D space: a real shell on a PTY (via
+ * {@link PtySession}) rendered onto a {@link TextCanvas}.
+ *
+ * <p>Click the panel to focus it; keystrokes are translated to terminal
+ * input bytes and written to the PTY. Shift+ESC releases focus (plain
+ * ESC goes to the shell — terminal programs need it).</p>
+ *
+ * <p>Terminal output arrives on the PTY reader thread, which copies the
+ * screen buffer into the canvas and requests a repaint. This is the same
+ * benign writer/renderer race the text editor already accepts.</p>
+ */
+public class TerminalPanel extends GuiComponent {
+
+    /**
+     * Standard xterm palette, indices 0-15 (normal + bright).
+     */
+    private static final Color[] PALETTE = {
+            new Color(0, 0, 0),           // 0 black
+            new Color(205, 49, 49),       // 1 red
+            new Color(13, 188, 121),      // 2 green
+            new Color(229, 229, 16),      // 3 yellow
+            new Color(36, 114, 200),      // 4 blue
+            new Color(188, 63, 188),      // 5 magenta
+            new Color(17, 168, 205),      // 6 cyan
+            new Color(229, 229, 229),     // 7 white
+            new Color(102, 102, 102),     // 8 bright black
+            new Color(241, 76, 76),       // 9 bright red
+            new Color(35, 209, 139),      // 10 bright green
+            new Color(245, 245, 67),      // 11 bright yellow
+            new Color(59, 142, 234),      // 12 bright blue
+            new Color(214, 112, 214),     // 13 bright magenta
+            new Color(41, 184, 219),      // 14 bright cyan
+            new Color(255, 255, 255),     // 15 bright white
+    };
+
+    private static final Color DEFAULT_FOREGROUND = new Color(220, 220, 220);
+    private static final Color DEFAULT_BACKGROUND = new Color(16, 16, 24);
+    private static final Color CURSOR_COLOR = new Color(200, 255, 200);
+
+    private final PtySession session;
+    private final TextCanvas textCanvas;
+
+    /**
+     * Creates a terminal panel running the given PTY session.
+     *
+     * @param transform              position in the world
+     * @param viewPanel              the view panel this component belongs to
+     * @param sizeInWorldCoordinates panel size; determines terminal columns
+     *                               and rows through the engine font metrics
+     * @param session                a running PTY session whose buffer
+     *                               dimensions match the panel grid
+     */
+    public TerminalPanel(final Transform transform, final ViewPanel viewPanel,
+                         final Point2D sizeInWorldCoordinates,
+                         final PtySession session) {
+        super(transform, viewPanel, sizeInWorldCoordinates.to3D());
+        this.session = session;
+
+        final int columns = (int) (sizeInWorldCoordinates.x
+                / TextCanvas.FONT_CHAR_WIDTH);
+        final int rows = (int) (sizeInWorldCoordinates.y
+                / TextCanvas.FONT_CHAR_HEIGHT);
+
+        textCanvas = new TextCanvas(new Transform(),
+                new TextPointer(rows, columns),
+                DEFAULT_FOREGROUND, DEFAULT_BACKGROUND);
+        textCanvas.setMouseInteractionController(this);
+        addShape(textCanvas);
+
+        session.setContentListener(this::onTerminalContent);
+    }
+
+    private void onTerminalContent() {
+        syncBufferToCanvas();
+        viewPanel.repaintDuringNextViewUpdate();
+    }
+
+    /**
+     * Copies the terminal screen buffer into the text canvas, resolving
+     * palette colors and painting the cursor as an inverted cell.
+     */
+    private void syncBufferToCanvas() {
+        final ScreenBuffer buffer = session.getScreenBuffer();
+        synchronized (buffer) {
+            for (int row = 0; row < buffer.getRows(); row++)
+                for (int column = 0; column < buffer.getColumns(); column++) {
+                    final ScreenBuffer.Cell cell = buffer.getCell(row, column);
+                    textCanvas.setForegroundColor(resolveForeground(cell));
+                    textCanvas.setBackgroundColor(resolveBackground(cell));
+                    textCanvas.putChar(row, column, cell.ch);
+                }
+
+            if (buffer.cursorVisible) {
+                final ScreenBuffer.Cell cell = buffer.getCell(buffer.cursorY,
+                        buffer.cursorX);
+                textCanvas.setForegroundColor(DEFAULT_BACKGROUND);
+                textCanvas.setBackgroundColor(CURSOR_COLOR);
+                textCanvas.putChar(buffer.cursorY, buffer.cursorX,
+                        cell.ch == ' ' ? ' ' : cell.ch);
+            }
+        }
+    }
+
+    private Color resolveForeground(final ScreenBuffer.Cell cell) {
+        if (cell.reverse)
+            return resolveColor(cell.bg, DEFAULT_BACKGROUND, false);
+        return resolveColor(cell.fg, DEFAULT_FOREGROUND, cell.bold);
+    }
+
+    private Color resolveBackground(final ScreenBuffer.Cell cell) {
+        if (cell.reverse)
+            return resolveColor(cell.fg, DEFAULT_FOREGROUND, cell.bold);
+        return resolveColor(cell.bg, DEFAULT_BACKGROUND, false);
+    }
+
+    private Color resolveColor(final int index, final Color defaultColor,
+                               final boolean bold) {
+        if (index == ScreenBuffer.DEFAULT_COLOR)
+            return defaultColor;
+        if (index < 16) {
+            final int adjusted = (bold && index < 8) ? index + 8 : index;
+            return PALETTE[adjusted];
+        }
+        // 256-color palette: 16-231 color cube, 232-255 grayscale
+        final int i = index - 16;
+        if (i < 216) {
+            final int r = i / 36;
+            final int g = (i / 6) % 6;
+            final int b = i % 6;
+            return new Color(cubeChannel(r), cubeChannel(g), cubeChannel(b));
+        }
+        final int gray = 8 + (i - 216) * 10;
+        return new Color(gray, gray, gray);
+    }
+
+    private static int cubeChannel(final int level) {
+        return level == 0 ? 0 : 55 + level * 40;
+    }
+
+    @Override
+    public boolean keyPressed(final KeyEvent event,
+                              final ViewPanel viewPanel) {
+        // Focus model: Shift+ESC releases focus (GuiComponent convention
+        // uses plain ESC, but terminal programs need ESC themselves —
+        // mc uses ESC for dialogs and ESC 0 for F10. Terminal apps cannot
+        // distinguish Shift+ESC from plain ESC on the wire, so no program
+        // loses a binding; Ctrl+ESC was avoided because desktops commonly
+        // intercept it).
+        if (event.getKeyChar() == '\u001B') {
+            if (event.isShiftDown())
+                return super.keyPressed(event, viewPanel);
+            session.send("\u001B");
+            return true;
+        }
+
+        final String sequence = translate(event);
+        if (sequence != null)
+            session.send(sequence);
+        return true;
+    }
+
+    @Override
+    public boolean mouseWheelMoved(final int verticalUnits,
+                                   final int horizontalUnits) {
+        // The emulator has no scrollback and no mouse reporting, so the
+        // wheel is translated to arrow keys — scrolls less/htop/mc, and
+        // walks the command history at a shell prompt. Three lines per
+        // notch, matching xterm.
+        for (int i = 0; i < Math.abs(verticalUnits) * 3; i++)
+            session.send(verticalUnits < 0 ? cursorKey('A', 'A')
+                    : cursorKey('B', 'B'));
+        for (int i = 0; i < Math.abs(horizontalUnits) * 3; i++)
+            session.send(horizontalUnits < 0 ? cursorKey('D', 'D')
+                    : cursorKey('C', 'C'));
+        return true;
+    }
+
+    /**
+     * Whether the terminal is in application cursor keys mode (DECCKM).
+     * Curses programs (mc, htop, vim) enable it; cursor keys must then be
+     * reported as SS3 instead of CSI.
+     */
+    public boolean isApplicationCursorKeys() {
+        synchronized (session.getScreenBuffer()) {
+            return session.getScreenBuffer().applicationCursorKeys;
+        }
+    }
+
+    /**
+     * Cursor key sequence honoring application cursor mode.
+     */
+    private String cursorKey(final char csiFinal, final char ss3Final) {
+        return isApplicationCursorKeys()
+                ? "\u001BO" + ss3Final
+                : "\u001B[" + csiFinal;
+    }
+
+    /**
+     * Translates an AWT key event into the byte sequence a terminal would
+     * send. Returns null for keys with no terminal representation.
+     */
+    private String translate(final KeyEvent event) {
+        switch (event.getKeyCode()) {
+            case KeyEvent.VK_ENTER -> {
+                return "\r";
+            }
+            case KeyEvent.VK_BACK_SPACE -> {
+                return "\u007F";
+            }
+            case KeyEvent.VK_TAB -> {
+                // xterm: Shift+Tab is backtab (kcbt)
+                return event.isShiftDown() ? "\u001B[Z" : "\t";
+            }
+            case KeyEvent.VK_UP -> {
+                return cursorKey('A', 'A');
+            }
+            case KeyEvent.VK_DOWN -> {
+                return cursorKey('B', 'B');
+            }
+            case KeyEvent.VK_RIGHT -> {
+                return cursorKey('C', 'C');
+            }
+            case KeyEvent.VK_LEFT -> {
+                return cursorKey('D', 'D');
+            }
+            case KeyEvent.VK_HOME -> {
+                return cursorKey('H', 'H');
+            }
+            case KeyEvent.VK_END -> {
+                return cursorKey('F', 'F');
+            }
+            case KeyEvent.VK_DELETE -> {
+                return "\u001B[3~";
+            }
+            case KeyEvent.VK_PAGE_UP -> {
+                return "\u001B[5~";
+            }
+            case KeyEvent.VK_PAGE_DOWN -> {
+                return "\u001B[6~";
+            }
+            // xterm function keys (mc is unusable without F10 = quit)
+            case KeyEvent.VK_F1 -> {
+                return "\u001BOP";
+            }
+            case KeyEvent.VK_F2 -> {
+                return "\u001BOQ";
+            }
+            case KeyEvent.VK_F3 -> {
+                return "\u001BOR";
+            }
+            case KeyEvent.VK_F4 -> {
+                return "\u001BOS";
+            }
+            case KeyEvent.VK_F5 -> {
+                return "\u001B[15~";
+            }
+            case KeyEvent.VK_F6 -> {
+                return "\u001B[17~";
+            }
+            case KeyEvent.VK_F7 -> {
+                return "\u001B[18~";
+            }
+            case KeyEvent.VK_F8 -> {
+                return "\u001B[19~";
+            }
+            case KeyEvent.VK_F9 -> {
+                return "\u001B[20~";
+            }
+            case KeyEvent.VK_F10 -> {
+                return "\u001B[21~";
+            }
+            case KeyEvent.VK_F11 -> {
+                return "\u001B[23~";
+            }
+            case KeyEvent.VK_F12 -> {
+                return "\u001B[24~";
+            }
+            default -> {
+            }
+        }
+
+        final char keyChar = event.getKeyChar();
+        if (keyChar == KeyEvent.CHAR_UNDEFINED)
+            return null;
+        // control combinations arrive as C0 control characters already
+        if (event.isControlDown() && keyChar < 0x20)
+            return String.valueOf(keyChar);
+        if (keyChar >= 0x20 && keyChar != 0x7F) {
+            // Alt+key is reported as ESC prefix (Meta), like xterm
+            if (event.isAltDown())
+                return "\u001B" + keyChar;
+            return String.valueOf(keyChar);
+        }
+        return null;
+    }
+
+    /**
+     * Programmatic text injection, used by the self-test and future
+     * automation.
+     */
+    public void typeText(final String text) {
+        session.send(text);
+    }
+
+    public PtySession getSession() {
+        return session;
+    }
+
+    /**
+     * Current terminal screen as plain text (attributes stripped).
+     */
+    public String getScreenText() {
+        synchronized (session.getScreenBuffer()) {
+            return session.getScreenBuffer().dumpText();
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/workspace/VmwarePanel.java b/src/main/java/eu/svjatoslav/aukio/workspace/VmwarePanel.java
new file mode 100644 (file)
index 0000000..ec42e76
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.workspace;
+
+import eu.svjatoslav.aukio.bridge.x11.GuiAppSession;
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+
+import java.io.IOException;
+
+/**
+ * A live VMware Workstation panel in 3D space. All capture and
+ * interaction machinery lives in {@link GuiAppPanel}; this class only
+ * defines the VMware session.
+ *
+ * <p>Unlike Firefox, the VMware window cannot be sized on the command
+ * line, so the session continuously moves/resizes windows titled
+ * "VMware" to fill the virtual screen (see
+ * {@link GuiAppSession#vmware}).</p>
+ */
+public class VmwarePanel extends GuiAppPanel {
+
+    /**
+     * Virtual screen (and texture) resolution; the session fits the
+     * VMware window to fill it.
+     */
+    public static final int CAPTURE_WIDTH = 1280;
+    public static final int CAPTURE_HEIGHT = 960;
+
+    /**
+     * Creates the panel. Call {@link #start()} to launch VMware
+     * Workstation and begin streaming its screen into the texture.
+     *
+     * @param transform              position in the world
+     * @param viewPanel              the view panel this component belongs to
+     * @param sizeInWorldCoordinates panel size in world units; the capture
+     *                               is stretched to fill it
+     */
+    public VmwarePanel(final Transform transform, final ViewPanel viewPanel,
+                       final Point2D sizeInWorldCoordinates)
+            throws IOException {
+        super(transform, viewPanel, sizeInWorldCoordinates,
+                CAPTURE_WIDTH, CAPTURE_HEIGHT,
+                GuiAppSession.vmware(CAPTURE_WIDTH, CAPTURE_HEIGHT));
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/workspace/Workspace.java b/src/main/java/eu/svjatoslav/aukio/workspace/Workspace.java
new file mode 100644 (file)
index 0000000..37a772c
--- /dev/null
@@ -0,0 +1,208 @@
+/*
+ * Aukio spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.workspace;
+
+import eu.svjatoslav.aukio.bridge.pty.PtySession;
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.geometry.Rectangle;
+import eu.svjatoslav.aukio.e3d.gui.ViewFrame;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.gui.textEditorComponent.LookAndFeel;
+import eu.svjatoslav.aukio.e3d.gui.textEditorComponent.TextEditComponent;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.Grid2D;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static eu.svjatoslav.aukio.e3d.geometry.Point3D.point;
+import static eu.svjatoslav.aukio.e3d.renderer.raster.Color.hex;
+
+/**
+ * The Aukio workspace: a persistent 3D world where programs are placeable
+ * objects.
+ *
+ * <p>Current population: one text editor (engine component), a 4x4
+ * grid of live terminals, each running its own bash on a real PTY, a
+ * live Firefox browser and a live VMware Workstation window (private
+ * Xvfb displays captured into textures).
+ * Click a panel to focus it and type; ESC or middle click releases focus; fly with the
+ * engine's standard camera controls.</p>
+ */
+public class Workspace {
+
+    /**
+     * Terminal geometry: 100 x 30 characters.
+     */
+    public static final int TERMINAL_COLUMNS = 100;
+    public static final int TERMINAL_ROWS = 30;
+    public static final int TERMINAL_GRID_COLUMNS = 4;
+    public static final int TERMINAL_GRID_ROWS = 4;
+
+    private final ViewFrame viewFrame;
+    private final List<TerminalPanel> terminalPanels = new ArrayList<>();
+    private FirefoxPanel firefoxPanel;
+    private VmwarePanel vmwarePanel;
+    private eu.svjatoslav.aukio.env.EnvironmentProvider environment;
+
+    public Workspace() {
+        viewFrame = new ViewFrame("Aukio");
+    }
+
+    /**
+     * Attaches a pluggable environment (game world, procedural scene)
+     * after open(); the workspace then owns its lifecycle.
+     */
+    public void openEnvironment(
+            final eu.svjatoslav.aukio.env.EnvironmentProvider provider)
+            throws Exception {
+        environment = provider;
+        provider.open(new eu.svjatoslav.aukio.env.EnvironmentContext(
+                viewFrame.getViewPanel()));
+    }
+
+    /**
+     * Builds the scene and shows the window.
+     *
+     * @throws IOException if the terminal PTY cannot be created
+     */
+    public void open() throws IOException {
+        final ViewPanel viewPanel = viewFrame.getViewPanel();
+        final ShapeCollection scene = viewPanel.getRootShapeCollection();
+
+        viewPanel.getCamera().getTransform().set(150, -120, -350, 0,
+                -0.12, 0);
+
+        addGrid(scene);
+        addTextEditor(viewPanel, scene);
+        addTerminals(viewPanel, scene);
+        addFirefox(viewPanel, scene);
+        addVmware(viewPanel, scene);
+
+        viewPanel.repaintDuringNextViewUpdate();
+    }
+
+    private void addGrid(final ShapeCollection scene) {
+        final Transform transform = Transform.fromAngles(0, 100, 0, 0,
+                Math.PI / 2, 0);
+        final Rectangle rectangle = new Rectangle(2000);
+        final LineAppearance appearance = new LineAppearance(10,
+                hex("00b3ad"));
+        scene.addShape(new Grid2D(transform, rectangle, 10, 10, appearance));
+    }
+
+    private void addTextEditor(final ViewPanel viewPanel,
+                               final ShapeCollection scene) {
+        final TextEditComponent editor = new TextEditComponent(
+                new Transform(point(-700, 0, 300)), viewPanel,
+                new Point2D(400, 240), new LookAndFeel());
+        editor.setText("Aukio workspace\n\n"
+                + "Click a panel to focus it.\n"
+                + "Type into it. Middle click or ESC releases\n"
+                + "focus (terminal, browser, VMware: Shift+ESC).\n\n"
+                + "The 4x4 grid on the right contains\n"
+                + "independent bash shells.\n"
+                + "Try: ls, mc, htop\n\n"
+                + "Below this editor: Firefox and\n"
+                + "VMware Workstation, captured live.");
+        scene.addShape(editor);
+    }
+
+    private void addTerminals(final ViewPanel viewPanel,
+                             final ShapeCollection scene) throws IOException {
+        for (int x = 0; x < TERMINAL_GRID_COLUMNS; x++) {
+            for (int y = 0; y < TERMINAL_GRID_ROWS; y++) {
+                final PtySession session = new PtySession(TERMINAL_COLUMNS,
+                        TERMINAL_ROWS);
+                final TerminalPanel terminalPanel = new TerminalPanel(
+                        new Transform(point(100 + (x * 900),
+                                -100 - (y * 600), 300)), viewPanel,
+                        new Point2D(
+                                TERMINAL_COLUMNS * TextCanvas.FONT_CHAR_WIDTH,
+                                TERMINAL_ROWS * TextCanvas.FONT_CHAR_HEIGHT),
+                        session);
+                terminalPanels.add(terminalPanel);
+                scene.addShape(terminalPanel);
+            }
+        }
+    }
+
+    /**
+     * Adds a live Firefox browser below the text editor: a private Xvfb
+     * display whose screen is captured into a textured rectangle.
+     */
+    private void addFirefox(final ViewPanel viewPanel,
+                            final ShapeCollection scene) throws IOException {
+        firefoxPanel = new FirefoxPanel(new Transform(point(-700, -450, 300)),
+                viewPanel, new Point2D(640, 480));
+        firefoxPanel.start();
+        scene.addShape(firefoxPanel);
+    }
+
+    /**
+     * Adds a live VMware Workstation window below the Firefox panel: a
+     * private Xvfb display whose screen is captured into a textured
+     * rectangle.
+     */
+    private void addVmware(final ViewPanel viewPanel,
+                           final ShapeCollection scene) throws IOException {
+        vmwarePanel = new VmwarePanel(new Transform(point(-700, -1050, 300)),
+                viewPanel, new Point2D(640, 480));
+        vmwarePanel.start();
+        scene.addShape(vmwarePanel);
+    }
+
+    /**
+     * Returns the Firefox panel, used by the automated GUI test.
+     */
+    public FirefoxPanel getFirefoxPanel() {
+        return firefoxPanel;
+    }
+
+    /**
+     * Returns the VMware Workstation panel.
+     */
+    public VmwarePanel getVmwarePanel() {
+        return vmwarePanel;
+    }
+
+    /**
+     * Returns the first terminal, used by the automated terminal selftest.
+     */
+    public TerminalPanel getTerminalPanel() {
+        return terminalPanels.get(0);
+    }
+
+    public List<TerminalPanel> getTerminalPanels() {
+        return Collections.unmodifiableList(terminalPanels);
+    }
+
+    public eu.svjatoslav.aukio.e3d.gui.headtrack.HeadTracker getHeadTracker() {
+        return viewFrame.getViewPanel().getHeadTracker();
+    }
+
+    public ViewFrame getViewFrame() {
+        return viewFrame;
+    }
+
+    /**
+     * Shuts down all background shell processes.
+     */
+    public void close() {
+        if (environment != null)
+            environment.close();
+        for (final TerminalPanel terminalPanel : terminalPanels)
+            terminalPanel.getSession().stop();
+        if (firefoxPanel != null)
+            firefoxPanel.stop();
+        if (vmwarePanel != null)
+            vmwarePanel.stop();
+    }
+}
diff --git a/start.sh b/start.sh
new file mode 100755 (executable)
index 0000000..4750596
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+# Start the Aukio workspace with the Fallout 4 environment.
+#
+# Rebuilds every module in dependency order first, so the classpath is
+# always at the latest source state (the FO4 provider resolves `aukio`
+# and `aukio-3d` from the local Maven repository — stale installs there
+# are the classic reason the world silently doesn't show up).
+#
+# Extra args are forwarded to the JVM launcher, e.g.:
+#   ./start.sh -Daukio.fo4.cell=ConcordExt
+# Actually: args go to Main. To pass -D properties, edit JAVA_OPTS below
+# or export them before calling.
+
+set -euo pipefail
+
+# Sibling checkouts are resolved relative to this script (Aukio/*
+# layout), so nothing here is tied to a particular machine.
+ROOT="$(cd "${0%/*}/.." && pwd)"
+FO4="$ROOT/aukio-environment-fo4"
+
+echo "==> building aukio-3d"
+mvn -f "$ROOT/aukio-3d/pom.xml" install -q -DskipTests -Dmaven.javadoc.skip=true
+
+echo "==> building aukio"
+mvn -f "$ROOT/aukio/pom.xml" install -q -DskipTests -Dmaven.javadoc.skip=true
+
+echo "==> building aukio-environment-fo4"
+mvn -f "$FO4/pom.xml" package -q -DskipTests -Dmaven.javadoc.skip=true
+mvn -f "$FO4/pom.xml" dependency:build-classpath -q -Dmdep.outputFile=target/cp.txt
+
+echo "==> starting Aukio with --env=fallout4"
+cd "$FO4"
+mkdir -p "$HOME/.cache/aukio/heapdumps"
+exec java -Xmx20g --enable-native-access=ALL-UNNAMED \
+  -XX:+HeapDumpOnOutOfMemoryError \
+  -XX:HeapDumpPath="$HOME/.cache/aukio/heapdumps" \
+  -Daukio.cull.subpixel=0.4 \
+  ${AUKIO_FO4_PATH:+-Daukio.fo4.path=$AUKIO_FO4_PATH} \
+  ${JAVA_OPTS:-} \
+  -cp "target/classes:$(cat target/cp.txt)" \
+  eu.svjatoslav.aukio.core.Main --env=fallout4 "$@"