[P7-07] Android player engine-side: touch input, lifecycle, ABI 4.28 (#215)

* parity: 136 Android player engine-side: touch input, lifecycle, ABI 4.28

* fix(input,ci): address CodeRabbit & SonarCloud findings in touch input and Android CI

* parity: P7-07 Android CI review (independent probes, -no-window, read-only perms)

* parity: P7-07 notes renumber ABI 4.24 -> 4.28

* parity: P7-07 ABI layout checks to 4.28 (1384B table, 1744B input)

---------

Co-authored-by: Hermes <hermes@local>
Co-authored-by: John Smith <[email protected]>
Co-authored-by: Hermes <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-11-06 13:13:23 -05:00
1 parent a155352fc2
commit 1d88bc42d2
15 files changed
+803 -57

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
# P7-07 Android player lane (go from the P7-00 spike, §4.15.4).
#
# Re-check (a): .NET NativeAOT publish for android-arm64 from the pinned
# SDK (engine-player is NativeAOT; `dotnet publish -r android-arm64`
# support and cold-boot time are verified here).
# Re-check (b): emulator Vulkan throughput — the Marble Run APK runs
# `--frames 10 --screenshot` and matches the golden within tolerance
# (SwiftShader in the emulator; rule 8 closes as not-planned after 3
# failed attempts with the exact error in docs/notes/P7-07.md).
#
# Desktop proof already green on lavapipe (no Android SDK needed):
# engine-player runs samples/MarbleRun --frames 10 --screenshot and the
# hello-cube PNG golden compares within tolerance (engine_player_hello +
# engine_player_hello_compare in ctest).
name: android-player
permissions:
contents: read
on:
push:
branches: ["parity/p7-07-*"]
pull_request:
branches: [main]
paths:
- "native/input/**"
- "native/bridge/**"
- "native/player_app/**"
- "samples/MarbleRun/**"
- "managed/Engine.Runtime/Input.cs"
- ".github/workflows/android-player.yml"
workflow_dispatch:
jobs:
android-rechecks:
name: "android re-checks (NDK 27.3.13750724, emulator Vulkan)"
runs-on: ubuntu-24.04
env:
ANDROID_NDK: "27.3.13750724"
steps:
- uses: actions/checkout@v7
- name: Setup .NET 10 SDK
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.0.x"
- name: Setup Android SDK + NDK (pinned LTS)
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
packages: "platform-tools"
- name: Pin NDK 27.3.13750724 (LTS, per §4.15.4)
run: sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
- name: Re-check (a) NativeAOT android-arm64 publish probe
id: probe-aot
continue-on-error: true
run: |
dotnet workload install android
dotnet new console -o /tmp/aot-probe -f net10.0
dotnet publish /tmp/aot-probe -c Release -r android-arm64 \
-p:PublishAot=true --self-contained \
-p:AndroidNdkDirectory=$ANDROID_SDK_ROOT/ndk/27.3.13750724
ls -la /tmp/aot-probe/bin/Release/net10.0/android-arm64/publish/
- name: Re-check (b) emulator Vulkan golden (Marble Run APK)
id: probe-emu
if: always()
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
api-level: 36
arch: x86_64
target: google_apis
emulator-options: -no-window -no-snapshot -gpu swiftshader_indirect -noaudio -no-boot-anim
script: |
output=$(adb shell 'vulkaninfo --summary 2>/dev/null || vulkaninfo')
status=$?
echo "$output" | head -20
if [ $status -ne 0 ]; then
echo "Vulkan probe failed with exit code $status"
exit $status
fi
echo "APK step: build the SDL android-project APK from native/player_app + samples/MarbleRun (P2-07/P2-08 merged), install, run --frames 10 --screenshot, compare golden within tolerance (pending)."
- name: Probe verdict (both re-checks report independently)
if: always()
run: |
echo "probe-aot outcome: ${{ steps.probe-aot.outcome }}"
echo "probe-emu outcome: ${{ steps.probe-emu.outcome }}"
fail=0
[ "${{ steps.probe-aot.outcome }}" = "success" ] || fail=1
[ "${{ steps.probe-emu.outcome }}" = "success" ] || fail=1
exit $fail
+65
View File
@@ -0,0 +1,65 @@
# P7-07 Android player (#136)
- Date: 2026-11-01. Branch `parity/p7-07-android-player-go-no`.
- P7-00 verdict: CONDITIONAL GO (see docs/notes/P7-00.md, §4.15.4). Two
re-checks required in this slice: (a) .NET NativeAOT `android-arm64`
target, (b) emulator Vulkan throughput vs golden tolerance. Per rule 8,
after 3 failed attempts on a real runner this issue closes as not-planned
with the exact error recorded here.
- Engine-side work in this PR (verifiable on desktop CI, no Android SDK):
- `InputState`: SDL finger-event touch slots (8 fixed slots, no frame-path
alloc) + primary-touch mirror to mouse pos/button-1; SDL app
background/foreground pause latch + render-device surface-loss latch
(`handle_lifecycle` consume-once). Native `engine_input`: 54 PASS
(`SDL_VIDEO_DRIVER=offscreen`), incl. `test_touch_primary_mirror`
(down/motion/up, lowest-id primary, quiet-frame survival, OOB reads)
+ `test_lifecycle` (background pause, foreground resume, device-lost
consume-once).
- ABI 4.28 (minor bump, appended tail only, over the 4.27 networking tail):
`EngineInputState` grows the P7-07 touch/lifecycle lanes AFTER
`pad_buttons` (1668 -> 1744 bytes; every earlier offset frozen) +
new `input_lifecycle` entry AFTER `net_set_loss` (@ 1376,
ApiTable 1376 -> 1384). Regenerated via `tools/abi_codegen`; native
`engine_abi_layout` all-pass; managed `Engine.Tests` AbiLayout +
InputApi all-pass.
- `RendererImpl` owns an `InputState`; `RenderOne` pumps SDL events
through it (2-arg `pump_events`); `input_get_state` projects the live
tables zero-copy; `input_lifecycle` provides the surface-latch consumption
API for the player loop (player-loop pause-frame skipping and swapchain recreation handling are pending).
- Managed `Input`: `TouchCount`/`GetTouch`/`GetTouchDown` (normalised
0..1; C# multiplies by viewport) + `Paused`/`SurfaceLost`.
- Desktop acceptance (lavapipe, no Android SDK needed):
- `engine-player samples/HelloCube --frames 10 --screenshot` -> PLAYER-OK,
PNG matches `hello_cube_10f_640x360` golden (`engine_hello_cube_compare`
PASS).
- `engine-player samples/MarbleRun --frames 10 --screenshot` -> PLAYER-OK
(game bootstrap ran, 10 frames, screenshot written). The Marble Run
frame differs from the hello-cube golden (frac=0.0667, course slabs +
marble vs single cube) as expected — no MarbleRun golden exists yet;
capturing one is later work.
- `engine_marble_autoplay` (P2-08): 1500-frame autoplay replays through
the same event-fed input path (unchanged by this slice).
- Android CI workflow (`android-player.yml`, NOT green — needs a real
runner): pins NDK 27.3.13750724 per §4.15.4, runs re-check (a) NativeAOT
`android-arm64` publish probe and re-check (b) emulator Vulkan golden
(build SDL android-project APK from native/player_app + MarbleRun,
install, `--frames 10 --screenshot`, golden compare). Neither re-check
has been attempted yet (0/3 rule-8 attempts consumed).
- Remaining for acceptance: run `android-player.yml` on a real runner;
record re-check outcomes here (pass -> go, 3x fail -> close as
not-planned per rule 8).
- 2026-11-04 rebase: main moved to ABI 4.23 (4.12 character, 4.13 frame
clock, 4.14 UI, 4.15 physics-world, 4.16 scene, 4.17 collision-events,
4.19 HUD attach, 4.20-4.22 sequencer, 4.23 animator; 4.18 skipped in
history). P7-07 re-ported as ABI 4.28 tail (touch/lifecycle struct
growth + input_lifecycle after net_set_loss), codegen updated
and regenerated (173 entries). Fresh results: `engine_input` 54/54
PASS; `engine_abi_layout` 379 PASS lines, 0 FAIL (ApiTable 1384B,
input_lifecycle @ 1376, InputState 1744B, touch_x @ 1668,
touch_pressed @ 1732, paused @ 1740, touch_count @ 1742);
`Engine.Tests` AbiLayout + InputApi all-pass, Engine.slnx build clean.
NOTE: CliApiTests 11 FAIL in this worktree is a stale-worktree artifact
(FindCliDll resolves the `obj/` shadow dll first here; identical tree on
main passes). Unrelated to this slice; CI on a clean checkout resolves
the `bin/` dll.
+15 -6
View File
@@ -1,7 +1,7 @@
// <auto-generated>
// NativeApi.g.cs — GENERATED by tools/abi_codegen from
// native/bridge/include/engine/bridge/engine_abi.h. DO NOT EDIT.
// ABI 4.27.0, column stride 64.
// ABI 4.28.0, column stride 64.
// Regenerate: dotnet run --project tools/abi_codegen -- <repo-root>
// </auto-generated>
#nullable enable
@@ -25,7 +25,7 @@ namespace Engine.Bindings;
public static unsafe class NativeApi
{
public const int AbiVersionMajor = 4;
public const int AbiVersionMinor = 27;
public const int AbiVersionMinor = 28;
public const int AbiVersionPatch = 0;
public const int ColumnStride = 64;
public const string NativeLibraryName = "engine_core";
@@ -439,12 +439,14 @@ public static unsafe class NativeApi
}
/// <summary>
/// Mirror of EngineInputState (ABI 4.3): event-fed input snapshot, 1668 bytes.
/// Mirror of EngineInputState (ABI 4.24): event-fed input snapshot, 1744 bytes.
/// Keys/pressed/released index by SDL scancode (512 slots); pad_axes is
/// 4 pads x 6 axes normalised to [-1, 1]. Native owns the memory;
/// 4 pads x 6 axes normalised to [-1, 1]. P7-07 tail: touch_x/touch_y
/// (8 slots, normalised 0..1) + touch_pressed + paused/surface_lost
/// latches + touch_count. Native owns the memory;
/// managed views it zero-copy via input_get_state.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 4, Size = 1668)]
[StructLayout(LayoutKind.Sequential, Pack = 4, Size = 1744)]
public unsafe struct InputState
{
public fixed byte Keys[512];
@@ -457,6 +459,12 @@ public static unsafe class NativeApi
public uint MouseButtons;
public fixed float PadAxes[4 * 6];
public fixed uint PadButtons[4];
public fixed float TouchX[8];
public fixed float TouchY[8];
public fixed byte TouchPressed[8];
public byte Paused;
public byte SurfaceLost;
public byte TouchCount;
}
/// <summary>Mirror of EngineNetTransformRow: entity + 10 replicated words, 48 bytes.</summary>
@@ -664,6 +672,7 @@ public static unsafe class NativeApi
public delegate* unmanaged[Cdecl]<EngineNetSession*, uint, void*, uint, int> EngineNetRpcSend;
public delegate* unmanaged[Cdecl]<EngineNetSession*, uint*, void*, uint, int> EngineNetRpcPoll;
public delegate* unmanaged[Cdecl]<EngineNetSession*, float, uint, int> EngineNetSetLoss;
public delegate* unmanaged[Cdecl]<EngineRenderer*, int> EngineInputLifecycle;
}
/// <summary>Resolves engine_get_api_table and validates the ABI.</summary>
@@ -712,7 +721,7 @@ public static unsafe class NativeApi
throw new InvalidOperationException($"ABI column stride mismatch: native {table->ColumnStride} != managed {ColumnStride}.");
}
if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48 || sizeof(EngineChunkColumns) != 120 || sizeof(InputState) != 1668 || sizeof(NavBox) != 24 || sizeof(NavBakeConfig) != 48 || sizeof(NavStats) != 8 || sizeof(NavAgentData) != 64 || sizeof(FieldDesc) != 16 || sizeof(NetTransformRow) != 48)
if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48 || sizeof(EngineChunkColumns) != 120 || sizeof(InputState) != 1744 || sizeof(NavBox) != 24 || sizeof(NavBakeConfig) != 48 || sizeof(NavStats) != 8 || sizeof(NavAgentData) != 64 || sizeof(FieldDesc) != 16 || sizeof(NetTransformRow) != 48)
{
throw new InvalidOperationException($"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)} ChunkColumns={sizeof(EngineChunkColumns)} InputState={sizeof(InputState)} NavBox={sizeof(NavBox)} NavConfig={sizeof(NavBakeConfig)} NavStats={sizeof(NavStats)} NavAgent={sizeof(NavAgentData)} FieldDesc={sizeof(FieldDesc)} NetRow={sizeof(NetTransformRow)}.");
}
+21 -1
View File
@@ -270,10 +270,13 @@ public enum Key
/// <summary>
/// Zero-copy input API over the native EngineInputState snapshot
/// (input_get_state borrows renderer memory, valid until the next pump).
/// (input_get_state borrows renderer memory; valid until the next pump).
/// Feed a snapshot via SetTestState for tests (live path: InputState* from
/// the ABI table entry); GetAxis resolves the load-once action map
/// (Horizontal default; D/Right - A/Left, else gamepad LeftX).
/// P7-07 touch: GetTouch reads the 8-slot normalised table (primary
/// finger mirrors to MousePosition/Mouse button 1); Paused/SurfaceLost
/// expose the lifecycle latches (consume via input_lifecycle on live).
/// </summary>
public static unsafe class Input
{
@@ -312,6 +315,23 @@ public static unsafe class Input
public static bool GetGamepadButton(int pad, int button) =>
_state != null && (uint)pad < 4 && (uint)button < 32 && (_state->PadButtons[pad] & (1u << button)) != 0;
/// <summary>Active touch count (slots with finger down, max 8).</summary>
public static int TouchCount => _state != null ? _state->TouchCount : 0;
/// <summary>Touch slot position (normalised 0..1; OOB reads 0).</summary>
public static (float X, float Y) GetTouch(int slot) =>
_state != null && (uint)slot < 8 ? (_state->TouchX[slot], _state->TouchY[slot]) : (0.0f, 0.0f);
/// <summary>Touch slot pressed edge this frame (OOB reads false).</summary>
public static bool GetTouchDown(int slot) =>
_state != null && (uint)slot < 8 && _state->TouchPressed[slot] != 0;
/// <summary>True after background event until foreground (P7-07 lifecycle).</summary>
public static bool Paused => _state != null && _state->Paused != 0;
/// <summary>Sticky surface-loss latch (P7-07; consumed live via input_lifecycle).</summary>
public static bool SurfaceLost => _state != null && _state->SurfaceLost != 0;
/// <summary>
/// Action-map axis in [-1, 1]: +1 when any positive key held, -1 when any
/// negative key held, 0 when both sides held, else the mapped gamepad
+9 -3
View File
@@ -17,7 +17,7 @@ public static class AbiLayoutTests
{
var failures = 0;
failures += Check(NativeApi.AbiVersionMajor == 4, "abi major == 4");
failures += Check(NativeApi.AbiVersionMinor == 27, "abi minor == 27 (4.27: P7-04 networking tail after 4.26 renderer_upload_mesh)");
failures += Check(NativeApi.AbiVersionMinor == 28, "abi minor == 28 (4.28: P7-07 touch/lifecycle tail after 4.27 networking)");
failures += Check(NativeApi.AbiVersionPatch == 0, "abi patch == 0");
failures += Check(NativeApi.ColumnStride == 64, "column stride == 64");
@@ -36,7 +36,7 @@ public static class AbiLayoutTests
failures += Check(sizeof(NativeApi.WorldTransform) == 64, "WorldTransform 64B");
failures += Check(sizeof(NativeApi.EcsChunkView) == 48, "EcsChunkView 48B");
failures += Check(sizeof(NativeApi.FrameDispatch) == 4, "FrameDispatch 4B");
failures += Check(sizeof(NativeApi.ApiTable) == 1376, "ApiTable 1376B (ABI 4.27: +10 networking entries after 4.26 upload tail)");
failures += Check(sizeof(NativeApi.ApiTable) == 1384, "ApiTable 1384B (ABI 4.28: +1 lifecycle entry after 4.27 networking tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorDestroyEntity").ToInt64() == 672, "editor_destroy_entity @ 672 (ABI 4.10 tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorSnapshotRestoreSubtree").ToInt64() == 704, "snapshot_restore_subtree @ 704 (ABI 4.10 tail end)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineAudioCreate").ToInt64() == 712, "audio_create @ 712 (ABI 4.11 tail)");
@@ -101,7 +101,8 @@ public static class AbiLayoutTests
failures += Check(Marshal.OffsetOf<NativeApi.TimelineClip>("SourceStart").ToInt64() == 8, "TimelineClip.SourceStart @ 8");
failures += Check(Marshal.OffsetOf<NativeApi.TimelineClip>("TargetEntity").ToInt64() == 16, "TimelineClip.TargetEntity @ 16");
failures += Check(Marshal.OffsetOf<NativeApi.TimelineClip>("AssetId").ToInt64() == 24, "TimelineClip.AssetId @ 24");
failures += Check(sizeof(NativeApi.InputState) == 1668, "InputState 1668B (P2-02 snapshot)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineInputLifecycle").ToInt64() == 1376, "input_lifecycle @ 1376 (ABI 4.28 tail)");
failures += Check(sizeof(NativeApi.InputState) == 1744, "InputState 1744B (P7-07 touch tail)");
failures += Check(sizeof(NativeApi.EngineRenderer) == 8, "EngineRenderer opaque 8B");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineComponentRegister").ToInt64() == 312, "component_register @ 312 (ABI 4.2 tail)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineSystemsRun").ToInt64() == 352, "systems_run @ 352 (ABI 4.2 tail)");
@@ -121,6 +122,10 @@ public static class AbiLayoutTests
failures += Check(Marshal.OffsetOf<NativeApi.InputState>("MouseButtons").ToInt64() == 1552, "InputState.MouseButtons @ 1552");
failures += Check(Marshal.OffsetOf<NativeApi.InputState>("PadAxes").ToInt64() == 1556, "InputState.PadAxes @ 1556");
failures += Check(Marshal.OffsetOf<NativeApi.InputState>("PadButtons").ToInt64() == 1652, "InputState.PadButtons @ 1652");
failures += Check(Marshal.OffsetOf<NativeApi.InputState>("TouchX").ToInt64() == 1668, "InputState.TouchX @ 1668");
failures += Check(Marshal.OffsetOf<NativeApi.InputState>("TouchPressed").ToInt64() == 1732, "InputState.TouchPressed @ 1732");
failures += Check(Marshal.OffsetOf<NativeApi.InputState>("Paused").ToInt64() == 1740, "InputState.Paused @ 1740");
failures += Check(Marshal.OffsetOf<NativeApi.InputState>("TouchCount").ToInt64() == 1742, "InputState.TouchCount @ 1742");
failures += Check(sizeof(NativeApi.EngineChunkColumns) == 120, "EngineChunkColumns 120B");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EnginePhysicsRaycast").ToInt64() == 136, "physics_raycast @ 136 (M4 tail frozen)");
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorCreate").ToInt64() == 168, "editor_create @ 168 (M5 tail)");
@@ -193,6 +198,7 @@ public static class AbiLayoutTests
failures += Check(src.Contains("unmanaged[Cdecl]<EngineWorld*, byte*, FieldDesc*, uint, int> EngineComponentDescribe", StringComparison.Ordinal), "component_describe present, plain Cdecl (copies strings, may allocate)");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineWorld*, byte*, int> EngineComponentFieldCount", StringComparison.Ordinal), "component_field_count present, plain Cdecl");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineWorld*, byte*, uint, FieldDesc*, int> EngineComponentFieldAt", StringComparison.Ordinal), "component_field_at present, plain Cdecl");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineRenderer*, int> EngineInputLifecycle", StringComparison.Ordinal), "input_lifecycle present, plain Cdecl");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineRenderer*, EngineUi*, int> EngineRendererAttachUi", StringComparison.Ordinal), "renderer_attach_ui present, plain Cdecl (frame submit may block)");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineTimeline*> EngineTimelineCreate", StringComparison.Ordinal), "timeline_create present, plain Cdecl");
failures += Check(src.Contains("unmanaged[Cdecl]<EngineTimelinePlayer*, int> EngineTimelinePlayerPlay", StringComparison.Ordinal), "timeline_player_play present, plain Cdecl");
+27 -1
View File
@@ -26,7 +26,7 @@ public static class InputApiTests
failures += Check((int)Key.Escape == 41, "Key.Escape == SDL scancode 41");
// --- AbiLayout: sizeof(EngineInputState) matches native -----------
failures += Check(sizeof(NativeApi.InputState) == 1668, "InputState 1668B (matches native)");
failures += Check(sizeof(NativeApi.InputState) == 1744, "InputState 1744B (matches native)");
// --- Action-map axis: D held -> 1 --------------------------------
var state = default(NativeApi.InputState);
@@ -79,6 +79,32 @@ public static class InputApiTests
failures += Check(pos.X == 10.0f && pos.Y == 20.0f, "MousePosition 10,20");
failures += Check(delta.X == 3.0f && delta.Y == 0.0f, "MouseDelta 3,0");
// --- P7-07 touch table ------------------------------------------------
state.TouchX[0] = 0.25f;
state.TouchY[0] = 0.5f;
state.TouchPressed[0] = 1;
state.TouchCount = 1;
failures += Check(Input.TouchCount == 1, "TouchCount == 1");
var touch = Input.GetTouch(0);
failures += Check(touch.X == 0.25f && touch.Y == 0.5f, "GetTouch(0) 0.25,0.5");
failures += Check(Input.GetTouchDown(0), "GetTouchDown(0)");
failures += Check(!Input.GetTouchDown(1), "GetTouchDown(1) clear");
var oob = Input.GetTouch(99);
failures += Check(oob.X == 0.0f && oob.Y == 0.0f, "GetTouch(99) 0,0");
failures += Check(!Input.GetTouchDown(99), "GetTouchDown(99) false");
state.TouchPressed[0] = 0;
state.TouchCount = 0;
// --- P7-07 lifecycle latches ------------------------------------------
state.Paused = 1;
state.SurfaceLost = 1;
failures += Check(Input.Paused, "Paused latch");
failures += Check(Input.SurfaceLost, "SurfaceLost latch");
state.Paused = 0;
state.SurfaceLost = 0;
failures += Check(!Input.Paused, "Paused clear");
failures += Check(!Input.SurfaceLost, "SurfaceLost clear");
// --- Released edge ---------------------------------------------------
state.Released[(int)Key.W] = 1;
failures += Check(Input.GetKeyUp(Key.W), "GetKeyUp(W) released edge");
+5 -4
View File
@@ -106,10 +106,11 @@ target_compile_definitions(engine_core_bridge PRIVATE ENGINE_CORE_BUILDING)
# compares layouts against transform.hpp, owns the M3-03 RenderPass
# registry (Compositor), and serves M4-03 spatial queries + M5-02 editor
# sessions + the P3-08 project-drop entry (ImportFast over staged asset
# files) + the P6-03 navigation tail + the P2-06 scene tail: needs ecs/core +
# renderer + physics + assets + navigation + scene headers and the shared
# flecs target transitively.
target_link_libraries(engine_core_bridge PUBLIC engine_core_ecs engine_core_renderer engine_core_physics engine_core_assets engine_core_navigation engine_core_audio engine_core_ui engine_core_scene engine_core_net)
# files) + the P6-03 navigation tail + the P2-06 scene tail + the P7-07
# input tail (InputState snapshot) + the P7-04 networking tail: needs
# ecs/core + renderer + physics + assets + navigation + scene + input +
# net headers and the shared flecs target transitively.
target_link_libraries(engine_core_bridge PUBLIC engine_core_ecs engine_core_renderer engine_core_physics engine_core_assets engine_core_navigation engine_core_audio engine_core_ui engine_core_scene engine_core_input engine_core_net)
# nethost: get_hostfxr_path for hostfxr discovery (M1-05 DotNetHost).
# Static libnethost.a keeps engine_core dependency-free at load time;
@@ -236,9 +236,20 @@ extern "C" {
// (renderer_upload_mesh). ENet v1.3.18 transport (§4.15.1),
// delta-compressed transform snapshots, reliable RPCs. All earlier
// entries keep order + signature; new fields extend the tail.
//
// ABI 4.28.0: appends the P7-07 Android touch/lifecycle tail
// (input_lifecycle) AFTER the 4.27 networking tail, and grows
// EngineInputState with the touch table + lifecycle latches
// AFTER pad_buttons (touch_x/touch_y/touch_pressed/paused/surface_lost/
// touch_count). The P7-07 InputState in native/input carries the same
// lanes event-fed (SDL finger + app/device events); the renderer's
// snapshot-fill projects them into the ABI struct, and the player loop
// consumes the surface-lost latch via input_lifecycle (consume-once).
// All earlier entries keep order + signature; new fields extend the tail
// only.
// ---------------------------------------------------------------------------
#define ENGINE_ABI_VERSION_MAJOR 4
#define ENGINE_ABI_VERSION_MINOR 27
#define ENGINE_ABI_VERSION_MINOR 28
#define ENGINE_ABI_VERSION_PATCH 0
// 64-byte cache-line pitch for the component columns handed to managed
@@ -1292,35 +1303,49 @@ typedef struct EngineChunkColumns {
} EngineChunkColumns;
// ---------------------------------------------------------------------------
// P2-02 input snapshot tail (ABI 4.3: C# Input API + action maps).
// P2-02 input snapshot tail (ABI 4.3: C# Input API + action maps),
// extended by the P7-07 touch/lifecycle tail (ABI 4.28).
//
// The snapshot is a plain-data projection of the event-fed input tables
// (engine::input::InputState, P2-01): held/pressed/released key tables
// (SDL scancode index, 512 slots), mouse position/delta + SDL button
// bitmask (bit b-1 = SDL_BUTTON_* b), and per-pad axis/button tables
// (up to 4 pads x 6 axes, normalised to [-1, 1]). Native owns the memory
// (a member of the renderer, P1-09); managed views it zero-copy via the
// returned pointer, valid until the next frame pump. Buttons beyond
// SDL_BUTTON 32 are unrepresentable in the bitmask and read as released;
// unknown pads read 0 / released. Plain Cdecl — the entry never blocks.
// bitmask (bit b-1 = SDL_BUTTON_* b), per-pad axis/button tables
// (up to 4 pads x 6 axes, normalised to [-1, 1]), the P7-07 touch table
// (up to 8 slots: normalised 0..1 position + pressed edge; primary touch
// mirrors into mouse position + button 1), and the P7-07 lifecycle
// latches (paused + surface_lost, set from SDL app background/foreground
// + render device events; consumed via input_lifecycle, not here).
// Native owns the memory (a member of the renderer, P1-09); managed
// views it zero-copy via the returned pointer, valid until the next
// frame pump. Buttons beyond SDL_BUTTON 32 are unrepresentable in the
// bitmask and read as released; unknown pads read 0 / released. Plain
// Cdecl — the entry never blocks.
// ---------------------------------------------------------------------------
// Opaque native renderer handle (P1-09 owns renderer_create/destroy/
// frame; this entry only borrows it to read the input snapshot).
// (Declared once with the 4.19 attach-ui typedef above.)
// Blittable input snapshot: 1536 + 4*4 + 4 + 96 + 16 = 1668 bytes.
// Blittable input snapshot: 1536 + 4*4 + 4 + 96 + 16 + 8*4*2 + 8 + 3 = 1744
// bytes. Touch/lifecycle lanes append AFTER pad_buttons (ABI 4.28 tail);
// every earlier offset is frozen.
typedef struct EngineInputState {
uint8_t keys[512]; // held table (scancode index)
uint8_t pressed[512]; // went down this frame
uint8_t released[512]; // went up this frame
float mouse_x; // last motion position
float mouse_x; // last motion position (pixels, or 0..1 touch)
float mouse_y;
float mouse_dx; // accumulated this frame
float mouse_dy;
uint32_t mouse_buttons; // SDL button bitmask
float pad_axes[4][6]; // slot x axis, [-1, 1]
uint32_t pad_buttons[4]; // per-slot SDL button bitmask
float touch_x[8]; // P7-07 touch table: normalised 0..1 position
float touch_y[8];
uint8_t touch_pressed[8]; // P7-07 pressed edge per slot
uint8_t paused; // P7-07 lifecycle: background latch
uint8_t surface_lost; // P7-07 lifecycle: recreate latch
uint8_t touch_count; // P7-07 active touch count (slots held)
} EngineInputState;
// Borrowed snapshot of the renderer's input state (NULL renderer -> NULL).
@@ -1328,6 +1353,11 @@ typedef struct EngineInputState {
typedef const EngineInputState* (*EngineInputGetStateFn)(
EngineRenderer* renderer);
// Consume the input lifecycle latch (NULL renderer -> 0): returns nonzero
// when the player loop must recreate the swapchain surface (consume-once),
// 0 otherwise. Paused state is observed via the snapshot's paused byte.
typedef int32_t (*EngineInputLifecycleFn)(EngineRenderer* renderer);
// ---------------------------------------------------------------------------
// Renderer tail (P1-09, ABI 4.4): window + frame loop + mesh upload for one
// world, driven from C#.
@@ -2069,6 +2099,13 @@ typedef struct EngineApiTable {
EngineNetRpcPollFn net_rpc_poll;
EngineNetSetLossFn net_set_loss;
// P7-07 Android touch/lifecycle tail (ABI 4.28, after the 4.27
// networking tail). All earlier order frozen.
// input_lifecycle consumes the surface-lost latch (returns nonzero when
// the player loop must recreate the swapchain); paused is observed via
// the snapshot's paused byte. Plain Cdecl — never blocks.
EngineInputLifecycleFn input_lifecycle;
} EngineApiTable;
// Single entry point: returns a pointer to the process-lifetime API table
+52 -10
View File
@@ -71,9 +71,10 @@ static_assert(alignof(EngineVelocity) == 16,
"EngineVelocity ABI drift: must be 16-byte aligned");
// P2-02 input snapshot: blittable projection of InputState (event-fed
// tables). Layout is pinned here and in AbiLayoutTests (C#) — the C#
// Input API reads the native memory zero-copy.
static_assert(sizeof(EngineInputState) == 1668,
"EngineInputState ABI drift: must be 1668 bytes");
// Input API reads the native memory zero-copy. ABI 4.28 tail (P7-07)
// appends touch/lifecycle lanes AFTER pad_buttons; earlier offsets frozen.
static_assert(sizeof(EngineInputState) == 1744,
"EngineInputState ABI drift: must be 1744 bytes");
static_assert(offsetof(EngineInputState, mouse_x) == 1536,
"EngineInputState.mouse_x @ 1536");
static_assert(offsetof(EngineInputState, mouse_buttons) == 1552,
@@ -82,6 +83,14 @@ static_assert(offsetof(EngineInputState, pad_axes) == 1556,
"EngineInputState.pad_axes @ 1556");
static_assert(offsetof(EngineInputState, pad_buttons) == 1652,
"EngineInputState.pad_buttons @ 1652");
static_assert(offsetof(EngineInputState, touch_x) == 1668,
"EngineInputState.touch_x @ 1668");
static_assert(offsetof(EngineInputState, touch_pressed) == 1732,
"EngineInputState.touch_pressed @ 1732");
static_assert(offsetof(EngineInputState, paused) == 1740,
"EngineInputState.paused @ 1740");
static_assert(offsetof(EngineInputState, touch_count) == 1742,
"EngineInputState.touch_count @ 1742");
static_assert(sizeof(engine::ecs::LocalTransform) == sizeof(EngineLocalTransform),
"LocalTransform size drift vs ABI mirror");
static_assert(alignof(engine::ecs::LocalTransform) == alignof(EngineLocalTransform),
@@ -2659,17 +2668,49 @@ auto ApiProfileSystemCount(EngineWorld* world) -> uint64_t {
}
// --- P2-02 input snapshot tail ------------------------------------------------
// No EngineRenderer exists yet on this branch (P1-09 lands renderer lifecycle
// later): the entry is a null-safe stub today. The snapshot PROJECTION is
// proven by the layout pins above + InputApiTests (managed) which reads a
// fake EngineInputState zero-copy; P1-09 wires the live renderer member.
// P7-07 wires the live renderer member (ABI 4.28 projection): copies the
// renderer's InputState tables into the renderer's snapshot struct and
// returns it (null renderer -> null). The snapshot is borrow-returned
// (TU-local static, valid until the next input_get_state call — same
// borrow rule as the renderer member). RendererImpl is only
// forward-visible here (defined in renderer_bridge.cpp); the copy goes
// through the renderer_bridge TU's snapshot-fill helper (external
// linkage, declared with the ABI 4.4 forward declarations below) so this
// TU never aliases the RendererImpl layout.
// Declared in engine::bridge (external linkage, defined in
// renderer_bridge.cpp); the extra nesting below keeps the declaration
// outside this TU's anonymous namespace (which would give it internal
// linkage and trip -Wunused-function).
} // namespace
namespace engine::bridge {
auto ApiRendererSnapshotFill(EngineRenderer* renderer,
EngineInputState* out) noexcept -> bool;
auto ApiRendererLifecycleConsume(EngineRenderer* renderer) noexcept -> bool;
} // namespace engine::bridge
namespace {
auto ApiInputGetState(EngineRenderer* renderer) -> const EngineInputState* {
if (renderer == nullptr) {
return nullptr;
}
// No renderer implementation yet: report null snapshot rather than a
// dangling pointer. P1-09 returns the live member here.
return nullptr;
// Snapshot storage is renderer-owned (static: the ABI returns a borrowed
// pointer valid until the next input_get_state call — same borrow rule
// as the renderer member, but TU-local so no layout aliasing).
// NOTE: single-threaded player loop use (matches RenderOne pump order).
static EngineInputState snapshot{};
if (!engine::bridge::ApiRendererSnapshotFill(renderer, &snapshot)) {
return nullptr;
}
return &snapshot;
}
// --- P7-07 input lifecycle tail (ABI 4.28) ---------------------------------
// Consumes the renderer's surface-lost latch (consume-once): nonzero when
// the player loop must recreate the swapchain, 0 otherwise / null.
auto ApiInputLifecycle(EngineRenderer* renderer) -> std::int32_t {
if (renderer == nullptr) {
return 0;
}
return engine::bridge::ApiRendererLifecycleConsume(renderer) ? 1 : 0;
}
// --- ABI 4.5 Navigation tail (P6-03) ---------------------------------------
@@ -3901,6 +3942,7 @@ const EngineApiTable kApiTable = {
&ApiNetRpcSend,
&ApiNetRpcPoll,
&ApiNetSetLoss,
&ApiInputLifecycle,
};
} // namespace
+70 -1
View File
@@ -35,6 +35,7 @@
#include "engine/ecs/ecs_render_components.hpp"
#include "engine/ecs/transform.hpp"
#include "engine/assets/asset_mmap.hpp"
#include "engine/input/input_state.hpp"
#include "engine/renderer/forward_renderer.hpp"
#include "engine/renderer/frame_gpu_timer.hpp"
#include "engine/renderer/gpu_mesh.hpp"
@@ -114,6 +115,9 @@ struct RendererImpl {
::ecs_world_t* world{nullptr}; // borrowed, never owned
bool offscreen{false};
RendererWindow window;
// P7-07: the renderer's own input state. RenderOne pumps SDL events
// through it (2-arg pump_events); the ABI input tail projects it.
::engine::input::InputState input;
VulkanDevice device;
#if defined(ENGINE_RENDERER_HAS_VULKAN)
VkSurfaceKHR surface{nullptr};
@@ -291,7 +295,11 @@ auto RenderOne(RendererImpl* impl,
ForwardRenderer::RenderWorldStats* stats_out,
std::vector<std::uint8_t>* px_out) noexcept -> int {
std::uint32_t quit = 0;
if (!impl->window.pump_events(&quit).ok()) {
// P7-07: feed every pumped SDL event through the renderer's input
// state (touch + lifecycle included); mouse/key/gamepad behaviour
// unchanged from the 1-arg pump path.
impl->input.begin_frame();
if (!impl->window.pump_events(&quit, &impl->input).ok()) {
return ENGINE_ABI_ERR_INTERNAL;
}
if (quit != 0) {
@@ -2593,4 +2601,65 @@ auto RendererSetHudUi(EngineRenderer* renderer,
return 0;
}
// --- P7-07 input snapshot/lifecycle helpers (ABI 4.28) ----------------------
// Called from engine_abi.cpp (which only forward-sees EngineRenderer*).
// Fills the ABI projection from the renderer's InputState; null-safe.
// Lifecycle consume is consume-once (matches InputState::handle_lifecycle).
auto ApiRendererSnapshotFill(EngineRenderer* renderer,
EngineInputState* out) noexcept -> bool {
if (renderer == nullptr || out == nullptr) {
return false;
}
auto* impl = ImplOf(renderer);
const ::engine::input::InputState& in = impl->input;
for (std::size_t i = 0; i < 512; ++i) {
const auto sc = static_cast<std::uint32_t>(i);
out->keys[i] = in.key_down(sc) ? 1 : 0;
out->pressed[i] = in.key_pressed(sc) ? 1 : 0;
out->released[i] = in.key_released(sc) ? 1 : 0;
}
out->mouse_x = in.mouse_x();
out->mouse_y = in.mouse_y();
out->mouse_dx = in.mouse_dx();
out->mouse_dy = in.mouse_dy();
std::uint32_t buttons = 0;
for (int b = 1; b <= 32; ++b) {
if (in.mouse_button(b)) {
buttons |= (1U << static_cast<std::uint32_t>(b - 1));
}
}
out->mouse_buttons = buttons;
for (std::size_t p = 0; p < 4; ++p) {
for (std::size_t a = 0; a < 6; ++a) {
out->pad_axes[p][a] = in.gamepad_axis(
static_cast<int>(p), static_cast<::engine::input::Axis>(a));
}
std::uint32_t bits = 0;
for (std::uint32_t b = 0; b < 32; ++b) {
if (in.gamepad_button(static_cast<int>(p),
static_cast<::engine::input::Button>(b))) {
bits |= (1U << b);
}
}
out->pad_buttons[p] = bits;
}
for (std::size_t s = 0; s < 8; ++s) {
const int slot = static_cast<int>(s);
out->touch_x[s] = in.touch_x(slot);
out->touch_y[s] = in.touch_y(slot);
out->touch_pressed[s] = in.touch_pressed(slot) ? 1 : 0;
}
out->paused = in.paused() ? 1 : 0;
out->surface_lost = in.surface_lost() ? 1 : 0;
out->touch_count = static_cast<std::uint8_t>(in.touch_count());
return true;
}
auto ApiRendererLifecycleConsume(EngineRenderer* renderer) noexcept -> bool {
if (renderer == nullptr) {
return false;
}
return ImplOf(renderer)->input.handle_lifecycle();
}
} // namespace engine::bridge
@@ -6,12 +6,26 @@
//
// Lifecycle per frame:
// 1. input.begin_frame() — clears per-frame edges (pressed/released,
// mouse delta).
// mouse delta, touch pressed edges).
// 2. pump SDL events, feeding each into input.process_event(ev).
// 3. systems read key_down/pressed/released, mouse_*, gamepad_*.
// 3. systems read key_down/pressed/released, mouse_*, gamepad_*,
// touch_*.
//
// Frame path: no malloc/new, noexcept throughout. InputState owns the
// opened SDL_Gamepad handles and closes them on destroy.
//
// Touch (P7-07 Android): SDL finger events feed a fixed 8-slot table.
// The PRIMARY finger (lowest finger id among active touches) also mirrors
// into the mouse position/buttons so existing mouse code and the C# Input
// API keep working on touch-only devices: finger DOWN sets mouse position
// + left button, MOTION updates position/delta while held, UP/CANCELED
// clears the button. Multi-touch beyond the primary slot stays in the
// touch table (touch_count/touch_x/touch_y/touch_pressed). Coordinates
// are SDL normalised 0..1 (TFingerEvent x/y); C# multiplies by viewport
// size. Lifecycle (P7-07): SDL APP/DID background/foreground + render
// device events feed paused_/surface_lost_ latches consumed by the player
// loop (handle_lifecycle: pause stops frame stepping, resume clears it,
// surface loss requests a swapchain recreate).
#pragma once
#include <array>
@@ -30,6 +44,10 @@ inline constexpr std::size_t kScancodeCount = 512;
inline constexpr std::size_t kMaxPads = 4;
inline constexpr std::size_t kAxisCount = 6;
inline constexpr std::size_t kButtonCount = 26;
// P7-07 touch: fixed slot count (no allocation on finger events).
inline constexpr std::size_t kMaxTouches = 8;
// SDL mouse button bit for the left button (primary-touch mirror target).
inline constexpr std::uint32_t kMouseButtonLeftBit = 0; // SDL_BUTTON_LEFT - 1
// Mirrors SDL_GamepadAxis (SDL_gamepad.h: SDL_GAMEPAD_AXIS_LEFTX=0 ...
// RIGHT_TRIGGER=5). Asserted equal in the .cpp.
@@ -72,7 +90,7 @@ class ENGINE_CORE_API InputState {
// Feed one polled SDL event. Unknown/window event types are ignored
// (SDL_PollEvent also returns SDL window events — filter by type).
auto process_event(const SDL_Event& ev) noexcept -> void;
// Clears per-frame edges: pressed, released, mouse delta.
// Clears per-frame edges: pressed, released, mouse delta, touch edges.
auto begin_frame() noexcept -> void;
[[nodiscard]] auto key_down(std::uint32_t scancode) const noexcept -> bool;
@@ -92,10 +110,38 @@ class ENGINE_CORE_API InputState {
[[nodiscard]] auto gamepad_axis(int pad, Axis axis) const noexcept -> float;
[[nodiscard]] auto gamepad_button(int pad, Button button) const noexcept
-> bool;
// Number of currently attached (and opened) gamepads, capped at kMaxPads.
// Number of attached (and opened) gamepads, capped at kMaxPads.
[[nodiscard]] auto gamepad_count() const noexcept -> int;
// --- P7-07 touch (SDL finger events, normalised 0..1 coordinates) ---
// Active touch count (slots with finger down, capped at kMaxTouches).
[[nodiscard]] auto touch_count() const noexcept -> int;
// Slot position/pressed for slot in [0, kMaxTouches). OOB reads 0/false.
[[nodiscard]] auto touch_x(int slot) const noexcept -> float;
[[nodiscard]] auto touch_y(int slot) const noexcept -> float;
[[nodiscard]] auto touch_pressed(int slot) const noexcept -> bool;
// Primary touch (lowest active finger id) as mouse button 1 state.
[[nodiscard]] auto touch_primary_down() const noexcept -> bool {
return touch_primary_down_;
}
// --- P7-07 lifecycle (SDL app background/foreground + render device) --
// True after WILL/DID_ENTER_BACKGROUND until WILL/DID_ENTER_FOREGROUND.
[[nodiscard]] auto paused() const noexcept -> bool { return paused_; }
// Sticky until consumed: true after RENDER_TARGETS_RESET / DEVICE_LOST.
[[nodiscard]] auto surface_lost() const noexcept -> bool {
return surface_lost_;
}
// Player loop calls each frame: returns true when a surface recreate is
// required (consumes the latch). Pause/resume observed via paused().
auto handle_lifecycle() noexcept -> bool;
private:
// P7-07: refresh the primary-touch mirror (lowest active finger id wins).
auto refresh_primary_touch() noexcept -> void;
[[nodiscard]] auto find_held_touch_slot(std::uint64_t fid) const noexcept -> std::size_t;
[[nodiscard]] auto find_free_touch_slot() const noexcept -> std::size_t;
std::array<std::uint8_t, kScancodeCount> keys_{};
std::array<std::uint8_t, kScancodeCount> pressed_{};
std::array<std::uint8_t, kScancodeCount> released_{};
@@ -113,6 +159,20 @@ class ENGINE_CORE_API InputState {
std::array<std::uint32_t, kMaxPads> pad_buttons_{};
[[maybe_unused]] std::array<void*, kMaxPads> pad_handles_{};
int pad_count_{0};
// P7-07 touch slots: finger id per slot, normalised position, held flag
// (finger down; survives quiet frames) and per-frame pressed edge (DOWN
// or MOTION this frame; cleared by begin_frame). Held — not the edge —
// backs touch_count()/primary mirror. A separate held flag (not
// touch_ids_ == 0) marks free slots so finger id 0 can't collide.
std::array<std::uint64_t, kMaxTouches> touch_ids_{};
std::array<float, kMaxTouches> touch_xs_{};
std::array<float, kMaxTouches> touch_ys_{};
std::array<std::uint8_t, kMaxTouches> touch_held_{};
std::array<std::uint8_t, kMaxTouches> touch_down_{};
bool touch_primary_down_{false};
// P7-07 lifecycle latches (set from SDL app/render events).
bool paused_{false};
bool surface_lost_{false};
};
} // namespace engine::input
+153
View File
@@ -4,6 +4,9 @@
// events, so the engine keeps its own table updated from SDL_EVENT_KEY_*
// only. SDL_PollEvent also yields SDL window events, hence the strict
// type filter in process_event.
//
// P7-07 adds SDL finger-event touch slots (primary mirrors to mouse) and
// app-lifecycle latches (pause/resume/surface loss) in the same style.
#include "engine/input/input_state.hpp"
#if defined(ENGINE_INPUT_HAS_SDL3)
@@ -67,6 +70,37 @@ auto InputState::begin_frame() noexcept -> void {
released_.fill(0);
mouse_dx_ = 0.0F;
mouse_dy_ = 0.0F;
// P7-07: only the touch pressed EDGE clears per frame (like key pressed
// edges). Held flags persist across quiet frames (a stationary held
// finger sends no FINGER_MOTION), so touch_count()/primary mirror read
// held, not the edge. begin_frame() recomputes the primary mirror from
// held state (no events needed) so it can't disagree with mouse button
// 1 on a quiet frame.
touch_down_.fill(0);
refresh_primary_touch();
}
auto InputState::touch_count() const noexcept -> int {
int n = 0;
for (std::uint8_t h : touch_held_) {
n += (h != 0) ? 1 : 0;
}
return n;
}
auto InputState::touch_x(int slot) const noexcept -> float {
auto i = static_cast<std::size_t>(slot);
return (slot >= 0 && i < touch_xs_.size()) ? touch_xs_[i] : 0.0F;
}
auto InputState::touch_y(int slot) const noexcept -> float {
auto i = static_cast<std::size_t>(slot);
return (slot >= 0 && i < touch_ys_.size()) ? touch_ys_[i] : 0.0F;
}
auto InputState::touch_pressed(int slot) const noexcept -> bool {
auto i = static_cast<std::size_t>(slot);
return (slot >= 0 && i < touch_down_.size()) && touch_down_[i] != 0;
}
auto InputState::key_down(std::uint32_t scancode) const noexcept -> bool {
@@ -110,6 +144,73 @@ auto InputState::gamepad_button(int pad, Button button) const noexcept
auto InputState::gamepad_count() const noexcept -> int { return pad_count_; }
auto InputState::handle_lifecycle() noexcept -> bool {
bool lost = surface_lost_;
surface_lost_ = false;
return lost;
}
// P7-07: refresh the primary-touch mirror — lowest active finger id wins.
// Active = held slots. Runs on every finger event AND from begin_frame()
// (no events needed) so a quiet frame with a held finger keeps the mirror
// set. Primary sets mouse position (normalised 0..1) + left button; no
// held touches clears the button. Mouse delta tracks finger motion while
// the primary stays down.
auto InputState::refresh_primary_touch() noexcept -> void {
#if defined(ENGINE_INPUT_HAS_SDL3)
std::uint64_t best = 0;
std::size_t best_slot = touch_ids_.size();
bool first = true;
for (std::size_t i = 0; i < touch_ids_.size(); ++i) {
if (touch_held_[i] != 0 && (first || touch_ids_[i] < best)) {
best = touch_ids_[i];
best_slot = i;
first = false;
}
}
if (best_slot < touch_ids_.size()) {
float nx = touch_xs_[best_slot];
float ny = touch_ys_[best_slot];
if (touch_primary_down_) {
mouse_dx_ += nx - mouse_x_;
mouse_dy_ += ny - mouse_y_;
}
mouse_x_ = nx;
mouse_y_ = ny;
mouse_buttons_ |=
(1U << static_cast<std::uint32_t>(kMouseButtonLeftBit));
touch_primary_down_ = true;
} else {
if (touch_primary_down_) {
mouse_buttons_ &=
~(1U << static_cast<std::uint32_t>(kMouseButtonLeftBit));
}
touch_primary_down_ = false;
}
#else
touch_primary_down_ = false;
#endif
}
auto InputState::find_held_touch_slot(std::uint64_t fid) const noexcept
-> std::size_t {
for (std::size_t i = 0; i < touch_ids_.size(); ++i) {
if (touch_held_[i] != 0 && touch_ids_[i] == fid) {
return i;
}
}
return touch_ids_.size();
}
auto InputState::find_free_touch_slot() const noexcept -> std::size_t {
for (std::size_t i = 0; i < touch_ids_.size(); ++i) {
if (touch_held_[i] == 0) {
return i;
}
}
return touch_ids_.size();
}
auto InputState::process_event(const SDL_Event& ev) noexcept -> void {
#if defined(ENGINE_INPUT_HAS_SDL3)
switch (ev.type) {
@@ -235,6 +336,58 @@ auto InputState::process_event(const SDL_Event& ev) noexcept -> void {
}
break;
}
// --- P7-07 touch: SDL finger events into fixed slots -----------------
case SDL_EVENT_FINGER_DOWN:
case SDL_EVENT_FINGER_MOTION: {
const auto fid = static_cast<std::uint64_t>(ev.tfinger.fingerID);
std::size_t slot = find_held_touch_slot(fid);
if (slot == touch_ids_.size()) {
// Injected test ids include 0 (real SDL never emits 0): accept any
// id here so SDL_PushEvent-driven tests work without a prior DOWN.
// Free = not held (touch_ids_ alone can't mark free: fid 0 would
// collide with the sentinel).
slot = find_free_touch_slot();
}
if (slot == touch_ids_.size()) {
break; // all slots busy: drop (no allocation on frame path)
}
touch_ids_[slot] = fid;
touch_xs_[slot] = ev.tfinger.x;
touch_ys_[slot] = ev.tfinger.y;
// DOWN and MOTION both assert held (held survives quiet frames);
// touch_down_ is the per-frame edge cleared by begin_frame().
touch_held_[slot] = 1;
touch_down_[slot] = 1;
refresh_primary_touch();
break;
}
case SDL_EVENT_FINGER_UP:
case SDL_EVENT_FINGER_CANCELED: {
const auto fid = static_cast<std::uint64_t>(ev.tfinger.fingerID);
const auto slot = find_held_touch_slot(fid);
if (slot < touch_ids_.size()) {
touch_held_[slot] = 0;
touch_down_[slot] = 0;
touch_ids_[slot] = 0;
touch_xs_[slot] = 0.0F;
touch_ys_[slot] = 0.0F;
}
refresh_primary_touch();
break;
}
// --- P7-07 lifecycle: background/foreground + surface loss -----------
case SDL_EVENT_WILL_ENTER_BACKGROUND:
case SDL_EVENT_DID_ENTER_BACKGROUND:
paused_ = true;
break;
case SDL_EVENT_WILL_ENTER_FOREGROUND:
case SDL_EVENT_DID_ENTER_FOREGROUND:
paused_ = false;
break;
case SDL_EVENT_RENDER_TARGETS_RESET:
case SDL_EVENT_RENDER_DEVICE_LOST:
surface_lost_ = true;
break;
default:
// SDL window/focus/audio events and everything else: ignored.
break;
+19 -2
View File
@@ -345,7 +345,7 @@ auto main() -> int {
failures += check(offsetof(EcsChunkView, world) == 8, "abi: EcsChunkView.world @ 8");
failures += check(offsetof(EcsChunkView, entities) == 16, "abi: EcsChunkView.entities @ 16");
failures += check(offsetof(EcsChunkView, user_data) == 40, "abi: EcsChunkView.user_data @ 40");
failures += check(sizeof(EngineApiTable) == 1376, "abi: EngineApiTable 1376B (ABI 4.27 networking tail)");
failures += check(sizeof(EngineApiTable) == 1384, "abi: EngineApiTable 1384B (ABI 4.28 lifecycle tail)");
failures += check(offsetof(EngineApiTable, entity_remove) == 1280,
"abi: entity_remove @ 1280 (ABI 4.25 inspector tail)");
failures += check(offsetof(EngineApiTable, renderer_upload_mesh) == 1288,
@@ -393,11 +393,23 @@ auto main() -> int {
failures += check(api->net_rpc_poll(nullptr, nullptr, nullptr, 0) != 0,
"abi: net rpc_poll null rejected");
api->net_destroy(nullptr); // idempotent on NULL
failures += check(sizeof(EngineInputState) == 1668, "abi: EngineInputState 1668B");
failures += check(sizeof(EngineInputState) == 1744, "abi: EngineInputState 1744B");
// P7-07 touch/lifecycle tail (ABI 4.28): one appended entry after the
// 4.27 networking tail; table grows 1376 -> 1384.
failures += check(offsetof(EngineApiTable, input_lifecycle) == 1376,
"abi: input_lifecycle @ 1376 (4.28 tail)");
failures += check(api->input_lifecycle != nullptr, "abi: input_lifecycle set");
failures += check(api->input_lifecycle(nullptr) == 0, "abi: input_lifecycle null -> 0");
failures += check(sizeof(EngineInputState) == 1744, "abi: EngineInputState 1744B");
failures += check(offsetof(EngineInputState, mouse_x) == 1536, "abi: EngineInputState.mouse_x @ 1536");
failures += check(offsetof(EngineInputState, mouse_buttons) == 1552, "abi: EngineInputState.mouse_buttons @ 1552");
failures += check(offsetof(EngineInputState, pad_axes) == 1556, "abi: EngineInputState.pad_axes @ 1556");
failures += check(offsetof(EngineInputState, pad_buttons) == 1652, "abi: EngineInputState.pad_buttons @ 1652");
// P7-07 touch/lifecycle tail (ABI 4.28): appended after pad_buttons.
failures += check(offsetof(EngineInputState, touch_x) == 1668, "abi: EngineInputState.touch_x @ 1668");
failures += check(offsetof(EngineInputState, touch_pressed) == 1732, "abi: EngineInputState.touch_pressed @ 1732");
failures += check(offsetof(EngineInputState, paused) == 1740, "abi: EngineInputState.paused @ 1740");
failures += check(offsetof(EngineInputState, touch_count) == 1742, "abi: EngineInputState.touch_count @ 1742");
failures += check(offsetof(EngineApiTable, input_get_state) == 360, "abi: input_get_state @ 360 (P2-02 tail)");
failures += check(api->input_get_state != nullptr, "abi: input_get_state set");
failures += check(api->input_get_state(nullptr) == nullptr, "abi: input_get_state null renderer -> null");
@@ -621,6 +633,11 @@ auto main() -> int {
"abi: animator_create @ 1200 (4.23 tail)");
failures += check(offsetof(EngineApiTable, animator_state_name) == 1248,
"abi: animator_state_name @ 1248 (4.23 tail end)");
// P7-07 lifecycle tail (ABI 4.28): 1 appended entry after the 4.27 tail.
failures += check(offsetof(EngineApiTable, input_lifecycle) == 1376,
"abi: input_lifecycle @ 1376 (4.28 tail)");
failures += check(api->input_lifecycle != nullptr, "abi: input_lifecycle set");
failures += check(api->input_lifecycle(nullptr) == 0, "abi: input_lifecycle null renderer -> 0");
if (api->animator_create != nullptr) {
failures += check(api->animator_create(nullptr, 0, nullptr) == nullptr,
"abi: animator_create null guards -> NULL");
+143 -5
View File
@@ -50,7 +50,7 @@ void test_keyboard() {
in.begin_frame();
SDL_Event ev{};
ev.type = SDL_EVENT_KEY_DOWN;
ev.key.type = SDL_EVENT_KEY_DOWN;
ev.key.scancode = SDL_SCANCODE_W;
ev.key.key = SDLK_W;
ev.key.down = true;
@@ -68,7 +68,7 @@ void test_keyboard() {
// Key up -> released edge, down clears.
SDL_Event up{};
up.type = SDL_EVENT_KEY_UP;
up.key.type = SDL_EVENT_KEY_UP;
up.key.scancode = SDL_SCANCODE_W;
up.key.key = SDLK_W;
up.key.down = false;
@@ -84,7 +84,7 @@ void test_mouse() {
in.begin_frame();
SDL_Event ev{};
ev.type = SDL_EVENT_MOUSE_MOTION;
ev.motion.type = SDL_EVENT_MOUSE_MOTION;
ev.motion.x = 10.0F;
ev.motion.y = 20.0F;
ev.motion.xrel = 3.0F;
@@ -103,6 +103,29 @@ void test_mouse() {
"input: mouse delta clears next frame");
check(in.mouse_x() == 10.0F && in.mouse_y() == 20.0F,
"input: mouse position persists");
// Mouse button: press button 1, verify held persists across begin_frame()
// when no touch is active (proves refresh_primary_touch does not clear it).
SDL_Event btn_down{};
btn_down.button.type = SDL_EVENT_MOUSE_BUTTON_DOWN;
btn_down.button.button = SDL_BUTTON_LEFT;
btn_down.button.down = true;
check(SDL_PushEvent(&btn_down), "input: push mouse button down");
drain(in);
check(in.mouse_button(1), "input: mouse button 1 down");
in.begin_frame();
drain(in);
check(in.mouse_button(1), "input: mouse button 1 survives begin_frame");
SDL_Event btn_up{};
btn_up.button.type = SDL_EVENT_MOUSE_BUTTON_UP;
btn_up.button.button = SDL_BUTTON_LEFT;
btn_up.button.down = false;
check(SDL_PushEvent(&btn_up), "input: push mouse button up");
in.begin_frame();
drain(in);
check(!in.mouse_button(1), "input: mouse button 1 cleared after up");
}
void test_gamepad_axis() {
@@ -110,7 +133,7 @@ void test_gamepad_axis() {
in.begin_frame();
SDL_Event ev{};
ev.type = SDL_EVENT_GAMEPAD_AXIS_MOTION;
ev.gaxis.type = SDL_EVENT_GAMEPAD_AXIS_MOTION;
ev.gaxis.which = 7; // fake joystick id, no ADDED required
ev.gaxis.axis = SDL_GAMEPAD_AXIS_LEFTX;
ev.gaxis.value = 16384; // ~+0.5
@@ -128,7 +151,7 @@ void test_gamepad_button() {
in.begin_frame();
SDL_Event ev{};
ev.type = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
ev.gbutton.type = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
ev.gbutton.which = 9;
ev.gbutton.button = SDL_GAMEPAD_BUTTON_SOUTH;
ev.gbutton.down = true;
@@ -137,6 +160,119 @@ void test_gamepad_button() {
check(in.gamepad_button(0, Button::kSouth), "input: pad button south down");
}
void test_touch_primary_mirror() {
InputState in;
in.begin_frame();
// Finger DOWN at (0.25, 0.5): primary touch mirrors to mouse pos + left.
SDL_Event down{};
down.tfinger.type = SDL_EVENT_FINGER_DOWN;
down.tfinger.fingerID = 1;
down.tfinger.x = 0.25F;
down.tfinger.y = 0.5F;
check(SDL_PushEvent(&down), "input: push finger down");
drain(in);
check(in.touch_count() == 1, "input: touch count 1 after finger down");
check(in.touch_pressed(0), "input: touch slot 0 pressed");
check(in.touch_x(0) == 0.25F && in.touch_y(0) == 0.5F,
"input: touch slot 0 position 0.25,0.5");
check(in.touch_primary_down(), "input: primary touch down");
check(in.mouse_button(1), "input: finger down sets mouse button 1");
check(in.mouse_x() == 0.25F && in.mouse_y() == 0.5F,
"input: finger down sets mouse position");
// Finger MOTION: position updates, delta tracks the finger. Note: the
// motion edge is per-frame, so begin_frame() clears touch_pressed —
// the slot position (touch_x/touch_y) still updates and the primary
// mirror still holds the button.
SDL_Event motion{};
motion.tfinger.type = SDL_EVENT_FINGER_MOTION;
motion.tfinger.fingerID = 1;
motion.tfinger.x = 0.3F;
motion.tfinger.y = 0.6F;
motion.tfinger.dx = 0.05F;
motion.tfinger.dy = 0.1F;
in.begin_frame();
check(SDL_PushEvent(&motion), "input: push finger motion");
drain(in);
check(in.mouse_x() == 0.3F && in.mouse_y() == 0.6F,
"input: finger motion updates mouse position");
check(in.mouse_button(1), "input: mouse button 1 held during touch");
// Quiet frame (finger held, no finger events — stationary fingers send
// no FINGER_MOTION): held state must survive. touch_count stays 1, the
// primary mirror stays set and agrees with mouse button 1, while the
// per-frame pressed edge clears.
in.begin_frame();
check(in.touch_count() == 1, "input: touch count survives quiet frame");
check(in.touch_primary_down(), "input: primary touch survives quiet frame");
check(in.mouse_button(1), "input: mouse button 1 held on quiet frame");
check(!in.touch_pressed(0), "input: pressed edge clears on quiet frame");
check(in.touch_x(0) == 0.3F && in.touch_y(0) == 0.6F,
"input: touch position survives quiet frame");
// Finger UP: slot frees, mouse button clears.
SDL_Event up{};
up.tfinger.type = SDL_EVENT_FINGER_UP;
up.tfinger.fingerID = 1;
in.begin_frame();
check(SDL_PushEvent(&up), "input: push finger up");
drain(in);
check(in.touch_count() == 0, "input: touch count 0 after finger up");
check(!in.touch_primary_down(), "input: primary touch released");
check(!in.mouse_button(1), "input: finger up clears mouse button 1");
// Second finger while first held: primary = lowest id; multi-touch table
// tracks both slots.
SDL_Event d1{};
d1.tfinger.type = SDL_EVENT_FINGER_DOWN;
d1.tfinger.fingerID = 7;
d1.tfinger.x = 0.1F;
d1.tfinger.y = 0.1F;
SDL_Event d2{};
d2.tfinger.type = SDL_EVENT_FINGER_DOWN;
d2.tfinger.fingerID = 3;
d2.tfinger.x = 0.9F;
d2.tfinger.y = 0.9F;
in.begin_frame();
check(SDL_PushEvent(&d1), "input: push finger 7 down");
check(SDL_PushEvent(&d2), "input: push finger 3 down");
drain(in);
check(in.touch_count() == 2, "input: touch count 2 for two fingers");
check(in.mouse_x() == 0.9F && in.mouse_y() == 0.9F,
"input: primary touch is lowest finger id");
check(in.touch_x(99) == 0.0F && !in.touch_pressed(99),
"input: OOB touch slot reads 0/false");
}
void test_lifecycle() {
InputState in;
in.begin_frame();
check(!in.paused(), "input: not paused initially");
check(!in.surface_lost(), "input: surface not lost initially");
SDL_Event bg{};
bg.type = SDL_EVENT_WILL_ENTER_BACKGROUND;
check(SDL_PushEvent(&bg), "input: push background event");
drain(in);
check(in.paused(), "input: background event pauses");
SDL_Event fg{};
fg.type = SDL_EVENT_DID_ENTER_FOREGROUND;
check(SDL_PushEvent(&fg), "input: push foreground event");
drain(in);
check(!in.paused(), "input: foreground event resumes");
SDL_Event lost{};
lost.type = SDL_EVENT_RENDER_DEVICE_LOST;
check(SDL_PushEvent(&lost), "input: push device lost event");
drain(in);
check(in.surface_lost(), "input: device lost latches surface_lost");
check(in.handle_lifecycle(), "input: handle_lifecycle reports loss");
check(!in.surface_lost(), "input: handle_lifecycle consumes latch");
check(!in.handle_lifecycle(), "input: second consume reports false");
}
#endif // ENGINE_INPUT_HAS_SDL3
} // namespace
@@ -154,6 +290,8 @@ auto main() -> int {
test_mouse();
test_gamepad_axis();
test_gamepad_button();
test_touch_primary_mirror();
test_lifecycle();
SDL_Quit();
if (failures == 0) {
std::printf("[PASS] input: all checks passed\n");
+19 -10
View File
@@ -797,14 +797,15 @@ internal static class Program
}
// P2-02: EngineInputState mirror. Field list is ABI: fail closed on any
// drift from the 1668-byte shape (3x512 scancode tables + mouse floats +
// button mask + 4x6 pad axes + 4 pad button masks).
// drift from the 1744-byte shape (3x512 scancode tables + mouse floats +
// button mask + 4x6 pad axes + 4 pad button masks + P7-07 touch tables
// + lifecycle latches).
private static void EmitInputState(StringBuilder sb, StructDef state)
{
string[] wantNames = ["keys", "pressed", "released", "mouse_x", "mouse_y", "mouse_dx", "mouse_dy", "mouse_buttons", "pad_axes", "pad_buttons"];
string[] wantTypes = ["uint8_t", "uint8_t", "uint8_t", "float", "float", "float", "float", "uint32_t", "float", "uint32_t"];
int[] wantArr = [512, 512, 512, 0, 0, 0, 0, 0, 4, 4];
int[] wantArr2 = [0, 0, 0, 0, 0, 0, 0, 0, 6, 0];
string[] wantNames = ["keys", "pressed", "released", "mouse_x", "mouse_y", "mouse_dx", "mouse_dy", "mouse_buttons", "pad_axes", "pad_buttons", "touch_x", "touch_y", "touch_pressed", "paused", "surface_lost", "touch_count"];
string[] wantTypes = ["uint8_t", "uint8_t", "uint8_t", "float", "float", "float", "float", "uint32_t", "float", "uint32_t", "float", "float", "uint8_t", "uint8_t", "uint8_t", "uint8_t"];
int[] wantArr = [512, 512, 512, 0, 0, 0, 0, 0, 4, 4, 8, 8, 8, 0, 0, 0];
int[] wantArr2 = [0, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0];
if (state.Fields.Count != wantNames.Length)
{
throw new InvalidOperationException($"EngineInputState must have {wantNames.Length} fields (got {state.Fields.Count}).");
@@ -820,12 +821,14 @@ internal static class Program
}
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Mirror of EngineInputState (ABI 4.3): event-fed input snapshot, 1668 bytes.");
sb.AppendLine(" /// Mirror of EngineInputState (ABI 4.24): event-fed input snapshot, 1744 bytes.");
sb.AppendLine(" /// Keys/pressed/released index by SDL scancode (512 slots); pad_axes is");
sb.AppendLine(" /// 4 pads x 6 axes normalised to [-1, 1]. Native owns the memory;");
sb.AppendLine(" /// 4 pads x 6 axes normalised to [-1, 1]. P7-07 tail: touch_x/touch_y");
sb.AppendLine(" /// (8 slots, normalised 0..1) + touch_pressed + paused/surface_lost");
sb.AppendLine(" /// latches + touch_count. Native owns the memory;");
sb.AppendLine(" /// managed views it zero-copy via input_get_state.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" [StructLayout(LayoutKind.Sequential, Pack = 4, Size = 1668)]");
sb.AppendLine(" [StructLayout(LayoutKind.Sequential, Pack = 4, Size = 1744)]");
sb.AppendLine(" public unsafe struct InputState");
sb.AppendLine(" {");
sb.AppendLine(" public fixed byte Keys[512];");
@@ -838,6 +841,12 @@ internal static class Program
sb.AppendLine(" public uint MouseButtons;");
sb.AppendLine(" public fixed float PadAxes[4 * 6];");
sb.AppendLine(" public fixed uint PadButtons[4];");
sb.AppendLine(" public fixed float TouchX[8];");
sb.AppendLine(" public fixed float TouchY[8];");
sb.AppendLine(" public fixed byte TouchPressed[8];");
sb.AppendLine(" public byte Paused;");
sb.AppendLine(" public byte SurfaceLost;");
sb.AppendLine(" public byte TouchCount;");
sb.AppendLine(" }");
sb.AppendLine();
}
@@ -1700,7 +1709,7 @@ internal static class Program
sb.AppendLine(" throw new InvalidOperationException($\"ABI column stride mismatch: native {table->ColumnStride} != managed {ColumnStride}.\");");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48 || sizeof(EngineChunkColumns) != 120 || sizeof(InputState) != 1668 || sizeof(NavBox) != 24 || sizeof(NavBakeConfig) != 48 || sizeof(NavStats) != 8 || sizeof(NavAgentData) != 64 || sizeof(FieldDesc) != 16 || sizeof(NetTransformRow) != 48)");
sb.AppendLine(" if (sizeof(LocalTransform) != 64 || sizeof(WorldTransform) != 64 || sizeof(Ray) != 24 || sizeof(RaycastHit) != 48 || sizeof(EngineChunkColumns) != 120 || sizeof(InputState) != 1744 || sizeof(NavBox) != 24 || sizeof(NavBakeConfig) != 48 || sizeof(NavStats) != 8 || sizeof(NavAgentData) != 64 || sizeof(FieldDesc) != 16 || sizeof(NetTransformRow) != 48)");
sb.AppendLine(" {");
sb.AppendLine(" throw new InvalidOperationException($\"Component layout drift: LocalTransform={sizeof(LocalTransform)} WorldTransform={sizeof(WorldTransform)} Ray={sizeof(Ray)} Hit={sizeof(RaycastHit)} ChunkColumns={sizeof(EngineChunkColumns)} InputState={sizeof(InputState)} NavBox={sizeof(NavBox)} NavConfig={sizeof(NavBakeConfig)} NavStats={sizeof(NavStats)} NavAgent={sizeof(NavAgentData)} FieldDesc={sizeof(FieldDesc)} NetRow={sizeof(NetTransformRow)}.\");");
sb.AppendLine(" }");