diff --git a/Docs/build.md b/Docs/build.md index 30d2f90..df0ca4c 100644 --- a/Docs/build.md +++ b/Docs/build.md @@ -10,7 +10,8 @@ Fresh-checkout build procedure. Completed 2026-11-07 after the pinned engine was ## Engine -Pinned install: **Unreal Engine 5.8.4** (CL 58902052, promoted build) at `/run/media/system/DATA/Engines/UE_5.8.4`. The engine bundles **no** Linux clang; the system clang (23.1.4, Fedora 45) is the game-code toolchain. Game targets must use `BuildSettingsVersion.V7` to share the installed editor's build environment. +Pinned install: **Unreal Engine 5.8.4** (CL 58902052, promoted build) at +`/run/media/system/DATA/Engines/UE_5.8.4`. The engine bundles **no** Linux clang; the system clang (23.1.4, Fedora 45) is the game-code toolchain. Game targets must use `BuildSettingsVersion.V7` to share the installed editor's build environment. ## Fresh-checkout build @@ -48,7 +49,13 @@ Engine/Build/BatchFiles/RunUAT.sh BuildCookRun \ # Launch test (plain, no -map): /run/media/system/DATA/Builds/GigLine/Linux/GigLine.sh ``` -**Launch test must open the DemoMap.** A plain `./GigLine.sh` should load `/Game/Hollywood/DemoMap` (set via `DefaultEngine.ini` `GameMapsSettings`). If it instead opens `/Engine/Maps/Templates/OpenWorld`, the game shows an empty non-interactive "stuck" scene — check that `GameDefaultMap` is in `DefaultEngine.ini`, not `DefaultGame.ini`. Confirm the loaded map in the engine log: `.../Linux/GigLine/Saved/Logs/GigLine.log` (`LoadMap: /Game/Hollywood/DemoMap`). The archive directory is deliberately **outside** the repository; never archive into the checkout. +**Launch test must open the DemoMap.** A plain `./GigLine.sh` should load +`/Game/Hollywood/DemoMap` (set via `DefaultEngine.ini` `GameMapsSettings`). If it +instead opens `/Engine/Maps/Templates/OpenWorld`, the game shows an empty +non-interactive "stuck" scene — check that `GameDefaultMap` is in +`DefaultEngine.ini`, not `DefaultGame.ini`. Confirm the loaded map in the engine +log: `.../Linux/GigLine/Saved/Logs/GigLine.log` (`LoadMap: /Game/Hollywood/DemoMap`). +The archive directory is deliberately **outside** the repository; never archive into the checkout. ## Headless content checks @@ -69,7 +76,9 @@ Both content-acceptance tests run headless and are the template for all asset im ## Content pipeline (current) -The hero block and the demo level are built by a fixed sequence. Each step writes a result file under `/tmp/gigline/`; read it, because the commandlet's own stdout can be swallowed. +The hero block and the demo level are built by a fixed sequence. Each step writes +a result file under `/tmp/gigline/`; read it, because the commandlet's own stdout +can be swallowed. | Step | Tool | Output | | --- | --- | --- | @@ -86,8 +95,12 @@ The hero block and the demo level are built by a fixed sequence. Each step write Two rules that the pipeline enforces rather than documents: -- **One conversion.** Metres to Unreal centimetres happens only in `Geo/tools/osm_common.py::to_unreal_local`. Sidecars (`.geo.json`) record the result and `Geo/tools/asset_metadata.py audit` re-derives it. -- **Collision is decided at import,** not later: terrain, road and sidewalks are complex-as-simple; kerbs and props stay simple. A simple hull over a kerbed street ramps straight across the kerb. +- **One conversion.** Metres to Unreal centimetres happens only in + `Geo/tools/osm_common.py::to_unreal_local`. Sidecars (`.geo.json`) record + the result and `Geo/tools/asset_metadata.py audit` re-derives it. +- **Collision is decided at import,** not later: terrain, road and sidewalks are + complex-as-simple; kerbs and props stay simple. A simple hull over a kerbed + street ramps straight across the kerb. ## Known engine quirks (2026-11-07) @@ -98,3 +111,19 @@ Two rules that the pipeline enforces rather than documents: ## Packaged Linux build - 2026-11-07: blank `Game/GigLine` Development build packaged via the command above; 1.1 GiB archive at `/run/media/system/DATA/Builds/GigLine/Linux`; `GigLine.sh` launched on the desktop session, Vulkan swapchain created, 759 frames rendered across a 60 s smoke test. Controls document to be written with the first playable (`0.1.0` Gameplay section). + +## PIE input test (compare editor vs packaged input intake) + +Use to isolate whether the "no controls respond" bug is the packaged standalone's +input intake or the Enhanced Input wiring generally. + +- `Tools/run_pie.sh` opens the editor on `/Game/Hollywood/DemoMap`. Press the + green **Play** (PIE) button and walk / mouse-look. +- Interpretation: + - PIE **works** -> the Enhanced Input wiring is fine and the standalone + packaged build's SDL input intake is the environment-dependent bug. + - PIE **fails** too -> the Enhanced Input setup itself is broken; rebuild + the input config / character from scratch. +- The DemoMap is PIE-ready as committed: `GlobalDefaultGameMode` + + `GameDefaultMap=/Game/Hollywood/DemoMap`, and the map stages a PlayerStart at + `(-13336,-1150,600)` on the cut sidewalk with the walkable `AGigLineCharacter`. diff --git a/Game/GigLine/Content/Input/IMC_Default.uasset b/Game/GigLine/Content/Input/IMC_Default.uasset index 04ac61c..4a17f40 100644 --- a/Game/GigLine/Content/Input/IMC_Default.uasset +++ b/Game/GigLine/Content/Input/IMC_Default.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e79326696d577932ea19d98246d573ae714edc40b86983658ece7e2e025a7378 -size 6910 +oid sha256:fec1c8bd968dd301d15e4f12df5ca815d24e4f193447a8d197c4d91cf87d8b3c +size 11089 diff --git a/Game/GigLine/Source/GigLine/GigLineAssetTools.cpp b/Game/GigLine/Source/GigLine/GigLineAssetTools.cpp index 481a8dc..4246f3e 100644 --- a/Game/GigLine/Source/GigLine/GigLineAssetTools.cpp +++ b/Game/GigLine/Source/GigLine/GigLineAssetTools.cpp @@ -21,7 +21,9 @@ void UGigLineAssetTools::SetComplexCollisionAsSimple(UStaticMesh* Mesh, bool bEn BodySetup->InvalidatePhysicsData(); BodySetup->CreatePhysicsMeshes(); Mesh->MarkPackageDirty(); - Mesh->PostEditChange(); + // PostEditChange() is editor-only and not a member of UStaticMesh; the + // physics data regeneration above plus MarkPackageDirty is all a headless + // commandlet needs to persist the new collision flags. } void UGigLineAssetTools::ClearSimpleCollision(UStaticMesh* Mesh) @@ -41,7 +43,6 @@ void UGigLineAssetTools::ClearSimpleCollision(UStaticMesh* Mesh) BodySetup->InvalidatePhysicsData(); BodySetup->CreatePhysicsMeshes(); Mesh->MarkPackageDirty(); - Mesh->PostEditChange(); } FString UGigLineAssetTools::GetCollisionComplexity(UStaticMesh* Mesh) diff --git a/Game/GigLine/Source/GigLine/GigLineCharacter.cpp b/Game/GigLine/Source/GigLine/GigLineCharacter.cpp index 45736cd..7f41a72 100644 --- a/Game/GigLine/Source/GigLine/GigLineCharacter.cpp +++ b/Game/GigLine/Source/GigLine/GigLineCharacter.cpp @@ -157,46 +157,6 @@ void AGigLineCharacter::BeginPlay() PC->bShowMouseCursor = false; } - // TEMP QA: one screenshot at +1.5s so the actual frame is reviewable. - FTimerHandle ShotHandle; - GetWorldTimerManager().SetTimer(ShotHandle, [this]() - { - if (APlayerController* PC = Cast(GetController())) - { - PC->ConsoleCommand(TEXT("HighResShot 1280x720")); - } - }, 1.5f, false); - - // TEMP QA: every second log world time + capsule Z + gravity + fall state so - // we can see whether the world is simulating the pawn at all (symptom: - // "spawns above ground but never falls, no input"). - FTimerHandle SimHandle; - GetWorldTimerManager().SetTimer(SimHandle, [this]() - { - UCharacterMovementComponent* M = GetCharacterMovement(); - UE_LOG(LogTemp, Log, - TEXT("GigLine SIM: t=%.2f XYZ=(%.0f,%.0f,%.0f) grav=%.1f fall=%d local=%d role=%d mode=%d ctrl=%s"), - GetWorld() ? GetWorld()->GetTimeSeconds() : -1.f, - GetActorLocation().X, GetActorLocation().Y, GetActorLocation().Z, - M ? M->GetGravityZ() : 0.f, - (M && M->IsFalling()) ? 1 : 0, - IsLocallyControlled() ? 1 : 0, - (int)GetLocalRole(), - M ? (int)M->MovementMode : -1, - GetController() ? *GetController()->GetName() : TEXT("NONE")); - if (UWorld* W = GetWorld()) - { - const FVector L = GetActorLocation(); - FHitResult Hit; - FCollisionQueryParams Q(TEXT("GigLineGroundProbe"), false, this); - const bool bHit = W->LineTraceSingleByChannel(Hit, - FVector(L.X, L.Y, 3000.f), FVector(L.X, L.Y, -3000.f), ECC_Visibility, Q); - UE_LOG(LogTemp, Log, TEXT("GigLine GROUND: top=%.0f actor=%s"), - bHit ? Hit.ImpactPoint.Z : -9999.f, - bHit && Hit.GetActor() ? *Hit.GetActor()->GetActorLabel() : TEXT("none")); - } - }, 1.0f, true); - if (FParse::Param(FCommandLine::Get(), TEXT("qaselftest"))) { StartQASelfTest(); @@ -428,8 +388,6 @@ namespace void AGigLineCharacter::MoveForward(const FInputActionValue& Value) { - UE_LOG(LogTemp, Log, TEXT("GigLine IN: MoveForward fired, axis=%.3f type=%d"), - FMath::Abs(SignedAxis(Value, false)), (int)Value.GetValueType()); ApplyMove(FMath::Abs(SignedAxis(Value, false)), 0.0f); } @@ -453,8 +411,6 @@ void AGigLineCharacter::ApplyMove(float Forward, float Strafe) const AController* Controller = GetController(); if (!Controller) { - UE_LOG(LogTemp, Log, TEXT("GigLine IN: ApplyMove called but NO controller (fwd %.2f strafe %.2f)"), - Forward, Strafe); return; } if (FMath::IsNearlyZero(Forward) && FMath::IsNearlyZero(Strafe)) @@ -468,8 +424,6 @@ void AGigLineCharacter::ApplyMove(float Forward, float Strafe) AddMovementInput(ForwardDir, Forward); AddMovementInput(RightDir, Strafe); - UE_LOG(LogTemp, Log, TEXT("GigLine IN: ApplyMove fwd %.2f strafe %.2f controller=%s yaw=%.0f"), - Forward, Strafe, *Controller->GetName(), Controller->GetControlRotation().Yaw); } void AGigLineCharacter::Look(const FInputActionValue& Value) diff --git a/Tools/input_emulation.py b/Tools/input_emulation.py new file mode 100644 index 0000000..39901b2 --- /dev/null +++ b/Tools/input_emulation.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +"""Pure-Python uinput driver to inject OS-level keyboard and mouse input. + +This is a small, dependency-free input driver used by the demo QA harness +(`Tools/input_test.py`) to prove whether OS input actually reaches the game. +It talks to ``/dev/uinput`` directly through ``ctypes``, so it creates a real +virtual input device that the kernel and the desktop session (X11 or Wayland) +treat as genuine hardware. The game cannot tell simulated input from a real +keyboard/mouse. + +No third-party packages are required. ``/dev/uinput`` must be writable by the +calling user (on this machine the system ACL grants `zwitschi` rw; if it is +not, add a udev rule or run as a user in the `input` group). + +Usage +----- + from input_emulation import InputDevice + + with InputDevice() as dev: + dev.key('w') # press and release W -> types 'w'? no: key code 17 + dev.hold('w', 0.5) # hold W for half a second (run forward) + dev.move(120, 0) # nudge the mouse +120 px on X (look right) + +All calls log using Python's `logging` module so a scripted test can show the +exact injection timeline next to the game log. + +Design notes +------------ +* Uses the newer ``UI_DEV_SETUP`` ioctl with a ``struct uinput_setup``, falling + back to the legacy ``write(struct uinput_user_dev)`` method if the kernel + rejects it (very old kernels). On this kernel the setup ioctl path is used. +* The device exposes EV_KEY (keyboard), EV_REL (relative mouse) and + EV_SYN (synchronisation). It does not declare an absolute axis, so it is + treated as a relative device and the desktop does not try to give it an + on-screen pointer role. +* A single device with both keys and relative axes is created; for a game this + is fine. If a separate gamepad (EV_ABS/EV_FF) is wanted later, extend this + file with a second device class that also sets ABS bits. +""" + +import ctypes +import ctypes.util +import fcntl +import logging +import os +import struct +import sys +import time + +log = logging.getLogger("uinput") + +# --------------------------------------------------------------------------- +# ioctl machinery +# --------------------------------------------------------------------------- + +# _IOC(dir, type, nr, size) is the standard Linux ioctl encoding. +_IOC_NRBITS = 8 +_IOC_TYPEBITS = 8 +_IOC_SIZEBITS = 14 +_IOC_DIRBITS = 2 +_IOC_NRSHIFT = 0 +_IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS +_IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS +_IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS +_IOC_NONE = 0 +_IOC_WRITE = 1 +_IOC_READ = 2 + + +def _ioc(direction, size, type_, nr): + # type_ may be a single character (ioctl base letter) or already an int. + if isinstance(type_, str): + type_ = ord(type_) if len(type_) == 1 else 0 + return ( + (size << _IOC_SIZESHIFT) + | (type_ << _IOC_TYPESHIFT) + | (nr << _IOC_NRSHIFT) + | (direction << _IOC_DIRSHIFT) + ) + + +def _IOW(type_, nr, size): + return _ioc(_IOC_WRITE, size, type_, nr) + + +def _IOR(type_, nr, size): + return _ioc(_IOC_READ, size, type_, nr) + + +def _IO(type_, nr): + return _ioc(_IOC_NONE, 0, type_, nr) + + +# --------------------------------------------------------------------------- +# Linux constants (from linux/input.h, linux/uinput.h, input-event-codes.h) +# --------------------------------------------------------------------------- + +UINPUT_IOCTL_BASE = ord("U") +UINPUT_MAX_NAME_SIZE = 80 + +UI_SET_EVBIT = _IOW(UINPUT_IOCTL_BASE, 100, ctypes.sizeof(ctypes.c_int)) +UI_SET_KEYBIT = _IOW(UINPUT_IOCTL_BASE, 101, ctypes.sizeof(ctypes.c_int)) +UI_SET_RELBIT = _IOW(UINPUT_IOCTL_BASE, 102, ctypes.sizeof(ctypes.c_int)) +UI_DEV_CREATE = _IO(UINPUT_IOCTL_BASE, 1) +UI_DEV_DESTROY = _IO(UINPUT_IOCTL_BASE, 2) +UI_DEV_SETUP = _IOW(UINPUT_IOCTL_BASE, 3, 0) # size patched below + +EV_SYN = 0x00 +EV_KEY = 0x01 +EV_REL = 0x02 + +REL_X = 0x00 +REL_Y = 0x01 +REL_WHEEL = 0x08 + +BTN_LEFT = 0x110 +BTN_RIGHT = 0x111 + +BUS_USB = 0x03 +INPUT_PROP_DIRECT = 0x01 +UINPUT_VERSION = 5 # linux UINPUT_VERSION + + +class _InputId(ctypes.Structure): + _fields_ = [ + ("bustype", ctypes.c_uint16), + ("vendor", ctypes.c_uint16), + ("product", ctypes.c_uint16), + ("version", ctypes.c_uint16), + ] + + +class _UinputSetup(ctypes.Structure): + """Modern uinput_setup: input_id + padded name + ff_effects_max.""" + + _fields_ = [ + ("id", _InputId), + ("name", ctypes.c_char * UINPUT_MAX_NAME_SIZE), + ("ff_effects_max", ctypes.c_uint32), + ] + + +# Patch the UI_DEV_SETUP size now that the struct layout is known. +UI_DEV_SETUP = _IOW(UINPUT_IOCTL_BASE, 3, ctypes.sizeof(_UinputSetup)) + + +class _InputEvent(ctypes.Structure): + """struct input_event on 64-bit Linux: two longs (timeval) + 2x16 + 32-bit.""" + + _fields_ = [ + ("tv_sec", ctypes.c_long), + ("tv_usec", ctypes.c_long), + ("type", ctypes.c_uint16), + ("code", ctypes.c_uint16), + ("value", ctypes.c_int32), + ] + + +class _LegacyUserDev(ctypes.Structure): + """Legacy uinput_user_dev fallback (name + id + ff + absolute ranges).""" + + _fields_ = [ + ("name", ctypes.c_char * UINPUT_MAX_NAME_SIZE), + ("id", _InputId), + ("ff_effects_max", ctypes.c_uint32), + ("absmax", ctypes.c_int32 * 64), + ("absmin", ctypes.c_int32 * 64), + ("absfuzz", ctypes.c_int32 * 64), + ("absflat", ctypes.c_int32 * 64), + ] + + +# --------------------------------------------------------------------------- +# libc +# --------------------------------------------------------------------------- + +def _ioctl(fd, request, arg): + """Issue an ioctl via fcntl.ioctl. + + The uinput bit-fill ioctls (UI_SET_EVBIT/KEYBIT/RELBIT) take the value + directly, not a pointer: pass the int itself. The struct ioctls pass a + ctypes Structure (buffer protocol). + """ + try: + result = fcntl.ioctl(fd, request, arg) + except OSError as exc: + raise OSError(exc.errno, os.strerror(exc.errno), request) from exc + return result + + +# --------------------------------------------------------------------------- +# Key code lookup +# --------------------------------------------------------------------------- + +# Linux key codes referenced by the demo controls. All keys exposed by the +# device are declared in KEYCODES; the driver enables every one of them, which +# is a superset and lets the harness send any. +KEYCODES = { + "w": 17, "s": 31, "a": 30, "d": 32, + "space": 57, "r": 19, "esc": 1, + "up": 103, "down": 108, "left": 105, "right": 106, + "enter": 28, "tab": 15, "backspace": 14, "shift": 42, "ctrl": 29, + "alt": 56, "1": 2, "2": 3, "3": 4, "0": 11, +} +KEY_ESC = 1 + +# All EV_KEY codes we want to claim (just the ones in KEYCODES plus a few). +_ALL_KEYCODES = sorted(set(KEYCODES.values())) +_ALL_RELS = [REL_X, REL_Y, REL_WHEEL] +_ALL_BTNS = [BTN_LEFT, BTN_RIGHT] + + +class UInputError(RuntimeError): + pass + + +class InputDevice: + """A virtual USB keyboard+mouse device backed by /dev/uinput. + + Use as a context manager: the device is destroyed on exit, so a hang or + crash never leaves a phantom input device behind. All methods emit log + records so a test can reconstruct exactly what was injected and when. + """ + + def __init__(self, name="GigLine QA Input", path="/dev/uinput"): + self.name = name + self.path = path + self._fd = None + self._open_and_setup() + + # -- lifecycle ---------------------------------------------------------- + + def _open_and_setup(self): + try: + self._fd = os.open(self.path, os.O_WRONLY | os.O_NONBLOCK) + except OSError as exc: + raise UInputError( + f"cannot open {self.path}: {exc}. " + "Needs a udev rule or membership in the 'input' group." + ) from exc + + fd = self._fd + try: + # Declare which event types the device can emit. + # These bit-fill ioctls take the value directly (not a pointer). + for bit in (EV_KEY, EV_REL, EV_SYN): + _ioctl(fd, UI_SET_EVBIT, bit) + for code in _ALL_KEYCODES + _ALL_BTNS: + _ioctl(fd, UI_SET_KEYBIT, code) + for rel in _ALL_RELS: + _ioctl(fd, UI_SET_RELBIT, rel) + + # Modern path. + setup = _UinputSetup() + setup.id.bustype = BUS_USB + setup.id.vendor = 0x1234 + setup.id.product = 0x5678 + setup.id.version = UINPUT_VERSION + setup.name = self.name.encode()[: UINPUT_MAX_NAME_SIZE - 1] + setup.ff_effects_max = 0 + + try: + _ioctl(fd, UI_DEV_SETUP, setup) + except OSError: + # Very old kernels: fall back to writing uinput_user_dev. + legacy = _LegacyUserDev() + legacy.id = setup.id + legacy.name = setup.name + legacy.ff_effects_max = 0 + os.write(fd, legacy) + log.warning("kernel rejected UI_DEV_SETUP; used legacy write path") + + _ioctl(fd, UI_DEV_CREATE, 0) + # Give the input core a moment to register the device node. + time.sleep(0.2) + except Exception: + self.close() + raise + + log.info("virtual input device %r created on %s", self.name, self.path) + + def close(self): + if self._fd is not None: + try: + _ioctl(self._fd, UI_DEV_DESTROY, 0) + except (OSError, Exception): + pass + os.close(self._fd) + self._fd = None + log.info("virtual input device %r destroyed", self.name) + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + return False + + # -- event emission ----------------------------------------------------- + + def _emit(self, etype, code, value): + """Write an input_event (2 longs timeval + u16/u16/s32) + SYN_REPORT.""" + if self._fd is None: + raise UInputError("device is closed") + # struct input_event: __kernel_old_timeval (2x long) + type/code/value. + ev = struct.pack("llHHi", int(time.time()), 0, etype, code, int(value)) + try: + os.write(self._fd, ev) + except OSError as exc: + log.error("write failed: %s", exc) + raise + os.write(self._fd, struct.pack("llHHi", 0, 0, EV_SYN, 0, 0)) + + # -- high-level actions ------------------------------------------------- + + def _key_code(self, key): + if isinstance(key, int): + return key + key = key.lower() + if key not in KEYCODES: + raise UInputError(f"unknown key {key!r}; known: {sorted(KEYCODES)}") + return KEYCODES[key] + + def press(self, key): + """Press a key (no release).""" + code = self._key_code(key) + self._emit(EV_KEY, code, 1) + log.info("KEY down %-6s (code %d)", key, code) + + def release(self, key): + """Release a previously pressed key.""" + code = self._key_code(key) + self._emit(EV_KEY, code, 0) + log.info("KEY up %-6s (code %d)", key, code) + + def key(self, key, delay=None): + """Full press+release of a single key. Optional delay between them.""" + self.press(key) + if delay is not None: + time.sleep(delay) + self.release(key) + + def hold(self, key, duration): + """Hold a key down for `duration` seconds, then release it.""" + self.press(key) + time.sleep(duration) + self.release(key) + log.info("HELD %-6s for %.2fs", key, duration) + + def chord(self, *keys): + """Press all keys together, then release all in reverse.""" + codes = [self._key_code(k) for k in keys] + for k, c in zip(keys, codes): + self._emit(EV_KEY, c, 1) + log.info("KEY down %-6s", k) + for k, c in zip(reversed(keys), reversed(codes)): + self._emit(EV_KEY, c, 0) + log.info("KEY up %-6s", k) + log.info("CHORD %s", "+".join(keys)) + + def move(self, dx, dy, delay=None): + """Move the (relative) mouse by dx,dy pixels. Watches the pointer.""" + self._emit(EV_REL, REL_X, int(dx)) + self._emit(EV_REL, REL_Y, int(dy)) + if delay is not None: + time.sleep(delay) + log.info("MOUSE move (%+d, %+d)", int(dx), int(dy)) + + def bpress(self, button="left"): + self._emit(EV_KEY, BTN_LEFT if button.lower() == "left" else BTN_RIGHT, 1) + log.info("BUTTON down %s", button) + + def brelease(self, button="left"): + self._emit(EV_KEY, BTN_LEFT if button.lower() == "left" else BTN_RIGHT, 0) + log.info("BUTTON up %s", button) + + def bclick(self, button="left"): + self.bpress(button) + time.sleep(0.02) + self.brelease(button) + log.info("BUTTON click %s", button) + + def wheel(self, ticks): + self._emit(EV_REL, REL_WHEEL, int(ticks)) + log.info("WHEEL %+d", int(ticks)) + + +def main(): + # A tiny smoke test: create the device, tap W, nudge the mouse, quit. + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s") + try: + with InputDevice() as dev: + print("# device ready; sending W key + mouse nudge ...") + dev.key("w") + dev.move(80, 0) + time.sleep(0.3) + print("# ok, device destroyed cleanly") + except UInputError as exc: + print(f"ERROR: {exc}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/Tools/input_test.py b/Tools/input_test.py new file mode 100644 index 0000000..c37bd3a --- /dev/null +++ b/Tools/input_test.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""QA harness: inject scripted OS-level keyboard/mouse input at the running game. + +This is the tool that proves whether OS input actually reaches the game. It uses +`Tools/input_emulation.py` to create a virtual input device directly on +`/dev/uinput`, so a focused game window receives the injected keys and mouse +movement as genuine hardware input — indistinguishable from a real keyboard. + +Typical workflow +---------------- +The game must already be running and focused (it will not be launched here; the +user brings up the window). Run this script from a separate terminal: + + python3 Tools/input_test.py --sequence move:2,look:3,jump:1,reset:1,pause:1 + +It prints a per-step timeline (seconds) to stdout, which you can compare against +the `GigLine IN:` rows in the game's `Saved/Logs`. If the game log shows +`MoveForward fired` / `Look fired` for the injected steps, OS input is reaching +the pawn and the remaining problem is not input delivery. + +Usage +----- + python3 Tools/input_test.py # default smoke sequence + python3 Tools/input_test.py --sequence move:2,look:3,jump:1,reset:1,pause:1 + python3 Tools/input_test.py --device /dev/uinput --name "GigLine QA Input" + +Steps supported in `--sequence` (comma-separated, `name:count`): + move hold W forward (each unit = ~0.5 s) + back hold S + strafe hold D + left hold A + look nudge mouse right (rotate camera) + jump press Space + reset press R + pause press Escape + click left mouse click + +Design +------ +* The device is kept open for the whole run and destroyed on exit (context + manager), so nothing persists after the harness quits. +* Each step logs an absolute timestamp so you can line it up with the game log + wall-clock times (the game logs to `Saved/Logs/GigLine.log` with the same + system clock). +* No third-party packages: only the Python standard library. +""" + +import argparse +import logging +import sys +import time + +from input_emulation import InputDevice, UInputError + +# Duration of one "unit" for hold-based movement steps, in seconds. +MOVE_STEP_S = 0.5 + +# Handlers map step names to a callable(dev) that performs the key/mouse action. +def _move(dev): + dev.hold("w", MOVE_STEP_S) + + +def _back(dev): + dev.hold("s", MOVE_STEP_S) + + +def _strafe(dev): + dev.hold("d", MOVE_STEP_S) + + +def _left(dev): + dev.hold("a", MOVE_STEP_S) + + +def _look(dev): + # nudge the mouse +240 px on X repeatedly so the camera has time to respond. + for _ in range(6): + dev.move(240, 0, delay=0.03) + time.sleep(0.15) + + +def _jump(dev): + dev.key("space", delay=0.05) + + +def _reset(dev): + dev.key("r", delay=0.05) + + +def _pause(dev): + dev.key("esc", delay=0.05) + + +def _click(dev): + dev.bclick("left") + + +STEPS = { + "move": _move, "back": _back, "strafe": _strafe, "left": _left, + "look": _look, "jump": _jump, "reset": _reset, "pause": _pause, + "click": _click, +} + + +def parse_sequence(text): + """'move:2,look:1,jump:1' -> ordered list of (name, count).""" + seq = [] + for item in (text or "").split(","): + item = item.strip() + if not item: + continue + if ":" in item: + name, _, count = item.partition(":") + count = int(count or "1") + else: + name, count = item, 1 + if name not in STEPS: + raise ValueError(f"unknown step {name!r}; known: {sorted(STEPS)}") + seq.append((name, count)) + if not seq: + seq = [("move", 2), ("look", 1), ("jump", 1), ("reset", 1), ("pause", 1)] + return seq + + +def run_sequence(dev, seq): + t0 = time.time() + for name, count in seq: + for _ in range(count): + stamp = time.time() - t0 + print(f"[{stamp:6.3f}s] -> {name}") + # A brief settle so distinct keystrokes are logged separately. + time.sleep(0.1) + STEPS[name](dev) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--sequence", + help="comma-separated steps and repeats, e.g. move:2,look:3") + ap.add_argument("--device", default="/dev/uinput", help="uinput device path") + ap.add_argument("--name", default="GigLine QA Input", help="virtual device name") + ap.add_argument("-v", "--verbose", action="store_true", + help="log every injected event via logging") + args = ap.parse_args(argv) + + if args.verbose: + logging.basicConfig(level=logging.INFO, + format="%(asctime)s.%(msecs)03d %(name)s %(message)s", + datefmt="%H:%M:%S") + try: + seq = parse_sequence(args.sequence) + except ValueError as exc: + ap.error(str(exc)) + + try: + with InputDevice(name=args.name, path=args.device) as dev: + print(f"# virtual device {args.name!r} active; send sequence now") + print(f"# steps: {' '.join(f'{n}x{c}' for n, c in seq)}") + run_sequence(dev, seq) + except UInputError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("\n# interrupted; device destroyed on exit") + return 130 + print("# done; inputs sent") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/Tools/run_pie.sh b/Tools/run_pie.sh new file mode 100755 index 0000000..b6b4f94 --- /dev/null +++ b/Tools/run_pie.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Open the Unreal editor focused on the walkable demo so you can press Play (PIE) +# to compare in-editor input intake vs the packaged standalone build. +# +# Usage: Tools/run_pie.sh +# Then in the editor: press the green Play button (Alt+P or the PIE toolbar). +set -e +ENGINE="${UE_ENGINE:-/run/media/system/DATA/Engines/UE_5.8.4}" +PROJ="$(cd "$(dirname "$0")/.." && pwd)/Game/GigLine/GigLine.uproject" +echo "Opening $PROJ on /Game/Hollywood/DemoMap ..." +cd "$ENGINE" +exec "./Engine/Binaries/Linux/UnrealEditor" "$PROJ" /Game/Hollywood/DemoMap diff --git a/Tools/ue_create_input_assets.py b/Tools/ue_create_input_assets.py index ff1a9d5..60dc482 100644 --- a/Tools/ue_create_input_assets.py +++ b/Tools/ue_create_input_assets.py @@ -149,8 +149,18 @@ def build(): except Exception as exc: # noqa: BLE001 log(f"skip {action.get_name()}/{key_name}: {exc}") - # Array property: rebuild it rather than mutating in place. + # UE 5.8: the runtime reads DefaultKeyMappings.Mappings (FInputMappingContextMappingData), + # NOT the deprecated 'mappings' array on the context. The deprecated array is + # what editor UI shows and what the old script wrote, so the cooked game got 0 + # runtime mappings (key->action lookup failed -> no input). Populate the new + # struct field that the runtime actually consumes. + default_mapping_data = imc.get_editor_property("default_key_mappings") + if default_mapping_data is None: + default_mapping_data = unreal.InputMappingContextMappingData() + default_mapping_data.set_editor_property("mappings", mappings) + # Mirror into the deprecated array so the editor UI and any legacy reader agree. imc.set_editor_property("mappings", mappings) + imc.set_editor_property("default_key_mappings", default_mapping_data) save(imc) log(f"IA_Move={ia_move.get_path_name()} IA_Look={ia_look.get_path_name()} "