From 44353987ab7f85585ef378a6659d9e9536d15c43 Mon Sep 17 00:00:00 2001 From: Chris Butler Date: Mon, 3 Aug 2026 17:50:56 -0400 Subject: [PATCH] feat: automate bounded driver motion --- CMakeLists.txt | 9 +- README.md | 15 +++- client/bin/RakSAMPClient.xml | 3 + client/src/drive_position.cpp | 152 +++++++++++++++++++++++++++++++++ client/src/drive_position.h | 68 +++++++++++++++ client/src/native_driver.cpp | 141 ++++++++++++++++++++++++++++++ common/common.h | 2 +- docs/AGENT-HANDOFF.md | 27 ++++-- tests/drive_position_tests.cpp | 75 ++++++++++++++++ 9 files changed, 481 insertions(+), 11 deletions(-) create mode 100644 client/src/drive_position.cpp create mode 100644 client/src/drive_position.h create mode 100644 tests/drive_position_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 94d1de5..6b74fd4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.24) project(RakSAMP - VERSION 0.10.1 + VERSION 0.11.0 DESCRIPTION "Cross-platform SA-MP 0.3.7 and 0.3DL fake client and development server" LANGUAGES C CXX) @@ -78,6 +78,12 @@ if(BUILD_TESTING) target_include_directories(key-state-tests PRIVATE client/src common raknet/SAMP) add_test(NAME key-state COMMAND key-state-tests) + add_executable(drive-position-tests + tests/drive_position_tests.cpp + client/src/drive_position.cpp) + target_include_directories(drive-position-tests PRIVATE client/src) + add_test(NAME drive-position COMMAND drive-position-tests) + add_executable(tinyxml-extension-tests tests/tinyxml_extension_tests.cpp tinyxml/tinystr.cpp @@ -167,6 +173,7 @@ if(RAKSAMP_BUILD_CLIENT) add_executable(raksamp-client client/src/automation_protocol.cpp client/src/cmds.cpp + client/src/drive_position.cpp client/src/key_state.cpp client/src/localplayer.cpp client/src/load_mode.cpp diff --git a/README.md b/README.md index 645b494..6841a18 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,8 @@ Normal input is sent as chat; input beginning with `/` is sent as a server comma `!goto`, `!gotocp`, `!autogotocp`, `!spawn`, `!class`, `!pickup`, `!weapon`, `!shoot`, `!shootmiss`, `!damage`, `!takedamage`, `!key`, `!pos`, `!follow`, `!selplayer`, `!selveh`, `!vlist`, `!dialogresponse`, `!menusel`, `!seltd`, `!sendrates`, `!log`, `!logstatus`, `!teleport`, -`!change_name`, `!change_server`, `!imitate`, and `!scmevent`. +`!change_name`, `!change_server`, `!imitate`, `!driveposition`, `!driveto`, +`!drivecancel`, `!drivestatus`, and `!scmevent`. `!shoot [weapon]` emits correlated bullet sync and damage RPCs. `!shootmiss [weapon]` emits a non-damaging bullet miss for callback testing. @@ -174,6 +175,18 @@ damage received by the headless client. The last command is explicit because RakSAMP has no GTA physics engine to independently simulate explosions, collisions, drowning, or falls; the server must still validate every report. +`!driveposition ` moves the currently assigned driver vehicle and +immediately emits driver sync. Coordinates must be finite and within the supported +SA-MP world bounds. It intentionally provides an instant-position probe for testing +server containment of malicious or corrective position changes. + +`!driveto ` linearly interpolates the assigned driver +vehicle over a bounded 100–60,000 milliseconds while normal driver sync continues. +`!drivestatus` reports an active target and remaining duration, and `!drivecancel` +stops motion at its current position. Starting another motion, teleporting to a +checkpoint, changing seats/vehicles, or exiting cancels the prior motion. These +commands reject passengers and clients without an assigned streamed vehicle. + `!key [action|mask ...]` holds or releases one or more server-visible SA-MP controls and immediately emits the appropriate on-foot, driver, or passenger sync. Named actions cover `action`, `crouch`/`horn`/`h`, diff --git a/client/bin/RakSAMPClient.xml b/client/bin/RakSAMPClient.xml index 1006c00..cede7e5 100644 --- a/client/bin/RakSAMPClient.xml +++ b/client/bin/RakSAMPClient.xml @@ -49,6 +49,9 @@ Avalable runmodes: !rcon: send an RCON command. !goto: go to players position. !gotocp: go to the current checkpoint. + !driveposition : move the assigned driver vehicle and send sync. + !driveto : interpolate assigned-driver movement. + !drivestatus / !drivecancel: inspect or cancel active driver movement. !autogotocp: toggle automatic checkpoint teleporter. !imitate: change imitate name. !vlist: shows list of vehicles. diff --git a/client/src/drive_position.cpp b/client/src/drive_position.cpp new file mode 100644 index 0000000..5154a2c --- /dev/null +++ b/client/src/drive_position.cpp @@ -0,0 +1,152 @@ +#include "drive_position.h" + +#include +#include + +namespace +{ +constexpr float MaximumCoordinateMagnitude = 20000.0f; +constexpr uint32_t MinimumDriveDurationMilliseconds = 100; +constexpr uint32_t MaximumDriveDurationMilliseconds = 60000; + +bool IsFiniteAndBounded(const DriveVector &position) +{ + return std::isfinite(position.x) && std::isfinite(position.y) && + std::isfinite(position.z) && + std::fabs(position.x) <= MaximumCoordinateMagnitude && + std::fabs(position.y) <= MaximumCoordinateMagnitude && + std::fabs(position.z) <= MaximumCoordinateMagnitude; +} + +DriveVector Interpolate( + const DriveVector &start, + const DriveVector &target, + float progress) +{ + return { + start.x + (target.x - start.x) * progress, + start.y + (target.y - start.y) * progress, + start.z + (target.z - start.z) * progress + }; +} +} + +DriveCommandResult ParseDriveCommand( + const std::string &command, + DriveCommand &parsed, + std::string &error) +{ + std::istringstream input(command); + std::string name; + input >> name; + if(name != "!driveposition" && name != "!driveto" && + name != "!drivecancel" && name != "!drivestatus") + return DriveCommandResult::NotDriveCommand; + + DriveCommand candidate; + std::string trailing; + if(name == "!drivecancel" || name == "!drivestatus") + { + if(input >> trailing) + { + error = name + " does not accept arguments."; + return DriveCommandResult::Error; + } + candidate.kind = name == "!drivecancel" + ? DriveCommandKind::Cancel + : DriveCommandKind::Status; + } + else + { + if(!(input >> candidate.target.x >> candidate.target.y >> + candidate.target.z)) + { + error = name == "!driveto" + ? "Usage: !driveto " + : "Usage: !driveposition "; + return DriveCommandResult::Error; + } + + if(name == "!driveto") + { + unsigned long duration = 0; + if(!(input >> duration) || duration < MinimumDriveDurationMilliseconds || + duration > MaximumDriveDurationMilliseconds) + { + error = "Drive duration must be between 100 and 60000 milliseconds."; + return DriveCommandResult::Error; + } + candidate.kind = DriveCommandKind::To; + candidate.durationMilliseconds = static_cast(duration); + } + else + candidate.kind = DriveCommandKind::Position; + + if(input >> trailing) + { + error = name + " received unexpected trailing input."; + return DriveCommandResult::Error; + } + if(!IsFiniteAndBounded(candidate.target)) + { + error = "Drive coordinates must be finite and within +/-20000."; + return DriveCommandResult::Error; + } + } + + parsed = candidate; + error.clear(); + return DriveCommandResult::Parsed; +} + +bool DriveMotion::Start( + const DriveVector &start, + const DriveVector &target, + uint32_t durationMilliseconds, + uint64_t nowMilliseconds, + std::string &error) +{ + if(!IsFiniteAndBounded(start) || !IsFiniteAndBounded(target)) + { + error = "Drive motion endpoints must be finite and within +/-20000."; + return false; + } + if(durationMilliseconds < MinimumDriveDurationMilliseconds || + durationMilliseconds > MaximumDriveDurationMilliseconds || + nowMilliseconds > UINT64_MAX - durationMilliseconds) + { + error = "Drive motion duration is outside the supported bounds."; + return false; + } + + start_ = start; + target_ = target; + startedAtMilliseconds_ = nowMilliseconds; + finishesAtMilliseconds_ = nowMilliseconds + durationMilliseconds; + active_ = true; + error.clear(); + return true; +} + +DriveMotionSample DriveMotion::Sample(uint64_t nowMilliseconds) +{ + if(!active_) + return { target_, false, false }; + if(nowMilliseconds <= startedAtMilliseconds_) + return { start_, true, false }; + + const bool completed = nowMilliseconds >= finishesAtMilliseconds_; + const float progress = completed + ? 1.0f + : static_cast(nowMilliseconds - startedAtMilliseconds_) / + static_cast(finishesAtMilliseconds_ - startedAtMilliseconds_); + const DriveVector position = Interpolate(start_, target_, progress); + if(completed) + active_ = false; + return { position, !completed, completed }; +} + +void DriveMotion::Cancel() +{ + active_ = false; +} diff --git a/client/src/drive_position.h b/client/src/drive_position.h new file mode 100644 index 0000000..6af238e --- /dev/null +++ b/client/src/drive_position.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include + +struct DriveVector +{ + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; +}; + +enum class DriveCommandKind +{ + Position, + To, + Cancel, + Status +}; + +struct DriveCommand +{ + DriveCommandKind kind = DriveCommandKind::Status; + DriveVector target; + uint32_t durationMilliseconds = 0; +}; + +enum class DriveCommandResult +{ + NotDriveCommand, + Parsed, + Error +}; + +DriveCommandResult ParseDriveCommand( + const std::string &command, + DriveCommand &parsed, + std::string &error); + +struct DriveMotionSample +{ + DriveVector position; + bool active = false; + bool completed = false; +}; + +class DriveMotion +{ +public: + bool Start( + const DriveVector &start, + const DriveVector &target, + uint32_t durationMilliseconds, + uint64_t nowMilliseconds, + std::string &error); + DriveMotionSample Sample(uint64_t nowMilliseconds); + void Cancel(); + bool IsActive() const { return active_; } + const DriveVector &Target() const { return target_; } + uint64_t FinishesAtMilliseconds() const { return finishesAtMilliseconds_; } + +private: + DriveVector start_; + DriveVector target_; + uint64_t startedAtMilliseconds_ = 0; + uint64_t finishesAtMilliseconds_ = 0; + bool active_ = false; +}; diff --git a/client/src/native_driver.cpp b/client/src/native_driver.cpp index 87d49a3..3882963 100644 --- a/client/src/native_driver.cpp +++ b/client/src/native_driver.cpp @@ -1,6 +1,8 @@ #include "main.h" #include "automation_protocol.h" +#include "drive_position.h" #include "key_state.h" +#include #include #include #include @@ -18,6 +20,13 @@ static int forcedVehicleId = -1; static bool forcedPassenger = false; static eRunModes forcedPreviousRunMode = RUNMODE_NORMAL; static AutomationKeyState automationKeyState; +static DriveMotion driveMotion; + +static uint64_t MonotonicMilliseconds() +{ + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); +} const AutomationKeyState &GetAutomationKeyState() { @@ -27,6 +36,7 @@ const AutomationKeyState &GetAutomationKeyState() void ResetAutomationKeyState() { automationKeyState = {}; + driveMotion.Cancel(); } static void SendAssignedDriverSync(bool force) @@ -42,6 +52,50 @@ static void SendAssignedDriverSync(bool force) SendInCarFullSyncData(&sync, 1, (PLAYERID)-1, force); } +static bool HasAssignedDriverVehicle() +{ + return forcedVehicleId >= 0 && forcedVehicleId < MAX_VEHICLES && + vehiclePool[forcedVehicleId].iDoesExist && !forcedPassenger; +} + +static void ApplyDriverPosition(const DriveVector &position, bool force) +{ + settings.fNormalModePos[0] = position.x; + settings.fNormalModePos[1] = position.y; + settings.fNormalModePos[2] = position.z; + settings.fCurrentPosition[0] = position.x; + settings.fCurrentPosition[1] = position.y; + settings.fCurrentPosition[2] = position.z; + vehiclePool[forcedVehicleId].fPos[0] = position.x; + vehiclePool[forcedVehicleId].fPos[1] = position.y; + vehiclePool[forcedVehicleId].fPos[2] = position.z; + SendAssignedDriverSync(force); +} + +static void PumpDriveMotion() +{ + if(!driveMotion.IsActive()) + return; + if(!HasAssignedDriverVehicle()) + { + driveMotion.Cancel(); + Log("[DRIVE_TO] Cancelled because the assigned driver vehicle is unavailable."); + return; + } + + const DriveMotionSample sample = + driveMotion.Sample(MonotonicMilliseconds()); + ApplyDriverPosition(sample.position, sample.completed); + if(sample.completed) + { + Log("[DRIVE_TO] Vehicle %d reached %.2f %.2f %.2f.", + forcedVehicleId, + sample.position.x, + sample.position.y, + sample.position.z); + } +} + static void SendCurrentKeyState() { if(forcedVehicleId >= 0 && forcedVehicleId < MAX_VEHICLES && @@ -61,6 +115,7 @@ static bool AssignVehicle(VEHICLEID vehicleId, BYTE seatId) if(vehicleId >= MAX_VEHICLES) return false; + driveMotion.Cancel(); playerInfo[g_myPlayerID].iAreWeInAVehicle = 1; if(forcedVehicleId < 0) forcedPreviousRunMode = settings.runMode; @@ -86,6 +141,7 @@ void NativePutPlayerInVehicle(VEHICLEID vehicleId, BYTE seatId) static int ClearAssignedVehicle() { + driveMotion.Cancel(); playerInfo[g_myPlayerID].iAreWeInAVehicle = 0; int exitedVehicleId = forcedVehicleId; forcedVehicleId = -1; @@ -112,6 +168,7 @@ static void *ReadCommands(void *) void NativePumpCommands() { + PumpDriveMotion(); if(forcedVehicleId >= 0 && forcedVehicleId < MAX_VEHICLES && vehiclePool[forcedVehicleId].iDoesExist) { @@ -145,6 +202,7 @@ void NativePumpCommands() Log("[GOTOCP] There is no active checkpoint."); return; } + driveMotion.Cancel(); settings.fNormalModePos[0] = settings.CurrentCheckpoint.fPosition[0]; settings.fNormalModePos[1] = settings.CurrentCheckpoint.fPosition[1]; settings.fNormalModePos[2] = settings.CurrentCheckpoint.fPosition[2]; @@ -197,6 +255,89 @@ void NativePumpCommands() return; } + DriveCommand driveCommand; + std::string driveError; + const DriveCommandResult driveResult = + ParseDriveCommand( + buffer, + driveCommand, + driveError); + if(driveResult == DriveCommandResult::Error) + { + Log("[DRIVE] %s", driveError.c_str()); + return; + } + if(driveResult == DriveCommandResult::Parsed) + { + if(driveCommand.kind == DriveCommandKind::Cancel) + { + const bool wasActive = driveMotion.IsActive(); + driveMotion.Cancel(); + Log("[DRIVE_CANCEL] %s.", + wasActive ? "Active motion cancelled" : "No active motion"); + return; + } + if(driveCommand.kind == DriveCommandKind::Status) + { + if(!driveMotion.IsActive()) + Log("[DRIVE_STATUS] No active motion."); + else + { + const DriveVector &target = driveMotion.Target(); + const uint64_t now = MonotonicMilliseconds(); + const uint64_t remaining = driveMotion.FinishesAtMilliseconds() > now + ? driveMotion.FinishesAtMilliseconds() - now + : 0; + Log("[DRIVE_STATUS] Target %.2f %.2f %.2f, %llu ms remaining.", + target.x, + target.y, + target.z, + static_cast(remaining)); + } + return; + } + if(!HasAssignedDriverVehicle()) + { + Log("[DRIVE] An assigned driver vehicle is required."); + return; + } + + if(driveCommand.kind == DriveCommandKind::Position) + { + driveMotion.Cancel(); + ApplyDriverPosition(driveCommand.target, true); + Log("[DRIVE_POSITION] Vehicle %d moved to %.2f %.2f %.2f.", + forcedVehicleId, + driveCommand.target.x, + driveCommand.target.y, + driveCommand.target.z); + return; + } + + const DriveVector start = { + vehiclePool[forcedVehicleId].fPos[0], + vehiclePool[forcedVehicleId].fPos[1], + vehiclePool[forcedVehicleId].fPos[2] + }; + if(!driveMotion.Start( + start, + driveCommand.target, + driveCommand.durationMilliseconds, + MonotonicMilliseconds(), + driveError)) + { + Log("[DRIVE] %s", driveError.c_str()); + return; + } + Log("[DRIVE_TO] Vehicle %d moving to %.2f %.2f %.2f over %u ms.", + forcedVehicleId, + driveCommand.target.x, + driveCommand.target.y, + driveCommand.target.z, + driveCommand.durationMilliseconds); + return; + } + if(!strncmp(buffer, "!entervehicle ", 14)) { int vehicleId = atoi(&buffer[14]); diff --git a/common/common.h b/common/common.h index 339734a..4ab756a 100644 --- a/common/common.h +++ b/common/common.h @@ -9,7 +9,7 @@ #include "SAMP_VER.h" #include "protocol.h" -#define RAKSAMP_VERSION "0.10.1" +#define RAKSAMP_VERSION "0.11.0" #define NETCODE_CONNCOOKIELULZ 0x6969 #define NETGAME_VERSION_037 4057 diff --git a/docs/AGENT-HANDOFF.md b/docs/AGENT-HANDOFF.md index 3ad4774..cf9d736 100644 --- a/docs/AGENT-HANDOFF.md +++ b/docs/AGENT-HANDOFF.md @@ -5,12 +5,16 @@ ## Active slice -- Status: generic key-state automation is published as immutable 0.10.1 and proven by Roleplay ATM flows -- Outcome: publish RakSAMP 0.10.1 with protocol-correct automation for every server-visible key action, - then prove and pin it in the Roleplay ATM workflow -- Next action: none in RakSAMP; Roleplay is pinning the immutable release and completing stack acceptance -- Scope: `!key down|up`, on-foot/driver/passenger packing, aliases/raw masks, tests, docs, and 0.10.1 release -- Do not add: gamemode-specific commands or arbitrary OS scancodes +- Status: generic bounded driver-position automation for 0.11.0 is implemented and has passed native, + sanitizer, configuration, and two-protocol Roleplay consumer gates; commit/PR/release remain +- Outcome: publish RakSAMP 0.11.0 with instant `!driveposition` abuse probes plus bounded smooth + `!driveto`, `!drivestatus`, and `!drivecancel` automation so consumers can distinguish gradual + route evidence from instant checkpoint teleporting +- Next action: commit, push, open and merge the RakSAMP PR, run the five-platform build-only workflow, + publish immutable 0.11.0, verify artifacts, and update the Roleplay dependency pin +- Scope: generic finite/bounded driver position parsing, 100–60,000 ms interpolation, motion status + and cancellation, assigned-driver enforcement, tests, docs, and the explicit 0.11.0 release +- Do not add: gamemode names, objectives, routes, fixture coordinates, or server-side bypasses ## Decisions @@ -21,9 +25,16 @@ - Named controls and raw decimal/hex masks are supported; mutually exclusive additional keys are rejected - The user explicitly authorized committing and pushing this completed slice directly to `master` and publishing immutable 0.10.1 after the Roleplay consumer gate +- The user explicitly requested immutable 0.11.0 after the new motion primitive passes the Roleplay + consumer gate; no other version should be published ## Verification +- Passed 0.11.0: Release build and all 12 native tests; focused `drive-position` parser/interpolation + coverage; both sample configuration checks; sanitizer build and all 12 tests +- Passed 0.11.0 consumer gate: Roleplay gradual two-player Forklift completion and exact payouts on 0.3.7 + and 0.3DL; instant checkpoint teleport rejection with unchanged balances on both protocols; rapid + exit/re-entry retained one session; passenger competition did not acquire the leased job vehicle - Passed: Release client/server build, all 11 native tests, and both sample configuration checks - Passed: AddressSanitizer/UndefinedBehaviorSanitizer build and all 11 tests - Passed: focused `key-state` test with named actions, aliases, raw masks, invalid combinations, and exact @@ -37,5 +48,5 @@ ## State -- Feature and portability fixes are pushed through `32f592a`; immutable 0.10.1 is published -- Roleplay implementation is complete and its dependency pin/full acceptance are in progress +- The 0.11.0 change is uncommitted on `codex/driver-position-probe`; immutable 0.10.1 remains published +- Roleplay hardening changes remain local until the required immutable 0.11.0 pin is available diff --git a/tests/drive_position_tests.cpp b/tests/drive_position_tests.cpp new file mode 100644 index 0000000..6be9535 --- /dev/null +++ b/tests/drive_position_tests.cpp @@ -0,0 +1,75 @@ +#include +#include + +#include "drive_position.h" + +int main() +{ + DriveCommand command; + std::string error; + + assert(ParseDriveCommand("hello", command, error) == + DriveCommandResult::NotDriveCommand); + assert(ParseDriveCommand( + "!driveposition 123.5 -456.25 13.75", command, error) == + DriveCommandResult::Parsed); + assert(command.kind == DriveCommandKind::Position); + assert(command.target.x == 123.5f); + assert(command.target.y == -456.25f); + assert(command.target.z == 13.75f); + + assert(ParseDriveCommand( + "!driveto 10 20 30 2500", command, error) == + DriveCommandResult::Parsed); + assert(command.kind == DriveCommandKind::To); + assert(command.durationMilliseconds == 2500); + assert(ParseDriveCommand("!drivecancel", command, error) == + DriveCommandResult::Parsed); + assert(command.kind == DriveCommandKind::Cancel); + assert(ParseDriveCommand("!drivestatus", command, error) == + DriveCommandResult::Parsed); + assert(command.kind == DriveCommandKind::Status); + + assert(ParseDriveCommand("!driveposition", command, error) == + DriveCommandResult::Error); + assert(ParseDriveCommand( + "!driveposition 1 2 3 trailing", command, error) == + DriveCommandResult::Error); + assert(ParseDriveCommand( + "!driveposition nan 2 3", command, error) == + DriveCommandResult::Error); + assert(ParseDriveCommand( + "!driveposition 20001 2 3", command, error) == + DriveCommandResult::Error); + assert(ParseDriveCommand("!driveto 1 2 3 99", command, error) == + DriveCommandResult::Error); + assert(ParseDriveCommand("!driveto 1 2 3 60001", command, error) == + DriveCommandResult::Error); + assert(ParseDriveCommand("!drivecancel now", command, error) == + DriveCommandResult::Error); + + DriveMotion motion; + assert(motion.Start({ 0, 0, 0 }, { 10, 20, 30 }, 1000, 5000, error)); + assert(motion.IsActive()); + auto early = motion.Sample(4999); + assert(early.active && !early.completed); + assert(early.position.x == 0); + auto start = motion.Sample(5000); + assert(start.active && !start.completed); + assert(start.position.x == 0); + auto middle = motion.Sample(5500); + assert(middle.active && !middle.completed); + assert(middle.position.x == 5); + assert(middle.position.y == 10); + assert(middle.position.z == 15); + auto finish = motion.Sample(6000); + assert(!finish.active && finish.completed); + assert(finish.position.x == 10); + assert(!motion.IsActive()); + + assert(!motion.Start({ 0, 0, 0 }, { 1, 1, 1 }, 99, 0, error)); + assert(motion.Start({ 1, 2, 3 }, { 4, 5, 6 }, 100, 0, error)); + motion.Cancel(); + assert(!motion.IsActive()); + return 0; +}