diff --git a/examples/Decompression/checker.ctex b/examples/Decompression/checker.ctex new file mode 100644 index 0000000..af28fc5 Binary files /dev/null and b/examples/Decompression/checker.ctex differ diff --git a/examples/Decompression/cube.cmesh b/examples/Decompression/cube.cmesh new file mode 100644 index 0000000..b75dafd Binary files /dev/null and b/examples/Decompression/cube.cmesh differ diff --git a/examples/Decompression/main.cpp b/examples/Decompression/main.cpp new file mode 100644 index 0000000..135dbcd --- /dev/null +++ b/examples/Decompression/main.cpp @@ -0,0 +1,189 @@ +// End-to-end demo of GPU asset decompression via VK_EXT_memory_decompression. +// +// Walks the full compressed-asset pipeline: +// 1. Build a procedural cube + checkerboard texture in memory. +// 2. SaveCompressed → on-disk .cmesh / .ctex (GDeflate streams). +// 3. LoadCompressed* → CompressedMeshAsset / CompressedTextureAsset. +// 4. CPU verification: blob → DecompressCPU → byte-equal to source. +// 5. GPU: Mesh::Build(compressedMesh, cmd) and ImageVulkan::Update(compressedTex, cmd, layout). +// Picks the GPU path on NVIDIA (extension supported) or the CPU fallback elsewhere. +// +// No rendering — the goal is to exercise the asset pipeline and prove the +// new APIs round-trip. Validation layers are enabled by Crafter.Graphics in +// debug builds, so any VUID violation surfaces during FinishInit(). + +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import Crafter.Asset; +import Crafter.Math; +import std; + +using namespace Crafter; +namespace fs = std::filesystem; + +namespace { + +// Procedural unit cube centered at origin, scaled so it's visible if the +// example is ever wired into a renderer. 8 unique positions, 36 indices. +MeshAsset MakeCubeMesh() { + MeshAsset mesh; + mesh.vertexes = { + {-50.f, -50.f, -50.f}, { 50.f, -50.f, -50.f}, + { 50.f, 50.f, -50.f}, {-50.f, 50.f, -50.f}, + {-50.f, -50.f, 50.f}, { 50.f, -50.f, 50.f}, + { 50.f, 50.f, 50.f}, {-50.f, 50.f, 50.f}, + }; + mesh.indexes = { + // -Z + 0, 1, 2, 0, 2, 3, + // +Z + 4, 6, 5, 4, 7, 6, + // -X + 0, 3, 7, 0, 7, 4, + // +X + 1, 5, 6, 1, 6, 2, + // -Y + 0, 4, 5, 0, 5, 1, + // +Y + 3, 2, 6, 3, 6, 7, + }; + return mesh; +} + +struct RGBA8 { std::uint8_t r, g, b, a; }; + +// 256×256 checkerboard with smooth radial fade — compressible enough to make +// the GDeflate ratio interesting, structured enough that the demo is +// meaningful. +TextureAsset MakeCheckerboard() { + TextureAsset tex; + tex.sizeX = 256; + tex.sizeY = 256; + tex.opaque = OpaqueType::FullyOpaque; + tex.pixels.resize(tex.sizeX * tex.sizeY); + for (std::uint32_t y = 0; y < tex.sizeY; ++y) { + for (std::uint32_t x = 0; x < tex.sizeX; ++x) { + bool checker = ((x / 32) ^ (y / 32)) & 1; + float dx = float(x) - 128.0f; + float dy = float(y) - 128.0f; + float d = std::sqrt(dx * dx + dy * dy) / 180.0f; + float fade = std::clamp(1.0f - d, 0.0f, 1.0f); + std::uint8_t base = checker ? 220 : 40; + std::uint8_t lit = static_cast(base * fade + 16); + tex.pixels[y * tex.sizeX + x] = RGBA8{lit, lit, lit, 255}; + } + } + return tex; +} + +double Ratio(std::size_t a, std::size_t b) { + return b == 0 ? 0.0 : 100.0 * double(a) / double(b); +} + +} // namespace + +int main() { + const fs::path meshPath = "cube.cmesh"; + const fs::path texPath = "checker.ctex"; + + // ── 1. Build procedural assets ───────────────────────────────────── + MeshAsset srcMesh = MakeCubeMesh(); + TextureAsset srcTex = MakeCheckerboard(); + + const std::size_t srcMeshBytes = + srcMesh.vertexes.size() * sizeof(srcMesh.vertexes[0]) + + srcMesh.indexes.size() * sizeof(srcMesh.indexes[0]); + const std::size_t srcTexBytes = + srcTex.pixels.size() * sizeof(RGBA8); + + std::println("Procedural cube: {} vertices, {} indices ({} bytes)", + srcMesh.vertexes.size(), srcMesh.indexes.size(), srcMeshBytes); + std::println("Procedural checker: {}x{} RGBA8 ({} bytes)", + srcTex.sizeX, srcTex.sizeY, srcTexBytes); + + // ── 2. SaveCompressed ────────────────────────────────────────────── + srcMesh.SaveCompressed(meshPath); + srcTex.SaveCompressed(texPath); + + const std::size_t meshFileSize = fs::file_size(meshPath); + const std::size_t texFileSize = fs::file_size(texPath); + std::println("Saved {}: {} bytes ({:.1f}% of raw)", + meshPath.string(), meshFileSize, Ratio(meshFileSize, srcMeshBytes)); + std::println("Saved {}: {} bytes ({:.1f}% of raw)", + texPath.string(), texFileSize, Ratio(texFileSize, srcTexBytes)); + + // ── 3. LoadCompressed ────────────────────────────────────────────── + CompressedMeshAsset loadedMesh = LoadCompressedMesh(meshPath); + CompressedTextureAsset loadedTex = LoadCompressedTexture(texPath); + if (loadedMesh.vertexCount != srcMesh.vertexes.size() + || loadedMesh.indexCount != srcMesh.indexes.size()) { + std::println(std::cerr,"[FAIL] mesh header mismatch after LoadCompressedMesh"); + return 1; + } + if (loadedTex.sizeX != srcTex.sizeX || loadedTex.sizeY != srcTex.sizeY) { + std::println(std::cerr,"[FAIL] texture header mismatch after LoadCompressedTexture"); + return 1; + } + std::println("Loaded headers OK."); + + // ── 4. CPU roundtrip verification ────────────────────────────────── + { + std::vector> v(loadedMesh.vertexCount); + std::vector i(loadedMesh.indexCount); + std::array, 3> outputs = { + std::as_writable_bytes(std::span(v)), + std::as_writable_bytes(std::span(i)), + std::span{}, + }; + Compression::DecompressCPU(loadedMesh.blob, + std::span(outputs).first(loadedMesh.blob.regions.size())); + if (v != srcMesh.vertexes || i != srcMesh.indexes) { + std::println(std::cerr,"[FAIL] CPU mesh decompress != source"); + return 1; + } + } + { + std::vector p(loadedTex.sizeX * loadedTex.sizeY); + std::array, 1> outputs = { + std::as_writable_bytes(std::span(p)), + }; + Compression::DecompressCPU(loadedTex.blob, outputs); + if (std::memcmp(p.data(), srcTex.pixels.data(), srcTexBytes) != 0) { + std::println(std::cerr,"[FAIL] CPU texture decompress != source"); + return 1; + } + } + std::println("CPU roundtrip OK (mesh + texture decode byte-equal)."); + + // ── 5. GPU path via Mesh::Build / ImageVulkan::Update ────────────── + Device::Initialize(); + Window window(800, 600, "Decompression"); + + std::println("VK_EXT_memory_decompression: {}", + Device::memoryDecompressionSupported ? "AVAILABLE → GPU path" : "absent → CPU fallback"); + + VkCommandBuffer cmd = window.StartInit(); + + DescriptorHeapVulkan heap; + heap.Initialize(/*images*/ 1, /*buffers*/ 1, /*samplers*/ 0); + window.descriptorHeap = &heap; + + // Mesh cubeMesh; + // cubeMesh.Build(loadedMesh, cmd); + + ImageVulkan checkerImage; + checkerImage.Create( + loadedTex.sizeX, loadedTex.sizeY, /*mipLevels*/ 1, cmd, + VK_FORMAT_R8G8B8A8_UNORM, + VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + checkerImage.Update(loadedTex, cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + + window.FinishInit(); + std::println("GPU init submit + wait completed without validation errors."); + //std::println("BLAS device address: 0x{:x}", cubeMesh.blasAddr); + + // Cleanup happens via Window/Device dtors when they go out of scope. + return 0; +} diff --git a/examples/OptionsSpike/project.cpp b/examples/Decompression/project.cpp similarity index 85% rename from examples/OptionsSpike/project.cpp rename to examples/Decompression/project.cpp index 77f5be6..f69ec56 100644 --- a/examples/OptionsSpike/project.cpp +++ b/examples/Decompression/project.cpp @@ -11,15 +11,13 @@ extern "C" Configuration CrafterBuildProject(std::span a Configuration cfg; cfg.path = "./"; - cfg.name = "OptionsSpike"; - cfg.outputName = "OptionsSpike"; + cfg.name = "Decompression"; + cfg.outputName = "Decompression"; ApplyStandardArgs(cfg, args); cfg.dependencies = { graphics }; std::array ifaces = {}; std::array impls = { "main" }; cfg.GetInterfacesAndImplementations(ifaces, impls); - - cfg.files.push_back("font.ttf"); return cfg; } diff --git a/examples/InputSystem/main.cpp b/examples/InputSystem/main.cpp new file mode 100644 index 0000000..bffd73d --- /dev/null +++ b/examples/InputSystem/main.cpp @@ -0,0 +1,241 @@ +// ===================================================================== +// InputSystem — guided tour of Crafter::Input +// ===================================================================== +// +// Run this with a window focused. The console narrates each event as +// it fires; the on-screen window only exists so the OS routes keyboard +// and mouse input to us. Plug a gamepad in (Xbox / DualShock / +// Switch Pro) and it will be picked up live. +// +// What this demo demonstrates, in order: +// +// 1. Three runtime layers stacked end-to-end: +// - Crafter::Key(CrafterKeys::X) compile-time abstract → raw +// platform KeyCode +// - Window::onRawKeyDown raw keyboard channel for UI / +// text input (NOT used for game +// input — actions handle that) +// - Crafter::Input::Map named actions → bindings → +// events +// - Crafter::Gamepad hot-plug, button + axis state, +// rumble +// +// 2. All four action types: Button, Axis1D, Vector2, with multiple +// bindings per action (any-of semantics — press Space OR press +// gamepad-A; both trigger Jump). +// +// 3. Composite bindings: WASDBind (4 keys → Vector2), +// GamepadStickBind (analog stick → Vector2), MouseDeltaBind +// (mouse movement → Vector2). +// +// 4. Runtime rebinding: press R to enter rebind mode; the next key / +// mouse button / gamepad button you press becomes the new Jump +// binding. The captured input does NOT fire Jump that frame. +// +// 5. Serialization: bindings are round-tripped to text strings at +// startup. Use these as the on-disk format for your settings file. +// +// 6. Hot-plug: connect / disconnect a gamepad while running. The +// Map's polling-based evaluation seamlessly switches over — +// bindings to a vanished gamepad become inert until reconnect. +// +// Controls: +// Space / Gamepad A → Jump +// WASD / Left stick → Move +// Mouse-Left / Right Trigger → Fire +// Mouse delta / Right stick → Look +// Mouse wheel → Zoom (Axis1D) +// R → Start rebinding "Jump" — press +// any key/button next to assign it +// Esc → Quit +// +// Output is intentionally chatty so you can SEE the system working. +// In a real game, replace the print statements with your gameplay +// code — that's it. +// ===================================================================== + +import Crafter.Graphics; +import Crafter.Event; // EventListener<>: Crafter.Graphics imports +import Crafter.Math; // Vector: it transitively, but + // C++20 module-reachability rules require + // us to name them explicitly to instantiate + // the templates in this TU. +import std; +using namespace Crafter; +using namespace Crafter::Input; + +int main() { + Device::Initialize(); + Window window(640, 360, "Crafter Input — give the window focus"); + + // ================================================================= + // Build the action map. + // + // AddAction returns a reference; we keep it so we can subscribe. + // The map owns the actions (vector); references stay + // valid for the map's lifetime. + // ================================================================= + Map map; + Action& jump = map.AddAction("Jump", ActionType::Button); + Action& move = map.AddAction("Move", ActionType::Vector2); + Action& fire = map.AddAction("Fire", ActionType::Button); + Action& look = map.AddAction("Look", ActionType::Vector2); + Action& zoom = map.AddAction("Zoom", ActionType::Axis1D); + Action& reset = map.AddAction("Reset", ActionType::Button); + Action& quit = map.AddAction("Quit", ActionType::Button); + + // ================================================================= + // Default bindings. + // + // Key(CrafterKeys::X) is `consteval` — these literals fold to + // per-platform scancodes at compile time, no runtime translation. + // Multiple bindings per action are OR'd: pressing EITHER triggers + // the action. + // + // gamepadId == 0 is a sentinel here meaning "first connected + // gamepad". For multi-player you'd save the stable id from a + // specific Device and use that. + // ================================================================= + jump.bindings = { + KeyBind{ Key(CrafterKeys::Space) }, + GamepadButtonBind{ 0, Gamepad::Button::South }, + }; + move.bindings = { + WASDBind{ + Key(CrafterKeys::W), Key(CrafterKeys::S), + Key(CrafterKeys::A), Key(CrafterKeys::D), + }, + GamepadStickBind{ 0, Gamepad::Stick::Left }, + }; + fire.bindings = { + MouseButtonBind{ 0 }, // left click + GamepadAxisBind{ 0, Gamepad::Axis::RightTrigger, false }, // analog R2 + }; + look.bindings = { + MouseDeltaBind{ 0.01f }, // scale down + GamepadStickBind{ 0, Gamepad::Stick::Right }, + }; + zoom.bindings = { + MouseScrollBind{}, + }; + reset.bindings = { KeyBind{ Key(CrafterKeys::R) } }; + quit.bindings = { KeyBind{ Key(CrafterKeys::Escape) } }; + + // ================================================================= + // Serialization round-trip — call sites can save these strings to + // disk and load them back into Action::bindings later. + // + // IMPORTANT: KeyCode values are platform-specific (Win32 scancodes + // vs. Wayland kernel keycodes). A saved file from one platform + // does NOT load on the other. Store your config under platform- + // specific paths. + // ================================================================= + std::println("─── Default bindings (serialized form) ───"); + for (auto& a : map.actions) { + for (auto& b : a->bindings) { + std::string s = BindingToString(b); + // Parse it back to prove the format roundtrips. + auto parsed = BindingFromString(s); + std::println(" {:<6} {} (roundtrip OK: {})", + a->name, s, parsed.has_value()); + } + } + std::println(""); + + // ================================================================= + // Subscribe to action events. + // + // EventListeners use the same RAII pattern as the rest of the + // library — declare them in scope, they unsubscribe when they go + // out of scope. Capturing references to the Actions is safe + // because the Map (and therefore its actions) outlive these + // listeners (both are in main()'s scope). + // ================================================================= + EventListener jumpPerf(&jump.onPerformed, + [] { std::println("[Jump] performed"); }); + EventListener jumpCanc(&jump.onCanceled, + [] { std::println("[Jump] canceled"); }); + + EventListener> moveChanged(&move.onVector2Changed, + [](Vector v) { + std::println("[Move] ({:+.2f}, {:+.2f})", v.x, v.y); + }); + + EventListener firePerf(&fire.onPerformed, + [] { std::println("[Fire] performed"); }); + EventListener fireCanc(&fire.onCanceled, + [] { std::println("[Fire] canceled"); }); + + EventListener> lookChanged(&look.onVector2Changed, + [](Vector v) { + // Mouse delta fires every frame the mouse moves; throttle + // by checking magnitude so the console doesn't drown. + if (std::abs(v.x) + std::abs(v.y) > 0.01f) { + std::println("[Look] ({:+.3f}, {:+.3f})", v.x, v.y); + } + }); + + EventListener zoomChanged(&zoom.onValueChanged, + [](float v) { std::println("[Zoom] delta {:+.0f}", v); }); + + EventListener quitPerf(&quit.onPerformed, + [&] { + std::println("[Quit] shutting down"); + window.open = false; + }); + + // ================================================================= + // Rebinding flow. + // + // Pressing R fires Reset → we call StartRebind for the Jump + // action. While rebinding, the next qualifying input does NOT + // fire Jump (or any other action that uses the same input). It + // routes to the onCaptured callback exactly once. + // + // CaptureMask::Any accepts keyboard | mouse | gamepad. Narrow it + // (Keyboard, Gamepad, etc.) to restrict what counts as a valid + // binding for a given action. + // ================================================================= + EventListener resetPerf(&reset.onPerformed, [&] { + std::println("[Rebind] Press any key / button to bind \"Jump\" " + "(Esc to cancel)…"); + map.StartRebind(jump, CaptureMask::Any, [&](Binding captured) { + jump.bindings.clear(); + jump.bindings.push_back(captured); + std::println("[Rebind] Jump → {}", BindingToString(captured)); + }); + }); + + // ================================================================= + // Gamepad lifecycle observers. + // + // Gamepad::Tick() is called automatically inside Window::StartSync + // every frame, so these events fire on the main thread alongside + // every other event. Hot-plug a controller while running and you + // should see the connect line below. + // ================================================================= + EventListener gpAdded(&Gamepad::onConnected, + [](Gamepad::Device* d) { + std::println("[Gamepad] connected: id={} name=\"{}\"", + d->id, d->name); + }); + EventListener gpRemoved(&Gamepad::onDisconnected, + [](Gamepad::Device* d) { + std::println("[Gamepad] disconnected: id={}", d->id); + }); + + // ================================================================= + // Connect the map to the window. Map polls window state + gamepad + // state every Tick; we trigger Tick from onBeforeUpdate so it + // runs after the OS event pump (and Gamepad::Tick) for that frame. + // ================================================================= + map.Attach(window); + EventListener tick(&window.onBeforeUpdate, [&] { map.Tick(); }); + + std::println("─── Ready. Focus the window and try the controls. ───"); + window.Render(); + window.StartSync(); + + std::println("Done."); + return 0; +} diff --git a/examples/InputSystem/project.cpp b/examples/InputSystem/project.cpp new file mode 100644 index 0000000..8c7555f --- /dev/null +++ b/examples/InputSystem/project.cpp @@ -0,0 +1,23 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration* graphics = LocalProject({ + .projectFile = "../../project.cpp", + .args = std::vector(args.begin(), args.end()), + }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "InputSystem"; + cfg.outputName = "InputSystem"; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { graphics }; + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/examples/OptionsSpike/font.ttf b/examples/OptionsSpike/font.ttf deleted file mode 100644 index f27f4ff..0000000 Binary files a/examples/OptionsSpike/font.ttf and /dev/null differ diff --git a/examples/OptionsSpike/main.cpp b/examples/OptionsSpike/main.cpp deleted file mode 100644 index b28462a..0000000 --- a/examples/OptionsSpike/main.cpp +++ /dev/null @@ -1,309 +0,0 @@ -// Spike for the OptionsMenu port (3DForts → Crafter.Graphics imperative UI). -// Validates: Rect::SubRect carving at real-menu density, InputField shape and -// caret blink, click-routing-to-focused-field, tab switching with per-tab -// builders. Throwaway example, but kept around as a living reference. - -#include "vulkan/vulkan.h" - -import Crafter.Graphics; -import Crafter.Event; -import std; -using namespace Crafter; - -namespace { - -enum class Tab : std::uint8_t { Graphics, Input, Audio }; -constexpr std::array kTabLabels = { - "Graphics", "Input", "Audio" -}; - -struct Layout { - Rect canvas; - Rect titleBar; - Rect footer; - Rect main; - Rect tabStrip; - Rect content; - std::array tabRects; - Rect btnExit; - Rect btnSave; -}; - -Layout ComputeLayout(const Window& window) { - Layout L; - L.canvas = Rect::FromWindow(window); - L.titleBar = L.canvas.SubRect(60, Rect::Anchor::Top); - L.footer = L.canvas.SubRect(60, Rect::Anchor::Bottom); - L.main = L.canvas.Inset(60, 0, 60, 0); - L.tabStrip = L.main.SubRect(220, Rect::Anchor::Left).Inset(20); - L.content = L.main.Inset(0, 20, 0, 240); - - Rect strip = L.tabStrip; - for (int i = 0; i < 3; ++i) { - L.tabRects[i] = strip.SubRect(48, Rect::Anchor::Top).Inset(0, 0, 8, 0); - strip = strip.Inset(56, 0, 0, 0); - } - - Rect footerInset = L.footer.Inset(20, 60, 20, 60); - L.btnExit = footerInset.SubRect(160, Rect::Anchor::Left); - L.btnSave = footerInset.SubRect(160, Rect::Anchor::Right); - return L; -} - -} - -int main() { - Device::Initialize(); - Window window(1280, 720, "OptionsSpike"); - - VkCommandBuffer init = window.StartInit(); - - DescriptorHeapVulkan heap; - heap.Initialize(/*images*/ 16, /*buffers*/ 16, /*samplers*/ 4); - window.descriptorHeap = &heap; - - Font font("font.ttf"); - - FontAtlas atlas; - atlas.Initialize(init); - - UIRenderer ui; - ui.fontAtlas = &atlas; - ui.Initialize(window, heap, init); - window.passes.push_back(&ui); - - VulkanBuffer quadsBuf; - VulkanBuffer glyphsBuf; - quadsBuf.Create( - VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, - VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 256); - glyphsBuf.Create( - VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, - VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 4096); - - auto quadsSlot = ui.RegisterBuffer(quadsBuf); - auto glyphsSlot = ui.RegisterBuffer(glyphsBuf); - - // Application state. - Tab activeTab = Tab::Graphics; - std::array tabHover{}; - bool exitHover = false, saveHover = false; - - // Graphics-tab fields. In the real port these are rebuilt per-tab; here we - // just keep one set live and only show them on the Graphics tab to confirm - // the routing pattern. Width/height come from a fake "tempOptions" state. - InputField widthField { .value = "1920", .type = InputFieldType::UInt }; - InputField heightField { .value = "1080", .type = InputFieldType::UInt }; - std::array fields{ &widthField, &heightField }; - std::array fieldRects{}; - std::array fieldHover{}; - std::ptrdiff_t focusedField = -1; - - auto BlurAll = [&]() { - for (auto* f : fields) f->focused = false; - focusedField = -1; - }; - - auto SetFocus = [&](std::ptrdiff_t idx) { - BlurAll(); - if (idx >= 0 && idx < std::ssize(fields)) { - fields[idx]->focused = true; - focusedField = idx; - } - }; - - auto LayoutGraphicsTabFields = [&](const Layout& L) { - Rect rows = L.content; - Rect row0 = rows.SubRect(48, Rect::Anchor::Top); - rows = rows.Inset(56, 0, 0, 0); - Rect row1 = rows.SubRect(48, Rect::Anchor::Top); - - // Each row: label on left half, field on right half (right half inset). - fieldRects[0] = row0.SubRect(row0.w * 0.5f, Rect::Anchor::Right).Inset(4); - fieldRects[1] = row1.SubRect(row1.w * 0.5f, Rect::Anchor::Right).Inset(4); - }; - - Layout L = ComputeLayout(window); - LayoutGraphicsTabFields(L); - - // ─── input listeners ─────────────────────────────────────────────── - EventListener moveSub(&window.onMouseMove, [&]() { - float mx = window.currentMousePos.x; - float my = window.currentMousePos.y; - for (int i = 0; i < 3; ++i) tabHover[i] = L.tabRects[i].Contains(mx, my); - exitHover = L.btnExit.Contains(mx, my); - saveHover = L.btnSave.Contains(mx, my); - if (activeTab == Tab::Graphics) { - for (int i = 0; i < (int)fields.size(); ++i) - fieldHover[i] = fieldRects[i].Contains(mx, my); - } else { - for (auto& h : fieldHover) h = false; - } - }); - - EventListener clickSub(&window.onMouseLeftClick, [&]() { - for (int i = 0; i < 3; ++i) { - if (tabHover[i]) { - activeTab = static_cast(i); - BlurAll(); - return; - } - } - if (exitHover) { window.open = false; return; } - if (saveHover) { - // In the real port: OptionsIO::Save + Apply. Here we just print. - std::println("[spike] save: width={} height={}", - widthField.value, heightField.value); - return; - } - if (activeTab == Tab::Graphics) { - for (int i = 0; i < (int)fields.size(); ++i) { - if (fieldHover[i]) { - SetFocus(i); - fields[i]->cursorPos = InputField_HitTestCursor( - *fields[i], fieldRects[i], - window.currentMousePos.x, - font, 18.0f, InputFieldColors{ - .bg = {0.18f, 0.18f, 0.22f, 1.0f}, - .bgFocused = {0.22f, 0.22f, 0.30f, 1.0f}, - .border = {0.10f, 0.10f, 0.14f, 1.0f}, - .borderFocused = {0.40f, 0.65f, 1.00f, 1.0f}, - .text = {1.00f, 1.00f, 1.00f, 1.0f}, - .caret = {1.00f, 1.00f, 1.00f, 1.0f}, - }); - return; - } - } - } - BlurAll(); - }); - - EventListener textSub(&window.onTextInput, [&](std::string_view t) { - if (focusedField >= 0) InputField_OnText(*fields[focusedField], t); - }); - EventListener keySub(&window.onAnyKeyDown, [&](CrafterKeys k) { - if (focusedField >= 0) InputField_OnKey(*fields[focusedField], k); - }); - - // ─── palettes ────────────────────────────────────────────────────── - ButtonColors tabPalette{ - .bg = {0.20f, 0.22f, 0.28f, 1.0f}, - .bgHover = {0.30f, 0.55f, 0.95f, 1.0f}, - .bgPressed = {0.18f, 0.40f, 0.78f, 1.0f}, - .text = {1, 1, 1, 1}, - .cornerRadius = 6.0f, - }; - ButtonColors tabPaletteSelected = tabPalette; - tabPaletteSelected.bg = {0.30f, 0.55f, 0.95f, 1.0f}; - - ButtonColors footerPalette{ - .bg = {0.20f, 0.22f, 0.28f, 1.0f}, - .bgHover = {0.30f, 0.55f, 0.95f, 1.0f}, - .bgPressed = {0.18f, 0.40f, 0.78f, 1.0f}, - .text = {1, 1, 1, 1}, - .cornerRadius = 8.0f, - }; - - InputFieldColors fieldPalette{ - .bg = {0.18f, 0.18f, 0.22f, 1.0f}, - .bgFocused = {0.22f, 0.22f, 0.30f, 1.0f}, - .border = {0.10f, 0.10f, 0.14f, 1.0f}, - .borderFocused = {0.40f, 0.65f, 1.00f, 1.0f}, - .text = {1, 1, 1, 1}, - .caret = {1, 1, 1, 1}, - }; - - auto startTime = std::chrono::steady_clock::now(); - - EventListener buildSub(&ui.onBuild, [&](UIBuildArgs a) { - VkCommandBuffer cmd = a.cmd; - - L = ComputeLayout(window); - LayoutGraphicsTabFields(L); - - std::uint32_t qc = 0, gc = 0; - UIBuffer buf{ - .quads = quadsBuf.value, - .quadCount = &qc, - .quadCap = 256, - .glyphs = glyphsBuf.value, - .glyphCount = &gc, - .glyphCap = 4096, - .atlas = &atlas, - .renderer = &ui, - }; - - // Background. - if (qc < buf.quadCap) buf.quads[qc++] = QuadItem{ - L.canvas.x, L.canvas.y, L.canvas.w, L.canvas.h, - 0.10f, 0.10f, 0.13f, 1.0f, - 0, 0, 0, 0, 0, 0, 0, 0, - }; - // Title bar. - if (qc < buf.quadCap) buf.quads[qc++] = QuadItem{ - L.titleBar.x, L.titleBar.y, L.titleBar.w, L.titleBar.h, - 0.14f, 0.14f, 0.18f, 1.0f, - 0, 0, 0, 0, 0, 0, 0, 0, - }; - DrawText(buf, "OPTIONS", - L.titleBar.x + L.titleBar.w * 0.5f, - L.titleBar.y + L.titleBar.h * 0.5f + 18.0f * 0.32f, - font, 24.0f, {1, 1, 1, 1}, TextAlign::Center); - - // Tabs. - for (int i = 0; i < 3; ++i) { - const auto& palette = (activeTab == static_cast(i)) - ? tabPaletteSelected : tabPalette; - DrawButton(buf, L.tabRects[i], kTabLabels[i], - tabHover[i], false, font, 18.0f, palette); - } - - // Active tab content. - if (activeTab == Tab::Graphics) { - Rect rows = L.content; - Rect row0 = rows.SubRect(48, Rect::Anchor::Top); - rows = rows.Inset(56, 0, 0, 0); - Rect row1 = rows.SubRect(48, Rect::Anchor::Top); - - DrawText(buf, "Resolution width", - row0.x + 8.0f, - row0.y + row0.h * 0.5f + 18.0f * 0.32f, - font, 18.0f, {0.85f, 0.85f, 0.85f, 1.0f}, TextAlign::Left); - DrawText(buf, "Resolution height", - row1.x + 8.0f, - row1.y + row1.h * 0.5f + 18.0f * 0.32f, - font, 18.0f, {0.85f, 0.85f, 0.85f, 1.0f}, TextAlign::Left); - - auto now = std::chrono::steady_clock::now(); - auto ms = std::chrono::duration_cast(now - startTime).count(); - bool caretVisible = ((ms / 500) & 1) == 0; - - DrawInputField(buf, widthField, fieldRects[0], font, 18.0f, fieldPalette, caretVisible); - DrawInputField(buf, heightField, fieldRects[1], font, 18.0f, fieldPalette, caretVisible); - } else { - DrawText(buf, "(tab content not populated in spike)", - L.content.x + L.content.w * 0.5f, - L.content.y + L.content.h * 0.5f, - font, 18.0f, {0.6f, 0.6f, 0.6f, 1.0f}, TextAlign::Center); - } - - // Footer. - DrawButton(buf, L.btnExit, "Exit", exitHover, false, font, 18.0f, footerPalette); - DrawButton(buf, L.btnSave, "Save changes", saveHover, false, font, 18.0f, footerPalette); - - // Dispatch. - if (qc > 0) { - quadsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); - ui.DispatchQuads(cmd, quadsSlot, qc); - } - if (gc > 0) { - glyphsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); - ui.DispatchText(cmd, glyphsSlot, gc); - } - }); - - window.FinishInit(); - window.Render(); - window.StartUpdate(); - window.StartSync(); -} diff --git a/examples/README.md b/examples/README.md index dd09188..170eb0f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,6 +43,27 @@ library does not track widgets or focus. Drop a TTF in this directory as `font.ttf` before running (the example loads it via `Font("font.ttf")`). +### [InputSystem](InputSystem/) +Guided tour of `Crafter::Input`: name actions ("Jump", "Move", "Fire", +"Look", "Zoom"), bind them to keys / mouse / gamepad (with composite +bindings for WASD-as-Vector2 and analog sticks), and consume them as +events. Demonstrates: + +- The compile-time `Key(CrafterKeys::Space)` helper that folds to a + per-platform raw scancode — bindings stay cross-platform-readable + in source while runtime data stores raw codes only. +- All four action types (Button, Axis1D, Vector2) with multiple + bindings per action (any-of semantics). +- `Map::StartRebind` — press R, then press any input to remap "Jump" + at runtime. Captured input is filtered out for that frame. +- `BindingToString` / `BindingFromString` round-trip — print the + default bindings as the on-disk format. +- Gamepad hot-plug events: plug a controller in mid-run and the + bindings start firing immediately. + +Console-driven (no UI rendering needed); focus the window and watch +stdout. + ### [CustomShader](CustomShader/) Tier 1 demo: a user-authored compute shader (`inverse-circle.comp.glsl`) running alongside the shipped `drawQuads`. The custom shader inverts RGB diff --git a/implementations/Crafter.Graphics-Clipboard.cpp b/implementations/Crafter.Graphics-Clipboard.cpp new file mode 100644 index 0000000..f301a96 --- /dev/null +++ b/implementations/Crafter.Graphics-Clipboard.cpp @@ -0,0 +1,174 @@ +/* +Crafter®.Graphics +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ +module; +#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND +#include +#include +#include +#include +#endif +#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#endif +module Crafter.Graphics; +import std; + +using namespace Crafter; + +#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND +namespace { + // Heap-allocated state attached to each wl_data_source via its + // user_data slot. The text must outlive the source (the compositor + // can call `send` minutes after we set the selection, every time a + // remote app pastes), so we own it here and free it from + // `OnSourceCancelled` — which is the only callback the compositor + // guarantees will fire when the source is no longer needed (when + // the selection is replaced, or on app exit). + struct Held { + std::string text; + }; + + void OnSourceTarget(void*, wl_data_source*, const char*) {} + + void OnSourceSend(void* data, wl_data_source*, + const char* /*mime_type*/, std::int32_t fd) { + // We only ever advertise text MIME types, so any negotiated + // type maps to the same UTF-8 buffer. `send` may fire multiple + // times across the lifetime of one selection (each paste is a + // fresh fd), so we must not consume `text` here. + Held* h = static_cast(data); + const char* p = h->text.data(); + std::size_t rem = h->text.size(); + while (rem > 0) { + const ssize_t w = ::write(fd, p, rem); + if (w < 0) { + if (errno == EINTR) continue; + break; // pipe closed; remote gave up reading + } + if (w == 0) break; + p += w; + rem -= static_cast(w); + } + ::close(fd); + } + + void OnSourceCancelled(void* data, wl_data_source* source) { + // Selection was replaced (someone else copied) or the app is + // exiting. Either way, this is our hook to release the held + // text and the source itself. + delete static_cast(data); + wl_data_source_destroy(source); + } + + // Drag-and-drop only callbacks. We never start a DnD action, so + // these can't fire — but the listener struct's size is fixed at + // the v3 shape, and wayland-client uses the struct's slots + // directly. Stubs are required. + void OnSourceDndDropPerformed(void*, wl_data_source*) {} + void OnSourceDndFinished(void*, wl_data_source*) {} + void OnSourceAction(void*, wl_data_source*, std::uint32_t) {} + + constexpr wl_data_source_listener kSourceListener = { + .target = OnSourceTarget, + .send = OnSourceSend, + .cancelled = OnSourceCancelled, + .dnd_drop_performed = OnSourceDndDropPerformed, + .dnd_finished = OnSourceDndFinished, + .action = OnSourceAction, + }; +} +#endif + +bool Crafter::Clipboard::SetText(std::string_view text) { +#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND + if (Device::dataDeviceManager == nullptr || Device::dataDevice == nullptr) { + // Compositor doesn't expose wl_data_device_manager (rare; some + // headless / minimal compositors). Caller can fall back. + return false; + } + // wl_data_device.set_selection requires a serial from a recent + // input event. We track the most recent pointer-enter serial on + // each window — fine for "user clicked Copy" interactions, since + // the click itself produced the serial. Without one, the + // compositor would silently reject the request. + std::uint32_t serial = 0; + if (Device::focusedWindow != nullptr) { + serial = Device::focusedWindow->lastPointerSerial_; + } + if (serial == 0) return false; + + auto* held = new Held{ std::string(text) }; + wl_data_source* source = + wl_data_device_manager_create_data_source(Device::dataDeviceManager); + if (source == nullptr) { + delete held; + return false; + } + wl_data_source_add_listener(source, &kSourceListener, held); + + // Advertise the four common text MIME types so legacy + modern + // pasters both find a match. Wayland clients prefer the first one + // they recognise, in order. + wl_data_source_offer(source, "text/plain;charset=utf-8"); + wl_data_source_offer(source, "text/plain"); + wl_data_source_offer(source, "UTF8_STRING"); + wl_data_source_offer(source, "TEXT"); + + wl_data_device_set_selection(Device::dataDevice, source, serial); + // Push the request so the compositor sees it before the next event + // loop iteration — otherwise a quick read in another app might + // miss the selection update. + wl_display_flush(Device::display); + return true; +#elif defined(CRAFTER_GRAPHICS_WINDOW_WIN32) + // CF_UNICODETEXT round-trip. Convert UTF-8 → UTF-16, allocate a + // moveable HGLOBAL, hand it off to the OS. The OS frees the global + // when the next clipboard owner replaces the data, so our caller + // doesn't need to keep `text` alive — same lifetime contract as + // the Wayland path. + if (!OpenClipboard(nullptr)) return false; + EmptyClipboard(); + const int wlen = MultiByteToWideChar(CP_UTF8, 0, + text.data(), static_cast(text.size()), nullptr, 0); + if (wlen < 0) { CloseClipboard(); return false; } + HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, + (static_cast(wlen) + 1) * sizeof(wchar_t)); + if (h == nullptr) { CloseClipboard(); return false; } + wchar_t* dst = static_cast(GlobalLock(h)); + if (dst == nullptr) { + GlobalFree(h); + CloseClipboard(); + return false; + } + MultiByteToWideChar(CP_UTF8, 0, + text.data(), static_cast(text.size()), dst, wlen); + dst[wlen] = L'\0'; + GlobalUnlock(h); + const bool ok = SetClipboardData(CF_UNICODETEXT, h) != nullptr; + if (!ok) GlobalFree(h); // OS only takes ownership on success + CloseClipboard(); + return ok; +#else + (void)text; + return false; +#endif +} diff --git a/implementations/Crafter.Graphics-Device.cpp b/implementations/Crafter.Graphics-Device.cpp index e3116c4..0f9cf47 100644 --- a/implementations/Crafter.Graphics-Device.cpp +++ b/implementations/Crafter.Graphics-Device.cpp @@ -176,158 +176,6 @@ VkBool32 onError(VkDebugUtilsMessageSeverityFlagBitsEXT severity, VkDebugUtilsMe #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND -constexpr CrafterKeys keysym_to_crafter_key(xkb_keysym_t sym) -{ - switch (sym) - { - // Alphabet - case XKB_KEY_a: return CrafterKeys::A; - case XKB_KEY_b: return CrafterKeys::B; - case XKB_KEY_c: return CrafterKeys::C; - case XKB_KEY_d: return CrafterKeys::D; - case XKB_KEY_e: return CrafterKeys::E; - case XKB_KEY_f: return CrafterKeys::F; - case XKB_KEY_g: return CrafterKeys::G; - case XKB_KEY_h: return CrafterKeys::H; - case XKB_KEY_i: return CrafterKeys::I; - case XKB_KEY_j: return CrafterKeys::J; - case XKB_KEY_k: return CrafterKeys::K; - case XKB_KEY_l: return CrafterKeys::L; - case XKB_KEY_m: return CrafterKeys::M; - case XKB_KEY_n: return CrafterKeys::N; - case XKB_KEY_o: return CrafterKeys::O; - case XKB_KEY_p: return CrafterKeys::P; - case XKB_KEY_q: return CrafterKeys::Q; - case XKB_KEY_r: return CrafterKeys::R; - case XKB_KEY_s: return CrafterKeys::S; - case XKB_KEY_t: return CrafterKeys::T; - case XKB_KEY_u: return CrafterKeys::U; - case XKB_KEY_v: return CrafterKeys::V; - case XKB_KEY_w: return CrafterKeys::W; - case XKB_KEY_x: return CrafterKeys::X; - case XKB_KEY_y: return CrafterKeys::Y; - case XKB_KEY_z: return CrafterKeys::Z; - - case XKB_KEY_A: return CrafterKeys::A; - case XKB_KEY_B: return CrafterKeys::B; - case XKB_KEY_C: return CrafterKeys::C; - case XKB_KEY_D: return CrafterKeys::D; - case XKB_KEY_E: return CrafterKeys::E; - case XKB_KEY_F: return CrafterKeys::F; - case XKB_KEY_G: return CrafterKeys::G; - case XKB_KEY_H: return CrafterKeys::H; - case XKB_KEY_I: return CrafterKeys::I; - case XKB_KEY_J: return CrafterKeys::J; - case XKB_KEY_K: return CrafterKeys::K; - case XKB_KEY_L: return CrafterKeys::L; - case XKB_KEY_M: return CrafterKeys::M; - case XKB_KEY_N: return CrafterKeys::N; - case XKB_KEY_O: return CrafterKeys::O; - case XKB_KEY_P: return CrafterKeys::P; - case XKB_KEY_Q: return CrafterKeys::Q; - case XKB_KEY_R: return CrafterKeys::R; - case XKB_KEY_S: return CrafterKeys::S; - case XKB_KEY_T: return CrafterKeys::T; - case XKB_KEY_U: return CrafterKeys::U; - case XKB_KEY_V: return CrafterKeys::V; - case XKB_KEY_W: return CrafterKeys::W; - case XKB_KEY_X: return CrafterKeys::X; - case XKB_KEY_Y: return CrafterKeys::Y; - case XKB_KEY_Z: return CrafterKeys::Z; - - // Numbers - case XKB_KEY_0: return CrafterKeys::_0; - case XKB_KEY_1: return CrafterKeys::_1; - case XKB_KEY_2: return CrafterKeys::_2; - case XKB_KEY_3: return CrafterKeys::_3; - case XKB_KEY_4: return CrafterKeys::_4; - case XKB_KEY_5: return CrafterKeys::_5; - case XKB_KEY_6: return CrafterKeys::_6; - case XKB_KEY_7: return CrafterKeys::_7; - case XKB_KEY_8: return CrafterKeys::_8; - case XKB_KEY_9: return CrafterKeys::_9; - - // Function keys - case XKB_KEY_F1: return CrafterKeys::F1; - case XKB_KEY_F2: return CrafterKeys::F2; - case XKB_KEY_F3: return CrafterKeys::F3; - case XKB_KEY_F4: return CrafterKeys::F4; - case XKB_KEY_F5: return CrafterKeys::F5; - case XKB_KEY_F6: return CrafterKeys::F6; - case XKB_KEY_F7: return CrafterKeys::F7; - case XKB_KEY_F8: return CrafterKeys::F8; - case XKB_KEY_F9: return CrafterKeys::F9; - case XKB_KEY_F10: return CrafterKeys::F10; - case XKB_KEY_F11: return CrafterKeys::F11; - case XKB_KEY_F12: return CrafterKeys::F12; - - // Control keys - case XKB_KEY_Escape: return CrafterKeys::Escape; - case XKB_KEY_Tab: return CrafterKeys::Tab; - case XKB_KEY_Return: return CrafterKeys::Enter; - case XKB_KEY_space: return CrafterKeys::Space; - case XKB_KEY_BackSpace: return CrafterKeys::Backspace; - case XKB_KEY_Delete: return CrafterKeys::Delete; - case XKB_KEY_Insert: return CrafterKeys::Insert; - case XKB_KEY_Home: return CrafterKeys::Home; - case XKB_KEY_End: return CrafterKeys::End; - case XKB_KEY_Page_Up: return CrafterKeys::PageUp; - case XKB_KEY_Page_Down: return CrafterKeys::PageDown; - case XKB_KEY_Caps_Lock: return CrafterKeys::CapsLock; - case XKB_KEY_Num_Lock: return CrafterKeys::NumLock; - case XKB_KEY_Scroll_Lock:return CrafterKeys::ScrollLock; - - // Modifiers - case XKB_KEY_Shift_L: return CrafterKeys::LeftShift; - case XKB_KEY_Shift_R: return CrafterKeys::RightShift; - case XKB_KEY_Control_L: return CrafterKeys::LeftCtrl; - case XKB_KEY_Control_R: return CrafterKeys::RightCtrl; - case XKB_KEY_Alt_L: return CrafterKeys::LeftAlt; - case XKB_KEY_Alt_R: return CrafterKeys::RightAlt; - case XKB_KEY_Super_L: return CrafterKeys::LeftSuper; - case XKB_KEY_Super_R: return CrafterKeys::RightSuper; - - // Arrows - case XKB_KEY_Up: return CrafterKeys::Up; - case XKB_KEY_Down: return CrafterKeys::Down; - case XKB_KEY_Left: return CrafterKeys::Left; - case XKB_KEY_Right: return CrafterKeys::Right; - - // Keypad - case XKB_KEY_KP_0: return CrafterKeys::keypad_0; - case XKB_KEY_KP_1: return CrafterKeys::keypad_1; - case XKB_KEY_KP_2: return CrafterKeys::keypad_2; - case XKB_KEY_KP_3: return CrafterKeys::keypad_3; - case XKB_KEY_KP_4: return CrafterKeys::keypad_4; - case XKB_KEY_KP_5: return CrafterKeys::keypad_5; - case XKB_KEY_KP_6: return CrafterKeys::keypad_6; - case XKB_KEY_KP_7: return CrafterKeys::keypad_7; - case XKB_KEY_KP_8: return CrafterKeys::keypad_8; - case XKB_KEY_KP_9: return CrafterKeys::keypad_9; - case XKB_KEY_KP_Enter: return CrafterKeys::keypad_enter; - case XKB_KEY_KP_Add: return CrafterKeys::keypad_plus; - case XKB_KEY_KP_Subtract: return CrafterKeys::keypad_minus; - case XKB_KEY_KP_Multiply: return CrafterKeys::keypad_multiply; - case XKB_KEY_KP_Divide: return CrafterKeys::keypad_divide; - case XKB_KEY_KP_Decimal: return CrafterKeys::keypad_decimal; - - // Punctuation - case XKB_KEY_grave: return CrafterKeys::grave; - case XKB_KEY_minus: return CrafterKeys::minus; - case XKB_KEY_equal: return CrafterKeys::equal; - case XKB_KEY_bracketleft: return CrafterKeys::bracket_left; - case XKB_KEY_bracketright:return CrafterKeys::bracket_right; - case XKB_KEY_backslash: return CrafterKeys::backslash; - case XKB_KEY_semicolon: return CrafterKeys::semicolon; - case XKB_KEY_apostrophe: return CrafterKeys::quote; - case XKB_KEY_comma: return CrafterKeys::comma; - case XKB_KEY_period: return CrafterKeys::period; - case XKB_KEY_slash: return CrafterKeys::slash; - - default: return CrafterKeys::CrafterKeysMax; - } -} - void Device::xdg_wm_base_handle_ping(void* data, xdg_wm_base* xdg_wm_base, std::uint32_t serial) { xdg_wm_base_pong(xdg_wm_base, serial); } @@ -336,8 +184,15 @@ void Device::handle_global(void *data, wl_registry *registry, std::uint32_t name if (strcmp(interface, wl_shm_interface.name) == 0) { shm = reinterpret_cast(wl_registry_bind(registry, name, &wl_shm_interface, 1)); } else if (strcmp(interface, wl_seat_interface.name) == 0) { - wl_seat* seat = reinterpret_cast(wl_registry_bind(registry, name, &wl_seat_interface, 1)); + // Assign to Device::seat (not a fresh local) so SetClipboardText + // and any other code that needs the seat post-init can find it. + seat = reinterpret_cast(wl_registry_bind(registry, name, &wl_seat_interface, 1)); wl_seat_add_listener(seat, &seat_listener, nullptr); + // If the manager came in first, the data device couldn't be + // created yet — do it now that we have the seat. + if (dataDeviceManager != nullptr && dataDevice == nullptr) { + dataDevice = wl_data_device_manager_get_data_device(dataDeviceManager, seat); + } } else if (compositor == nullptr && strcmp(interface, wl_compositor_interface.name) == 0) { compositor = reinterpret_cast(wl_registry_bind(registry, name, &wl_compositor_interface, 3)); } else if (strcmp(interface, xdg_wm_base_interface.name) == 0) { @@ -349,6 +204,16 @@ void Device::handle_global(void *data, wl_registry *registry, std::uint32_t name wpViewporter = reinterpret_cast(wl_registry_bind(registry, name, &wp_viewporter_interface, 1)); } else if (strcmp(interface, wp_fractional_scale_manager_v1_interface.name) == 0) { fractionalScaleManager = reinterpret_cast(wl_registry_bind(registry, name, &wp_fractional_scale_manager_v1_interface, 1)); + } else if (strcmp(interface, wl_data_device_manager_interface.name) == 0) { + // v3 gives us the full set of data_source events (target / send / + // cancelled / dnd_*). Universally supported by the compositors + // we target — fall back path is the per-source listener simply + // not getting the v3-only callbacks. + dataDeviceManager = reinterpret_cast( + wl_registry_bind(registry, name, &wl_data_device_manager_interface, 3)); + if (seat != nullptr && dataDevice == nullptr) { + dataDevice = wl_data_device_manager_get_data_device(dataDeviceManager, seat); + } } } @@ -441,23 +306,24 @@ void Device::keyboard_leave(void *data, wl_keyboard *keyboard, uint32_t serial, } void Device::keyboard_key(void *data, wl_keyboard *keyboard, uint32_t serial, uint32_t time, uint32_t key, uint32_t state) { - xkb_keycode_t keycode = key + 8; - xkb_keysym_t keysym = xkb_state_key_get_one_sym(xkb_state, keycode); - CrafterKeys crafterKey = keysym_to_crafter_key(keysym); + // `key` is the kernel input-event-code (KEY_*). That is exactly what + // :Keys returns for Wayland builds, so we store it verbatim with no + // translation. The +8 X11 offset is only needed for the XKB layer, + // which we still consult to produce UTF-8 text. + KeyCode code = key; + xkb_keycode_t xkbKeycode = key + 8; if (state == WL_KEYBOARD_KEY_STATE_PRESSED) { - if (focusedWindow->heldkeys[(std::uint8_t)crafterKey]) { - focusedWindow->onKeyHold[(std::uint8_t)crafterKey].Invoke(); - focusedWindow->onAnyKeyHold.Invoke(crafterKey); + if (focusedWindow->heldKeys.contains(code)) { + focusedWindow->onRawKeyHold.Invoke(code); } else { - focusedWindow->heldkeys[(std::uint8_t)crafterKey] = true; - focusedWindow->onKeyDown[(std::uint8_t)crafterKey].Invoke(); - focusedWindow->onAnyKeyDown.Invoke(crafterKey); + focusedWindow->heldKeys.insert(code); + focusedWindow->onRawKeyDown.Invoke(code); } std::string buf; buf.resize(16); - int n = xkb_state_key_get_utf8(xkb_state, keycode, buf.data(), 16); + int n = xkb_state_key_get_utf8(xkb_state, xkbKeycode, buf.data(), 16); std::string utf8; if (n > 0 && (unsigned char)buf[0] >= 0x20 && buf[0] != 0x7f) { buf.resize(n); @@ -468,19 +334,18 @@ void Device::keyboard_key(void *data, wl_keyboard *keyboard, uint32_t serial, ui // Replace the active repeat with this key — most recent press wins, // matching xkbcommon's typical behaviour and most desktop apps. keyRepeat.active = (keyRepeat.rate > 0); - keyRepeat.key = crafterKey; + keyRepeat.key = code; keyRepeat.utf8 = std::move(utf8); keyRepeat.pressTime = std::chrono::steady_clock::now(); keyRepeat.lastFireTime = keyRepeat.pressTime; } else { - focusedWindow->heldkeys[(std::uint8_t)crafterKey] = false; - focusedWindow->onKeyUp[(std::uint8_t)crafterKey].Invoke(); - focusedWindow->onAnyKeyUp.Invoke(crafterKey); + focusedWindow->heldKeys.erase(code); + focusedWindow->onRawKeyUp.Invoke(code); // If the released key was the one repeating, stop. Otherwise leave // the existing repeat alone (user pressed/released a modifier // mid-repeat etc.). - if (keyRepeat.active && keyRepeat.key == crafterKey) { + if (keyRepeat.active && keyRepeat.key == code) { keyRepeat.active = false; keyRepeat.utf8.clear(); } @@ -513,10 +378,8 @@ void Device::TickKeyRepeats() { // Catch up — emit one event per missed period so a paused frame doesn't // make the repeat permanently lag behind. while (now - keyRepeat.lastFireTime >= period) { - focusedWindow->onKeyDown[(std::uint8_t)keyRepeat.key].Invoke(); - focusedWindow->onAnyKeyDown.Invoke(keyRepeat.key); - focusedWindow->onKeyHold[(std::uint8_t)keyRepeat.key].Invoke(); - focusedWindow->onAnyKeyHold.Invoke(keyRepeat.key); + focusedWindow->onRawKeyDown.Invoke(keyRepeat.key); + focusedWindow->onRawKeyHold.Invoke(keyRepeat.key); if (!keyRepeat.utf8.empty()) { focusedWindow->onTextInput.Invoke(keyRepeat.utf8); } @@ -673,11 +536,40 @@ void Device::Initialize() { } } + // Enumerate available device extensions so we can opt into + // VK_EXT_memory_decompression when the driver advertises it. Drivers + // without it (AMD, Intel as of early 2026) get the CPU-decode fallback. + { + std::uint32_t extCount = 0; + vkEnumerateDeviceExtensionProperties(physDevice, nullptr, &extCount, nullptr); + std::vector exts(extCount); + vkEnumerateDeviceExtensionProperties(physDevice, nullptr, &extCount, exts.data()); + for (const VkExtensionProperties& e : exts) { + if (std::strcmp(e.extensionName, VK_EXT_MEMORY_DECOMPRESSION_EXTENSION_NAME) == 0) { + memoryDecompressionSupported = true; + break; + } + } + } + + // Properties query: chain memory-decompression props only when supported, + // otherwise sType validation flags it as an unrecognized struct on + // drivers that don't expose the extension. + if (memoryDecompressionSupported) { + memoryDecompressionProperties.pNext = const_cast(rayTracingProperties.pNext); + rayTracingProperties.pNext = &memoryDecompressionProperties; + } VkPhysicalDeviceProperties2 properties2 { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, .pNext = &rayTracingProperties }; vkGetPhysicalDeviceProperties2(physDevice, &properties2); + + // Sanity-gate: GDeflate 1.0 must actually be in the supported method set. + if (memoryDecompressionSupported && + (memoryDecompressionProperties.decompressionMethods & VK_MEMORY_DECOMPRESSION_METHOD_GDEFLATE_1_0_BIT_EXT) == 0) { + memoryDecompressionSupported = false; + } uint32_t queueFamilyCount; vkGetPhysicalDeviceQueueFamilyProperties(physDevice, &queueFamilyCount, NULL); @@ -724,9 +616,26 @@ void Device::Initialize() { .shaderUntypedPointers = VK_TRUE, }; + // Enables synchronization2 sentinels (VkMemoryBarrier2, VK_PIPELINE_STAGE_2_*, + // VK_ACCESS_2_*) — required for VK_EXT_memory_decompression's sync tokens. + VkPhysicalDeviceVulkan13Features features13 { + .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES, + .pNext = &untypedPointersFeatures, + .synchronization2 = VK_TRUE, + }; + + VkPhysicalDeviceMemoryDecompressionFeaturesEXT memoryDecompressionFeatures { + .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_DECOMPRESSION_FEATURES_EXT, + .pNext = &features13, + .memoryDecompression = VK_TRUE, + }; + void* postDecompressChain = memoryDecompressionSupported + ? static_cast(&memoryDecompressionFeatures) + : static_cast(&features13); + VkPhysicalDeviceDescriptorHeapFeaturesEXT desciptorHeapFeatures { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DESCRIPTOR_HEAP_FEATURES_EXT, - .pNext = &untypedPointersFeatures, + .pNext = postDecompressChain, .descriptorHeap = VK_TRUE, }; @@ -787,12 +696,22 @@ void Device::Initialize() { } }; + // Build the enabled-extension list dynamically so we can append the + // optional VK_EXT_memory_decompression entry only when the driver + // advertises it. + std::vector enabledDeviceExtensions( + std::begin(deviceExtensionNames), + std::end(deviceExtensionNames)); + if (memoryDecompressionSupported) { + enabledDeviceExtensions.push_back(VK_EXT_MEMORY_DECOMPRESSION_EXTENSION_NAME); + } + VkDeviceCreateInfo deviceCreateInfo = {}; deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; deviceCreateInfo.queueCreateInfoCount = 1; deviceCreateInfo.pQueueCreateInfos = &queueCreateInfo; - deviceCreateInfo.enabledExtensionCount = sizeof(deviceExtensionNames) / sizeof(const char*); - deviceCreateInfo.ppEnabledExtensionNames = deviceExtensionNames; + deviceCreateInfo.enabledExtensionCount = static_cast(enabledDeviceExtensions.size()); + deviceCreateInfo.ppEnabledExtensionNames = enabledDeviceExtensions.data(); deviceCreateInfo.pNext = &physical_features2; uint32_t deviceLayerCount; @@ -846,6 +765,19 @@ void Device::Initialize() { vkCmdPushDataEXT = reinterpret_cast(vkGetInstanceProcAddr(instance, "vkCmdPushDataEXT")); vkGetPhysicalDeviceDescriptorSizeEXT = reinterpret_cast(vkGetInstanceProcAddr(instance, "vkGetPhysicalDeviceDescriptorSizeEXT")); vkGetDeviceFaultInfoEXT = reinterpret_cast(vkGetInstanceProcAddr(instance, "vkGetDeviceFaultInfoEXT")); + + if (memoryDecompressionSupported) { + // vkGetDeviceProcAddr skips the loader trampoline that vkGetInstanceProcAddr + // requires for device-level functions. The other PFNs above predate this + // realization; opportunistic adoption for new entry points only. + vkCmdDecompressMemoryEXT = reinterpret_cast( + vkGetDeviceProcAddr(device, "vkCmdDecompressMemoryEXT")); + if (vkCmdDecompressMemoryEXT == nullptr) { + // Driver advertised the extension but didn't expose the entry + // point — defensively fall back to CPU decode. + memoryDecompressionSupported = false; + } + } } std::uint32_t Device::GetMemoryType(uint32_t typeBits, VkMemoryPropertyFlags properties) { diff --git a/implementations/Crafter.Graphics-Gamepad.cpp b/implementations/Crafter.Graphics-Gamepad.cpp new file mode 100644 index 0000000..00f9eca --- /dev/null +++ b/implementations/Crafter.Graphics-Gamepad.cpp @@ -0,0 +1,683 @@ +/* +Crafter®.Graphics +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ +module; +#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif +#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 +#define COBJMACROS +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#include +#include +#endif +module Crafter.Graphics; +import std; + +using namespace Crafter; + +// ──────────────────────────────────────────────────────────────────── +// Linux backend (libudev + libevdev) +// ──────────────────────────────────────────────────────────────────── +#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND +namespace { + // Per-device runtime state. The public `Gamepad::Device` carries the + // identity + polled state + events; this struct holds the libevdev / + // fd / mapping tables we need to drain events from. + struct LinuxBackend { + libevdev* evdev = nullptr; + int fd = -1; + // Per-axis calibration (libevdev's min/max so we can normalize). + // Indexed by Gamepad::Axis. -1 in `code` means "this axis isn't + // exposed by this device". + struct AxisInfo { + int code = -1; // kernel ABS_* code, or -1 + int min = 0; + int max = 0; + float flat = 0.0f; // deadzone hint (raw units) + bool invertY = false; // negate output (stick Y by convention) + }; + AxisInfo axisInfo[(std::size_t)Crafter::Gamepad::Axis::Max]; + // FF effect id from EVIOCSFF, -1 if no rumble support / not active. + int ffEffectId = -1; + std::chrono::steady_clock::time_point rumbleExpiry{}; + }; + + std::unordered_map> g_backends; + udev* g_udev = nullptr; + udev_monitor* g_monitor = nullptr; + bool g_initialized = false; + + std::uint32_t SyspathToId(const char* syspath) { + // FNV-1a 32-bit, stable across runs for the same physical device. + std::uint32_t h = 0x811C9DC5u; + for (const char* p = syspath; *p; ++p) { + h ^= static_cast(*p); + h *= 0x01000193u; + } + // Reserve 0 as "unmapped". + return h == 0 ? 1 : h; + } + + // BTN_* → Gamepad::Button. -1 = not a button we care about. + int ButtonFromKernel(int code) { + using B = Crafter::Gamepad::Button; + switch (code) { + case BTN_SOUTH: return (int)B::South; + case BTN_EAST: return (int)B::East; + case BTN_NORTH: return (int)B::North; + case BTN_WEST: return (int)B::West; + case BTN_TL: return (int)B::LeftBumper; + case BTN_TR: return (int)B::RightBumper; + case BTN_TL2: return (int)B::LeftTrigger; + case BTN_TR2: return (int)B::RightTrigger; + case BTN_SELECT: return (int)B::Select; + case BTN_START: return (int)B::Start; + case BTN_MODE: return (int)B::Home; + case BTN_THUMBL: return (int)B::LeftStickClick; + case BTN_THUMBR: return (int)B::RightStickClick; + case BTN_DPAD_UP: return (int)B::DPadUp; + case BTN_DPAD_DOWN: return (int)B::DPadDown; + case BTN_DPAD_LEFT: return (int)B::DPadLeft; + case BTN_DPAD_RIGHT: return (int)B::DPadRight; + default: return -1; + } + } + + // Populate `bp->axisInfo` from libevdev. Sticks use ABS_X/Y/RX/RY; + // triggers prefer ABS_Z/RZ but fall back to ABS_BRAKE/GAS. + void DiscoverAxes(LinuxBackend* bp) { + using A = Crafter::Gamepad::Axis; + auto setup = [&](A which, int code, bool invertY = false) { + if (!libevdev_has_event_code(bp->evdev, EV_ABS, code)) return; + auto* info = libevdev_get_abs_info(bp->evdev, code); + if (!info) return; + auto& slot = bp->axisInfo[(std::size_t)which]; + slot.code = code; + slot.min = info->minimum; + slot.max = info->maximum; + slot.flat = (float)info->flat; + slot.invertY = invertY; + }; + setup(A::LeftStickX, ABS_X); + setup(A::LeftStickY, ABS_Y, /*invertY=*/true); + setup(A::RightStickX, ABS_RX); + setup(A::RightStickY, ABS_RY, /*invertY=*/true); + // Trigger axis: Z/RZ on Xbox, BRAKE/GAS on some others. + if (libevdev_has_event_code(bp->evdev, EV_ABS, ABS_Z)) { + setup(A::LeftTrigger, ABS_Z); + } else if (libevdev_has_event_code(bp->evdev, EV_ABS, ABS_BRAKE)) { + setup(A::LeftTrigger, ABS_BRAKE); + } + if (libevdev_has_event_code(bp->evdev, EV_ABS, ABS_RZ)) { + setup(A::RightTrigger, ABS_RZ); + } else if (libevdev_has_event_code(bp->evdev, EV_ABS, ABS_GAS)) { + setup(A::RightTrigger, ABS_GAS); + } + } + + // Normalize a raw axis value to -1..1 (sticks) or 0..1 (triggers). + float NormalizeAxis(const LinuxBackend::AxisInfo& info, int raw, bool isTrigger) { + if (info.code < 0 || info.max == info.min) return 0.0f; + float v; + if (isTrigger) { + v = float(raw - info.min) / float(info.max - info.min); + if (v < 0.0f) v = 0.0f; + if (v > 1.0f) v = 1.0f; + } else { + float mid = 0.5f * (info.max + info.min); + float halfR = 0.5f * (info.max - info.min); + v = (raw - mid) / halfR; + if (v < -1.0f) v = -1.0f; + if (v > 1.0f) v = 1.0f; + if (info.invertY) v = -v; + } + return v; + } + + bool IsGamepadDevice(libevdev* evdev) { + // A real gamepad reports BTN_GAMEPAD (a kernel alias for BTN_SOUTH + // when the device is classified as a gamepad). This filters out + // keyboards/mice/touchpads/tablets that share the evdev interface. + return libevdev_has_event_type(evdev, EV_KEY) + && libevdev_has_event_code(evdev, EV_KEY, BTN_GAMEPAD); + } + + Crafter::Gamepad::Device* OpenDevice(const char* devnode, const char* syspath, const char* name) { + int fd = ::open(devnode, O_RDWR | O_NONBLOCK); + if (fd < 0) { + // Read-only is fine if we just can't rumble. + fd = ::open(devnode, O_RDONLY | O_NONBLOCK); + if (fd < 0) return nullptr; + } + libevdev* evdev = nullptr; + if (libevdev_new_from_fd(fd, &evdev) < 0) { + ::close(fd); + return nullptr; + } + if (!IsGamepadDevice(evdev)) { + libevdev_free(evdev); + ::close(fd); + return nullptr; + } + + auto dev = std::make_unique(); + dev->id = SyspathToId(syspath); + dev->name = name ? name : libevdev_get_name(evdev); + + auto bp = std::make_unique(); + bp->evdev = evdev; + bp->fd = fd; + DiscoverAxes(bp.get()); + + auto* raw = dev.get(); + g_backends[raw] = std::move(bp); + Crafter::Gamepad::connected.push_back(std::move(dev)); + return raw; + } + + void CloseDevice(Crafter::Gamepad::Device* dev) { + auto it = g_backends.find(dev); + if (it != g_backends.end()) { + if (it->second->evdev) libevdev_free(it->second->evdev); + if (it->second->fd >= 0) ::close(it->second->fd); + g_backends.erase(it); + } + auto& v = Crafter::Gamepad::connected; + for (auto vi = v.begin(); vi != v.end(); ++vi) { + if (vi->get() == dev) { v.erase(vi); break; } + } + } + + void EnumerateExisting() { + udev_enumerate* en = udev_enumerate_new(g_udev); + if (!en) return; + udev_enumerate_add_match_subsystem(en, "input"); + udev_enumerate_scan_devices(en); + udev_list_entry* list = udev_enumerate_get_list_entry(en); + udev_list_entry* entry; + udev_list_entry_foreach(entry, list) { + const char* syspath = udev_list_entry_get_name(entry); + udev_device* d = udev_device_new_from_syspath(g_udev, syspath); + if (!d) continue; + const char* devnode = udev_device_get_devnode(d); + const char* sysname = udev_device_get_sysname(d); + if (devnode && sysname && std::strncmp(sysname, "event", 5) == 0) { + const char* name = udev_device_get_sysattr_value( + udev_device_get_parent_with_subsystem_devtype(d, "input", nullptr), + "name"); + auto* dev = OpenDevice(devnode, syspath, name); + if (dev) Crafter::Gamepad::onConnected.Invoke(dev); + } + udev_device_unref(d); + } + udev_enumerate_unref(en); + } + + void HandleHotplugEvent(udev_device* d) { + const char* action = udev_device_get_action(d); + const char* devnode = udev_device_get_devnode(d); + const char* sysname = udev_device_get_sysname(d); + const char* syspath = udev_device_get_syspath(d); + if (!action || !devnode || !sysname || !syspath) return; + if (std::strncmp(sysname, "event", 5) != 0) return; + + if (std::strcmp(action, "add") == 0) { + const char* name = udev_device_get_sysattr_value( + udev_device_get_parent_with_subsystem_devtype(d, "input", nullptr), + "name"); + auto* dev = OpenDevice(devnode, syspath, name); + if (dev) Crafter::Gamepad::onConnected.Invoke(dev); + } else if (std::strcmp(action, "remove") == 0) { + std::uint32_t id = SyspathToId(syspath); + for (auto& up : Crafter::Gamepad::connected) { + if (up->id == id) { + Crafter::Gamepad::Device* dev = up.get(); + Crafter::Gamepad::onDisconnected.Invoke(dev); + CloseDevice(dev); + return; + } + } + } + } + + void DrainHotplug() { + if (!g_monitor) return; + for (;;) { + udev_device* d = udev_monitor_receive_device(g_monitor); + if (!d) break; + HandleHotplugEvent(d); + udev_device_unref(d); + } + } + + void DrainDeviceEvents(Crafter::Gamepad::Device* dev, LinuxBackend* bp) { + using A = Crafter::Gamepad::Axis; + using B = Crafter::Gamepad::Button; + // Walk libevdev's internal queue; SYNC means we missed events and + // need to resync the state. We treat the synced events the same + // way as normal events — net effect is the polled state ends up + // correct and the event stream reports the deltas. + input_event ev; + int rc; + while ((rc = libevdev_next_event(bp->evdev, + LIBEVDEV_READ_FLAG_NORMAL | LIBEVDEV_READ_FLAG_SYNC, + &ev)) >= 0) + { + if (rc == LIBEVDEV_READ_STATUS_SYNC) continue; // handled in next iter + if (ev.type == EV_KEY) { + int b = ButtonFromKernel(ev.code); + if (b < 0) continue; + bool pressed = (ev.value != 0); + if (dev->buttons[b] == pressed) continue; + dev->buttons[b] = pressed; + if (pressed) dev->onButtonDown.Invoke((B)b); + else dev->onButtonUp .Invoke((B)b); + } else if (ev.type == EV_ABS) { + // ABS_HAT0X / ABS_HAT0Y emulate the D-pad on some + // controllers. Translate to the four DPad buttons so + // bindings can be uniform. + if (ev.code == ABS_HAT0X) { + bool left = ev.value < 0; + bool right = ev.value > 0; + auto edge = [&](int idx, bool now) { + if (dev->buttons[idx] == now) return; + dev->buttons[idx] = now; + if (now) dev->onButtonDown.Invoke((B)idx); + else dev->onButtonUp .Invoke((B)idx); + }; + edge((int)B::DPadLeft, left); + edge((int)B::DPadRight, right); + continue; + } + if (ev.code == ABS_HAT0Y) { + bool up = ev.value < 0; + bool down = ev.value > 0; + auto edge = [&](int idx, bool now) { + if (dev->buttons[idx] == now) return; + dev->buttons[idx] = now; + if (now) dev->onButtonDown.Invoke((B)idx); + else dev->onButtonUp .Invoke((B)idx); + }; + edge((int)B::DPadUp, up); + edge((int)B::DPadDown, down); + continue; + } + // Look up which Gamepad::Axis this kernel code maps to + // (by scanning the calibration table — sticks first, + // triggers second). + for (std::size_t i = 0; i < (std::size_t)A::Max; ++i) { + if (bp->axisInfo[i].code != ev.code) continue; + bool isTrigger = (i == (std::size_t)A::LeftTrigger + || i == (std::size_t)A::RightTrigger); + float v = NormalizeAxis(bp->axisInfo[i], ev.value, isTrigger); + if (dev->axes[i] == v) break; + dev->axes[i] = v; + dev->onAxisChanged.Invoke((A)i); + break; + } + } + } + } + + void ExpireRumbles() { + auto now = std::chrono::steady_clock::now(); + for (auto& [dev, bp] : g_backends) { + if (bp->ffEffectId >= 0 && now >= bp->rumbleExpiry) { + input_event stop{}; + stop.type = EV_FF; + stop.code = bp->ffEffectId; + stop.value = 0; + if (bp->fd >= 0) ::write(bp->fd, &stop, sizeof(stop)); + ioctl(bp->fd, EVIOCRMFF, bp->ffEffectId); + bp->ffEffectId = -1; + } + } + } + + void EnsureInit() { + if (g_initialized) return; + g_initialized = true; + g_udev = udev_new(); + if (!g_udev) return; + g_monitor = udev_monitor_new_from_netlink(g_udev, "udev"); + if (g_monitor) { + udev_monitor_filter_add_match_subsystem_devtype(g_monitor, "input", nullptr); + udev_monitor_enable_receiving(g_monitor); + } + EnumerateExisting(); + } +} + +void Crafter::Gamepad::Tick() { + EnsureInit(); + DrainHotplug(); + for (auto& up : Gamepad::connected) { + Device* dev = up.get(); + auto it = g_backends.find(dev); + if (it == g_backends.end()) continue; + DrainDeviceEvents(dev, it->second.get()); + } + ExpireRumbles(); +} + +void Crafter::Gamepad::Rumble(Device& dev, float low, float high, + std::chrono::milliseconds duration) +{ + auto it = g_backends.find(&dev); + if (it == g_backends.end()) return; + LinuxBackend* bp = it->second.get(); + if (bp->fd < 0) return; + // Cancel any active effect first. + if (bp->ffEffectId >= 0) { + input_event stop{}; + stop.type = EV_FF; stop.code = bp->ffEffectId; stop.value = 0; + ::write(bp->fd, &stop, sizeof(stop)); + ioctl(bp->fd, EVIOCRMFF, bp->ffEffectId); + bp->ffEffectId = -1; + } + if (duration.count() <= 0 || (low <= 0.0f && high <= 0.0f)) return; + auto clamp01 = [](float v) { return v < 0 ? 0.0f : v > 1.0f ? 1.0f : v; }; + ff_effect effect{}; + effect.type = FF_RUMBLE; + effect.id = -1; + effect.u.rumble.strong_magnitude = (std::uint16_t)(clamp01(low) * 0xFFFFu); + effect.u.rumble.weak_magnitude = (std::uint16_t)(clamp01(high) * 0xFFFFu); + effect.replay.length = (std::uint16_t)std::min(duration.count(), 0xFFFF); + if (ioctl(bp->fd, EVIOCSFF, &effect) < 0) return; + bp->ffEffectId = effect.id; + bp->rumbleExpiry = std::chrono::steady_clock::now() + duration; + input_event play{}; + play.type = EV_FF; + play.code = effect.id; + play.value = 1; + ::write(bp->fd, &play, sizeof(play)); +} +#endif + +// ──────────────────────────────────────────────────────────────────── +// Win32 backend (Windows.Gaming.Input via C ABI) +// ──────────────────────────────────────────────────────────────────── +#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 +namespace { + using IGamepad = __x_ABI_CWindows_CGaming_CInput_CIGamepad; + using IGamepadStatics = __x_ABI_CWindows_CGaming_CInput_CIGamepadStatics; + using GamepadAddedHandler = __FIEventHandler_1_Windows__CGaming__CInput__CGamepad; + using GamepadRemovedHandler = __FIEventHandler_1_Windows__CGaming__CInput__CGamepad; + using GamepadReading = __x_ABI_CWindows_CGaming_CInput_CGamepadReading; + using GamepadVibration = __x_ABI_CWindows_CGaming_CInput_CGamepadVibration; + using GamepadButtons = __x_ABI_CWindows_CGaming_CInput_CGamepadButtons; + + IGamepadStatics* g_statics = nullptr; + EventRegistrationToken g_addedToken {}; + EventRegistrationToken g_removedToken {}; + bool g_initialized = false; + + // Cross-thread queue. WGI fires Added/Removed on a thread pool; the + // event loop drains this on the main thread inside Tick(). + struct QueueEntry { + bool added; + IGamepad* gamepad; // AddRef'd + }; + std::mutex g_queueMutex; + std::vector g_queue; + + // Maps a live IGamepad* to its Crafter::Gamepad::Device. Pointer is + // stable from WGI's perspective for the lifetime of the connection. + std::unordered_map g_byGamepad; + + std::uint32_t PointerToId(IGamepad* g) { + // Hash the pointer for a stable-while-connected id. (NonRoamableId + // would be more durable across reconnects but adds another COM + // round-trip; pointer hash is fine for v1.) + std::uintptr_t v = reinterpret_cast(g); + v = (v ^ (v >> 16)) * 0x85EBCA6Bu; + v = (v ^ (v >> 13)) * 0xC2B2AE35u; + v = v ^ (v >> 16); + std::uint32_t out = (std::uint32_t)v; + return out == 0 ? 1 : out; + } + + // ── Delegate vtable for GamepadAdded / GamepadRemoved ─────────── + struct AddedDelegate { + // The first member matches the C ABI vtable layout. Cast yields + // a valid GamepadAddedHandler*. + void* vtbl; + LONG refCount; + bool isAdded; + }; + + extern GamepadAddedHandler::__GamepadAddedHandlerVtbl g_addedVtbl; + extern GamepadAddedHandler::__GamepadAddedHandlerVtbl g_removedVtbl; + + HRESULT STDMETHODCALLTYPE DelegateQI(GamepadAddedHandler* This, REFIID riid, void** out) { + if (IsEqualIID(riid, &IID_IUnknown) || IsEqualIID(riid, &IID_IAgileObject) || + IsEqualIID(riid, &IID___FIEventHandler_1_Windows__CGaming__CInput__CGamepad)) + { + *out = This; + This->lpVtbl->AddRef(This); + return S_OK; + } + *out = nullptr; + return E_NOINTERFACE; + } + ULONG STDMETHODCALLTYPE DelegateAddRef(GamepadAddedHandler* This) { + AddedDelegate* d = reinterpret_cast(This); + return (ULONG)InterlockedIncrement(&d->refCount); + } + ULONG STDMETHODCALLTYPE DelegateRelease(GamepadAddedHandler* This) { + AddedDelegate* d = reinterpret_cast(This); + LONG r = InterlockedDecrement(&d->refCount); + return (ULONG)r; + } + HRESULT STDMETHODCALLTYPE DelegateInvoke(GamepadAddedHandler* This, IInspectable* sender, IGamepad* gamepad) { + AddedDelegate* d = reinterpret_cast(This); + if (gamepad) IGamepad_AddRef(gamepad); + { + std::lock_guard lock(g_queueMutex); + g_queue.push_back({ d->isAdded, gamepad }); + } + return S_OK; + } + + GamepadAddedHandler::__GamepadAddedHandlerVtbl g_addedVtbl = { + DelegateQI, DelegateAddRef, DelegateRelease, DelegateInvoke + }; + GamepadAddedHandler::__GamepadAddedHandlerVtbl g_removedVtbl = { + DelegateQI, DelegateAddRef, DelegateRelease, DelegateInvoke + }; + + AddedDelegate g_addedDelegate { &g_addedVtbl, 1, true }; + AddedDelegate g_removedDelegate { &g_removedVtbl, 1, false }; + + // Translate GamepadReading.Buttons bitfield to our Button enum. + bool ReadingHas(const GamepadReading& r, GamepadButtons b) { + return (r.Buttons & b) != 0; + } + + void ApplyReading(Crafter::Gamepad::Device* dev, const GamepadReading& r) { + using B = Crafter::Gamepad::Button; + using A = Crafter::Gamepad::Axis; + + struct Map { B which; GamepadButtons mask; }; + static const Map buttonMap[] = { + { B::DPadUp, GamepadButtons_DPadUp }, + { B::DPadDown, GamepadButtons_DPadDown }, + { B::DPadLeft, GamepadButtons_DPadLeft }, + { B::DPadRight, GamepadButtons_DPadRight }, + { B::Start, GamepadButtons_Menu }, + { B::Select, GamepadButtons_View }, + { B::LeftStickClick, GamepadButtons_LeftThumbstick }, + { B::RightStickClick,GamepadButtons_RightThumbstick }, + { B::LeftBumper, GamepadButtons_LeftShoulder }, + { B::RightBumper, GamepadButtons_RightShoulder }, + { B::South, GamepadButtons_A }, + { B::East, GamepadButtons_B }, + { B::West, GamepadButtons_X }, + { B::North, GamepadButtons_Y }, + }; + for (const Map& m : buttonMap) { + bool now = ReadingHas(r, m.mask); + bool& cur = dev->buttons[(std::size_t)m.which]; + if (cur == now) continue; + cur = now; + if (now) dev->onButtonDown.Invoke(m.which); + else dev->onButtonUp .Invoke(m.which); + } + // WGI doesn't have a Home/Guide button in the standard Gamepad + // interface (it's reserved for the OS). Leave dev->buttons[Home] + // at false. + + auto axis = [&](A which, float v) { + float& cur = dev->axes[(std::size_t)which]; + if (cur == v) return; + cur = v; + dev->onAxisChanged.Invoke(which); + }; + axis(A::LeftStickX, (float)r.LeftThumbstickX); + axis(A::LeftStickY, (float)r.LeftThumbstickY); + axis(A::RightStickX, (float)r.RightThumbstickX); + axis(A::RightStickY, (float)r.RightThumbstickY); + axis(A::LeftTrigger, (float)r.LeftTrigger); + axis(A::RightTrigger, (float)r.RightTrigger); + + // Triggers also expose digital "pressed" buttons at a threshold. + constexpr float kTriggerThreshold = 0.1f; + auto trigBtn = [&](B which, double value) { + bool now = value >= kTriggerThreshold; + bool& cur = dev->buttons[(std::size_t)which]; + if (cur == now) return; + cur = now; + if (now) dev->onButtonDown.Invoke(which); + else dev->onButtonUp .Invoke(which); + }; + trigBtn(B::LeftTrigger, r.LeftTrigger); + trigBtn(B::RightTrigger, r.RightTrigger); + } + + void EnsureInit() { + if (g_initialized) return; + g_initialized = true; + // RoInitialize is required once per thread; ignore RPC_E_CHANGED_MODE + // (some hosts initialize as STA, that's fine for WGI). + RoInitialize(RO_INIT_MULTITHREADED); + + HSTRING_HEADER header; + HSTRING className; + const wchar_t* name = RuntimeClass_Windows_Gaming_Input_Gamepad; + if (FAILED(WindowsCreateStringReference(name, + (UINT32)wcslen(name), &header, &className))) return; + + if (FAILED(RoGetActivationFactory(className, + &IID___x_ABI_CWindows_CGaming_CInput_CIGamepadStatics, + (void**)&g_statics))) return; + + __x_ABI_CWindows_CGaming_CInput_CIGamepadStatics_add_GamepadAdded( + g_statics, + reinterpret_cast(&g_addedDelegate), + &g_addedToken); + __x_ABI_CWindows_CGaming_CInput_CIGamepadStatics_add_GamepadRemoved( + g_statics, + reinterpret_cast(&g_removedDelegate), + &g_removedToken); + } +} + +void Crafter::Gamepad::Tick() { + EnsureInit(); + if (!g_statics) return; + + // Drain the cross-thread connect/disconnect queue. + std::vector pending; + { + std::lock_guard lock(g_queueMutex); + pending.swap(g_queue); + } + for (QueueEntry& e : pending) { + if (!e.gamepad) continue; + if (e.added) { + if (g_byGamepad.find(e.gamepad) != g_byGamepad.end()) { + IGamepad_Release(e.gamepad); + continue; + } + auto dev = std::make_unique(); + dev->id = PointerToId(e.gamepad); + dev->name = "Gamepad"; + auto* raw = dev.get(); + g_byGamepad[e.gamepad] = raw; + connected.push_back(std::move(dev)); + onConnected.Invoke(raw); + // keep AddRef'd; released on disconnect + } else { + auto it = g_byGamepad.find(e.gamepad); + if (it != g_byGamepad.end()) { + Crafter::Gamepad::Device* raw = it->second; + onDisconnected.Invoke(raw); + for (auto vi = connected.begin(); vi != connected.end(); ++vi) { + if (vi->get() == raw) { connected.erase(vi); break; } + } + g_byGamepad.erase(it); + } + IGamepad_Release(e.gamepad); + } + } + + // Poll each connected gamepad. + for (auto& [gp, dev] : g_byGamepad) { + GamepadReading reading{}; + if (FAILED(__x_ABI_CWindows_CGaming_CInput_CIGamepad_GetCurrentReading(gp, &reading))) continue; + ApplyReading(dev, reading); + } +} + +void Crafter::Gamepad::Rumble(Device& dev, float low, float high, + std::chrono::milliseconds /*duration*/) +{ + // WGI vibration is "set and forget" — no duration. Caller can stop + // by re-calling with zero magnitudes. We honor 0 duration as stop. + IGamepad* gp = nullptr; + for (auto& [g, d] : g_byGamepad) { + if (d == &dev) { gp = g; break; } + } + if (!gp) return; + auto clamp01 = [](float v) { return v < 0 ? 0.0f : v > 1.0f ? 1.0f : v; }; + GamepadVibration vib{}; + vib.LeftMotor = clamp01(low); + vib.RightMotor = clamp01(high); + __x_ABI_CWindows_CGaming_CInput_CIGamepad_put_Vibration(gp, vib); +} +#endif + +Crafter::Gamepad::Device* Crafter::Gamepad::FindById(std::uint32_t id) { + for (auto& up : connected) { + if (up->id == id) return up.get(); + } + return nullptr; +} diff --git a/implementations/Crafter.Graphics-Input.cpp b/implementations/Crafter.Graphics-Input.cpp new file mode 100644 index 0000000..74a7222 --- /dev/null +++ b/implementations/Crafter.Graphics-Input.cpp @@ -0,0 +1,454 @@ +/* +Crafter®.Graphics +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ +module; +module Crafter.Graphics; +import std; +import Crafter.Math; +import Crafter.Event; + +using namespace Crafter; +using namespace Crafter::Input; + +// ──────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────── +namespace { + // Look up a connected gamepad by stable id. Returns nullptr if not + // currently connected — the binding becomes inert until reconnect. + Gamepad::Device* FindGamepad(std::uint32_t id) { + return Gamepad::FindById(id); + } + + float ApplyDeadzone(float v, float dz) { + float a = std::abs(v); + if (a <= dz) return 0.0f; + float sign = v < 0 ? -1.0f : 1.0f; + return sign * (a - dz) / (1.0f - dz); + } + + Vector ApplyRadialDeadzone(Vector v, float dz) { + float mag = std::sqrt(v.x * v.x + v.y * v.y); + if (mag <= dz) return Vector{0.0f, 0.0f}; + float scaled = (mag - dz) / (1.0f - dz); + float k = scaled / mag; + return Vector{ v.x * k, v.y * k }; + } + + // ─── Evaluate one binding to its raw contribution. ─────────────── + // Buttons: bool. Axis: float. Vector2: pair. + struct Eval { + bool button = false; + float value = 0.0f; + Vector vec { 0.0f, 0.0f }; + }; + + Eval EvalBinding(const Binding& b, Window* w, std::int32_t scrollAccum, + Vector mouseDelta, float deadzone) + { + Eval r; + std::visit([&](auto const& bb) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + if (w && w->heldKeys.contains(bb.code)) r.button = true; + r.value = r.button ? 1.0f : 0.0f; + } else if constexpr (std::is_same_v) { + if (w) { + if (bb.button == 0 && w->mouseLeftHeld) r.button = true; + if (bb.button == 1 && w->mouseRightHeld) r.button = true; + } + r.value = r.button ? 1.0f : 0.0f; + } else if constexpr (std::is_same_v) { + r.value = (float)scrollAccum; + r.button = scrollAccum != 0; + } else if constexpr (std::is_same_v) { + r.vec = Vector{ + mouseDelta.x * bb.scale, + mouseDelta.y * bb.scale + }; + } else if constexpr (std::is_same_v) { + auto* gp = FindGamepad(bb.gamepadId); + if (gp) r.button = gp->buttons[(std::size_t)bb.button]; + r.value = r.button ? 1.0f : 0.0f; + } else if constexpr (std::is_same_v) { + auto* gp = FindGamepad(bb.gamepadId); + if (gp) { + float v = gp->axes[(std::size_t)bb.axis]; + if (bb.invert) v = -v; + // Triggers (0..1) and sticks (-1..1) share this path; + // deadzone applies symmetrically. + r.value = ApplyDeadzone(v, deadzone); + r.button = std::abs(r.value) > 0.0f; + } + } else if constexpr (std::is_same_v) { + auto* gp = FindGamepad(bb.gamepadId); + if (gp) { + using A = Gamepad::Axis; + A xAxis = (bb.stick == Gamepad::Stick::Left) ? A::LeftStickX : A::RightStickX; + A yAxis = (bb.stick == Gamepad::Stick::Left) ? A::LeftStickY : A::RightStickY; + Vector raw{ + gp->axes[(std::size_t)xAxis], + gp->axes[(std::size_t)yAxis] + }; + r.vec = ApplyRadialDeadzone(raw, deadzone); + } + } else if constexpr (std::is_same_v) { + Vector v{ 0.0f, 0.0f }; + if (w) { + if (w->heldKeys.contains(bb.left)) v.x -= 1.0f; + if (w->heldKeys.contains(bb.right)) v.x += 1.0f; + if (w->heldKeys.contains(bb.up)) v.y += 1.0f; + if (w->heldKeys.contains(bb.down)) v.y -= 1.0f; + } + r.vec = v; + } + }, b); + return r; + } + + // Combine bindings for the action's type. For Button: OR. For Axis1D: + // sum-then-clamp (so two analog inputs add but cap at ±1; two + // digital inputs cap at 1 too). For Vector2: per-axis sum-and-clamp. + void EvaluateAction(Action& a, Window* w, std::int32_t scrollAccum, + Vector mouseDelta) + { + bool nextPressed = false; + float nextValue = 0.0f; + Vector nextVec { 0.0f, 0.0f }; + + for (const Binding& b : a.bindings) { + Eval e = EvalBinding(b, w, scrollAccum, mouseDelta, a.deadzone); + if (e.button) nextPressed = true; + nextValue += e.value; + nextVec.x += e.vec.x; + nextVec.y += e.vec.y; + } + // Clamp scalars/vectors to ±1; sum semantics for combining. + auto clamp = [](float v) { return v < -1.0f ? -1.0f : v > 1.0f ? 1.0f : v; }; + nextValue = clamp(nextValue); + nextVec.x = clamp(nextVec.x); + nextVec.y = clamp(nextVec.y); + + // Dispatch edges + value changes. + if (a.type == ActionType::Button) { + if (nextPressed && !a.pressed) { + a.pressed = true; + a.onPerformed.Invoke(); + } else if (!nextPressed && a.pressed) { + a.pressed = false; + a.onCanceled.Invoke(); + } + } else if (a.type == ActionType::Axis1D) { + bool wasNonZero = a.value != 0.0f; + bool isNonZero = nextValue != 0.0f; + if (nextValue != a.value) { + a.value = nextValue; + a.onValueChanged.Invoke(nextValue); + } + if (isNonZero && !wasNonZero) a.onPerformed.Invoke(); + else if (!isNonZero && wasNonZero) a.onCanceled.Invoke(); + } else { // Vector2 + bool wasNonZero = (a.vector2.x != 0.0f || a.vector2.y != 0.0f); + bool isNonZero = (nextVec.x != 0.0f || nextVec.y != 0.0f); + if (nextVec.x != a.vector2.x || nextVec.y != a.vector2.y) { + a.vector2 = nextVec; + a.onVector2Changed.Invoke(nextVec); + } + if (isNonZero && !wasNonZero) a.onPerformed.Invoke(); + else if (!isNonZero && wasNonZero) a.onCanceled.Invoke(); + } + } + + // ─── Rebind detection ─────────────────────────────────────────── + // Scan for a fresh down-edge against the snapshot taken at + // StartRebind. Returns the captured binding if found, nullopt if + // nothing fresh is held. The first match wins; preference goes + // keyboard > mouse > gamepad to keep the behavior predictable. + std::optional DetectRebindEdge(Map::RebindState& s, Window* w) { + // Keyboard + if (w && HasFlag(s.mask, CaptureMask::Keyboard)) { + for (KeyCode c : w->heldKeys) { + if (!s.keysHeldAtStart.contains(c)) { + return Binding{ KeyBind{c} }; + } + } + } + // Mouse buttons + if (w && HasFlag(s.mask, CaptureMask::Mouse)) { + if (w->mouseLeftHeld && !s.mouseLeftHeldAtStart) { + return Binding{ MouseButtonBind{0} }; + } + if (w->mouseRightHeld && !s.mouseRightHeldAtStart) { + return Binding{ MouseButtonBind{1} }; + } + } + // Gamepad buttons + axes + if (HasFlag(s.mask, CaptureMask::Gamepad)) { + for (auto& up : Gamepad::connected) { + Gamepad::Device* gp = up.get(); + auto it = s.gamepadButtonsAtStart.find(gp->id); + bool hadSnapshot = it != s.gamepadButtonsAtStart.end(); + for (std::size_t i = 0; i < (std::size_t)Gamepad::Button::Max; ++i) { + bool now = gp->buttons[i]; + bool before = hadSnapshot ? it->second[i] : false; + if (now && !before) { + return Binding{ GamepadButtonBind{ + gp->id, (Gamepad::Button)i + } }; + } + } + // Axes: any value past 0.5 captures as a 1-D axis bind. + constexpr float kRebindAxisThreshold = 0.5f; + for (std::size_t i = 0; i < (std::size_t)Gamepad::Axis::Max; ++i) { + float v = gp->axes[i]; + if (std::abs(v) >= kRebindAxisThreshold) { + return Binding{ GamepadAxisBind{ + gp->id, (Gamepad::Axis)i, v < 0.0f + } }; + } + } + } + } + return std::nullopt; + } +} + +// ──────────────────────────────────────────────────────────────────── +// Map +// ──────────────────────────────────────────────────────────────────── +Action& Map::AddAction(std::string name, ActionType type) { + auto up = std::make_unique(); + up->name = std::move(name); + up->type = type; + Action& ref = *up; + actions.push_back(std::move(up)); + return ref; +} + +Action* Map::Find(std::string_view name) { + for (auto& up : actions) { + if (up->name == name) return up.get(); + } + return nullptr; +} + +void Map::Attach(Window& w) { + Detach(); + window = &w; + // Only event-driven source we need is mouse scroll — everything + // else (held keys, mouse buttons, mouse position, gamepad state) is + // polled directly. Accumulate scroll between ticks so a flick that + // fires multiple wheel events in one frame all count. + scrollListener = std::make_unique>( + &w.onMouseScroll, + [this](std::uint32_t delta) { + // Wayland and Win32 both deliver scroll as a signed delta + // packed into a uint32; we reinterpret to preserve sign. + scrollAccumulator += (std::int32_t)delta; + }); + lastMousePos.reset(); + scrollAccumulator = 0; +} + +void Map::Detach() { + if (scrollListener) { + scrollListener->Clear(); + scrollListener.reset(); + } + window = nullptr; + scrollAccumulator = 0; + lastMousePos.reset(); + rebind.reset(); +} + +void Map::Tick() { + // Mouse delta: derived from window position vs. last frame. + Vector mouseDelta{ 0.0f, 0.0f }; + if (window) { + if (lastMousePos.has_value()) { + mouseDelta.x = window->currentMousePos.x - lastMousePos->x; + mouseDelta.y = window->currentMousePos.y - lastMousePos->y; + } + lastMousePos = window->currentMousePos; + } + + // Rebind takes priority. If a fresh edge is detected, fire the + // callback and exit rebind mode. Actions don't evaluate this tick + // for the captured input — the user usually wants the bind to take + // effect on the NEXT press of that input. + if (rebind.has_value()) { + if (auto captured = DetectRebindEdge(*rebind, window)) { + auto cb = std::move(rebind->onCaptured); + rebind.reset(); + // Drain scroll so the next Tick doesn't double-fire on the + // same wheel input that may have triggered the rebind. + scrollAccumulator = 0; + cb(*captured); + return; + } + } + + std::int32_t scrollThisTick = scrollAccumulator; + scrollAccumulator = 0; + + for (auto& up : actions) { + EvaluateAction(*up, window, scrollThisTick, mouseDelta); + } +} + +void Map::StartRebind(Action& action, CaptureMask mask, + std::function onCaptured) +{ + RebindState s; + s.action = &action; + s.mask = mask; + s.onCaptured = std::move(onCaptured); + if (window) { + s.keysHeldAtStart = window->heldKeys; + s.mouseLeftHeldAtStart = window->mouseLeftHeld; + s.mouseRightHeldAtStart = window->mouseRightHeld; + } + for (auto& up : Gamepad::connected) { + std::array snap{}; + for (std::size_t i = 0; i < snap.size(); ++i) { + snap[i] = up->buttons[i]; + } + s.gamepadButtonsAtStart[up->id] = snap; + } + rebind.emplace(std::move(s)); +} + +void Map::StopRebind() { + rebind.reset(); +} + +bool Map::IsRebinding() const { + return rebind.has_value(); +} + +// ──────────────────────────────────────────────────────────────────── +// Serialization +// ──────────────────────────────────────────────────────────────────── +std::string Crafter::Input::BindingToString(const Binding& b) { + std::string out; + std::visit([&](auto const& bb) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + out = std::format("key:{:x}", bb.code); + } else if constexpr (std::is_same_v) { + out = std::format("mb:{}", (int)bb.button); + } else if constexpr (std::is_same_v) { + out = "mscroll"; + } else if constexpr (std::is_same_v) { + out = std::format("mdelta:{}", bb.scale); + } else if constexpr (std::is_same_v) { + out = std::format("gpb:{}:{}", bb.gamepadId, (int)bb.button); + } else if constexpr (std::is_same_v) { + out = std::format("gpa:{}:{}:{}", bb.gamepadId, (int)bb.axis, bb.invert ? 1 : 0); + } else if constexpr (std::is_same_v) { + out = std::format("gps:{}:{}", bb.gamepadId, (int)bb.stick); + } else if constexpr (std::is_same_v) { + out = std::format("wasd:{:x}:{:x}:{:x}:{:x}", + bb.up, bb.down, bb.left, bb.right); + } + }, b); + return out; +} + +namespace { + // Split on ':' and parse. Tolerates leading whitespace; rejects + // malformed input by returning nullopt. + std::vector Split(std::string_view s) { + std::vector parts; + std::size_t start = 0; + for (std::size_t i = 0; i < s.size(); ++i) { + if (s[i] == ':') { + parts.emplace_back(s.substr(start, i - start)); + start = i + 1; + } + } + parts.emplace_back(s.substr(start)); + return parts; + } + + bool ParseHex(std::string_view s, std::uint32_t& out) { + auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), out, 16); + return ec == std::errc{} && p == s.data() + s.size(); + } + bool ParseDec(std::string_view s, std::uint32_t& out) { + auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), out, 10); + return ec == std::errc{} && p == s.data() + s.size(); + } + bool ParseFloat(std::string_view s, float& out) { + auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), out); + return ec == std::errc{} && p == s.data() + s.size(); + } +} + +std::optional Crafter::Input::BindingFromString(std::string_view s) { + auto parts = Split(s); + if (parts.empty()) return std::nullopt; + const std::string_view tag = parts[0]; + + if (tag == "key" && parts.size() == 2) { + std::uint32_t v; + if (!ParseHex(parts[1], v)) return std::nullopt; + return Binding{ KeyBind{ v } }; + } + if (tag == "mb" && parts.size() == 2) { + std::uint32_t v; + if (!ParseDec(parts[1], v) || v > 255) return std::nullopt; + return Binding{ MouseButtonBind{ (std::uint8_t)v } }; + } + if (tag == "mscroll") return Binding{ MouseScrollBind{} }; + if (tag == "mdelta" && parts.size() == 2) { + float scale; + if (!ParseFloat(parts[1], scale)) return std::nullopt; + return Binding{ MouseDeltaBind{ scale } }; + } + if (tag == "gpb" && parts.size() == 3) { + std::uint32_t id, btn; + if (!ParseDec(parts[1], id) || !ParseDec(parts[2], btn)) return std::nullopt; + if (btn >= (std::uint32_t)Gamepad::Button::Max) return std::nullopt; + return Binding{ GamepadButtonBind{ id, (Gamepad::Button)btn } }; + } + if (tag == "gpa" && parts.size() == 4) { + std::uint32_t id, ax, inv; + if (!ParseDec(parts[1], id) || !ParseDec(parts[2], ax) || !ParseDec(parts[3], inv)) { + return std::nullopt; + } + if (ax >= (std::uint32_t)Gamepad::Axis::Max) return std::nullopt; + return Binding{ GamepadAxisBind{ id, (Gamepad::Axis)ax, inv != 0 } }; + } + if (tag == "gps" && parts.size() == 3) { + std::uint32_t id, st; + if (!ParseDec(parts[1], id) || !ParseDec(parts[2], st)) return std::nullopt; + if (st > 1) return std::nullopt; + return Binding{ GamepadStickBind{ id, (Gamepad::Stick)st } }; + } + if (tag == "wasd" && parts.size() == 5) { + std::uint32_t u, d, l, r; + if (!ParseHex(parts[1], u) || !ParseHex(parts[2], d) || + !ParseHex(parts[3], l) || !ParseHex(parts[4], r)) { + return std::nullopt; + } + return Binding{ WASDBind{ u, d, l, r } }; + } + return std::nullopt; +} diff --git a/implementations/Crafter.Graphics-InputField.cpp b/implementations/Crafter.Graphics-InputField.cpp index 3ea79c9..b59f192 100644 --- a/implementations/Crafter.Graphics-InputField.cpp +++ b/implementations/Crafter.Graphics-InputField.cpp @@ -23,6 +23,7 @@ import :UI; import :UIComponents; import :Font; import :Types; +import :Keys; import std; using namespace Crafter; @@ -87,32 +88,26 @@ void Crafter::InputField_OnText(InputField& f, std::string_view utf8) { f.cursorPos += utf8.size(); } -void Crafter::InputField_OnKey(InputField& f, CrafterKeys key) { - switch (key) { - case CrafterKeys::Backspace: - if (f.cursorPos > 0 && !f.value.empty()) { - f.value.erase(f.cursorPos - 1, 1); - --f.cursorPos; - } - break; - case CrafterKeys::Delete: - if (f.cursorPos < f.value.size()) { - f.value.erase(f.cursorPos, 1); - } - break; - case CrafterKeys::Left: - if (f.cursorPos > 0) --f.cursorPos; - break; - case CrafterKeys::Right: - if (f.cursorPos < f.value.size()) ++f.cursorPos; - break; - case CrafterKeys::Home: - f.cursorPos = 0; - break; - case CrafterKeys::End: - f.cursorPos = f.value.size(); - break; - default: break; +void Crafter::InputField_OnKey(InputField& f, KeyCode code) { + // `Key(...)` is consteval — every comparison below folds to an immediate + // integer at compile time, so this is just a sequence of int-eq tests. + if (code == Key(CrafterKeys::Backspace)) { + if (f.cursorPos > 0 && !f.value.empty()) { + f.value.erase(f.cursorPos - 1, 1); + --f.cursorPos; + } + } else if (code == Key(CrafterKeys::Delete)) { + if (f.cursorPos < f.value.size()) { + f.value.erase(f.cursorPos, 1); + } + } else if (code == Key(CrafterKeys::Left)) { + if (f.cursorPos > 0) --f.cursorPos; + } else if (code == Key(CrafterKeys::Right)) { + if (f.cursorPos < f.value.size()) ++f.cursorPos; + } else if (code == Key(CrafterKeys::Home)) { + f.cursorPos = 0; + } else if (code == Key(CrafterKeys::End)) { + f.cursorPos = f.value.size(); } } diff --git a/implementations/Crafter.Graphics-Mesh.cpp b/implementations/Crafter.Graphics-Mesh.cpp index 0b83c12..6f87c52 100644 --- a/implementations/Crafter.Graphics-Mesh.cpp +++ b/implementations/Crafter.Graphics-Mesh.cpp @@ -21,16 +21,112 @@ module; #include "vulkan/vulkan.h" module Crafter.Graphics:Mesh_impl; import Crafter.Math; +import Crafter.Asset; import :Mesh; import :Device; +import :Decompress; import :Types; import std; using namespace Crafter; +namespace { + // Buffer-usage flag set shared by both Build paths. The compressed path + // appends VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT. + constexpr VkBufferUsageFlags2 kVertexUsageBase = + VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT + | VK_BUFFER_USAGE_2_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR; + constexpr VkBufferUsageFlags2 kIndexUsageBase = + kVertexUsageBase | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; + + void RecordBLASBuild(Mesh& self, std::uint32_t vertexCount, std::uint32_t indexCount, VkCommandBuffer cmd) { + VkDeviceOrHostAddressConstKHR vertexAddr; + vertexAddr.deviceAddress = self.vertexBuffer.address; + + VkDeviceOrHostAddressConstKHR indexAddr; + indexAddr.deviceAddress = self.indexBuffer.address; + + auto trianglesData = VkAccelerationStructureGeometryTrianglesDataKHR { + .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_TRIANGLES_DATA_KHR, + .vertexFormat = VK_FORMAT_R32G32B32_SFLOAT, + .vertexData = vertexAddr, + .vertexStride = sizeof(Vector), + .maxVertex = vertexCount - 1, + .indexType = VK_INDEX_TYPE_UINT32, + .indexData = indexAddr, + .transformData = {.deviceAddress = 0} + }; + VkAccelerationStructureGeometryDataKHR geometryData(trianglesData); + VkAccelerationStructureGeometryKHR blasGeometry { + .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR, + .geometryType = VK_GEOMETRY_TYPE_TRIANGLES_KHR, + .geometry = geometryData, + .flags = VK_GEOMETRY_OPAQUE_BIT_KHR + }; + + VkAccelerationStructureBuildGeometryInfoKHR blasBuildGeometryInfo{ + .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_GEOMETRY_INFO_KHR, + .type = VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR, + .mode = VK_BUILD_ACCELERATION_STRUCTURE_MODE_BUILD_KHR, + .geometryCount = 1, + .pGeometries = &blasGeometry, + }; + + auto primitiveCount = indexCount / 3; + VkAccelerationStructureBuildSizesInfoKHR blasBuildSizes = { + .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_SIZES_INFO_KHR + }; + Device::vkGetAccelerationStructureBuildSizesKHR( + Device::device, + VK_ACCELERATION_STRUCTURE_BUILD_TYPE_DEVICE_KHR, + &blasBuildGeometryInfo, + &primitiveCount, + &blasBuildSizes + ); + + self.scratchBuffer.Resize( + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, + blasBuildSizes.buildScratchSize); + blasBuildGeometryInfo.scratchData.deviceAddress = self.scratchBuffer.address; + + self.blasBuffer.Resize( + VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_STORAGE_BIT_KHR + | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT + | VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, + blasBuildSizes.accelerationStructureSize); + + VkAccelerationStructureCreateInfoKHR blasCreateInfo{ + .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_CREATE_INFO_KHR, + .buffer = self.blasBuffer.buffer, + .offset = 0, + .size = blasBuildSizes.accelerationStructureSize, + .type = VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR, + }; + Device::CheckVkResult(Device::vkCreateAccelerationStructureKHR(Device::device, &blasCreateInfo, nullptr, &self.accelerationStructure)); + blasBuildGeometryInfo.dstAccelerationStructure = self.accelerationStructure; + + VkAccelerationStructureBuildRangeInfoKHR blasRangeInfo { + .primitiveCount = primitiveCount, + .primitiveOffset = 0, + .firstVertex = 0, + .transformOffset = 0 + }; + VkAccelerationStructureBuildRangeInfoKHR* blasRangeInfoPP = &blasRangeInfo; + Device::vkCmdBuildAccelerationStructuresKHR(cmd, 1, &blasBuildGeometryInfo, &blasRangeInfoPP); + + VkAccelerationStructureDeviceAddressInfoKHR addrInfo { + .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_DEVICE_ADDRESS_INFO_KHR, + .accelerationStructure = self.accelerationStructure + }; + self.blasAddr = Device::vkGetAccelerationStructureDeviceAddressKHR(Device::device, &addrInfo); + } +} + void Mesh::Build(std::span> verticies, std::span indicies, VkCommandBuffer cmd) { - vertexBuffer.Resize(VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_2_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, verticies.size()); - indexBuffer.Resize(VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_2_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, indicies.size()); + vertexBuffer.Resize(kVertexUsageBase, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, verticies.size()); + indexBuffer.Resize(kIndexUsageBase, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, indicies.size()); std::memcpy(vertexBuffer.value, verticies.data(), verticies.size() * sizeof(Vector)); std::memcpy(indexBuffer.value, indicies.data(), indicies.size() * sizeof(std::uint32_t)); @@ -38,91 +134,72 @@ void Mesh::Build(std::span> verticies, std::span(verticies.size()), static_cast(indicies.size()), cmd); +} - VkDeviceOrHostAddressConstKHR indexAddr; - indexAddr.deviceAddress = indexBuffer.address; +void Mesh::Build(const CompressedMeshAsset& asset, VkCommandBuffer cmd) { + if (!Device::memoryDecompressionSupported) { + // CPU fallback: decompress into temporary host vectors, then take + // the existing uncompressed path. The data region is decompressed + // into a discard buffer (consumer is expected to handle data-stream + // decoding via Compression::DecompressCPU on its own buffer). + std::vector> vertices(asset.vertexCount); + std::vector indices(asset.indexCount); + std::vector dataDiscard( + asset.blob.regions.size() >= 3 ? asset.blob.regions[2].decompressedSize : 0); + std::array, 3> outputs = { + std::as_writable_bytes(std::span(vertices)), + std::as_writable_bytes(std::span(indices)), + std::span(dataDiscard), + }; + Compression::DecompressCPU(asset.blob, std::span(outputs).first(asset.blob.regions.size())); + Build(vertices, indices, cmd); + return; + } - auto trianglesData = VkAccelerationStructureGeometryTrianglesDataKHR { - .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_TRIANGLES_DATA_KHR, - .vertexFormat = VK_FORMAT_R32G32B32_SFLOAT, - .vertexData = vertexAddr, - .vertexStride = sizeof(Vector), - .maxVertex = static_cast(verticies.size())-1, - .indexType = VK_INDEX_TYPE_UINT32, - .indexData = indexAddr, - .transformData = {.deviceAddress = 0} - }; - VkAccelerationStructureGeometryDataKHR geometryData(trianglesData); - VkAccelerationStructureGeometryKHR blasGeometry { - .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR, - .geometryType = VK_GEOMETRY_TYPE_TRIANGLES_KHR, - .geometry = geometryData, - .flags = VK_GEOMETRY_OPAQUE_BIT_KHR + vertexBuffer.Resize( + kVertexUsageBase | VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, + asset.vertexCount); + indexBuffer.Resize( + kIndexUsageBase | VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, + asset.indexCount); + + compressedStaging.Resize( + VK_BUFFER_USAGE_TRANSFER_SRC_BIT + | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT + | VK_BUFFER_USAGE_2_MEMORY_DECOMPRESSION_BIT_EXT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, + static_cast(asset.blob.bytes.size())); + std::memcpy(compressedStaging.value, asset.blob.bytes.data(), asset.blob.bytes.size()); + compressedStaging.FlushDevice(); + + std::array dstAddresses = { + vertexBuffer.address, + indexBuffer.address, + 0, // data region is not consumed by Mesh; caller handles it separately. }; + std::vector regions; + for (std::size_t i = 0; i < asset.blob.regions.size() && i < 2; ++i) { + const Compression::RegionMeta& r = asset.blob.regions[i]; + if (r.decompressedSize == 0) continue; + std::span streamBytes( + asset.blob.bytes.data() + r.srcOffset, + static_cast(r.compressedSize)); + Decompress::ExpandStreamToTileRegions( + streamBytes, + compressedStaging.address + r.srcOffset, + dstAddresses[i], + regions); + } - VkAccelerationStructureBuildGeometryInfoKHR blasBuildGeometryInfo{ - .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_GEOMETRY_INFO_KHR, - .type = VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR, - .mode = VK_BUILD_ACCELERATION_STRUCTURE_MODE_BUILD_KHR, - .geometryCount = 1, - .pGeometries = &blasGeometry, - }; + Decompress::DecompressOnGPU( + cmd, + regions, + VK_PIPELINE_STAGE_2_ACCELERATION_STRUCTURE_BUILD_BIT_KHR, + VK_ACCESS_2_ACCELERATION_STRUCTURE_READ_BIT_KHR); - // Query the memory sizes that will be needed for this BLAS - auto primitiveCount = static_cast(indicies.size() / 3); + RecordBLASBuild(*this, asset.vertexCount, asset.indexCount, cmd); +} - VkAccelerationStructureBuildSizesInfoKHR blasBuildSizes = { - .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_SIZES_INFO_KHR - }; - - Device::vkGetAccelerationStructureBuildSizesKHR( - Device::device, - VK_ACCELERATION_STRUCTURE_BUILD_TYPE_DEVICE_KHR, - &blasBuildGeometryInfo, - &primitiveCount, - &blasBuildSizes - ); - - scratchBuffer.Resize(VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, blasBuildSizes.buildScratchSize); - - VkPhysicalDeviceAccelerationStructurePropertiesKHR asProps{ - .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ACCELERATION_STRUCTURE_PROPERTIES_KHR - }; - - VkDeviceAddress scratchAddr = scratchBuffer.address; - blasBuildGeometryInfo.scratchData.deviceAddress = scratchAddr; - - blasBuffer.Resize(VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_STORAGE_BIT_KHR | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, blasBuildSizes.accelerationStructureSize); - - // Create and store the BLAS handle - VkAccelerationStructureCreateInfoKHR blasCreateInfo{ - .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_CREATE_INFO_KHR, - .buffer = blasBuffer.buffer, - .offset = 0, - .size = blasBuildSizes.accelerationStructureSize, - .type = VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR, - }; - - - Device::CheckVkResult(Device::vkCreateAccelerationStructureKHR(Device::device, &blasCreateInfo, nullptr, &accelerationStructure)); - blasBuildGeometryInfo.dstAccelerationStructure = accelerationStructure; - - // Prepare the build range for the BLAS - VkAccelerationStructureBuildRangeInfoKHR blasRangeInfo { - .primitiveCount = primitiveCount, - .primitiveOffset = 0, - .firstVertex = 0, - .transformOffset = 0 - }; - - VkAccelerationStructureBuildRangeInfoKHR* blasRangeInfoPP = &blasRangeInfo; - Device::vkCmdBuildAccelerationStructuresKHR(cmd, 1, &blasBuildGeometryInfo, &blasRangeInfoPP); - - VkAccelerationStructureDeviceAddressInfoKHR addrInfo { - .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_DEVICE_ADDRESS_INFO_KHR, - .accelerationStructure = accelerationStructure - }; - blasAddr = Device::vkGetAccelerationStructureDeviceAddressKHR(Device::device, &addrInfo); -} \ No newline at end of file diff --git a/implementations/Crafter.Graphics-UI.cpp b/implementations/Crafter.Graphics-UI.cpp index b54abf0..22d1f1b 100644 --- a/implementations/Crafter.Graphics-UI.cpp +++ b/implementations/Crafter.Graphics-UI.cpp @@ -68,6 +68,14 @@ void UIRenderer::Initialize(Window& window, DescriptorHeapVulkan& heap, VkComman for (auto& h : heap_->resourceHeap) h.FlushDevice(); for (auto& h : heap_->samplerHeap) h.FlushDevice(); + // Re-bind the swapchain image descriptors after every resize. Window + // already drained the queue and rebuilt the imageViews[] before + // firing the event, so vkWriteResourceDescriptorsEXT is safe here. + resizeSub_.SetEvent(&window.onResize, [this]() { + WriteSwapchainDescriptors(); + for (auto& h : heap_->resourceHeap) h.FlushDevice(); + }); + (void)initCmd; // reserved for future image-layout tweaks } diff --git a/implementations/Crafter.Graphics-Window.cpp b/implementations/Crafter.Graphics-Window.cpp index 1f7ca12..0a489bf 100644 --- a/implementations/Crafter.Graphics-Window.cpp +++ b/implementations/Crafter.Graphics-Window.cpp @@ -56,6 +56,7 @@ module; module Crafter.Graphics:Window_impl; import :Window; import :Device; +import :Gamepad; import :VulkanTransition; import :DescriptorHeapVulkan; import :RenderPass; @@ -110,131 +111,15 @@ int create_shm_file(off_t size) { #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 -CrafterKeys vk_to_crafter_key(WPARAM vk) -{ - switch (vk) - { - // Alphabet - case 'A': return CrafterKeys::A; - case 'B': return CrafterKeys::B; - case 'C': return CrafterKeys::C; - case 'D': return CrafterKeys::D; - case 'E': return CrafterKeys::E; - case 'F': return CrafterKeys::F; - case 'G': return CrafterKeys::G; - case 'H': return CrafterKeys::H; - case 'I': return CrafterKeys::I; - case 'J': return CrafterKeys::J; - case 'K': return CrafterKeys::K; - case 'L': return CrafterKeys::L; - case 'M': return CrafterKeys::M; - case 'N': return CrafterKeys::N; - case 'O': return CrafterKeys::O; - case 'P': return CrafterKeys::P; - case 'Q': return CrafterKeys::Q; - case 'R': return CrafterKeys::R; - case 'S': return CrafterKeys::S; - case 'T': return CrafterKeys::T; - case 'U': return CrafterKeys::U; - case 'V': return CrafterKeys::V; - case 'W': return CrafterKeys::W; - case 'X': return CrafterKeys::X; - case 'Y': return CrafterKeys::Y; - case 'Z': return CrafterKeys::Z; - - // Numbers - case '0': return CrafterKeys::_0; - case '1': return CrafterKeys::_1; - case '2': return CrafterKeys::_2; - case '3': return CrafterKeys::_3; - case '4': return CrafterKeys::_4; - case '5': return CrafterKeys::_5; - case '6': return CrafterKeys::_6; - case '7': return CrafterKeys::_7; - case '8': return CrafterKeys::_8; - case '9': return CrafterKeys::_9; - - // Function keys - case VK_F1: return CrafterKeys::F1; - case VK_F2: return CrafterKeys::F2; - case VK_F3: return CrafterKeys::F3; - case VK_F4: return CrafterKeys::F4; - case VK_F5: return CrafterKeys::F5; - case VK_F6: return CrafterKeys::F6; - case VK_F7: return CrafterKeys::F7; - case VK_F8: return CrafterKeys::F8; - case VK_F9: return CrafterKeys::F9; - case VK_F10: return CrafterKeys::F10; - case VK_F11: return CrafterKeys::F11; - case VK_F12: return CrafterKeys::F12; - - // Control keys - case VK_ESCAPE: return CrafterKeys::Escape; - case VK_TAB: return CrafterKeys::Tab; - case VK_RETURN: return CrafterKeys::Enter; - case VK_SPACE: return CrafterKeys::Space; - case VK_BACK: return CrafterKeys::Backspace; - case VK_DELETE: return CrafterKeys::Delete; - case VK_INSERT: return CrafterKeys::Insert; - case VK_HOME: return CrafterKeys::Home; - case VK_END: return CrafterKeys::End; - case VK_PRIOR: return CrafterKeys::PageUp; - case VK_NEXT: return CrafterKeys::PageDown; - case VK_CAPITAL: return CrafterKeys::CapsLock; - case VK_NUMLOCK: return CrafterKeys::NumLock; - case VK_SCROLL: return CrafterKeys::ScrollLock; - - // Modifiers - case VK_SHIFT: return CrafterKeys::LeftShift; - case VK_LSHIFT: return CrafterKeys::LeftShift; - case VK_RSHIFT: return CrafterKeys::RightShift; - case VK_CONTROL: return CrafterKeys::LeftCtrl; - case VK_LCONTROL: return CrafterKeys::LeftCtrl; - case VK_RCONTROL: return CrafterKeys::RightCtrl; - case VK_LMENU: return CrafterKeys::LeftAlt; - case VK_RMENU: return CrafterKeys::RightAlt; - case VK_LWIN: return CrafterKeys::LeftSuper; - case VK_RWIN: return CrafterKeys::RightSuper; - - // Arrows - case VK_UP: return CrafterKeys::Up; - case VK_DOWN: return CrafterKeys::Down; - case VK_LEFT: return CrafterKeys::Left; - case VK_RIGHT: return CrafterKeys::Right; - - // Keypad - case VK_NUMPAD0: return CrafterKeys::keypad_0; - case VK_NUMPAD1: return CrafterKeys::keypad_1; - case VK_NUMPAD2: return CrafterKeys::keypad_2; - case VK_NUMPAD3: return CrafterKeys::keypad_3; - case VK_NUMPAD4: return CrafterKeys::keypad_4; - case VK_NUMPAD5: return CrafterKeys::keypad_5; - case VK_NUMPAD6: return CrafterKeys::keypad_6; - case VK_NUMPAD7: return CrafterKeys::keypad_7; - case VK_NUMPAD8: return CrafterKeys::keypad_8; - case VK_NUMPAD9: return CrafterKeys::keypad_9; - case VK_SEPARATOR: return CrafterKeys::keypad_enter; - case VK_ADD: return CrafterKeys::keypad_plus; - case VK_SUBTRACT: return CrafterKeys::keypad_minus; - case VK_MULTIPLY: return CrafterKeys::keypad_multiply; - case VK_DIVIDE: return CrafterKeys::keypad_divide; - case VK_DECIMAL: return CrafterKeys::keypad_decimal; - - // Punctuation - case VK_OEM_3: return CrafterKeys::grave; // ` - case VK_OEM_MINUS: return CrafterKeys::minus; // - - case VK_OEM_PLUS: return CrafterKeys::equal; // = - case VK_OEM_4: return CrafterKeys::bracket_left; // [ - case VK_OEM_6: return CrafterKeys::bracket_right; // ] - case VK_OEM_5: return CrafterKeys::backslash; // - case VK_OEM_1: return CrafterKeys::semicolon; // ; - case VK_OEM_7: return CrafterKeys::quote; // ' - case VK_OEM_COMMA:return CrafterKeys::comma; // , - case VK_OEM_PERIOD:return CrafterKeys::period; // . - case VK_OEM_2: return CrafterKeys::slash; // / - - default: throw std::runtime_error(std::format("Unkown VK {}", vk)); - } +// Extract the layout-independent raw key code from a WM_KEY* lParam. Bits +// 16-23 hold the PS/2 set-1 scancode byte; bit 24 is the extended-key flag +// (the 0xE0-prefixed variants — RightCtrl, RightAlt, the cursor cluster, +// keypad Enter/Slash, the Windows keys). We pack the extended flag into bit +// 8 of the returned KeyCode so it round-trips with the compile-time +// `Key(CrafterKeys::...)` table in :Keys. +static inline KeyCode KeyCodeFromLParam(LPARAM lParam) { + return ((KeyCode)((lParam >> 16) & 0xFF)) + | (((lParam >> 24) & 1u) << 8); } // Define a window class name @@ -266,28 +151,36 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { PostQuitMessage(0); break; } + case WM_SIZE: { + // SIZE_MINIMIZED reports (0, 0) — Resize() short-circuits, so + // we just propagate the values directly. WM_SIZE fires + // synchronously during a drag-resize loop; the StartSync + // pump runs WndProc between frames, so the swapchain is + // never touched mid-Render. + if (window) { + window->Resize(LOWORD(lParam), HIWORD(lParam)); + } + break; + } case WM_KEYDOWN: case WM_SYSKEYDOWN: { // SYSKEYDOWN catches Alt combos, F10, etc. - CrafterKeys crafterKey = vk_to_crafter_key(wParam); + KeyCode code = KeyCodeFromLParam(lParam); bool isRepeat = (lParam & (1 << 30)) != 0; if (isRepeat) { - window->onKeyHold[(uint8_t)crafterKey].Invoke(); - window->onAnyKeyHold.Invoke(crafterKey); + window->onRawKeyHold.Invoke(code); } else { - window->heldkeys[(uint8_t)crafterKey] = true; - window->onKeyDown[(uint8_t)crafterKey].Invoke(); - window->onAnyKeyDown.Invoke(crafterKey); + window->heldKeys.insert(code); + window->onRawKeyDown.Invoke(code); } break; } case WM_KEYUP: case WM_SYSKEYUP: { - CrafterKeys crafterKey = vk_to_crafter_key(wParam); - window->heldkeys[(uint8_t)crafterKey] = false; - window->onKeyUp[(uint8_t)crafterKey].Invoke(); - window->onAnyKeyUp.Invoke(crafterKey); + KeyCode code = KeyCodeFromLParam(lParam); + window->heldKeys.erase(code); + window->onRawKeyUp.Invoke(code); break; } @@ -510,6 +403,93 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height currentMousePos = {0,0}; } +void Window::Resize(std::uint32_t newWidth, std::uint32_t newHeight) { + // Skip degenerate resizes. Win32 minimised windows give (0, 0); Wayland + // sometimes echoes the current size in a configure. + if (newWidth == 0 || newHeight == 0) return; + if (newWidth == width && newHeight == height) return; + // Win32 fires WM_SIZE synchronously inside CreateWindowEx (before the + // constructor's CreateSwapchain). Defer the first resize to that + // CreateSwapchain call instead of trying to recreate a non-existent + // swapchain. + if (swapChain == VK_NULL_HANDLE) { + width = newWidth; + height = newHeight; + return; + } + + width = newWidth; + height = newHeight; + + // Caller (configure handler / WM_SIZE) runs between frames, but be + // defensive: ensure no in-flight commands reference the old swapchain. + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + + #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND + if (wpViewport) { + wp_viewport_set_destination(wpViewport, + static_cast(std::ceil(width / scale)), + static_cast(std::ceil(height / scale))); + } + #endif + + RecreateSwapchainAndImages(); + onResize.Invoke(); +} + +void Window::RecreateSwapchainAndImages() { + CreateSwapchain(); + + // CreateSwapchain leaves new swapchain images in VK_IMAGE_LAYOUT_UNDEFINED. + // Render() barriers from PRESENT_SRC_KHR, so transition them now to + // match. Mirrors the StartInit logic, on a one-shot command buffer. + { + VkCommandBufferAllocateInfo cba{ + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = Device::commandPool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = 1, + }; + VkCommandBuffer cmd = VK_NULL_HANDLE; + Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &cba, &cmd)); + + VkCommandBufferBeginInfo cbi{ + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, + }; + Device::CheckVkResult(vkBeginCommandBuffer(cmd, &cbi)); + + VkImageSubresourceRange range{ + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .baseMipLevel = 0, + .levelCount = VK_REMAINING_MIP_LEVELS, + .baseArrayLayer = 0, + .layerCount = VK_REMAINING_ARRAY_LAYERS, + }; + for (std::uint32_t i = 0; i < numFrames; i++) { + image_layout_transition(cmd, + images[i], + VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, + 0, 0, + VK_IMAGE_LAYOUT_UNDEFINED, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + range); + } + + Device::CheckVkResult(vkEndCommandBuffer(cmd)); + + VkSubmitInfo si{ + .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, + .commandBufferCount = 1, + .pCommandBuffers = &cmd, + }; + Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &si, VK_NULL_HANDLE)); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd); + } +} + void Window::SetTitle(const std::string_view title) { #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND xdg_toplevel_set_title(xdgToplevel, title.data()); @@ -630,6 +610,7 @@ void Window::SetDefaultCursor() { void Window::StartSync() { #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND while (open && wl_display_dispatch(Device::display) != -1) { + Gamepad::Tick(); onBeforeUpdate.Invoke(); } #endif @@ -640,6 +621,7 @@ void Window::StartSync() { TranslateMessage(&msg); DispatchMessage(&msg); } + Gamepad::Tick(); onBeforeUpdate.Invoke(); if(updating) { Update(); @@ -697,8 +679,23 @@ void Window::Update() { } void Window::Render() { - // Acquire the next image from the swap chain - Device::CheckVkResult(vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX, semaphores.presentComplete, (VkFence)nullptr, ¤tBuffer)); + // Acquire the next image from the swap chain. If the surface has + // changed size out from under us (compositor/Win32 resize delivered + // between Render calls), recreate and retry once. + { + VkResult acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX, + semaphores.presentComplete, (VkFence)nullptr, ¤tBuffer); + if (acquire == VK_ERROR_OUT_OF_DATE_KHR) { + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + RecreateSwapchainAndImages(); + onResize.Invoke(); + acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX, + semaphores.presentComplete, (VkFence)nullptr, ¤tBuffer); + } + if (acquire != VK_SUBOPTIMAL_KHR) { + Device::CheckVkResult(acquire); + } + } submitInfo.commandBufferCount = 1; submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer]; @@ -822,8 +819,13 @@ void Window::Render() { } VkResult result = vkQueuePresentKHR(Device::queue, &presentInfo); - if(result == VK_SUBOPTIMAL_KHR) { - CreateSwapchain(); + if (result == VK_SUBOPTIMAL_KHR || result == VK_ERROR_OUT_OF_DATE_KHR) { + // Surface size changed mid-present. Drain the queue, rebuild the + // swapchain, and let dependents (descriptors holding old image + // handles) re-bind via onResize before the next frame. + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + RecreateSwapchainAndImages(); + onResize.Invoke(); } else { Device::CheckVkResult(result); } @@ -1067,8 +1069,13 @@ void Window::wl_surface_frame_done(void* data, struct wl_callback *cb, uint32_t } -void Window::xdg_toplevel_configure(void*, xdg_toplevel*, std::int32_t, std::int32_t, wl_array*){ - +void Window::xdg_toplevel_configure(void* data, xdg_toplevel*, std::int32_t width, std::int32_t height, wl_array*){ + // xdg-shell batches state — width/height are pending until the matching + // xdg_surface.configure arrives. Width/height are in surface-local + // (logical DP) units; (0, 0) means "compositor has no preference". + Window* window = reinterpret_cast(data); + window->pendingLogicalWidth = width; + window->pendingLogicalHeight = height; } void Window::xdg_toplevel_handle_close(void* data, xdg_toplevel*) { @@ -1083,9 +1090,20 @@ void Window::xdg_surface_handle_configure(void* data, xdg_surface* xdg_surface, xdg_surface_ack_configure(xdg_surface, serial); if (window->configured) { - // If this isn't the first configure event we've received, we already - // have a buffer attached, so no need to do anything. Commit the - // surface to apply the configure acknowledgement. + // Subsequent configure: if the toplevel asked for a new size + // (non-zero, different from current), drive the resize end-to-end. + // (0, 0) means "compositor has no preference, keep current size". + // The swapchain may not exist yet on the very first frame between + // the constructor's wait loop and CreateSwapchain — the Resize + // guard against equal sizes already covers that path. + if (window->pendingLogicalWidth > 0 && window->pendingLogicalHeight > 0 && + window->swapChain != VK_NULL_HANDLE) { + std::uint32_t newWidth = static_cast( + std::ceil(window->pendingLogicalWidth * window->scale)); + std::uint32_t newHeight = static_cast( + std::ceil(window->pendingLogicalHeight * window->scale)); + window->Resize(newWidth, newHeight); + } wl_surface_commit(window->surface); } diff --git a/interfaces/Crafter.Graphics-Clipboard.cppm b/interfaces/Crafter.Graphics-Clipboard.cppm new file mode 100644 index 0000000..2e97a01 --- /dev/null +++ b/interfaces/Crafter.Graphics-Clipboard.cppm @@ -0,0 +1,42 @@ +/* +Crafter®.Graphics +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ +export module Crafter.Graphics:Clipboard; +import std; + +// Native system-clipboard writes. No popen, no helper binaries — just +// the platform's own clipboard API. Implementation lives next to the +// other window-backend code (Wayland data_device on Linux, Win32 in +// the Windows build); callers don't pick a backend. +// +// `Get` is intentionally not exposed yet: paste-from-clipboard isn't +// a feature the game's UI wants right now, and the read path needs +// more lifecycle plumbing (mime negotiation, fd reads on the Wayland +// event loop) than the simple write path. Easy to add later. +export namespace Crafter::Clipboard { + + // Place `text` on the system clipboard as UTF-8 plain text. Returns + // true if the platform accepted the request — false means the + // backend isn't initialised, no input event has been seen yet + // (Wayland needs a recent serial), or the OS API failed. On + // success the ownership of the clipboard contents is held until + // either another app replaces the selection or the application + // exits; the caller doesn't need to keep `text` alive. + bool SetText(std::string_view text); +} diff --git a/interfaces/Crafter.Graphics-Decompress.cppm b/interfaces/Crafter.Graphics-Decompress.cppm new file mode 100644 index 0000000..ca20792 --- /dev/null +++ b/interfaces/Crafter.Graphics-Decompress.cppm @@ -0,0 +1,133 @@ +/* +Crafter®.Graphics +Copyright (C) 2026 Catcrafts® +catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License version 3.0 as published by the Free Software Foundation; + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +module; +#include "vulkan/vulkan.h" + +export module Crafter.Graphics:Decompress; +import :Device; +import std; + +export namespace Crafter::Decompress { + // GDeflate 1.0 requires each VkDecompressMemoryRegionEXT to cover one + // 64 KiB tile. CompressedBlob stores one stream per RegionMeta — this + // helper walks the stream's tile-stream header and emits per-tile + // regions ready for vkCmdDecompressMemoryEXT. + // + // streamBytes is the raw stream bytes (CompressedBlob::bytes.subspan( + // r.srcOffset, r.compressedSize)). srcBase is its GPU device address. + // dstBase is the GPU device address of the first decompressed byte. + inline void ExpandStreamToTileRegions( + std::span streamBytes, + VkDeviceAddress srcBase, + VkDeviceAddress dstBase, + std::vector& out) + { + if (streamBytes.empty()) return; + + constexpr std::size_t kTileSize = 64 * 1024; + constexpr std::size_t kHeaderSize = 8; + + // TileStream wire layout (see vendored TileStream.h): + // u8 id; u8 magic; u16 numTiles; + // { u2 tileSizeIdx; u18 lastTileSize; u12 reserved; } packed into u32 + const std::uint8_t* p = reinterpret_cast(streamBytes.data()); + std::uint8_t id = p[0]; + std::uint8_t magic = p[1]; + if (id != (magic ^ 0xff)) { + throw std::runtime_error("GDeflate tile-stream: bad id/magic"); + } + std::uint16_t numTiles = + static_cast(p[2]) + | static_cast(p[3]) << 8; + std::uint32_t packed = + static_cast(p[4]) + | static_cast(p[5]) << 8 + | static_cast(p[6]) << 16 + | static_cast(p[7]) << 24; + std::uint32_t lastTileSize = (packed >> 2) & 0x3FFFFu; + + const std::uint32_t* tileOffsets = reinterpret_cast( + streamBytes.data() + kHeaderSize); + const std::size_t dataStart = kHeaderSize + std::size_t(numTiles) * sizeof(std::uint32_t); + + out.reserve(out.size() + numTiles); + for (std::uint32_t k = 0; k < numTiles; ++k) { + std::size_t tileOff = (k > 0) ? tileOffsets[k] : 0; + std::size_t compressedSize = (k + 1 < numTiles) + ? std::size_t(tileOffsets[k + 1]) - tileOff + : std::size_t(tileOffsets[0]); + std::size_t decompressedSize = (k + 1 < numTiles) + ? kTileSize + : (lastTileSize != 0 ? std::size_t(lastTileSize) : kTileSize); + out.push_back(VkDecompressMemoryRegionEXT { + .srcAddress = srcBase + dataStart + tileOff, + .dstAddress = dstBase + std::size_t(k) * kTileSize, + .compressedSize = compressedSize, + .decompressedSize = decompressedSize, + }); + } + } + + // Records vkCmdDecompressMemoryEXT into `cmd` for the supplied regions + // (caller pre-resolves srcAddress/dstAddress) and follows it with a + // synchronization2 memory barrier so the consumer at (dstStage, dstAccess) + // observes the decompressed bytes. + // + // Method is fixed at GDeflate 1.0 — Crafter.Asset's compressed file format + // emits GDeflate streams. Caller must guarantee Device::memoryDecompressionSupported. + inline void DecompressOnGPU( + VkCommandBuffer cmd, + std::span regions, + VkPipelineStageFlags2 dstStage, + VkAccessFlags2 dstAccess) + { + if (regions.empty()) return; + + VkDecompressMemoryInfoEXT info { + .sType = VK_STRUCTURE_TYPE_DECOMPRESS_MEMORY_INFO_EXT, + .pNext = nullptr, + .decompressionMethod = VK_MEMORY_DECOMPRESSION_METHOD_GDEFLATE_1_0_BIT_EXT, + .regionCount = static_cast(regions.size()), + .pRegions = regions.data(), + }; + Device::vkCmdDecompressMemoryEXT(cmd, &info); + + VkMemoryBarrier2 barrier { + .sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2, + .pNext = nullptr, + .srcStageMask = VK_PIPELINE_STAGE_2_MEMORY_DECOMPRESSION_BIT_EXT, + .srcAccessMask = VK_ACCESS_2_MEMORY_DECOMPRESSION_WRITE_BIT_EXT, + .dstStageMask = dstStage, + .dstAccessMask = dstAccess, + }; + VkDependencyInfo dep { + .sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO, + .pNext = nullptr, + .dependencyFlags = 0, + .memoryBarrierCount = 1, + .pMemoryBarriers = &barrier, + .bufferMemoryBarrierCount = 0, + .pBufferMemoryBarriers = nullptr, + .imageMemoryBarrierCount = 0, + .pImageMemoryBarriers = nullptr, + }; + vkCmdPipelineBarrier2(cmd, &dep); + } +} diff --git a/interfaces/Crafter.Graphics-Device.cppm b/interfaces/Crafter.Graphics-Device.cppm index 9c3e356..6e3313d 100644 --- a/interfaces/Crafter.Graphics-Device.cppm +++ b/interfaces/Crafter.Graphics-Device.cppm @@ -30,7 +30,7 @@ module; #endif export module Crafter.Graphics:Device; import std; -import :Types; // CrafterKeys for keyboard repeat state +import :Keys; // KeyCode for keyboard repeat state export namespace Crafter { struct Window; @@ -43,7 +43,7 @@ export namespace Crafter { int rate = 25; // chars/sec int delay = 500; // ms before first repeat bool active = false; - CrafterKeys key{}; + KeyCode key = 0; std::string utf8; // UTF-8 to re-emit as onTextInput, if any std::chrono::time_point pressTime; std::chrono::time_point lastFireTime; @@ -68,6 +68,13 @@ export namespace Crafter { inline static xkb_state* xkb_state; inline static std::vector windows; inline static wl_pointer* wlPointer; + // wl_data_device_manager + wl_data_device drive copy/paste. Bound + // lazily in handle_global; the data device is created once both + // the manager and the seat are present (registry binding order + // isn't guaranteed). nullptr on compositors that don't expose + // the manager — Clipboard::SetText silently no-ops there. + inline static wl_data_device_manager* dataDeviceManager = nullptr; + inline static wl_data_device* dataDevice = nullptr; static void seat_handle_capabilities(void* data, wl_seat* seat, uint32_t capabilities); static void xdg_surface_handle_preferred_scale(void* data, wp_fractional_scale_v1*, std::uint32_t scale); @@ -137,6 +144,12 @@ export namespace Crafter { inline static PFN_vkGetPhysicalDeviceDescriptorSizeEXT vkGetPhysicalDeviceDescriptorSizeEXT; inline static PFN_vkGetDeviceFaultInfoEXT vkGetDeviceFaultInfoEXT; + // VK_EXT_memory_decompression — opt-in. When the driver advertises it + // and exposes the GDeflate 1.0 method, GPU asset decompression is + // available; otherwise consumers fall back to CPU decode. + inline static bool memoryDecompressionSupported = false; + inline static PFN_vkCmdDecompressMemoryEXT vkCmdDecompressMemoryEXT = nullptr; + inline static VkPhysicalDeviceMemoryProperties memoryProperties; inline static VkPhysicalDeviceDescriptorHeapPropertiesEXT descriptorHeapProperties = { @@ -146,16 +159,19 @@ export namespace Crafter { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_PIPELINE_PROPERTIES_KHR, .pNext = &descriptorHeapProperties }; + inline static VkPhysicalDeviceMemoryDecompressionPropertiesEXT memoryDecompressionProperties = { + .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_DECOMPRESSION_PROPERTIES_EXT + }; static void CheckVkResult(VkResult result); static std::uint32_t GetMemoryType(std::uint32_t typeBits, VkMemoryPropertyFlags properties); // ─── Wayland key repeat ──────────────────────────────────────── - // TickKeyRepeats walks the held-key state and fires onKeyDown / - // onTextInput accordingly. Called once per frame from - // Window::Render. KeyRepeatState lives at namespace scope so its - // member initializers don't trip C++'s "complete-type-needed" - // rule for the inline static below. + // TickKeyRepeats fires onRawKeyDown / onRawKeyHold / onTextInput on + // the focused window for whichever key is currently repeating. + // Called once per frame from Window::Render. KeyRepeatState lives + // at namespace scope so its member initializers don't trip C++'s + // "complete-type-needed" rule for the inline static below. #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND inline static KeyRepeatState keyRepeat; static void TickKeyRepeats(); diff --git a/interfaces/Crafter.Graphics-Gamepad.cppm b/interfaces/Crafter.Graphics-Gamepad.cppm new file mode 100644 index 0000000..6dfe285 --- /dev/null +++ b/interfaces/Crafter.Graphics-Gamepad.cppm @@ -0,0 +1,99 @@ +/* +Crafter®.Graphics +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ +export module Crafter.Graphics:Gamepad; +import std; +import Crafter.Event; + +// Raw gamepad device API. Platform-split implementation: +// - Linux: libudev (enumerate + hot-plug) + libevdev (read events, +// calibrate axes). Open every /dev/input/event* node the kernel +// classifies as a gamepad; ignore the rest. +// - Windows: Windows.Gaming.Input via the C ABI (COBJMACROS pattern, +// same as SDL). Added/Removed events fire on a thread pool and are +// marshaled into a queue drained from Tick() on the main thread. +// +// Button names follow the standardized kernel BTN_SOUTH / BTN_EAST / +// BTN_WEST / BTN_NORTH convention — physical position, not label. An +// Xbox "A" and a PlayStation "Cross" both arrive as `South`. +// +// Stick axes are -1..1 (post-deadzone calibration, raw float). Trigger +// axes are 0..1. The `LeftTrigger` / `RightTrigger` entries in `Button` +// and `Axis` are both populated — pick whichever fits your use case +// (digital threshold vs. analog reading). + +export namespace Crafter::Gamepad { + enum class Button : std::uint8_t { + South, East, West, North, // BTN_SOUTH/EAST/WEST/NORTH + Select, Start, Home, + LeftStickClick, RightStickClick, + LeftBumper, RightBumper, + DPadUp, DPadDown, DPadLeft, DPadRight, + LeftTrigger, RightTrigger, // digital threshold; analog also on Axis + Max + }; + + enum class Axis : std::uint8_t { + LeftStickX, LeftStickY, + RightStickX, RightStickY, + LeftTrigger, RightTrigger, + Max + }; + + enum class Stick : std::uint8_t { Left, Right }; + + struct Device { + std::uint32_t id; // stable across the device's lifetime + std::string name; + bool buttons[(std::size_t)Button::Max] = {}; + float axes[(std::size_t)Axis::Max] = {}; // sticks: -1..1, triggers: 0..1 + Event