diff --git a/docs/re/aim_bullet_jetpack_memory_probe_r5_20260728.md b/docs/re/aim_bullet_jetpack_memory_probe_r5_20260728.md new file mode 100644 index 0000000..07681ec --- /dev/null +++ b/docs/re/aim_bullet_jetpack_memory_probe_r5_20260728.md @@ -0,0 +1,270 @@ +# Original-R5 aim, bullet, and jetpack memory probe + +Date: 2026-07-28 + +Status: `STATIC_037` + `OBSERVED_037` + `PROBE_TRACE`; the guarded original-R5 +profile has run successfully, and one replacement angle regression has been +captured. Fine-grained visual and task parity remains `TODO_VERIFY`. + +## Scope + +This focused profile passively records the original client's aim-context, +remote-key, shot-dispatch, weapon/task, and jetpack transitions. It is meant +to distinguish packet parity from the GTA-side state changes that produce +visible looking direction, muzzle flash, firing, impact, hit, animation, and +jetpack behavior. + +It does not inject input, synthesize a shot, alter a task, or write a log from +a hooked thread. Each wrapper calls the original routine through a trampoline +and publishes a bounded pre/post record to the existing worker. + +## Binary identities and relocation policy + +Original R5: + +- file: `artifacts/binaries/samp_installer.dll`; +- SHA256: + `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2`; +- preferred base: `0x10000000`; +- PE timestamp: `0x6372C39E`; +- entry RVA: `0x000CBC90`; +- image size: `0x0027E000`. + +GTA San Andreas 1.0 US: + +- file: `GTA San Andreas RZ/gta_sa.exe`; +- SHA256: + `a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26`; +- preferred base: `0x00400000`; +- PE timestamp: `0x427101CA`; +- entry RVA: `0x00424570`; +- image size: `0x01177000`; +- PE checksum: `0x00DC5BEA`. + +Both PE identity proxies must match. For R5 that includes i386, the +preferred-image-base header, relocations-not-stripped, and a non-empty base +relocation directory. GTA has relocations stripped and must remain at its +fixed preferred base because snapshots read its fixed addresses. R5 may be +loader-relocated, as observed on the native Windows test machine. The +preflight normalizes only these statically confirmed HIGHLOW operands: + +| Relocation RVA | Guarded instruction | Preferred operand | Meaning | +| --- | --- | --- | --- | +| `+0x9C964` | entry at `+0x9C960`, operand offset 4 | `0x101039C8` | `samp.dll+0x1039C8` aim-context global | +| `+0xACD4B` | tail at `+0xACD49`, operand offset 2 | `0x10113978` | `samp.dll+0x113978` jetpack-related global | + +For an observed load base of `0x03F80000`, the required operands are +`0x040839C8` and `0x04093978`. Opcodes, RVAs, all other bytes, decoded patch +lengths, and all non-relocated tails remain exact. The relocated entry bytes +are copied into the generic hook's expected buffer only after validation, so +the resulting trampoline replays the loader-adjusted absolute instruction. + +`objdump -p artifacts/binaries/samp_installer.dll` confirms HIGHLOW entries at +`+0x9C964` and `+0xACD4B`. The GTA constant `0x008705C4` in the jetpack query +success tail has no R5 PE relocation and is deliberately not rebased. + +## Hook set + +All eight hooks and every tail guard must pass before the first byte is +changed: + +| RVA | Static role | ABI | +| --- | --- | --- | +| `+0x9C9C0` | install one remote 0x30-byte aim context | `stdcall(player_index)`, `ret 4` | +| `+0x9C960` | restore the saved local aim context | `cdecl()`, `ret` | +| `+0xAF340` | map remote sync keys into GTA pad state | `thiscall`, three stack arguments, `ret 0x0C` | +| `+0xAF280` | store the internal shot context | `thiscall`, one stack argument, `ret 4` or local tail jump | +| `+0xAFA70` | dispatch the remote GTA fire path | `thiscall`, integer return | +| `+0xACD10` | start jetpack | `thiscall`, void return | +| `+0xACD60` | stop jetpack | `thiscall`, void return | +| `+0xACDC0` | query jetpack mode | `thiscall`, integer return | + +Entry spans contain complete instructions and no relative call or branch. +`+0x9C960` contains the one normalized absolute operand described above. +Preflight also validates both shot-context exits, both jetpack-query exits, +and the known epilogue of every other hook. Installation is all-or-nothing. +A partial install is restored in reverse order. Shutdown restores only a +current `E9`/NOP patch owned by this profile, using the exact saved bytes. +Trampoline storage remains process-lifetime memory. + +## Bounded capture + +Hook threads publish to a fixed 512-record ring and do no file I/O. The worker +emits: + +- `aim_bullet_jetpack_r5`: event, tick, GTA frame, thread, caller and hook RVA, + arguments, target, and original return value; +- `aim_bullet_jetpack_state_r5`: ped/entity/matrix, basis, weapon slot/state, + and eleven raw task-root pointers before and after the call; +- `aim_bullet_jetpack_aim_r5`: active and per-player aim-context bytes plus + bounded shot input/context bytes. + +Packet 206 and Packet 207 are call-chain context only in this first profile; +their large packet handlers are not patched. Names inferred from static call +graphs or GTA layouts remain `STATIC_037` / `GTA_REVERSED_REF` / +`TODO_VERIFY`. + +## Captured original-R5 evidence + +The controlled native-Windows observer run is retained at: + +```text +artifacts/runs/20260728-aim-r5-memory-observer/ +``` + +The focused log is: + +```text +windows/20260728_132302_aim_r5_memory_observer_a46fb663/logs/samp_probe.log +``` + +Its SHA256 is +`93a9f49e5b749a6ecdc6a29b2ded29115f3d16922fd322626c42b1abe40574ad`. +The manifest identifies the original R5 and GTA binaries listed above. All +eight guarded hooks installed; the run contains no partial-install condition. + +`OBSERVED_037` + `PROBE_TRACE`: + +- 17,120 `aim_install` events and 17,120 matching `aim_restore` events were + captured. +- 29 remote shots each produced one `shot_context` and one `fire_remote` + event. Every captured shot followed the ordered scope + `shot_context -> aim_install -> GTA fire -> aim_restore -> return`. +- The fire wrapper installed the remote aim context at R5 `+0xAFAFA` and + restored it at `+0xAFC52`. The Packet 206 call sites remained + `+0x1658D` for shot-context storage and `+0x16598` for fire dispatch. +- Reconstructing the 0x30-byte camera basis from the trace produced a maximum + component error of approximately `1.77e-7`. For front vector + `(x, y, z)`, `h = sqrt(x*x + y*y)` and the observed up vector is + `(-x*z/h, -y*z/h, h)`, with the guarded near-vertical fallback handled by + the replacement helper. +- The probed BulletSync target resolution produced GTA entity targets for + player and vehicle hits. Object/player-object hit records did not resolve + to a GTA target in the captured original path; this is evidence for the + original target dispatch, not evidence that object impacts are visually + complete. +- The fire path did not consume the observed remote wrapper's ammunition or + clip fields. It also dispatched the GTA fire routine for a captured sniper + state with clip value zero. Remote ammo mutation must therefore not be + inferred merely from a visible shot. +- Jetpack start and stop each occurred once. The query reads the intelligence + task at offset `+0x10` and recognizes vtable `0x008705C4`. Start became + visible to the query on the following frame in this run; stop cleared the + query immediately. This is one captured timing sequence, not a universal + scheduling guarantee. + +## Corrected remote aim context model + +`STATIC_037`: + +- The remote ped path at R5 `+0xA2960` scopes GTA pad state, camera mode, + camera mode 2, camera zoom, camera aspect, the 0x30-byte aim context, + eleven weapon-skill values, and the current-player byte around one call to + GTA `CPlayerPed::ProcessControl` at `0x60EA90`. +- The camera scalar helpers are R5 `+0x9C7C0` / `+0x9C850`; the aim helpers + are `+0x9C940` / `+0x9C9C0`. +- `AimSync.aspect_ratio` is installed as + `1.0f + aspect_byte / 255.0f`. + +The ProcessControl body performs one GTA ProcessControl call. Earlier working +notes that described a two-pass ProcessControl sequence were incorrect and are +superseded by this static audit. The temporary GTA instruction suppression in +the R5 wrapper is separate choreography around that single call; it is not a +second ProcessControl pass. + +There is also a separate `STATIC_037` path which the replacement does not yet +implement: GTA's `CTaskSimpleUseGun::SetPedPosition` vtable slot at +`0x0086D744` is redirected to R5 `+0xA2DE0` instead of the GTA original +`0x624ED0`. That wrapper repeats the remote pad/camera/aim/skill/current-player +scope around `SetPedPosition`. It can run after the ProcessControl scope has +already been restored, so it remains a plausible source of frame-edge or +weapon-specific pose differences even though the current coarse screenshot +regression is visually aligned. + +## Replacement angle regression + +The post-aspect replacement run is retained at: + +```text +artifacts/runs/20260728-140456-distributed-sync-angles-1511727/ +``` + +The installed replacement SHA256 was +`7881a4c810573d235ac3b227347963c257ffe246e91a03efd45cc4b3087a53d1`. +Its runtime trace recorded an incoming aspect byte of 198 and installed +`1.776471`, matching `1.0 + 198 / 255` within the logged precision. The same +samples had aim key `0x0080`, camera mode 53, and both `aim_valid=1` and +`aim_built=1`. This is `PROBE_TRACE` evidence for the replacement's installed +state; it does not by itself prove original parity. + +Manual comparison of the baseline/left/right screenshots against +`artifacts/runs/20260728-angles-r5-original/` shows the same coarse aim pose +and the same left-to-right progression. This closes the previously reported +gross left/right inversion for that deterministic route at a visual +regression level. It is not a pixel-parity claim: the runner's machine verdict +remains `TRACE_CAPTURED_VISUAL_UNVERIFIED`, the three samples are not a +frame-locked image-diff oracle, and subtle bone/task/camera differences remain +possible. The replacement nameplate size/vertical placement still differs in +these images and is tracked as a separate rendering gap. + +## Enablement + +Environment: + +```text +SAMP_PROBE_AIM_BULLET_JETPACK_HOOKS=1 +``` + +Flag next to `samp_probe.asi`: + +```text +samp_probe_aim_bullet_jetpack_hooks.flag +``` + +Managed Windows profile: + +```bash +tools/windows/remote_lab/samp_lab.sh probe-profile aim-bullet-jetpack +``` + +The focused profile skips normal Winsock/IAT, render, trailer, and unrelated +code-hook sets. + +## Remaining runtime matrix + +The captured run covered ordinary remote aim, pistol/M4/sniper shot dispatch, +and one jetpack start/stop sequence. The remaining controlled matrix is: + +1. frame-locked aim comparison at several pitch/yaw extremes, including the + near-vertical basis fallback; +2. weapon switch, reload, crouch, enter/exit vehicle, and death boundaries, + while correlating `SetPedPosition` and the GTA task tree; +3. single shots and held fire into ground, wall, vehicle, remote ped, and + object, with GTA muzzle/impact/effect counters captured before and after; +4. jetpack idle, directional flight, fire while flying, landing, death, and + reconnect; +5. the same scenarios at the preferred R5 base and a relocated native-Windows + base where possible. + +Comparisons must retain caller RVAs, matrix/basis vectors, aim buffers, target +entity, weapon/task changes, and visible muzzle/impact behavior. Screenshot +inspection alone is insufficient for task or effect parity. + +## Open points + +- `TODO_VERIFY`: implement and guard the separate R5 + `CTaskSimpleUseGun::SetPedPosition` context wrapper, then determine whether + it changes frame-edge, pitch, reload, crouch, or weapon-specific pose parity. +- `TODO_VERIFY`: reproduce and test the remaining remote weapon-skill and + temporary-instruction scope from R5 ProcessControl without assuming a + second ProcessControl pass. +- `TODO_VERIFY`: exact GTA task and weapon-state edges responsible for remote + muzzle flash and each visible impact type. +- `TODO_VERIFY`: relationship between shot-context storage, fire dispatch, + damage handling, and local-only effects. +- `TODO_VERIFY`: complete jetpack task/animation lifecycle beyond the one + observed start/query/stop transition. +- `TODO_VERIFY`: whether any relevant packet path bypasses one of the eight + guarded functions. +- `TODO_VERIFY`: quantify the independent nameplate geometry gap; do not fold + it into aim-sync conclusions. diff --git a/docs/re/custom_modelinfo_static_r5_20260728.md b/docs/re/custom_modelinfo_static_r5_20260728.md new file mode 100644 index 0000000..a674fbd --- /dev/null +++ b/docs/re/custom_modelinfo_static_r5_20260728.md @@ -0,0 +1,441 @@ +# SA-MP 0.3.7-R5 custom ModelInfo static map + +Date: 2026-07-28 + +## Scope and binary identity + +This note isolates the original R5 custom-object, ModelInfo, and downloadable +model support. It does not describe the replacement implementation as proof of +R5 behavior and it does not enable any runtime patch. + +Evidence binaries: + +| Binary | SHA-256 | Ghidra MD5 | +|---|---|---| +| original `samp.dll` (0.3.7-R5) | `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2` | `5ba5f0be7af99dfd03fb39e88a970a2b` | +| GTA SA US 1.0 executable used by the reference prefix | `a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26` | `170b3a9108687b26da2d8901c6948a18` | + +All SA-MP addresses below are RVAs. GTA addresses are VAs for the exact +executable hash above. + +## Main correction + +`STATIC_037`: R5 reserves **20,000** `CAtomicModelInfo` objects. The previously +observed value **15,417 is the populated count**, not the capacity. + +`OBSERVED_037 + PROBE_TRACE`: the original startup run moved the GTA +`CAtomicModelInfo` count at `0x00AAE950` from 13,984 to 15,417 while registering +the 1,433 `objs` rows from the stock `SAMP.ide`. + +The original architecture has three distinct pieces: + +1. A 20,000-entry positive-ID `CAtomicModelInfo` arena used by the normal GTA + ModelInfo registration path. +2. A 65,535-entry relocated `CModelInfo::ms_modelInfoPtrs` arena whose origin is + centred to support signed IDs. +3. Heap-cloned ModelInfos and per-file TXD/DFF/COL loading for downloadable + models. This is separate from the built-in positive-ID `SAMP.ide` catalog. + +## Initialization order + +`STATIC_037`: + +1. `samp.dll+0xC3AB9` calls `samp.dll+0xA08E0`. +2. `samp.dll+0xA08E0` first calls the pre-game patch installer at + `samp.dll+0xAAEB0`. +3. The relevant order inside `samp.dll+0xAAEB0` is: + - `samp.dll+0xAAA80` + - `samp.dll+0xAA9C0` + - `samp.dll+0xAAD60` + - `samp.dll+0xAAA10` + - `samp.dll+0xAA590` +4. Later, custom-model manager initialization at `samp.dll+0xBD60`, guarded by + byte field `this+0x8`, calls: + - `samp.dll+0xA7A00` to relocate `ms_modelInfoPtrs`; + - `samp.dll+0xA7D90` to install the custom COL callback. + +`STATIC_037`: `samp.dll+0xAAA80` is not a ModelInfo or TXD store. It clears +20,000 eight-byte entries at `samp.dll+0x1A2618` and redirects GTA world-sector +linked-list operands formerly based at `0x00B7D0B8`. It must not be cited as +evidence for custom-model streaming. + +## Static storage layout + +All four regions below are inside the writable `.data` virtual block of +`samp.dll`; none is heap-allocated. + +| Purpose | Start RVA | End RVA, exclusive | Count / stride | +|---|---:|---:|---:| +| relocated ModelInfo pointer storage | `+0x1625B0` | `+0x1A25AC` | 65,535 / `0x4` | +| world-sector store, not ModelInfo | `+0x1A2618` | `+0x1C9718` | 20,000 / `0x8` | +| `CPedModelInfo` store | `+0x1C9718` | `+0x1CEBD4` | 319 / `0x44` | +| `CAtomicModelInfo` store | `+0x1CEBD8` | `+0x26AFD8` | 20,000 / `0x20` | + +Related globals: + +| RVA | Meaning | +|---:|---| +| `+0x114B08` | holds effective pointer-array origin VA `samp.dll+0x1825AC` | +| `+0x1A25AC` | relocation-active flag, set to 1 by `+0xA7970` | +| `+0x1A25B0` | current destination model ID used by the custom atomic/COL path | + +The effective pointer origin is exactly 32,767 DWORDs after the storage base: + +```text +storage index 0 -> signed model ID -32767 +storage index 32767 -> signed model ID 0 +storage index 65534 -> signed model ID +32767 +``` + +The physical arena therefore permits `-32767..+32767`. The post-relocation +accessor itself performs no range check, so callers still have to preserve this +invariant. + +## AtomicModelInfo store relocation + +Initializer: `samp.dll+0xAAA10`. + +`STATIC_037`: + +- It iterates exactly 20,000 times. +- Each `0x20`-byte entry gets GTA vtable `0x0085BBF0` at offset zero. +- The remaining seven DWORDs are cleared. +- The working pointer starts at `samp.dll+0x1CEBDC` and terminates when it + reaches `samp.dll+0x26AFDC`; the object region itself ends at + `samp.dll+0x26AFD8`. +- It then makes 14 GTA operand DWORDs writable and replaces + `0x00AAE954`, the old first-object address, with + `samp.dll+0x1CEBD8`. +- It does **not** relocate or overwrite the count at `0x00AAE950`. +- It does not call the GTA constructor. Construction is the direct vtable plus + zero-fill sequence above. + +The exact operand addresses are: + +```text +0x004C63F2 +0x004C662D +0x004C6822 +0x004C6829 +0x004C6877 +0x004C6881 +0x004C6890 +0x004C68A5 +0x004C68F3 +0x004C6932 +0x004C6971 +0x004C69B0 +0x004C69EF +0x004C6A2E +``` + +`STATIC_037`: R5 itself does not guard these 14 writes beyond choosing its GTA +version path. On the exact US 1.0 executable hash above, Ghidra verified all 14 +operand DWORDs are `0x00AAE954` before the patch. + +This differs materially from the current replacement's dormant OLA-derived +35-site recipe. The 14 sites above are the complete R5 list. The other 21 sites +in that recipe, including `0x4C63E1`, `0x4C6621`, and `0x4C68AC`, are not +patched by R5 and must not be treated as `STATIC_037` evidence. A raw +little-endian address scan of the R5 image found every one of the 14 listed +targets exactly once and each of those 21 extra targets zero times. + +## `ms_modelInfoPtrs` relocation + +Initializer: `samp.dll+0xA7A00`. + +`STATIC_037`: + +1. Clear 65,535 DWORDs starting at `samp.dll+0x1625B0`. +2. Copy exactly 20,000 DWORDs from GTA `0x00A9B0C8` to the effective origin + held at `samp.dll+0x114B08`. +3. Call patch engine `samp.dll+0xA7970`. +4. Install the negative-ID guard through `samp.dll+0xA6FF0`. + +### Patch engine + +`samp.dll+0xA7970` consumes 707 packed five-byte records: + +| Executable variant | Table RVA | Records | +|---|---:|---:| +| build flag 1 / exact US 1.0 reference | `+0x114B10` | 707 | +| build flag 2 | `+0x1158E0` | 707 | + +Each record contains: + +```text +DWORD GTA instruction VA +BYTE expected opcode +``` + +R5 first compares the live opcode. A match produces one four-byte operand +write: + +| Opcode | Operand offset | +|---:|---:| +| `8B`, `89`, `39` | instruction `+3` | +| `BE`, `BF` | instruction `+1` | + +After the loop, `samp.dll+0x1A25AC` is set to one. + +`STATIC_037`: an opcode mismatch only skips that individual write. R5 still +sets the relocation-active flag and returns success after the loop; it does not +count matches or roll back a partial patch. A replacement should validate the +entire set before performing its first write. + +`STATIC_037`, corroborated against the exact GTA executable: + +- all 707 US1 opcodes match; +- all 707 original operands equal `0x00A9B0C8`; +- opcode distribution is 692 × `8B`, 10 × `89`, 2 × `BE`, 2 × `BF`, + and 1 × `39`; +- 679 sites are in the primary `.text` section and 28 are in the + `.HOODLUM` section; +- target range is `0x0040122A..0x0157045D`. + +R5's runtime guard is only the one-byte opcode. A safe replacement should also +require the exact GTA hash or the complete expected instruction/operand bytes, +restore page protection, and flush the instruction cache. + +`STATIC_037`: R5's write helper at `samp.dll+0xAA4C0` changes each target range +to `PAGE_EXECUTE_READWRITE` (`0x40`) and discards the previous protection +without restoring it. + +### Accessor behavior + +Accessor: `samp.dll+0xA7A40`. + +- Before relocation: negative IDs and IDs greater than 20,000 return null; + IDs `0..20000` inclusive index the vanilla table. The inclusive upper bound + is what the binary implements even though only 20,000 entries are copied. +- After relocation: the accessor directly returns + `origin[signed_model_id]`, without a bounds check. + +### Negative-ID guard + +`samp.dll+0xA6FF0` replaces the six bytes at GTA `0x004087EA` +(`56 57 8D 7C AD 00`) with a detour to `samp.dll+0xA5E90`. + +`STATIC_037`: the trampoline returns early when signed model ID register `EBP` +is negative. Otherwise it replays `push esi; push edi; lea edi,[ebp+ebp*4]` +and resumes at GTA `0x004087F0`. + +## Ped and TXD support paired with the model expansion + +### Ped ModelInfos + +`samp.dll+0xAA9C0` creates 319 static `0x44`-byte `CPedModelInfo` entries at +`samp.dll+0x1C9718`, using vtable `0x0085BDC0`, and replaces the operand DWORD +at GTA `0x004C67AD`: + +```text +old: 0x00B478FC +new: samp.dll+0x1C9718 +``` + +The exact GTA operand guard was verified. + +### TXD capacity + +`samp.dll+0xAA590` is a broad pre-game limit patch group. One directly relevant +write changes the immediate operand at GTA `0x00731F60` from 5,000 +(`0x1388`) to 20,000 (`0x4E20`) in the CTxdStore initialization path. The +exact four-byte operand was verified. + +`STATIC_037`: the write of `0x4E20` at GTA `0x0055105F` is a separate GTA pool +limit in the same broad patch group and is not evidence for ModelInfo pointer +capacity. + +## Downloadable model registration + +These helpers operate on the relocated signed pointer table. They are distinct +from the startup `SAMP.ide` positive-ID population. + +### Cloning + +`samp.dll+0xA7A80`: + +- allocates a zeroed `0x44`-byte block; +- resolves the source ID through `+0xA7A40`; +- validates through `samp.dll+0xB3DD0` and accepts source IDs `0..30000` + whose ModelInfo vtable is `0x0085BDC0`; +- copies 17 DWORDs and installs the clone in the destination pointer slot; +- does not first test whether the destination slot is already populated. + +`samp.dll+0xA7AD0`: + +- first returns an existing destination slot unchanged; +- otherwise allocates a zeroed `0x20`-byte block; +- resolves and validates the source ModelInfo; +- validates through `samp.dll+0xB44E0` and accepts five atomic-family vtables: + `0x0085BBF0`, `0x0085BC30`, `0x0085BC70`, `0x0085BCB0`, + `0x0085BCF0`; +- copies eight DWORDs and installs the clone in the destination pointer slot. + +`samp.dll+0xA7B30` frees the pointer in a signed model slot and writes null back +to the slot. No direct call or address reference to this helper was found in +the R5 image, so its actual lifecycle use remains `TODO_VERIFY`. + +`STATIC_037`: both clone helpers allocate before source validation. Their +failure branches neither free that allocation nor check the allocator result +before a successful copy. The ped helper can also replace a non-null +destination without freeing it. These are original failure-path semantics, not +safe implementation guidance: the replacement must retain allocation checks +and should not intentionally reproduce a leak. + +### TXD, DFF, and COL + +`samp.dll+0xA7B60`: + +1. finds a TXD slot by name through `+0xB3880` / GTA `0x731850`; +2. if absent, adds the slot through `+0xB38A0` / GTA `0x731C80`, + verifies the TXD file, and loads it through `+0xB38C0` / + GTA `0x7320B0`; +3. writes the TXD index to ModelInfo offset `+0xA` through + `samp.dll+0xB4660`; +4. calls the wrappers for `PushCurrentTxd` and `SetCurrentTxd`. + +The helper does not itself call the `AddRef` wrapper at `samp.dll+0xB38F0`. +The exact ref-count ownership across the subsequent GTA model loader is +therefore still `TODO_VERIFY`. + +`STATIC_037`: an already named TXD slot is bound directly, without reloading +the file. For a new name, the slot is added before file validation; validation +failure returns false without removing that new slot. The return value of the +subsequent TXD-load wrapper is not tested. + +`samp.dll+0xA7BD0` clones a ped ModelInfo, binds/loads its TXD, loads the DFF, +and installs the resulting clump. + +`samp.dll+0xA7C30` clones an atomic-family ModelInfo, stores the destination ID +at `samp.dll+0x1A25B0`, binds/loads its TXD, loads the DFF, and installs the +resulting atomic. + +`samp.dll+0xA7CC0`: + +- rejects a COL buffer larger than `0x40000` bytes; +- requires `COL3`; +- loads the collision object and associates it with the destination ID held at + `samp.dll+0x1A25B0`. + +The COL callback is registered by `samp.dll+0xA7D90`. + +The high-level file paths are constructed by: + +- `samp.dll+0xC650` for downloadable ped models; +- `samp.dll+0xC770` for downloadable atomic models. + +Both use `%s\0x%X.dff` and `%s\0x%X.txd` cache paths and then call the helpers +above. + +## Ownership and cleanup + +`STATIC_037`: + +- The relocated pointer array, positive-ID AtomicModelInfo store, and ped + ModelInfo store are process-lifetime `samp.dll` storage. +- No reverse write restoring the 707 `ms_modelInfoPtrs` operands, the 14 + AtomicModelInfo operands, or the ped-store operand was found. +- No global-store teardown was identified. +- `samp.dll+0xD1E0` is a release tick over custom-model records. When its two + activity bytes are set and the countdown at record `+0x5A` reaches zero, it + calls `samp.dll+0xB2040`. +- `samp.dll+0xB2040` resolves the ModelInfo, remembers its TXD index, calls GTA + `0x004C4D50` on the ModelInfo, queries the TXD reference count through the + wrapper for GTA `0x731AA0`, and, when it reaches zero, calls the wrappers for + GTA `0x731E90` and `0x731CD0`. +- That path shuts down the GTA-side model/TXD resources, but it does not call + `samp.dll+0xA7B30` and does not clear the relocated ModelInfo pointer slot. +- The ped/atomic high-level loaders likewise return false after TXD or DFF + failure without calling `+0xA7B30`; a clone already installed in the pointer + slot remains installed. + +`TODO_VERIFY`: whether another indirect path frees cloned ModelInfos, whether a +GMX retains or recreates them, and whether DLL unload restores code operands +must be established dynamically. Static evidence is insufficient to claim any +of those behaviors. + +## Consequences for the replacement + +1. Do not enable the current 35-site native AtomicModelInfo relocation as an R5 + parity implementation. Its extra 21 sites are not in the original patch. + Its heap allocation and per-entry GTA-constructor calls also differ from + R5's process-lifetime static arena and direct vtable-plus-zero + initialization. +2. Treat the following as one initialization unit: + - 20,000-entry AtomicModelInfo store; + - exact 14 store-base operands; + - 65,535-slot signed pointer arena; + - exact 707 opcode-guarded pointer operands; + - negative-ID guard; + - 319-entry ped store; + - 20,000-slot TXD capacity. +3. Preserve `0x00AAE950` as the live AtomicModelInfo count. Capacity 20,000 and + observed populated count 15,417 are not interchangeable. +4. Keep all over-vanilla registration disabled unless every byte guard for the + exact GTA build passes before any write. Partial patch application is unsafe. +5. Do not use `samp.dll+0xAAA80` or its 20,000-entry world-sector store as a + ModelInfo/TXD implementation clue. + +## Reproducible Ghidra export + +The focused exporter is +`tools/ghidra/InspectCustomModelInfo.java`. It emits only bounded instruction, +reference, scalar, region, patch-record, string, and guard metadata; it does +not emit decompiler pseudocode. + +Local generated output: + +```text +analysis/generated/ghidra_custom_modelinfo_20260728/samp/ +analysis/generated/ghidra_custom_modelinfo_20260728/gta_us1/ +``` + +The generated directory is intentionally ignored. Important artifact checks: + +```text +patch_tables.tsv: + SHA-256 a98179152fb95c042ede3bbb9dbbc2e600f11ef8b33bc717d1035ee865452dfd + 707 US1 pointer records + 707 variant-2 records + 14 atomic operands + +gta_patch_guards.tsv: + SHA-256 ff00f9cbafa71e78695b265c6e8a8915d7e3ae9bdd4862233dfca6745639e759 + 707/707 US1 pointer opcode and operand matches + 14/14 AtomicModelInfo operand matches + +gta_fixed_guards.tsv: + SHA-256 2f6e32d9e9628b3748125c261517ed9c55d391e08232375da90bfdd2edb20979 + negative-ID hook, ped-store operand, and TXD-capacity operand all match +``` + +The variant-2 table is `STATIC_037` from the R5 DLL but was not validated +against a matching variant-2 GTA executable in this pass. + +## Next safe live probe + +Run a passive, read-only original-R5 Windows probe on the exact hashes above: + +1. At entry and return of `samp.dll+0xAAA10`, record: + - all 14 live operand DWORDs; + - AtomicModelInfo slots 0, 13,983, 13,984, 15,416, and 19,999; + - count `0x00AAE950`. +2. At entry and return of `samp.dll+0xA7A00`, record: + - all 707 patched operands; + - relocation flag `samp.dll+0x1A25AC`; + - pointer slots for IDs `-32767`, `-1`, `0`, `13983`, `13984`, + `15416`, `15417`, `19999`, and `32767`. +3. Break/log, without modifying behavior, at GTA `0x004C6620` while + `SAMP.ide` is registered. For each call, record the requested model ID, + count before/after, returned pointer, pointer-table slot, TXD index, and + streaming entry. Confirm: + `returned == atomic_store_base + count_before * 0x20`. +4. Exercise one downloadable negative atomic model and log + `+0xA7AD0`, `+0xA7B60`, `+0xA7C30`, the COL callback, `+0xD1E0`, and + `+0xB2040`, including TXD ref counts and the destination pointer slot before + and after release. +5. Take the same snapshots after disconnect, GMX, reconnect, and normal process + detach. This resolves the remaining ownership question without speculative + writes or forced unloading. + +No live probe or replacement deployment was performed as part of this static +analysis pass. diff --git a/docs/re/death_cleanup_memory_probe_r5_20260728.md b/docs/re/death_cleanup_memory_probe_r5_20260728.md new file mode 100644 index 0000000..096dc3e --- /dev/null +++ b/docs/re/death_cleanup_memory_probe_r5_20260728.md @@ -0,0 +1,719 @@ +# Original R5 Death, Respawn, F4, And Cleanup Memory Probe + +Date: 2026-07-28 + +Status: `STATIC_037` probe implementation complete, including a guarded +terminal clean-quit checkpoint; one normal +death-to-respawn path, one F4-after-death class-selection path, and one +populated GMX/rejoin path are `OBSERVED_037` + `PROBE_TRACE`. Connection loss, +final-destructor cleanup, terminal ring drain, and clean hook restore remain +`TODO_VERIFY`. + +## Scope + +This note defines the focused, passive ASI profile used to observe two related +compatibility areas: + +1. the local death, respawn, and F4 class-selection state machine; +2. pool, entity, and UI cleanup during GMX, connection loss, reconnect, and + final `CNetGame` destruction. + +The probe does not modify gameplay state or replace any original result. Each +wrapper takes bounded pre/post snapshots, calls the original routine through a +complete-instruction trampoline, and publishes the record to a fixed ring. +Only the probe worker writes the records to disk. The clean-quit wrapper can +wait on that worker for at most 1500 ms; it never performs file I/O itself. + +The original runtime traces now cover one health-zero death followed by an +ordinary respawn and one F4-latched health-zero death followed by class +selection. Field names backed by those traces are identified below as +`OBSERVED_037` + `PROBE_TRACE`. Unexercised fields remain either `STATIC_037`, +raw fields deliberately left unnamed, or `TODO_VERIFY`. + +## Supported Binary Identities + +Original SA-MP R5: + +- SHA256: + `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2` +- PE timestamp: `0x6372C39E` +- entry RVA: `0x000CBC90` +- image size: `0x0027E000` +- preferred image base: `0x10000000` +- base-relocation directory: RVA `0x272000`, size `0x9DA0` + +GTA San Andreas 1.0 US: + +- SHA256: + `a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26` +- PE timestamp: `0x427101CA` +- entry RVA: `0x00424570` +- image size: `0x01177000` +- PE checksum: `0x00DC5BEA` +- required load base: `0x00400000` + +The ASI cannot hash a loaded module without adding synchronous file work, so +the runtime gate uses the listed PE identity values, the R5 relocation-table +presence, and GTA's fixed preferred base. R5 is allowed at a loader-selected +base. The known hashes document which files supplied those identity values and +the static bytes. + +## Hook Table + +All six entries, all six return tails, and the terminal callsite/import guards +must match before the first patch is installed. A failed partial install is +restored in reverse order. + +| Routine | RVA | ABI | Entry bytes replaced | Guarded return tail | +|---|---:|---|---|---:| +| `CLocalPlayer::Process` | `0x74C0` | `thiscall int()` | `83 EC 10 53 55 56 8B F1` | `0x7E37`, plain `ret` | +| `CLocalPlayer::Spawn` | `0x3C20` | `thiscall int()` | `64 A1 00 00 00 00 6A FF 68 0B FF 0D 10` | `0x3EA7`, plain `ret` | +| `CLocalPlayer::HandleClassSelection` | `0x4080` | `thiscall void()` | `56 8B F1 8B 8E 04 01 00 00` | `0x40CA`, plain `ret` | +| `CNetGame::ShutdownForGameModeRestart` | `0xA540` | `thiscall void()` | `53 55 56 57 33 DB 33 FF 8B F1 33 ED` | `0xA730`, plain `ret` | +| `CNetGame::Packet_ConnectionLost` | `0xACF0` | `thiscall void(Packet *)` | `57 8B F9 8B 0F 85 C9` | `0xAD6E`, `ret 4` | +| `CNetGame::~CNetGame` | `0x9880` | `thiscall void()` | `53 56 57 8B F1 8B 0E` | `0x9A31`, plain `ret` | + +Evidence: `STATIC_037`, R5 image identified above. The runs below observe +Process, Spawn, class-selection, and GMX-reset call counts and callers. +Connection-loss, destructor, and transition interpretations not explicitly +listed as observed remain `TODO_VERIFY`. + +The Spawn entry's immediate DWORD at operand RVA `+0x3C29` is a PE HIGHLOW +relocation for preferred VA `0x100DFF0B`. The preflight normalizes only this +four-byte operand to `loaded samp.dll base + 0xDFF0B`; every other entry byte +and every return-tail byte remains exact. The trampoline copies the already +loader-relocated entry bytes. GTA has relocations stripped, so its absolute +memory sources remain gated to `0x00400000`. + +The class-selection hook is intentionally the small state-transition routine, +not a render or input hook. The GMX and connection-loss hooks can nest because +the latter calls the former in the statically observed path; separate event +sequence numbers preserve that ordering. + +## Terminal Clean-Quit Checkpoint + +`STATIC_037`, original R5 hash documented above: + +- the clean main-loop path calls `CNetGame::~CNetGame` at + `samp.dll+0xC5076`; the destructor hook therefore sees caller + `samp.dll+0xC507B`; +- the caller frees the returned object at `+0xC507C`, writes the zero held in + `EDI` to the global NetGame pointer at `+0xC5084`, then executes + `push edi; call [KERNEL32!ExitProcess]` at `+0xC508A`; +- the import call's exact return address is `samp.dll+0xC5091`; +- the R5 ExitProcess IAT slot is `samp.dll+0xE5188`. + +The guarded callsite span is: + +```text +RVA +0xC508A +57 FF 15 88 51 0E 10 61 5F 5E 5B C3 +``` + +The absolute operand at `+0xC508D` is normalized to the loaded R5 base plus +`0xE5188`; all surrounding bytes remain exact. The slot must still equal the +current `KERNEL32!ExitProcess` export. Installation changes the aligned +four-byte slot to the ASI wrapper with an atomic compare/exchange. Restoration +uses the inverse compare/exchange and refuses to overwrite a slot no longer +owned by the probe. + +Only caller `+0xC5091` requests terminal handling. Every other caller through +the shared import slot immediately reaches the saved original function. The +clean caller records the exit code, thread/tick, global NetGame pointer, +latest death-cleanup ring sequence, and clean-destructor sequence. It then +signals the existing worker stop event and waits at most 1500 ms. The worker: + +1. drains the focused ring through the captured sequence; +2. restores the owned ExitProcess slot and six entry hooks; +3. finishes its normal stop logging; +4. writes `death_cleanup_exit_r5` and signals the completion event. + +The game thread calls the saved real `ExitProcess` after the completion event +or after the timeout. A terminal marker with `drain=completed`, +`netgame=0x00000000`, nonzero `destructor_seq`, equal-or-newer +`flushed_seq/requested_seq`, `exit_iat_restored=1`, and caller `+0xC5091` is +the intended clean-quit oracle. None of those runtime claims have been +observed yet; they remain `TODO_VERIFY`. + +## Captured Local State + +The local snapshot starts at the `CNetGame` and pool chain, then records: + +- local-player active/wasted state at `+0xF0/+0xF4`; +- local `CPlayerPed` wrapper at `+0x104`; +- spectating and cleared-to-spawn state at `+0x108/+0x143`; +- the 46-byte SpawnInfo region at `+0x14F` as a bounded FNV-1a hash plus its + first 16 raw bytes; +- has-SpawnInfo at `+0x17D`; +- F4 wants-another-class at `+0x2FA`; +- class-selection/input-ownership state at `+0x302/+0x306`; +- raw class-selection ticks at `+0x147/+0x14B/+0x30A`; +- current vehicle wrapper at `+0x310`. + +From the player wrapper it follows the GTA ped pointer at `+0x2A4`. The GTA +snapshot records the entity matrix/RW object/flags/status, ped flags, +intelligence, raw action, health bits, ped state, and eleven root task +pointers. The `CPed+0x530` action offset is corroborated statically by the R5 +`CPlayerPed::GetActionTrigger` wrapper. Values 1, 54, and 55 occur in the +normal-death trace below; semantic names for 54 and 55 remain `TODO_VERIFY`. + +The same record contains scoreboard, dialog, textdraw-selector, chat, +class-selection GUI, input/cursor raw fields, two camera values, and three raw +frontend bytes. These are captured as raw transition evidence so an +original/replacement diff need not assume their semantics prematurely. + +`CLocalPlayer::Process` is hot. Its record is emitted only when the pre/post +snapshot differs, the post-state differs from the last published post-state, +or one second has elapsed. Spawn and class-selection calls always publish. + +## Captured Cleanup State + +`CNetGame+0x3DE` supplies the pool aggregate used by the R5 destructor and GMX +paths. The profile records all nine raw pool pointers and bounded occupancy +counts for: + +- vehicles: 2000 listed slots and wrappers; +- remote players: 1004 auxiliary slots and wrappers; +- pickups: 4096 raw GTA handles, server-ID entries, and timers; +- objects: 1000 listed slots and wrappers; +- actors: 1000 listed slots and wrappers; +- gang zones: 1024 listed slots; +- textdraws: 2048 listed slots; +- 3D text labels: 2304 listed slots; +- menus: 128 listed slots and the raw current-menu byte. + +For the first nonempty vehicle slot it also records wrapper, entity/GTA +vehicle, matrix, RW object, and entity flags. The first nonempty object and +actor chains are recorded in the same bounded fashion. This is sufficient to +detect a stale representative wrapper or GTA entity while keeping the hook +bounded; a later profile may add a full slot dump if a trace proves that is +needed. A count of `0xFFFFFFFF` means that the corresponding array was not +readable; it must not be interpreted as an empty pool. + +The raw DWORD at `samp.dll+0x14FD88` is logged as +`remove_building_count`. Existing static/lifecycle evidence suggests that the +RemoveBuilding list is process-lifetime rather than GMX-lifetime state. +Whether the value actually persists across the target GMX scenario is +`TODO_VERIFY` and is one of the explicit questions for the first GMX run. + +## Trace Contract + +The hook-side ring contains 256 fixed records. It uses monotonic ring and +event sequence numbers and reports an explicit overflow count if the worker +falls more than one ring behind. The worker emits: + +- `death_cleanup_r5`: event, thread, frame, caller/hook RVA, object, argument, + and original return value; +- `death_cleanup_local_r5`: local/player/ped state; +- `death_cleanup_tasks_r5`: GTA intelligence and root task pointers; +- `death_cleanup_ui_r5`: UI, input, camera, and frontend raw state; +- `death_cleanup_pools_r5`: pool pointers and occupancy counts; +- `death_cleanup_entities_r5`: representative entity chains. +- `death_cleanup_exit_r5`: terminal caller/exit code, final global NetGame + pointer, destructor/ring boundary, drain result, timeout bound, and IAT + restoration state. + +Pointers, ticks, frames, and caller addresses must be normalized before +original/replacement diffing. The event and phase fields should remain intact. + +## Activation + +Select the focused Windows profile: + +```bash +tools/windows/remote_lab/samp_lab.sh probe-profile death-cleanup +``` + +Equivalent direct toggles are: + +```text +SAMP_PROBE_DEATH_CLEANUP_HOOKS=1 +samp_probe_death_cleanup_hooks.flag +``` + +This focused mode skips normal Winsock/IAT and unrelated code-hook sets. It +must be run only against the documented original binaries, for a short +controlled scenario, without hot-unloading the ASI while a hook may execute. + +## Original R5 Normal-Death Capture + +Artifact: + +```text +artifacts/runs/20260728-141552-distributed-sync-death-1526481/ +``` + +Topology: + +```text +native_windows_pilot+local_original_r5_observer +``` + +The Windows pilot used the original R5 DLL and GTA executable documented +above. The deployed Death/Cleanup ASI SHA256 was +`2c8cf403cc7a5e15373abc1b766ec26da5fd4f04e096b6d7806ce565a9dab2c5`. +The focused probe log is: + +```text +windows/20260728_141608_dist_sync_death_pilot_e67f84f9/logs/samp_probe.log +``` + +Its SHA256 is +`2c9c57f60b922335affcdd98f4f70d88bb18cff332de73ae7c3f0aa69f4fc106`. +The server set the pilot's health to zero and recorded the matching +client-originated death. It later recorded the pilot as spawned again without +the test driver injecting a respawn key. + +### Probe Integrity + +`PROBE_TRACE`: + +- all six guarded hooks installed against the expected original identities at + relocated R5 base `0x03EA0000`; +- the log contains 58 focused records: 55 `local_process`, two `spawn`, and + one `class_selection`; +- there is no focused-ring overflow marker, exception marker, or crash marker; +- no GMX, connection-loss, or destructor event occurred, and every emitted + record has `cleanup=0`; +- there is no hook-restore or process-detach marker. The lab stopped the still + responsive GTA and launcher processes after collecting the run, so this + artifact is not evidence for clean shutdown restoration. + +The absence of cleanup events in this run is expected from its narrow +death-only scenario. It must not be interpreted as cleanup parity. + +### Death-to-Respawn Timeline + +The table is ordered by hook tick rather than output sequence. The respawn +`Spawn` hook is nested inside the `CLocalPlayer::Process` event numbered 893, +so the inner record (`seq=40`, event 894) is published before the outer record +(`seq=41`, event 893). Task roots use zero-based indices into the eleven raw +root slots; omitted slots are zero. + +| Tick / frame | Hook phase | Active / wasted | Action | Health / dead | Camera raw | Nonzero task roots | +|---|---|---|---:|---|---|---| +| `53011687 / 906` | last throttled live baseline, `local_process` pre/post | `1 / 0` | 1 | `100.0` (`0x42C80000`) / 0 | `4,0` | `[4]=101D60D8 [8]=101D6258 [10]=101D6158` | +| `53012578 / 952` | death edge, `local_process` pre | `1 / 0` | 1 | `0.0` (`0x00000000`) / 1 | `4,0` | `[4]=101D62D8 [8]=101D6258` | +| `53012578 / 952` | death edge, `local_process` post | `0 / 1` | 1 | `0.0` / 1 | `4,0` | `[4]=101D62D8 [8]=101D6358` | +| `53012609 / 953` | next `local_process` | `0 / 1` | 54 | `0.0` / 1 | `4,0` | `[2]=101D64D8 [4]=101D62D8 [8]=101D6358` | +| `53013187 / 983` | wasted camera/task phase | `0 / 1` | 55 | `0.0` / 1 | `29,0` | `[2]=101D76D8 [4]=101D62D8 [8]=101D6358` | +| `53014187 / 1034` | sustained wasted phase | `0 / 1` | 55 | `0.0` / 1 | `29,0` | `[2]=101D76D8 [4]=101D62D8 [8]=101D6358` | +| `53015187 / 1086` | sustained wasted phase | `0 / 1` | 55 | `0.0` / 1 | `29,0` | `[2]=101D76D8 [4]=101D62D8 [8]=101D6358` | +| `53016250 / 1138` | outer `local_process` pre | `0 / 1` | 1 | `100.0` / 1 | `29,0` | `[4]=101D64D8 [8]=101D65D8 [9]=101D66D8` | +| `53016265 / 1138` | nested `spawn` post | `1 / 0` | 1 | `100.0` / 0 | `29,0` | `[4]=101D66D8 [8]=101D6958` | +| `53016250 / 1138` | outer `local_process` post | `1 / 0` | 1 | `100.0` / 0 | `29,0` | `[4]=101D66D8 [8]=101D6958` | +| `53016281 / 1139` | following `local_process` | `1 / 0` | 1 | `100.0` / 0 | `4,0` | `[4]=101D6758 [8]=101D6A58` | + +`OBSERVED_037` + `PROBE_TRACE`: + +- the health/dead edge is visible before R5 flips its local fields from + `active=1,wasted=0` to `active=0,wasted=1` in the same Process call; +- action 54 follows 31 ms and one GTA frame later; action 55 and raw camera + value 29 are visible 609 ms and 31 frames after the death edge; +- the nested successful `Spawn` occurs 3687 ms and 186 frames after the death + edge. Raw health is already 100.0 and action is already 1 on entry, while + `dead_or_wasted` and the local active/wasted pair still retain the dead + state until the Spawn body returns; +- raw camera value 29 remains installed through Spawn, then returns to 4 on + the following frame; +- `ped_wrapper=0x0FE841E0`, `gta_ped=0x1011B540`, + `intelligence=0x10207F10`, `matrix=0x11FB598C`, and `rw=0x1212F260` + remain stable across this captured death and respawn. These concrete + pointers are run-local identities and must be normalized in diffs; +- `cleared_spawn=1` and `has_spawn=1` remain set. `wants_class`, + `class_selection`, and `class_input` remain zero throughout the death + interval; +- scoreboard, dialog, textdraw selector, chat input, and class GUI are + inactive in every death sample. This does not answer how an already-open UI + latch is cleared. + +### Screenshot Review + +The Windows burst contains 120 frames at a requested 50 ms interval, +800x600, from `2026-07-28T12:16:42.7612643Z` through +`2026-07-28T12:16:49.9876199Z`. A 14-frame overview and an 11-frame respawn +contact sheet were inspected locally. + +`OBSERVED_037` + `PROBE_TRACE`: + +- frame 001 already shows the falling/death pose, and frames 040-046 show the + body in the sustained wasted-camera view; +- frame 048 (`12:16:45.580Z`) is the dark transition; +- frame 050 (`12:16:45.689Z`) shows the new player/camera view, frames 050-052 + still contain the short camera/character transition, and frame 054 + (`12:16:45.925Z`) shows the fully standing character; +- the HUD changes from the death-state fist display to the respawn weapon and + restored health. No class-selection GUI appears. + +This is a qualitative visual description of one original run, not a +replacement comparison or a pixel-parity verdict. The machine verdict remains +`TRACE_CAPTURED_VISUAL_UNVERIFIED`. + +## Original R5 F4-After-Death Capture + +Artifact: + +```text +artifacts/runs/20260728-142159-distributed-sync-death-1536077/ +``` + +The topology, original DLL/GTA identities, and deployed focused ASI are the +same as in the normal-death run. The focused probe log is: + +```text +windows/20260728_142215_dist_sync_death_pilot_2df975bd/logs/samp_probe.log +``` + +Its SHA256 is +`4af7a6e12374ead4e408caea135b0c6cdf5ee909d4d82d1336ddda0c60f9319c`. +The driver sent the existing allowlisted `CLASS` input action while the +Windows original client was alive, then issued the request-scoped health-zero +scenario. The server observed the matching client death. It did not inject a +respawn or class-selection action. + +### Probe Integrity + +`PROBE_TRACE`: + +- all six guarded hooks installed against the expected identities at relocated + R5 base `0x03DD0000`; +- the log contains 65 focused records: 62 `local_process`, one initial + `spawn`, and two `class_selection` calls; +- there is no focused-ring overflow, exception, crash, GMX, connection-loss, + destructor, or hook-restore marker; +- the lab stopped the responsive process after collection, so this run also + does not establish clean detach or hook restoration. + +### F4-to-Class-Selection Timeline + +The first class-selection call belongs to initial join. The relevant +post-death call is nested in event 1060, so its inner event 1061 record is +published first. + +| Tick / frame | Hook phase | Active / wasted | Spawn flags | Wants class / input | Health / action | GUI / input depth | Camera raw | +|---|---|---|---|---|---|---|---| +| `53379390 / 901` | live `local_process` pre | `1 / 0` | cleared `1`, has `1` | `0 / 0` | `100.0 / 1` | hidden / `0,-1` | `4,0` | +| `53379390 / 901` | same Process post, F4 edge | `1 / 0` | cleared `1`, has `1` | `1 / 0` | `100.0 / 1` | hidden / `0,-1` | `4,0` | +| `53383609 / 1115` | death Process pre | `1 / 0` | cleared `1`, has `1` | `1 / 0` | `0.0 / 1` | hidden / `0,-1` | `4,0` | +| `53383609 / 1115` | death Process post | `0 / 1` | cleared `1`, has `0` | `1 / 0` | `0.0 / 1` | hidden / `0,-1` | `4,0` | +| `53384203 / 1146` | wasted phase | `0 / 1` | cleared `1`, has `0` | `1 / 0` | `0.0 / 55` | hidden / `0,-1` | `29,0` | +| `53387281 / 1301` | nested `HandleClassSelection` pre | `0 / 0` | cleared `1`, has `0` | `1 / 0` | `100.0 / 1` | hidden / `0,-1` | `29,0` | +| `53387281 / 1301` | nested `HandleClassSelection` post | `0 / 0` | cleared `0`, has `0` | `1 / 0` | `100.0 / 1` | hidden / `0,-1` | `29,0` | +| `53387281 / 1301` | outer Process post | `0 / 0` | cleared `0`, has `0` | `0 / 0` | `100.0 / 1` | hidden / `0,-1` | `29,0` | +| `53387296 / 1302` | following Process post | `0 / 0` | cleared `0`, has `0` | `0 / 1` | `100.0 / 1` | visible / `2,-1` | `4,0` | +| `53387312 / 1303` | next Process | `0 / 0` | cleared `1`, has `1` | `0 / 1` | `100.0 / 1` | visible / `2,-1` | `4,0` | +| `53387343 / 1304` | settled selection scene | `0 / 0` | cleared `1`, has `1` | `0 / 1` | `100.0 / 1` | visible / `2,-1` | `15,0` | + +`OBSERVED_037` + `PROBE_TRACE`: + +- F4 is consumed by `CLocalPlayer::Process` while alive: `wants_class` flips + from zero to one in that call without opening the class GUI or changing the + active state; +- death still performs the ordinary `active 1->0`, `wasted 0->1` transition + and reaches action 55/camera 29; +- 3672 ms and 186 frames after the death edge, the outer Process clears the + wasted flag before calling `HandleClassSelection` from + `samp.dll+0x7DE1`. There is no `CLocalPlayer::Spawn` call on this branch; +- the class-selection call clears the raw `cleared_spawn` field. The outer + Process then clears `wants_class`; +- on the next frame, class input becomes one, the class GUI becomes visible, + input depth changes from zero to two, and the raw camera value returns from + 29 to 4. One frame later a new SpawnInfo hash appears + (`0x147E70C9 -> 0xC19D308A`) while the local player remains inactive. The + class scene settles on raw camera value 15; +- the local wrapper, GTA ped, intelligence, matrix, and RW object remain the + same run-local pointers across death and entry into class selection. + +The raw field currently logged as `class_selection` remains zero even while +the GUI is visibly active. Its semantic label therefore remains +`TODO_VERIFY`; `class_input`, the GUI byte, and input depth are the directly +observed activation signals. + +### Screenshot Review + +The Windows burst contains 120 800x600 frames at a requested 75 ms interval +from `2026-07-28T12:22:53.6413383Z` through +`2026-07-28T12:23:03.5320648Z`. Frames 001-040 show the death fall and wasted +camera. By frame 050 the original class-selection interior, preview ped, and +the three legacy buttons are visible; that scene remains stable through frame +120. + +This establishes the visible branch outcome, not replacement pixel parity. +The machine verdict remains `TRACE_CAPTURED_VISUAL_UNVERIFIED`. + +## Replacement Death/Respawn Regression + +Replacement DLL: + +```text +build-win32/samp.dll +SHA256=c03d14beb6d1b0ab5dc0f3d47e7f85518f226c94ada1a6b3440913fe3d88458d +``` + +The manifest of each Windows run records that exact installed hash. The +Death/Cleanup ASI remained present but its focused hook flag was disabled, so +the following results are replacement runtime/network traces and screenshot +evidence, not original-hook memory snapshots. + +### Normal respawn + +Artifact: + +```text +artifacts/runs/20260728-142801-distributed-sync-death-1554847/ +``` + +Run-local Windows runtime log: + +```text +windows/20260728_142815_dist_sync_death_pilot_281c54a7/ + latest_log_bytes/samp_runtime.log +SHA256=690ef04a57255dcb68ceb548a328887044ca5f327607c7181db1947f9906f8b0 +``` + +`PROBE_TRACE`: + +- the isolated run-local log has one current `process_attach` and no exception + or fatal marker; +- health zero produces one local Death report at runtime line 918 and the + server observes the matching player death; +- when GTA recovers health/action after the wasted interval, the replacement + schedules the full shared spawn application at line 1064, completes it at + line 1093, and only then sends RespawnNotify at line 1094; +- the run-local net trace records RespawnNotify followed by health-100 + OnFootSync. The server's post-respawn position/health updates are then + applied without a second death report; +- the 120-frame, requested-50-ms burst shows the death pose through frame 51, + a short low/ground-occluded camera transition in frames 52-62, and a fully + visible standing ped with stable camera from frame 63 through frame 120. + +This closes the earlier functional defect where health recovery could notify +the server without executing the complete local spawn application. The short +replacement camera transition has not yet been frame/pixel-diffed against the +original transition and remains `TODO_VERIFY`. + +### F4-after-death branch + +Artifact: + +```text +artifacts/runs/20260728-143147-distributed-sync-death-1575191/ +``` + +Run-local Windows runtime log: + +```text +windows/20260728_143201_dist_sync_death_pilot_ad6fb1bd/ + latest_log_bytes/samp_runtime.log +SHA256=a9ff84831bc87084beb34ca431a7e17325f9d4057143980b338212e3d6d9d924 +``` + +`PROBE_TRACE`: + +- the controlled `CLASS` input latches F4 while alive at runtime line 882; +- death still emits one local Death report at line 927; +- after the wasted interval, line 1071 consumes the F4 latch and explicitly + suppresses the normal respawn application; +- the net trace records exactly one post-death RequestClass, the replacement + enables the class-selection overlay at runtime lines 1074-1075, and no + `respawn_apply_*` marker occurs on this branch; +- the 120-frame screenshot burst shows the death scene through frame 40 and + the class-selection interior, preview ped, and three legacy buttons by frame + 50. The scene remains stable through frame 120, matching the branch outcome + and approximate transition point of the original capture. + +There is no exception marker. This establishes functional branch parity for +the tested F4 latch and confirms that the normal-respawn fix does not bypass +class selection. Pixel parity of the class-selection renderer remains a +separate visual question. + +## Original R5 Populated-GMX Capture + +Artifact: + +```text +artifacts/runs/20260728-144428-distributed-sync-gmx-1646159/ +``` + +The Windows pilot used the documented original R5 DLL. The deployed integrated +ASI SHA256 was +`0b6b1a8d8a2d551fd2e17bf6cbb37ce6e6747f34661a3ed811bd36ba936a4440`. +The isolated focused log is: + +```text +windows/20260728_144443_dist_sync_gmx_pilot_ee1502b0/ + latest_log_bytes/samp_probe.log +SHA256=7474fe5d3e5f0b0c1a7ea4d8332daeb446dbbdf119d02e320a8673fc953d3b8f +``` + +The server-console driver issued `gmx` only after the native Windows pilot and +the sole local original-R5 observer were spawned and mutually streamed. The +server then emitted a second gamemode banner and accepted a second +`RPC137 ServerJoin` from `SyncPilot`. + +### Probe Integrity And Runner Note + +`PROBE_TRACE`: + +- all six guarded hooks installed at relocated R5 base `0x04130000`; +- the focused ring published one `gmx_reset` event with complete pre/post + cleanup snapshots and no overflow or parse error; +- the current Windows slice contains no crash or exception marker; +- the first runner verdict was `FAIL` only because its R5 branch incorrectly + required replacement-only `rpc-in id=139/40/139` log strings. The server + restart, pilot rejoin, original hash, and `kind=gmx_reset` were all present. + The runner contract now uses those original-compatible predicates when the + `death-cleanup` profile is selected; +- the lab stopped the responsive process after collection, so clean hook + restoration and the final `CNetGame` destructor are still not established. + +### Synchronous GMX Reset + +The GMX wrapper entered at `samp.dll+0xA540`, caller +`samp.dll+0x3AEBC`, tick `54727953`, frame 940. Its original body nested one +`CLocalPlayer::Process` event; consequently event 697 was published before the +outer GMX record for event 696. + +| State | Pre | Post | +|---|---:|---:| +| NetGame raw state | 5 | 11 | +| local active / has SpawnInfo | `1 / 1` | `0 / 0` | +| health / dead-or-wasted | `100.0 / 0` | `0.0 / 1` | +| vehicles listed / wrappers | `4 / 4` | `0 / 0` | +| objects listed / wrappers | `19 / 19` | `0 / 0` | +| textdraws listed | 1 | 0 | +| remote auxiliary / wrappers | `6 / 6` | `6 / 6` | +| RemoveBuilding rule count | 15 | 15 | + +`OBSERVED_037` + `PROBE_TRACE`: + +- R5 synchronously destroys the populated vehicle, object, and textdraw + entries during `+0xA540`; +- the six remote-player/NPC wrapper entries are not destroyed inside this + immediate reset call. This does not prove that they survive later network + processing, so delayed remote-pool lifetime remains `TODO_VERIFY`; +- the append-only RemoveBuilding count is unchanged at 15, corroborating the + static process-lifetime rule-store interpretation; +- pickups, actors, gang zones, labels, and menus were empty in this fixture. + Their zero deltas are not evidence for populated cleanup behavior; +- the local ped wrapper, GTA ped, intelligence, matrix, and RW-object pointers + remain allocated across the synchronous reset. R5 instead zeroes health, + marks the ped dead/wasted, changes task roots, and makes the local player + inactive; +- scoreboard, dialog, textdraw selection, chat input, class GUI, input depth, + and frontend bytes are unchanged inside the reset call because each was + inactive. Raw camera mode changes from 4 to 15 immediately after the hook + returns. + +### Rejoin Timeline + +The original keeps the same `CNetGame`, local-player wrapper, and GTA ped +through the restart. Approximately 12,000 ms after the GMX hook, the second +InitGame path invokes `HandleClassSelection`: health returns to 100, the +dead/wasted test clears, class input becomes active, and the class GUI/input +depth become `visible / 2`. A new SpawnInfo appears on the next frame. + +At tick `54741453`, 13,500 ms after GMX and 1,500 ms after class selection, +`CLocalPlayer::Spawn` succeeds. It changes local active `0 -> 1`, closes class +input/GUI, and returns the camera from raw mode 15 to mode 4 on the following +frame. This fixture's server automatically selected and spawned the returning +player; the timing is therefore evidence for this exact server sequence, not +a universal R5 reconnect timeout. + +The 120-frame burst began just after the console command and shows the original +restart/reconnect camera plus `The server is restarting..`/reconnection chat +sequence without a crash. It ends before the later class-selection and spawn +events, so those outcomes are established by the memory/server traces rather +than by this short visual burst. + +### Replacement GMX Comparison + +The comparable replacement run is: + +- artifact: + `artifacts/runs/20260728-145211-distributed-sync-gmx-1657080`; +- replacement DLL SHA256: + `c03d14beb6d1b0ab5dc0f3d47e7f85518f226c94ada1a6b3440913fe3d88458d`; +- result: `TRACE_CAPTURED_VISUAL_UNVERIFIED`. + +`PROBE_TRACE`: the replacement receives the expected +`RPC 139 -> RPC 40 -> RPC 139` sequence, destroys 19 objects, four vehicles, +and the listed remote player, clears its UI state, reconnects, and completes a +new spawn without a crash. + +The run also exposed one concrete parity defect. Its GMX reset logged only +`records_persisted=9`, while original R5 retained 15. The fixture sends repeated +RemoveBuilding rules; the replacement's former equality filter collapsed them +while R5's `samp.dll+0x9D3D0` appends each RPC. The filter has therefore been +removed under `OBSERVED_037 + PROBE_TRACE + STATIC_037`, while the replacement's +defensive 256-record capacity remains intentionally conservative. + +The corrected replacement was then rerun: + +- artifact: + `artifacts/runs/20260728-145828-distributed-sync-gmx-1669162`; +- replacement DLL SHA256: + `693a78e7f40e5d579997f9a126bd0082ddf2495c56d78bce8b657421b259e712`; +- result: `TRACE_CAPTURED_VISUAL_UNVERIFIED`. + +`PROBE_TRACE`: the reset now logs `records_persisted=15`, matching the +original fixture. The runner also confirms the full +`RPC 139 -> RPC 40 -> RPC 139` cycle, server restart, Windows pilot rejoin, +and no crash marker. This closes duplicate-rule count parity for the exercised +GMX sequence; it does not close later IPL stream-hook timing or capacity +parity. + +The replacement synchronously removes its listed remote player, whereas the +original hook retained all six remote/NPC wrappers at its immediate return. +This is not corrected yet: the original's delayed cleanup point has not been +captured, so changing ownership timing now would be speculative. + +## Remaining Runtime Matrix + +The remaining controlled original runs should capture one occurrence of each: + +1. select a class and spawn from the post-death F4 scene; +2. open dialog/chat/TAB state immediately before death and observe which + latches the original clears; +3. populated actor/pickup/menu/label pools and active UI immediately before + GMX; also follow remote-wrapper cleanup after the synchronous reset; +4. connection loss followed by reconnect; +5. clean quit through the actual `CNetGame` destructor and confirmed terminal + marker/ring drain/hook restore; `process_detach` is not required as the + evidence source; +6. frame-diff the short original and replacement normal-respawn camera + transitions under an identical capture cadence. + +The captured death paths are evidence for their exercised branches, not a +blanket death-system or pixel-parity conclusion. Only the fields directly +exercised in each future trace may be upgraded to `OBSERVED_037` or +`PROBE_TRACE`. + +## Implementation Checks + +Completed locally on 2026-07-28: + +- built the complete ASI as MinGW i686 `Release` with `-Wall -Wextra`; +- verified the result is a PE32 Intel i386 DLL; +- checked the six original entry and tail byte sequences directly against the + documented R5 file; +- checked the terminal `push edi; call [ExitProcess]` span at + `samp.dll+0xC508A`, return address `+0xC5091`, and IAT slot `+0xE5188` + directly against that file; +- verified the R5 relocation table contains HIGHLOW entries `+0x3C29` and + `+0xC508D` and made both guarded operands ASLR-aware; +- inspected the generated wrapper code: Process, Spawn, class selection, GMX, + and destructor return with plain `ret`; connection loss returns with + `ret 4`; +- ran `git diff --check` and `bash -n` on the lab wrapper. +- built the checkpoint-enabled integrated ASI with MinGW i686 and ran the five + terminal source-contract tests. + +The original death/F4 captures used integrated ASI SHA256 +`2c8cf403cc7a5e15373abc1b766ec26da5fd4f04e096b6d7806ce565a9dab2c5`; +the later GMX capture used +`0b6b1a8d8a2d551fd2e17bf6cbb37ce6e6747f34661a3ed811bd36ba936a4440`. +The Death/Cleanup translation unit also compiles separately with +`-Wall -Wextra -Werror`. The GMX hook is now runtime exercised; the +connection-loss/destructor hooks, terminal marker, and clean restore path +remain `TODO_VERIFY`. diff --git a/docs/re/dialog_menu_original_probe_runbook_20260717.md b/docs/re/dialog_menu_original_probe_runbook_20260717.md index ec247a5..0cff871 100644 --- a/docs/re/dialog_menu_original_probe_runbook_20260717.md +++ b/docs/re/dialog_menu_original_probe_runbook_20260717.md @@ -15,10 +15,14 @@ the original client's outgoing: - `STATIC_037` + `PROBE_TRACE`: local R5 `CNetGame` pointer at `samp.dll+0x26eb94`. -- `STATIC_037` + `PROBE_TRACE`: reconstructed legacy `CNetGame` places - RakClient first; the runtime object resolves slot 26 (`+0x68`) to - `samp.dll+0x345b0`. R5 disassembly shows that wrapper dereferences `RPCID*`, - copies an 8-byte packed `NetworkID`, and returns with `ret 0x24`. +- `STATIC_037`: reconstructed legacy `CNetGame` places RakClient first. R5 + ordinary BitStream sends dispatch through slot 25 (`+0x64`) to the short + wrapper at `samp.dll+0x34620`, which returns with `ret 0x18`. For example, + `CPickupPool::PickedUp` calls this slot at `samp.dll+0x134DE`. +- `STATIC_037`: slot 26 (`+0x68`) is the distinct extended overload at + `samp.dll+0x345B0`; it copies an 8-byte packed `NetworkID` and returns with + `ret 0x24`. Hooking only that slot cannot observe ordinary short-overload + sends. The focused probe now guards slot 25 and exact target `+0x34620`. - `OPENMP_REF`: RPC 62 payload is `int16 dialogId`, `uint8 response`, `int16 listItem`, `dynstr8 input`. - `STATIC_037`: reconstructed legacy `CMenuPool::Process` sends RPC 132 with a diff --git a/docs/re/direct_scene_streaming_hang_fix_r5_20260727.md b/docs/re/direct_scene_streaming_hang_fix_r5_20260727.md new file mode 100644 index 0000000..55e0e31 --- /dev/null +++ b/docs/re/direct_scene_streaming_hang_fix_r5_20260727.md @@ -0,0 +1,54 @@ +# Direct scene-streaming hang fix (2026-07-27) + +## Binary evidence + +- Original SA-MP 0.3.7-R5 `samp.dll` SHA256: + `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2`. +- GTA SA 1.0 US routines: + - `CStreaming::LoadAllRequestedModels`: `0x40EA10`; + - `CStreaming::LoadScene`: `0x40EB70`; + - `CStreaming::LoadSceneCollision`: `0x40ED80`. + +`STATIC_037`: an instruction scan of the complete original DLL finds no +reference to `0x40EB70` or `0x40ED80`. The only `0x40EA10` reference is the +`mov edx, 0x40EA10` at `samp.dll+0xA0A6A`, in a dedicated model-request/load +helper. + +## Runtime evidence + +`PROBE_TRACE`: + +- `20260727-165414-replacement-ui-680318` stopped after + `reason=preconnect step=LoadScene phase=begin`. +- `20260727-165627-replacement-ui-680318` and + `20260727-170425-replacement-labels-688825` stopped after + `reason=preconnect step=LoadSceneCollision phase=begin`. +- `20260727-170626-replacement-labels-688825` completed ClientJoin and + ScrInitGame, then stopped after `reason=spawn step=LoadScene phase=begin`. +- No run emitted `exception_filter`; the probe heartbeat continued while the + GTA/render thread remained blocked. +- Successful controls with the same semantic readiness snapshot prove that the + existing settle/gate cannot predict whether the synchronous call will + return. + +## Replacement behavior + +The replacement no longer calls `LoadSceneCollision` or `LoadScene` from: + +- pre-connect scene setup; +- class-selection and spawn scene setup; +- live `SetPlayerPos` scene setup; +- periodic movement-based streaming refresh. + +GTA's normal streamer remains authoritative. The existing script opcode +`04E4`/RefreshStreamingAt path remains, as do the specifically scoped +model-request/load helpers. + +## Open verification + +- Run at least ten cold connect/spawn cycles and require zero open + `scene_prepare_step` records and zero exceptions. +- Repeat class-selection skin cycling and live SetPlayerPos teleports. +- Compare initial world-pop-in timing against original R5; removing an + unreferenced blocking call fixes the hang but does not itself prove + frame-exact streaming timing. diff --git a/docs/re/f1_f5_f7_parity_r5_20260727.md b/docs/re/f1_f5_f7_parity_r5_20260727.md new file mode 100644 index 0000000..bbf5ec8 --- /dev/null +++ b/docs/re/f1_f5_f7_parity_r5_20260727.md @@ -0,0 +1,198 @@ +# F1/F5/F7 parity notes for SA-MP 0.3.7-R5 + +Date: 2026-07-27 + +## Reference binary + +- Version: SA-MP 0.3.7-R5 +- SHA256: + `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2` +- All addresses below are RVAs relative to `samp.dll`. +- Evidence in this document is `STATIC_037` unless a point is explicitly + marked `TODO_VERIFY`. + +## F1 help dialog + +### Original R5 path + +- `samp.dll+0x759DE` polls `GetAsyncKeyState(VK_F1)`. +- `samp.dll+0x6BB30` is the 45-byte help-dialog entry point. +- The help object has its own active guard at object offset `+0x28`. +- The help path opens the generic dialog through `samp.dll+0x6FFB0`. +- Title: `SA-MP Keys:` +- Style: `0` +- Primary button: `OK` +- Secondary button: empty +- Exact body: + +```text +{BB9999}F1 {BBBBBB}Display this help dialog +{BB9999}Tab {BBBBBB}Display the scoreboard +{BB9999}F4 {BBBBBB}Allows you to change class next time you respawn +{BB9999}F5 {BBBBBB}Show bandwidth statistics +{BB9999}F7 {BBBBBB}Toggle the chat box +{BB9999}F8 {BBBBBB}Take a screenshot +{BB9999}F9 {BBBBBB}Toggle the deathwindow +{BB9999}T/F6 {BBBBBB}Allows you to enter a chat message +{BB9999}G {BBBBBB}Enter vehicle as passenger +``` + +### Replacement semantics + +- F1 uses a local dialog kind rather than synthesizing an incoming RPC 61. +- Closing it with `OK`, Enter, Escape, or the dialog button does not enqueue + `DialogResponse`. +- An absent server-dialog flag does not close an active local help dialog. +- A real incoming server dialog replaces the local help dialog. +- Local-dialog state is explicitly cleared by dialog close, session/GMX reset, + and connect reset. +- The `{RRGGBB}` sequences remain available to the segmented renderer. They + are not stripped through the list-item response helper. + +### Remaining checks + +- `TODO_VERIFY`: capture an original/replacement pixel pair at the same + resolution and font settings. The replacement currently uses its generic + compatibility dialog renderer. +- `TODO_VERIFY`: the replacement uses a non-repeating WndProc key-down edge. + R5 polls `GetAsyncKeyState` from its process path, so focus and very short + key-tap edge cases may differ. +- `TODO_VERIFY`: compare cursor capture, hover skin, keyboard close, and + reopening behavior while another UI owner is active. + +## F7 chat display cycle + +### Original R5 path and state machine + +- `samp.dll+0x61360` contains the window-key dispatch. +- The F7 branch at `samp.dll+0x613BD` calls `samp.dll+0x612C0`. +- `samp.dll+0x612C0` marks the chat dirty and applies this exact cycle: + +```text +0 -> 2 +1 -> 0 +2 -> 1 +``` + +- The constructor at `samp.dll+0x68380` initializes the display mode to `2` + at `samp.dll+0x683A1`. +- `samp.dll+0x67E00` skips chat history rendering when the mode is `0`. +- The per-line path at `samp.dll+0x67470` distinguishes modes `1` and `2`. +- Mode meanings established statically: + +| Mode | Chat history | Inline `{RRGGBB}` tags | +|---:|---|---| +| `0` | hidden | not rendered | +| `1` | visible | interpreted as colour changes | +| `2` | visible | valid tags stripped; base line colour remains | + +- The mode-2 strip path calls the R5 colour-tag helper at + `samp.dll+0xB60C0`. + +### Replacement semantics + +- Initial mode is `2`. +- F7 applies `2 -> 1 -> 0 -> 2`. +- Mode `0` hides stored chat history but does not discard it. +- Chat input remains independently renderable while history is hidden. +- Both the primary D3DX history path and the GDI fallback honor the mode. + +### Remaining checks + +- `TODO_VERIFY`: record a three-press original/replacement golden trace with a + line containing multiple valid colour tags, an invalid tag, a timestamp, + and an open chat input. +- `TODO_VERIFY`: compare F7 behavior during dialogs, the scoreboard, the GTA + pause menu, focus loss, and reconnect/GMX. + +## F5 network statistics + +### Held-key dispatch + +- `samp.dll+0x75967` calls `GetAsyncKeyState(VK_F5)`. +- `samp.dll+0x7596B` is `test ax, ax`; R5 deliberately tests the complete + 16-bit return value rather than only bit `0x8000`. +- `samp.dll+0x75975` requires `CNetGame+0x3CD == 5`. +- The connected path calls `samp.dll+0x60D70`. +- `samp.dll+0x7599E` jumps to the end of the SA-MP overlay process for that + frame. F5 is therefore a direct held-key overlay, not a modal dialog or a + latched window. + +### Rate sampling and transport formatter + +- `samp.dll+0x60D70` verifies the NetGame/RakClient/connection state and reads + the peer's `RakNetStatisticsStruct`. +- Rates update only when more than 1000 milliseconds have elapsed. +- Download sampling uses `bitsReceived / 8`; upload sampling uses + `totalBitsSent / 8`. Integer byte conversion is a right shift, not + round-up division. +- Exact rate prefix: + +```text +Download Rate: %.2f KB/s +Upload Rate: %.2f KB/s +``` + +- `samp.dll+0x61160` pushes verbosity `4`. +- `samp.dll+0x61168` calls the RakNet statistics formatter at + `samp.dll+0x36610`. +- The title is `Client Network Stats`. +- The title draw at `samp.dll+0x611D6` uses colour `0xFF8888EE`. +- R5 draws the title and the assembled report directly from the top-left + origin beginning at `(10, 10)`; it does not open a dialog. + +The replacement exposes a small read-only adapter function that formats the +connected peer counters in the R5 verbosity-4 line order. It also keeps +per-connection byte samples for the displayed download/upload deltas. The +primary D3DX overlay gives F5 precedence over the other compatibility +overlays for that frame, matching the R5 process jump. + +### R5 game/GTA diagnostic addenda + +Static string references inside `samp.dll+0x60D70` confirm that R5 appends +these blocks before the RakNet report: + +```text +Vehicles: %u +Vehicle Models: %u +Vehicle Models Loaded: %u +RWObjects: %u +PedSlotsUsed: %u +ObjectSlotsUsed: %u +ShotCompMode: %u +FreeIDEs: %u +OnFoot Send Rate: %u +InCar Send Rate: %u +Streaming Mem: %uMB Total: %uMB System: %uMB +Process Mem: %uKB Working Set: %uKB +``` + +The current replacement intentionally does not display these addenda yet. +Several values require authoritative GTA pool, streaming-memory, process +memory, and shot-compensation sources. Substituting SA-MP compatibility-pool +counts would look plausible but would not prove R5 parity. + +### Remaining checks + +- `TODO_VERIFY`: implement the GTA diagnostic addenda only after each value is + mapped to its original source/call and validated at runtime. +- `TODO_VERIFY`: add F5 rendering to the emergency GDI fallback. The primary + D3DX path is implemented. +- `TODO_VERIFY`: capture original/replacement F5 frames at 800x600 and the + normal test resolution to compare font, line spacing, clipping, and colour. +- `TODO_VERIFY`: compare the first F5 sample after a delayed key press, a + continuously held sample across several seconds, disconnect/reconnect, and + counter wrap. +- `TODO_VERIFY`: corroborate all formatted numeric fields with a controlled + packet-loss/latency fixture and a simultaneous packet trace. + +## Evidence boundary + +The RVAs, instruction-level key tests, strings, mode transitions, formatter +verbosity, and R5 binary hash above are `STATIC_037`. + +No original/replacement visual golden or runtime probe was recorded as part +of this implementation pass. Pixel placement, input/focus edge behavior, +first-sample timing under real play, and the omitted GTA diagnostic values +remain `TODO_VERIFY`; they must not be presented as `OBSERVED_037` or +`PROBE_TRACE` until corresponding evidence exists. diff --git a/docs/re/f8_black_screenshot_fallback_r5_20260727.md b/docs/re/f8_black_screenshot_fallback_r5_20260727.md new file mode 100644 index 0000000..67746b5 --- /dev/null +++ b/docs/re/f8_black_screenshot_fallback_r5_20260727.md @@ -0,0 +1,100 @@ +# F8 black-screenshot fallback (R5, 2026-07-27) + +## Scope + +This note covers the screenshot path only. It does not change scene, +streaming, presentation, or device-reset behavior. + +## Original R5 evidence + +Reference binary: + +```text +SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2 +``` + +`STATIC_037`: the screenshot routine starts at `samp.dll+0x755C0`. It: + +1. creates a desktop-sized `D3DFMT_A8R8G8B8` scratch-pool surface; +2. calls `IDirect3DDevice9::GetFrontBufferData`; +3. converts the GTA client origin to screen coordinates and crops to the + client rectangle; and +4. calls `D3DXSaveSurfaceToFileA` with PNG output. + +The request flag is stored at `samp.dll+0x12DE64`. The render callback at +`samp.dll+0x75730` calls the screenshot routine at `samp.dll+0x7575D`. + +The replacement retains that sequence as its unconditional first capture +attempt. + +## Runtime observation + +`PROBE_TRACE`: under GE-Proton10-34 with its DXVK D3D9 implementation, +replacement screenshots `sa-mp-000.png` through `sa-mp-004.png` were: + +- valid 640x448 PNG files; +- byte-identical, 914 bytes each; +- completely black (`#000000`, including every sampled/counted pixel); and +- reported as successfully saved in `samp_runtime.log`. + +For example, `sa-mp-002.png` and `sa-mp-004.png` both had: + +```text +SHA256=f457319c60552399ed67162df57fbc13b7383880c12195938fe0f449671b0c8f +``` + +Thus the failure is not a filename or PNG-encoding failure: +`GetFrontBufferData` and the encoder both returned success while the captured +crop contained no visible RGB data. + +`INFERRED`: this is the known class of one-buffer/last-present front-buffer +behavior for which DXVK exposes `d3d9.extraFrontbuffer`. The DXVK reference +configuration describes that option as adding a framebuffer whose contents +are preserved for `GetFrontBufferData`: + + + +This inference does not replace the observed pixel/result evidence and does +not alter the original-first policy. + +## Compatibility fallback + +`PROBE_TRACE + INFERRED`: after a successful original-style front-buffer +capture, the replacement defensively locks and inspects only the validated +client crop. It falls back to the current render target only when every RGB +byte in that crop is zero. + +The fallback: + +1. obtains render target 0; +2. validates its descriptor, dimensions, format, pool, render-target usage, + and lack of multisampling; +3. creates a matching system-memory surface; +4. copies with `GetRenderTargetData`; and +5. saves that surface through the existing D3DX PNG writer. + +An unsupported format, invalid rectangle, bad pitch/bounds, unreadable +memory, failed lock/unlock, missing COM method, multisampled target, or failed +HRESULT leaves the original behavior in place or reports the screenshot as +failed. All acquired COM surfaces are released on every return path. Runtime +logs identify `backend=frontbuffer` or +`backend=render_target_fallback`. + +## Verification plan + +1. Build and install the replacement DLL without enabling + `d3d9.extraFrontbuffer`. +2. Enter a visibly non-black game scene and press F8. +3. Confirm the log reports the fully black front-buffer crop, a successful + render-target copy, and `backend=render_target_fallback`. +4. Confirm the PNG has the expected client dimensions and nonzero RGB + content (more than one color). +5. On a native/known-good front-buffer implementation, confirm a normal + screenshot still reports `backend=frontbuffer`. +6. As an optional causality check, repeat with + `DXVK_CONFIG="d3d9.extraFrontbuffer = True"` and confirm the original + front-buffer backend remains usable. +7. Repeat after a device reset and verify retries neither crash nor leak COM + references. + +Runtime verification of the new fallback is still `TODO_VERIFY`. diff --git a/docs/re/legacy_feature_gap_matrix_20260609.md b/docs/re/legacy_feature_gap_matrix_20260609.md new file mode 100644 index 0000000..295154a --- /dev/null +++ b/docs/re/legacy_feature_gap_matrix_20260609.md @@ -0,0 +1,151 @@ +# Legacy feature gap matrix + +Date: 2026-06-09 + +Scope: + +* Legacy source reviewed from + `/home/chairman/Projects/sa-mp-legacy-rebuild/samp/client`. +* Current replacement reviewed from `reimpl/src/runtime_bridge.c`, + `reimpl/src/net/raknet_client_adapter.cpp`, and + `reimpl/include/sampdll/net/raknet_client_adapter.h`. +* This is a behavior-oriented gap list, not proof of exact 0.3.7 behavior. + `STATIC_037` Ghidra evidence and/or `PROBE_TRACE` runs are still required + before treating any legacy-only behavior as original-DLL native behavior. + +Evidence tags used here: + +* `ALT_02X_CODE`: behavior exists in the legacy source tree. +* `CURRENT_REIMPL`: behavior exists in the current replacement. +* `OPENMP_REF`: current RPC naming/semantics align with open.mp references or + the current open.mp-oriented adapter table. +* `INFERRED`: current status inferred from local code inspection. +* `TODO_VERIFY`: needs focused original 0.3.7 static/runtime confirmation. + +## Current replacement baseline + +The replacement is no longer empty. The broad working surface is: + +* RakNet bootstrap/autojoin, reconnect text, `/q` disconnect, chat send and + command routing. (`CURRENT_REIMPL`, `PROBE_TRACE`) +* Class selection, left/right class requests, spawn request/response, spawn + camera/mouse restore, and pause menu pass-through. (`CURRENT_REIMPL`, + `PROBE_TRACE`, `TODO_VERIFY`) +* Basic local player placement, health/armour/armed weapon, controllable flag, + time/weather/interior, camera pos/look-at/behind-player. (`CURRENT_REIMPL`) +* Dialogs, client messages, textdraw show/hide/set-string/select/click, partial + D3D/GTA-font drawing. (`CURRENT_REIMPL`) +* Basic player pool events, score/ping updates, remote player add/remove/death, + partial remote on-foot sync and markers. (`CURRENT_REIMPL`) +* Basic world vehicle create/remove/health/put-local-player and local on-foot / + in-car sync send. (`CURRENT_REIMPL`) +* Basic object create/remove/set-pos/set-rot/move/stop plus partial object + material observation/application. (`CURRENT_REIMPL`) +* Early custom asset/archive inspection and guarded model registration notes, + not a full loader yet. (`CURRENT_REIMPL`, `PROBE_TRACE`, `TODO_VERIFY`) + +## Missing or mostly absent legacy subsystems + +| Area | Legacy behavior surface | Current status | Impact | First useful slice | +| --- | --- | --- | --- | --- | +| Pickup pool | `net/pickuppool.*` manages server pickups, dropped weapon pickups, pickup processing and `PickedUpPickup` flow. | `ScrCreatePickup` and `ScrDestroyPickup` are dummy; outgoing picked-up RPC exists only as metadata. | Pickup-heavy modes will not show or report pickups correctly. | Implement pool slots, create/destroy, local proximity pickup notification. | +| Gang zones | `net/gangzonepool.*` stores zones, draws radar overlays, flash/stop-flash/delete. | Implemented for RPC 108/120/121/85 with a 1024-slot recovery snapshot, shared 500 ms flash phase, and validated radar/pause-map call hooks. (`STATIC_037`, `CURRENT_REIMPL`, `TODO_VERIFY`) | Runtime path is complete; visual/color parity still needs a replacement run. | Run `/rpczones`, inspect radar and ESC map through all four phases, then archive the trace. | +| Menu pool | `net/menupool.*` handles SA-MP menu creation, show/hide, current menu, selection/quit. | Implemented: bounds-checked 128-slot pool, RPC 76/77/78 decode, 640x460-scaled two-column overlay, disabled-row keyboard navigation, and outgoing RPC 132/140. | Runtime path is complete; visual and wire parity still need an original/replacement trace pair. | Run `/rpcmenu` in the bare fixture and archive both RPC traces. | +| Checkpoints | Legacy local player code processes checkpoints and race checkpoints. | `ScrSetCheckpoint`, `ScrDisableCheckpoint`, `ScrSetRaceCheckpoint`, `ScrDisableRaceCheckpoint` are dummy. | Mission/race/guidance markers are absent. | Add normal checkpoint first, then race checkpoint arrow/ring semantics. | +| 3D labels and chat bubbles | `label.*` and RPCs create/update/draw text in world. | Create 3D label is decoded/observed only; update is dummy; `/dl` debug labels are separate ad hoc UI. | Roleplay/admin/debug labels and chat bubbles are missing. | Build a real label pool using the existing D3D overlay path. | +| Actor pool | `net/actorpool.*`, `remoteactor.*`, `game/actorped.*` manage server actors/NPC-like peds, animations, damage, visibility. | No equivalent actor pool in current runtime/header snapshot. | Actor-heavy scripts will silently lose peds. | Add actor slot model and spawn/remove first; defer animation/damage until traced. | +| Death window | `deathwindow.*` renders kill feed/connect/disconnect entries. | `ScrDeathMessage` is dummy; no death window renderer. | Kill feed and special connect/disconnect UI are absent. | Reuse D3D font overlay to render buffered death messages. | +| Player name tags | `newplayertags.*` renders name, health, armour, distance-scaled 3D tags. | Current markers/remote player display are partial; `ScrShowPlayerNameTagForPlayer` is dummy. | Remote players lack accurate legacy name-tag behavior. | Implement tag visibility flags, LOS/distance, health/armour bars. | +| Help and netstats | `helpdialog.*`, `netstats.*`, `svrnetstats.*` plus function-key toggles. | No full equivalents. | F1/F5/F10 legacy diagnostics are absent. | Add lightweight overlays fed by existing adapter state. | +| Game text | `CGame::DisplayGameText` path exists in legacy and is used by bounds/carjack/debug flows. | `ScrDisplayGameText` is dummy. | Common server HUD messages are missing. | Implement opcode-backed or overlay-backed gametext with style/time. | +| Map icons | Legacy player/color/radar helper paths and map-marker RPCs update radar blips. | `ScrSetPlayerMapIcon` dummy; remove map icon is merged with clock RPC metadata. | GPS/admin/map markers missing or inconsistent. | Confirm 0.3.7 RPC layouts and use GTA radar blip helpers. | +| Audio streams | Legacy has BASS-style audio stream flow and GTA sound helpers. | `ScrPlayAudioStream` dummy; `ScrStopAudioStream` decoded only with backend not wired. | Radio/audio-stream features do not work. | Wire BASS/Wine-safe stream open/stop behind guards. | +| Player weapons/money/stats | Legacy player/game wrappers handle money, ammo, weapon grant/reset, skills, drunk level, wanted, shop names. | Reset money/weapons and armed weapon exist; give weapon, ammo, money, skill, drunk, wanted, shop name are dummy. | Many gamemode effects are invisible or desynced locally. | Start with `GivePlayerWeapon`, `SetPlayerAmmo`, money and wanted level. | +| Player actions/animations | Legacy has special actions, fighting style, apply/clear animation, jetpack, goggles, dancing, hands-up and task helpers. | Apply animation is decoded only; clear/special/fighting are dummy. | RP/action servers will show wrong local/remote ped state. | Apply/clear animation through GTA script commands after layout confirmation. | +| Attached objects | Legacy ped/object paths handle held/attached objects. | Partial: RPC 75/113 lifecycle, guarded object creation, root attachment, RPC 116/117 edit overlay and outgoing cancel/final/update responses are implemented. Original bone-matrix attachment, anisotropic scale, material colours and 3D-gizmo parity remain open. (`STATIC_037`, `CURRENT_REIMPL`, `OPENMP_REF`, `TODO_VERIFY`) | Cosmetics and editing work at compatibility level, but bone/scale/colour visuals can differ from R5. | Reproduce the R5 `CPlayerPed` attachment wrapper/render update around `samp.dll+0xB0B10`, then trace all 18 bones. | +| Spectating/camera attach | Legacy local player has `ToggleSpectating`, `SpectatePlayer`, `SpectateVehicle`, and camera-on-actor/vehicle script paths. | Spectate RPCs and camera attach/interpolate RPCs are dummy. | Admin/spec modes and scripted cameras fail. | Implement spectate state machine using legacy script command sequence. | +| Vehicle advanced state | Legacy `vehiclepool.*` and `game/vehicle.*` include waiting list/model loading, damage model rebuild, mods, paintjob, number plate, trailer, respawn/wasted, unoccupied sync, params/interior/velocity. | Basic create/remove/health/put-driver exists; many create extras are deferred; params, component removal, link interior, remove from vehicle, velocity, number plate, trailer, set pos/z-angle are dummy. | Vehicle-heavy servers still have major visual/behavior gaps. | Next best: damage/mod/paintjob application, then params/interior/trailer/unoccupied. | +| Remote vehicle control | Legacy hooks GTA vehicle `ProcessControl` and temporarily swaps remote input/ped context for remote drivers. | Current runtime does not implement that hook/context switch. | Remote-driven vehicles may move less like legacy GTA simulation. | Instrument first; then consider guarded ProcessControl compatibility hook. | +| World edits | Legacy/game wrappers include world bounds, explosions, remove building, stunt bonus, gravity. | RemoveBuildingForPlayer RPC 43 now scans building/dummy/object pools, applies the R5 removed flag/Z lowering and persists rules for stream re-application. Other listed world edits have separate status. (`STATIC_037`, `CURRENT_REIMPL`, `TODO_VERIFY`) | Remove-building is implemented but still needs visual original/replacement confirmation. | Run the Area 51 fixture and compare pool/removal traces against R5. | +| Archive/custom assets | Legacy has `archive/*`, crypto/signing/hash helpers, memory module support. | Current docs/runtime only inspect/register pieces; no full SA-MP custom asset pipeline. | Custom objects/skins/assets remain incomplete. | Continue staged asset pipeline from `docs/re/samp_custom_asset_pipeline.md`. | +| Crash/reporting utilities | Legacy has `exceptions.*`, `runutil.*`, MD5/build helpers. | Replacement has traces/logging, but not the same rich client crash/report path. | Harder user-side crash triage and less parity with original diagnostics. | Add compact exception context only if it helps current crash work. | + +## RPCs that are currently dummy or decoded-only + +From the current adapter metadata, these are the clearest server-to-client gaps. +Some names come from compatibility references and still need original-DLL +layout confirmation. + +Player/world basics: + +* Dummy: `ScrSetPlayerName`, `ScrSetPlayerPosFindZ`, `ScrSetWorldBounds`, + `ScrGivePlayerMoney`, `ScrGivePlayerWeapon`, `ScrSetPlayerShopName`, + `ScrSetPlayerSkillLevel`, `ScrSetPlayerDrunkLevel`, `ScrToggleClock`, + `ScrSetPlayerAmmo`, `ScrSetGravity`, `ScrSetPlayerSkin`, + `ScrSetPlayerWantedLevel`. +* Decoded-only: `ScrSetPlayerTeam`, `ScrSetPlayerColor`. + +Vehicles: + +* Dummy: `ScrSetVehicleParamsEx`, `ScrRemoveVehicleComponent`, + `ScrLinkVehicleToInterior`, `ScrRemovePlayerFromVehicle`, + `ScrSetVehicleVelocity`, `ScrSetNumberPlate`, + `ScrAttachTrailerToVehicle`, `ScrDetachTrailerFromVehicle`, + `ScrSetVehiclePos`, `ScrSetVehicleZAngle`, + `ScrSetVehicleParamsForPlayer`. +* Partial: world vehicle create/remove/health and driver-seat put are present, + but create extras and advanced control are incomplete. + +World UI and markers: + +* Dummy: `ScrDeathMessage`, `ScrSetPlayerMapIcon`, `ScrUpdate3DTextLabel`, + `ScrChatBubble`, `ScrDisplayGameText`, `ScrShowPlayerNameTagForPlayer`, + `ScrInitMenu`, `ScrShowMenu`, `ScrHideMenu`, `ScrCreatePickup`, + `ScrDestroyPickup`, `ScrSetCheckpoint`, `ScrDisableCheckpoint`, + `ScrSetRaceCheckpoint`, `ScrDisableRaceCheckpoint`. +* Decoded-only: `ScrCreate3DTextLabel`. + +Camera/spectating/actions: + +* Dummy: `ScrAttachCameraToObject`, `ScrInterpolateCamera`, + `ScrTogglePlayerSpectating`, `ScrPlayerSpectatePlayer`, + `ScrPlayerSpectateVehicle`, `ScrClearAnimations`, + `ScrSetPlayerSpecialAction`, `ScrSetPlayerFightingStyle`, + `ScrSetPlayerVelocity`. +* Partial: `ScrSetPlayerAttachedObject`, `ScrAttachObjectToPlayer`, attached/object edit RPCs 116/117. +* Decoded-only: `ScrApplyAnimation`. + +Audio/world effects: + +* Dummy: `ScrPlayAudioStream`, `ScrCreateExplosion`, + `ScrEnableStuntBonusForPlayer`, + `ScrGameModeRestart`. +* Implemented pending runtime parity trace: `ScrPlayCrimeReport`, `ScrRemoveBuildingForPlayer`. +* Decoded-only: `ScrStopAudioStream`. + +## Suggested implementation order + +1. Vehicle advanced state: damage, params, mods, paintjob, number plate and + trailer/interior. This is closest to the issues we already debugged and has + good legacy/GTA-reversed address evidence. +2. GameText, checkpoints, 3D labels and death window. These are visible, + high-signal compatibility wins and mostly isolated from core physics. +3. Pickup pool and map icons. These unlock many ordinary gamemode mechanics. +4. Spectating and camera attach/interpolate. Important, but touches camera state + and should use Ghidra/static evidence before broad changes. +5. Player actions/animations/attached objects. High compatibility value, but + should be implemented defensively because model/animation loads can crash. +6. Menus and gang zones are implemented; capture original/replacement golden + traces and visual parity evidence before marking them done. +7. Full custom asset/archive pipeline. Large surface; continue staged and + trace-driven. + +## Main conclusion + +The current replacement has the essential session loop now, but the legacy +client had a much broader set of persistent pools and UI renderers. The largest +"we do not have this at all" gaps are pickups, menus, gang zones, actors, +checkpoints, death window, full 3D labels/chat bubbles, spectating, audio +streams, and advanced vehicle/player state. The most valuable next compatibility +work is to turn the existing dummy/decoded RPCs into small, evidence-backed +pools and render/update paths instead of continuing to add ad hoc one-off fixes. diff --git a/docs/re/legacy_gta_sa_address_crossref.md b/docs/re/legacy_gta_sa_address_crossref.md new file mode 100644 index 0000000..4737853 --- /dev/null +++ b/docs/re/legacy_gta_sa_address_crossref.md @@ -0,0 +1,113 @@ +# Legacy GTA SA address cross-reference + +Date: 2026-06-09 + +Scope: addresses used by the 0.2x-era client code under `samp/client/game/` +that call or patch `gta_sa.exe` directly. The old code uses GTA SA 1.0 US +absolute virtual addresses. The RVA column below subtracts the normal +`gta_sa.exe` image base (`0x400000`). + +Evidence tags: + +* `GTA_REVERSED_REF`: direct match in gta-reversed source/install metadata. +* `SAMPFUNCS_REF`: local SAMPFUNCS/ASI SDK reference in this tree. +* `ALT_02X_CODE`: direct use in this repository's legacy 0.2x client code. +* `INFERRED`: plausible match; still needs Ghidra/runtime confirmation. +* `TODO_VERIFY`: do not treat as final 0.3.7 behavior yet. + +Important: these names are GTA SA names, not SA-MP 0.3.7 proof by +themselves. Use them as naming and control-flow evidence. Any compatibility +claim for the replacement DLL still needs `STATIC_037`, `OBSERVED_037`, or +`PROBE_TRACE` evidence. + +## High-confidence vehicle matches + +| VA | RVA | Name | Legacy use | Evidence | +| --- | --- | --- | --- | --- | +| `0x6A3440` | `gta_sa.exe+0x2A3440` | `CAutomobile::Fix` | `vehicle.cpp` comments this as `CAutomobile::RepairDamageModel`; called when synced damage says fully repaired but GTA state has panel/door/light damage. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x6B3E90` | `gta_sa.exe+0x2B3E90` | `CAutomobile::SetupDamageAfterLoad` | `vehicle.cpp` comments this as `CAutomobile::UpdateDamageModel`; called after assigning panel/door/light damage fields. | `ALT_02X_CODE`, `GTA_REVERSED_REF`, `TODO_VERIFY` naming/semantics | +| `0x6B1880` | `gta_sa.exe+0x2B1880` | `CAutomobile::ProcessControl` | `hooks.cpp` dispatches vehicle process hook by vtable. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x6AE850` | `gta_sa.exe+0x2AE850` | `CAutomobile::TankControl` | Tank turret hook saves/restores camera/aim around this call for remote drivers. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x729B60` | `gta_sa.exe+0x329B60` | `CAutomobile::FireTruckControl` | Firetruck/SWAT water turret hook saves/restores camera/aim around this call for remote drivers. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x6F1770` | `gta_sa.exe+0x2F1770` | `CBoat::ProcessControl` | Same process-control dispatch. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x6B9250` | `gta_sa.exe+0x2B9250` | `CBike::ProcessControl` | Same process-control dispatch. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x6C9260` | `gta_sa.exe+0x2C9260` | `CPlane::ProcessControl` | Same process-control dispatch. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x6C7050` | `gta_sa.exe+0x2C7050` | `CHeli::ProcessControl` | Same process-control dispatch. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x6BFA30` | `gta_sa.exe+0x2BFA30` | `CBmx::ProcessControl` | Same process-control dispatch; old comment says pushbike/BMX. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x6C8250` | `gta_sa.exe+0x2C8250` | `CMonsterTruck::ProcessControl` | Same process-control dispatch; old code marks this as `UNKNOWN2`. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x6CDCC0` | `gta_sa.exe+0x2CDCC0` | `CQuadBike::ProcessControl` | Same process-control dispatch; old code marks this as `UNKNOWN1`. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x6F86A0` | `gta_sa.exe+0x2F86A0` | `CTrain::ProcessControl` | Same process-control dispatch. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | + +## Vehicle vtable matches + +| VA | RVA | Name | Legacy use | Evidence | +| --- | --- | --- | --- | --- | +| `0x871120` | `gta_sa.exe+0x471120` | `CAutomobile` vtable | `vehicle.cpp` subtype detection; `hooks.cpp` process dispatch. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x8721A0` | `gta_sa.exe+0x4721A0` | `CBoat` vtable | Same. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x871360` | `gta_sa.exe+0x471360` | `CBike` vtable | Same. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x871948` | `gta_sa.exe+0x471948` | `CPlane` vtable | Same. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x871680` | `gta_sa.exe+0x471680` | `CHeli` vtable | Same. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x871528` | `gta_sa.exe+0x471528` | `CBmx` vtable | Same. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x8717D8` | `gta_sa.exe+0x4717D8` | `CMonsterTruck` vtable | Same; resolves old `UNKNOWN2`. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x871AE8` | `gta_sa.exe+0x471AE8` | `CQuadBike` vtable | Same; resolves old `UNKNOWN1`. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x872370` | `gta_sa.exe+0x472370` | `CTrain` vtable | Same. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | + +## Vehicle audio matches + +| VA | RVA | Name | Legacy use | Evidence | +| --- | --- | --- | --- | --- | +| `0x4F5700` | `gta_sa.exe+0x0F5700` | `CAEVehicleAudioEntity::JustGotInVehicleAsDriver` | `vehicle.cpp` and `hooks.cpp` remote-driver audio path. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x4FCF40` | `gta_sa.exe+0x0FCF40` | `CAEVehicleAudioEntity::JustGotOutOfVehicleAsDriver` | Exit/remote-driver hook path. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x501E10` | `gta_sa.exe+0x101E10` | `CAEVehicleAudioEntity::ProcessVehicle` | Called with vehicle audio entity as `ecx` and vehicle as argument after setting `s_pPlayerDriver`. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0x502280` | `gta_sa.exe+0x102280` | `CAEVehicleAudioEntity::Service` | Hooked/called in vehicle audio processing. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | +| `0xB6B990` | `gta_sa.exe+0x76B990` | `CAEVehicleAudioEntity::s_pPlayerDriver` | Old code writes the active driver ped before `ProcessVehicle`. | `ALT_02X_CODE`, `GTA_REVERSED_REF` | + +## Pool and camera addresses + +| VA | RVA | Name | Legacy use | Evidence | +| --- | --- | --- | --- | --- | +| `0xB74490` | `gta_sa.exe+0x774490` | `CPools::ms_pPedPool` | `address.h` ped table pointer; ASI probe watches this. | `ALT_02X_CODE`, `PROBE_TRACE`, `GTA_REVERSED_REF` | +| `0xB74494` | `gta_sa.exe+0x774494` | `CPools::ms_pVehiclePool` | `address.h` vehicle table pointer; replacement currently mirrors the old direct pool lookup path. | `ALT_02X_CODE`, `PROBE_TRACE`, `GTA_REVERSED_REF` | +| `0x404910` | `gta_sa.exe+0x004910` | likely direct ped-pool handle lookup helper | Old `ADDR_ACTOR_FROM_ID`; called as method-style helper on `[0xB74490]`, not the public `CPools::GetPed` wrapper. | `ALT_02X_CODE`, `SAMPFUNCS_REF`, `INFERRED`, `TODO_VERIFY` | +| `0x4048E0` | `gta_sa.exe+0x0048E0` | likely direct vehicle-pool handle lookup helper | Old `ADDR_VEHICLE_FROM_ID`; local SannyBuilder data also shows `call_function_method 0x4048E0 struct 0xB74494`. | `ALT_02X_CODE`, `SAMPFUNCS_REF`, `INFERRED`, `TODO_VERIFY` | +| `0x42C4B0` | `gta_sa.exe+0x02C4B0` | likely vehicle ref/id helper | Old `ADDR_ID_FROM_VEHICLE`. Needs Ghidra confirmation. | `ALT_02X_CODE`, `INFERRED`, `TODO_VERIFY` | +| `0x4442D0` | `gta_sa.exe+0x0442D0` | likely ped ref/id helper | Old `ADDR_ID_FROM_ACTOR`. Needs Ghidra confirmation. | `ALT_02X_CODE`, `INFERRED`, `TODO_VERIFY` | +| `0xB6F028` | `gta_sa.exe+0x76F028` | `TheCamera` object base | gta-reversed camera singleton. | `GTA_REVERSED_REF` | +| `0xB6F1A8` | `gta_sa.exe+0x76F1A8` | active camera mode byte inside `TheCamera` | Old hook and ASI probe use this as camera mode. Offset from `TheCamera` base is `0x180`. | `ALT_02X_CODE`, `PROBE_TRACE`, `GTA_REVERSED_REF`, `INFERRED` field name | +| `0xB6F99C` | `gta_sa.exe+0x76F99C` | camera subfield, not object base | Old `ADDR_CAMERA`. Since gta-reversed puts `TheCamera` at `0xB6F028`, this should not be used as the camera base without field-level verification. | `ALT_02X_CODE`, `GTA_REVERSED_REF`, `TODO_VERIFY` | + +## Practical naming guidance + +* Use the gta-reversed names for the process-control and audio addresses above. + The matches are exact enough to rename local constants or comments. +* Keep old aliases in comments where they explain legacy intent, for example + `CAutomobile::Fix` / old `RepairDamageModel`. +* Do not rename `0x4048E0`, `0x404910`, `0x42C4B0`, or `0x4442D0` as public + `CPools::Get*` APIs yet. They appear to be lower-level pool helper calls. + Ghidra should verify the calling convention and handle/ref arithmetic first. +* Treat `0xB6F99C` carefully. For current camera work, prefer naming + `0xB6F028` as `TheCamera` and `0xB6F1A8` as the active mode byte; document + `0xB6F99C` only after field-level static evidence. + +## Sources checked + +* Legacy code: `samp/client/game/address.h`, `samp/client/game/vehicle.cpp`, + `samp/client/game/hooks.cpp`. +* Local ASI/SAMP resource: `sampfuncs/SAMPFUNCS SDK/SannyBuilder Data/opcodes.txt`. +* Local probe/reimpl references: `tools/asi_probe/src/samp_probe_asi.c`, + `reimpl/src/runtime_bridge.c`. +* gta-reversed online source: + * https://github.com/gta-reversed/gta-reversed + * `source/game_sa/Entity/Vehicle/Automobile.cpp` + * `source/game_sa/Entity/Vehicle/Boat.cpp` + * `source/game_sa/Entity/Vehicle/Bike.cpp` + * `source/game_sa/Entity/Vehicle/Bmx.cpp` + * `source/game_sa/Entity/Vehicle/Plane.cpp` + * `source/game_sa/Entity/Vehicle/Heli.cpp` + * `source/game_sa/Entity/Vehicle/MonsterTruck.cpp` + * `source/game_sa/Entity/Vehicle/QuadBike.cpp` + * `source/game_sa/Entity/Vehicle/Train.cpp` + * `source/game_sa/Audio/Entities/AEVehicleAudioEntity.cpp` + * `source/game_sa/Audio/Entities/AEVehicleAudioEntity.h` + * `source/game_sa/Pools.cpp` + * `source/game_sa/Camera.cpp` diff --git a/docs/re/legacy_vs_reimpl_vehicle_camera_paths.md b/docs/re/legacy_vs_reimpl_vehicle_camera_paths.md new file mode 100644 index 0000000..cb93ee4 --- /dev/null +++ b/docs/re/legacy_vs_reimpl_vehicle_camera_paths.md @@ -0,0 +1,208 @@ +# Legacy vehicle/camera paths vs replacement runtime + +Date: 2026-06-09 + +This note compares the newly named 0.2x-era client paths with the current +replacement implementation in `reimpl/src/runtime_bridge.c`. + +Evidence tags: + +* `ALT_02X_CODE`: `samp/client/game/*`. +* `GTA_REVERSED_REF`: names from gta-reversed, mapped in + `docs/re/legacy_gta_sa_address_crossref.md`. +* `PROBE_TRACE`: current replacement/original trace evidence already captured + in comments or runtime logs. +* `INFERRED` / `TODO_VERIFY`: needs 0.3.7 static/runtime confirmation. + +## Vehicle creation + +Legacy path: + +* `CVehicle::CVehicle()` requests and synchronously loads the model, then calls + script `create_car` at `z + 0.1f`, `set_car_z_angle`, disables gas tank + explosion, disables hydraulics, makes tyres invulnerable, reads the GTA pool + pointer and stores the GTA handle. +* For normal cars it writes `dwDoorsLocked = 0` directly after creation. +* Train models branch into a dedicated train creation path instead of `create_car`. + +Replacement path: + +* `vehicle_compat_create_slot()` queues RPC-created vehicles and + `vehicle_compat_apply_pending_slot()` creates them after spawn/session gates. +* It requests the model, calls `create_car`, sets angle, disables gas tank + explosion/hydraulics/tyre vulnerability, unlocks the car, sets interior and + health. +* It intentionally blocks trains and defers colors, paintjob, mods, siren and + damage. + +Current gap: + +* `TODO_VERIFY`: legacy adds `+0.1f` to Z for `create_car`; replacement currently + passes the server Z unchanged. This is worth checking against the floating + vehicle observations, although gravity/streaming can also explain those. +* `TODO_VERIFY`: deferred color/mod/siren/damage state means replacement-created + vehicles are visually incomplete compared with legacy. +* `TODO_VERIFY`: train creation remains intentionally unsupported. + +## Vehicle pool lookup + +Legacy path: + +* `ADDR_VEHICLE_TABLE` points to `CPools::ms_pVehiclePool` at `0xB74494`. +* `ADDR_VEHICLE_FROM_ID` calls a lower-level pool handle lookup helper at + `0x4048E0` method-style with `ECX=[0xB74494]`. + +Replacement path: + +* `vehicle_compat_game_pool_get_at()` mirrors that lower-level path with guards: + read `[0xB74494]`, call `0x4048E0`, validate the returned pointer. + +Current gap: + +* This is one of the closest matches already. The remaining work is Ghidra + confirmation of the helper's exact handle/ref arithmetic and failure cases. + +## Damage state + +Legacy path: + +* `CVehicle::UpdateDamageStatus()` only runs automobile damage updates when the + vtable identifies `CAutomobile`. +* If synced panel/door/light damage is all zero but GTA has existing damage, it + calls `CAutomobile::Fix` (`0x6A3440`) and exits. +* Otherwise it writes `dwPanelStatus`, `dwDoorStatus1`, `dwLightStatus`, then + calls `CAutomobile::SetupDamageAfterLoad` (`0x6B3E90`) to rebuild the visible + damage model. + +Replacement path: + +* RPC vehicle create captures `door_damage`, `panel_damage`, `light_damage` and + `tyre_damage`, but logs them as `extra_state_deferred`. + +Current gap: + +* This is a concrete missing behavior. The replacement should use the same + guarded vtable/subtype check before touching automobile damage fields, then + call the same GTA rebuild functions once layout offsets are verified. +* For all-zero damage, call the full `CAutomobile::Fix` path instead of just + clearing fields. + +## Remote-driver ProcessControl + +Legacy path: + +* `AllVehicles_ProcessControl_Hook()` replaces the virtual dispatch, resolves + the class-specific ProcessControl function by vtable, then calls the original + target. +* For a remote player driving a GTA player ped, it temporarily: + * stores local keys; + * installs remote-player keys; + * sets the GTA current-player context byte; + * changes the driver's `dwPedType` so `CPed::IsPlayer` returns false; + * services the inline `CAEVehicleAudioEntity` at `CVehicle + 312`; + * calls class-specific `ProcessControl`; + * restores local keys and ped type. +* Tank and firetruck turret hooks additionally save/restore camera mode and aim + state around `CAutomobile::TankControl` / `FireTruckControl`. + +Replacement path: + +* The current runtime creates vehicles and can put the local player into a + driver seat, but it does not install a GTA `ProcessControl` hook or emulate + the remote-driver key/aim context switch. + +Current gap: + +* This is the main semantic gap for remote-driven vehicles. If crashes or bad + movement happen when remote players/vehicles enter scope, the legacy solution + was not "just set transforms"; it let GTA run its own per-class control logic + under a temporary remote input context. +* A replacement-safe version should start as instrumentation: log vtable, + driver ped, `dwPedType`, current-player byte, audio entity bytes, and whether + the vehicle has a remote driver, before attempting hooks. + +## Vehicle audio + +Legacy path: + +* Vehicle audio entity is treated as inline at `CVehicle + 312`. +* Known calls: + * `CAEVehicleAudioEntity::JustGotInVehicleAsDriver` (`0x4F5700`) + * `CAEVehicleAudioEntity::JustGotOutOfVehicleAsDriver` (`0x4FCF40`) + * `CAEVehicleAudioEntity::ProcessVehicle` (`0x501E10`) + * `CAEVehicleAudioEntity::Service` (`0x502280`) + * `CAEVehicleAudioEntity::s_pPlayerDriver` (`0xB6B990`) +* The remote-driver path services audio inside the same temporary key/ped + context used for vehicle control. + +Replacement path: + +* Ped audio reset exists for spawn/model changes. +* Vehicle audio stream stop is observed but not wired; no vehicle audio entity + service path exists. + +Current gap: + +* Vehicle audio/radio is not just cosmetic. Some GTA vehicle logic checks + player-driver state through audio entity globals and current pad state. Keep + this in mind when diagnosing `PutPlayerInVehicle` or remote-driver anomalies. + +## PutPlayerInVehicle + +Legacy-relevant behavior: + +* The old code validates GTA vehicle existence through the SA-MP vehicle pool and + lets GTA's own vehicle/task paths run when entering/exiting. +* After a ped becomes a driver, subsequent vehicle control and audio are handled + by the ProcessControl hook described above. + +Replacement path: + +* `vehicle_compat_put_local_player()` resolves the active slot, resolves the GTA + vehicle through `[0xB74494] -> 0x4048E0`, and uses script opcode `036A` for + driver seat only. +* Passenger seats are intentionally blocked with `passenger_todo_verify`. + +Current gap: + +* Driver-seat `/vput` is present, but there is no follow-up equivalent of the + legacy ProcessControl/audio context. If the player is placed into a car but + control/audio/camera feels wrong, this is the next path to trace. +* Passenger seats need original-DLL evidence before enabling. + +## Spawn/camera + +Legacy-relevant behavior: + +* Vehicle turret hooks show that SA-MP actively saves/restores `TheCamera` mode + bytes while temporarily simulating remote aim. +* `0xB6F1A8` is the active camera mode byte inside `TheCamera`; `0xB6F99C` is + not the camera object base. + +Replacement path: + +* Spawn finalize restores camera through script commands, unlocks the actor, + releases UI mouse capture and after a delay writes gameplay mouse-camera + values back. + +Current gap: + +* Spawn camera restore is now closer to legacy behavior, but it is still an + inferred script/direct-memory repair rather than a traced copy of the 0.3.7 + spawn path. +* If mouse-look still fails after spawn, collect the same variables the legacy + code manipulates: `TheCamera` active mode byte, secondary mode word, mouse + accel/use-mouse bytes, frontend/menu bytes, and Win32 cursor capture/focus. + +## Next implementation order + +1. `STATIC_037`: confirm vehicle damage field offsets and the call order around + `CAutomobile::Fix` / `SetupDamageAfterLoad` in original `samp.dll`. +2. Implement automobile damage application in the replacement, gated by vtable + and pointer readability. +3. Instrument remote-driver vehicles before adding hooks: vtable, driver ped, + current-player byte, key context, `dwPedType`, audio entity bytes. +4. Use that trace to decide whether to emulate the legacy ProcessControl context + or leave vehicle movement purely server-transform driven. +5. Revisit `PutPlayerInVehicle` after instrumentation, because legacy behavior + depends on the subsequent ProcessControl/audio path. diff --git a/docs/re/lifecycle_ui_fps_resolution_parity_20260727.md b/docs/re/lifecycle_ui_fps_resolution_parity_20260727.md index d11f107..08496f4 100644 --- a/docs/re/lifecycle_ui_fps_resolution_parity_20260727.md +++ b/docs/re/lifecycle_ui_fps_resolution_parity_20260727.md @@ -54,20 +54,35 @@ The replacement now requires: 1. the byte-validated graphics callback to capture the GTA thread ID; 2. three graphics callbacks on that same thread; -3. only then may pre-connect `LoadSceneCollision`, `LoadScene`, `04E4`, and - script camera operations execute. +3. only then may the retained `04E4` refresh and script camera operations + execute. The monitor path only advances the GTA frontend and records pending work. It does not fall back to unsafe scene/camera calls if the callback is absent. -`PROBE_TRACE`: - -- `20260727-001418-replacement-player-340214` passed connect, spawn, and the - player fixture with the default one-second settle. -- Its order is explicit: - `waiting_for_game_thread` -> callback 1 -> callback 2 -> callback 3 -> - `LoadSceneCollision begin/end` -> `LoadScene begin/end` -> `04E4 begin/end`. -- No `exception_filter` record was emitted. +The earlier three-callback experiment did allow direct +`LoadSceneCollision`/`LoadScene` calls and produced successful controls, but +later stress runs proved that the same state can still block indefinitely +inside either routine: + +- pre-connect hangs: + `20260727-165414-replacement-ui-680318`, + `20260727-165627-replacement-ui-680318`, and + `20260727-170425-replacement-labels-688825`; +- post-Join spawn hang: + `20260727-170626-replacement-labels-688825`; +- successful controls: + `20260727-165838-replacement-ui-680318` and + `20260727-170837-replacement-labels-688825`. + +`STATIC_037` revalidation of the original R5 DLL found no reference to GTA +`CStreaming::LoadSceneCollision` (`0x40ED80`) and no reference to +`CStreaming::LoadScene` (`0x40EB70`). It found one reference to +`LoadAllRequestedModels` (`0x40EA10`) at `samp.dll+0xA0A6A`, inside the +specific request/load-model helper. The replacement therefore no longer calls +the two direct scene routines from pre-connect, spawn, class-selection, +SetPlayerPos, or movement refresh. GTA's normal streamer and the established +`04E4` refresh path remain active. The diagnostic semantic gate remains default-off. Its current TXD/clothes signals must not be presented as original behavior. @@ -235,6 +250,11 @@ Implemented: - legacy GTA menu panels are hidden; - TextDraws, objects, remote-player state, dialogs, audio, camera interpolation, checkpoints, pickups, and relevant overlay state are reset; +- local OnFoot, InCar, Passenger, Unoccupied, Trailer, Aim and Bullet sender + cadence/counters are reset, including Passenger entry/exit latches and the + BulletSync ammunition baseline; +- the cached armed weapon is cleared with its RPC sequence, preventing a + pre-GMX weapon from leaking into the first sync packet of the next mode; - jetpack, camera targeting, widescreen, controls, interior, gravity, and the local interior network value are restored to neutral state; - the WinSock wrapper is now restartable and idempotent, so repeated shutdown @@ -259,6 +279,19 @@ process and therefore did not observe a normal `process_detach`. This covers one complete regression cycle; repeated long-duration GMX/quit stress remains open. +`PROBE_TRACE`: the focused lifecycle run +`artifacts/runs/20260727-190810-replacement-lifecycle-gmx-849440`, replacement +SHA256 +`39031a3ab89d1d531674ec607a5564dac1369b633916455c218f3b9d139a9b68`, +completed connect, initial spawn, active TextDraw/menu/object preconditions, +RPC 40 reset, second InitGame, second class selection/spawn, real `/q` and +native `ExitProcess(0)`. The adapter reported +`session_probe_reset=1 transport_preserved=1`; the analyzer found two +`spawn_finalize` markers, no client exception, no remaining GTA process and +no failed `WSACleanup`. Its verdict is `PASS_WITH_MANUAL_VISUAL` only because +the fixture cannot automatically prove pixel-level TextDraw disappearance or +physical destruction of a remote ped that was never streamed. + ## AFK, pause, and background operation Both Proton prefixes use: @@ -289,14 +322,16 @@ Open: ## Remaining high-priority compatibility gaps 1. Repeated long-duration lifecycle stress across GMX, second spawn, `/q`, - and native exit; one 11-case coordinated cycle passes. + and native exit; one 11-case coordinated cycle and one focused full GMX + lifecycle pass. 2. Font 4 sprite TXD loading and Font 5 visual/device-reset goldens. 3. Resolution-list visual comparison beyond the now-matching Alt+Enter path. 4. AFK icon runtime placement, focus-loss cadence, and pause-menu network behavior. -5. Incoming unoccupied/trailer runtime playback and exact authority - arbitration; outgoing 209/210/211 and RPC 26/154 now have conservative - implementations. +5. Original-observer/visual parity for unoccupied and trailer playback, plus + exact authority/cadence arbitration. Original-sender to replacement-receiver + decode, application and GTA readback now have a focused live trace; outgoing + 209/210/211 and RPC 26/154 remain conservative implementations. 6. Complete surfing collision semantics, train edge cases, spectator lifecycle, virtual worlds, and outgoing interior RPC 118. 7. Two-client visual closure of remote BulletSync impacts/muzzle flash. diff --git a/docs/re/local_ped_clothes_visibility_r5_20260727.md b/docs/re/local_ped_clothes_visibility_r5_20260727.md new file mode 100644 index 0000000..abdc71c --- /dev/null +++ b/docs/re/local_ped_clothes_visibility_r5_20260727.md @@ -0,0 +1,108 @@ +# Local-ped clothes visibility parity (2026-07-27) + +## Scope + +- Original SA-MP 0.3.7-R5 DLL SHA256: + `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2` +- GTA executable SHA256: + `a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26` +- Replacement prefix: + `/home/chairman/Games/san-andreas-multiplayer-legacy-libsamp` +- Scenario: local skin 0, automatic spawn, labels fixture + +## Observed failure + +`PROBE_TRACE`: + +- The local `CPlayerPed` existed and continued to synchronize. +- The weapon rendered at the correct local-ped transform, but the body did not. +- Both GTA render gates were already open: + - `CPed+0x474 bit 1` (`bDontRender`) was `0`; + - `CEntity+0x1C bit 7` (`m_bIsVisible`) was `1`. +- The model-0 RenderWare clump was valid and had a render-enabled atomic, but + its only geometry contained just 2 triangles and 6 vertices. This is GTA's + undressed `MODEL_PLAYER` placeholder, not the constructed CJ body. + +Evidence: + +- Trace artifact: + `artifacts/runs/20260727-175501-replacement-labels-744129` +- Visual artifact: + `artifacts/runs/20260727-174914-replacement-labels-736458/screenshots/manual-ped-invisible.png` + +The old 0.2x `CPlayerPed::SetVisible` implementation calls `CPed+0x474 bit 3` +a visibility flag. GTA 1.0 US static analysis shows that bit is not a render +gate; forcing it is therefore not part of this fix. + +## Original lifecycle evidence + +`STATIC_037`: + +- `CLocalPlayer::HandleClassSelection` at `samp.dll+0x4080` calls the + `CPlayerPed::SetInitialState` wrapper at `+0x4098`. +- The wrapper at `samp.dll+0xABBD0` calls GTA + `CPlayerPed::SetInitialState` at `0x60CD20` with player number 0. +- `CLocalPlayer::Spawn` at `samp.dll+0x3C20` calls the same wrapper on + non-first spawns, refreshes streaming, calls `RestartIfWastedAt`, then calls + `CPlayerPed::SetModelIndex` at `+0x3D32`. +- `CPlayerPed::SetModelIndex` at `samp.dll+0xAFF82..+0xAFF9A` patches GTA + `CClothes::RebuildPlayer` at `0x5A82C0` from `0x56` to `0xC3` immediately + before the first SA-MP model change. +- R5 does not arm that guard during startup. +- The bundled `main.scm` contains opcode `070D BUILD_PLAYER_MODEL`, whose GTA + handler needs `CClothes::RebuildPlayer` to construct the initial CJ clump. + +## Root cause and fix + +The replacement armed the `0x5A82C0 -> RET` guard in its early entry-gate-7 +patch batch. `BUILD_PLAYER_MODEL` therefore became a no-op and left the local +ped with the 2-triangle placeholder. + +The replacement now: + +1. leaves `CClothes::RebuildPlayer` intact during GTA startup; +2. mirrors the R5 `SetInitialState` class-selection transition once per spawn + info sequence; +3. arms the byte-validated `0x56 -> 0xC3` clothes guard lazily inside the + direct R5 `SetModelIndex` path; +4. keeps the guard idempotent when the byte is already `0xC3`; +5. rejects an unexpected third-party byte instead of overwriting it blindly; +6. records clump/atomic/geometry diagnostics for future model regressions. + +## Validation + +`PROBE_TRACE`: + +- Visually captured fixed build SHA256: + `ce3520985a567d3668202237cd3ea6e08f78601d3a0317ccb093ea4fb248e87d` +- Fixed artifact: + `artifacts/runs/20260727-175757-replacement-labels-748814` +- Lazy guard marker: + `0x5A82C0 0x56 -> 0xC3` +- Fixed clump: + 1 render atomic, 3184 triangles, 2202 vertices +- Server fixture: + 2 passes, 0 failures, 2 visual observations +- No `exception_filter` marker +- Visual capture: + `artifacts/runs/20260727-175757-replacement-labels-748814/screenshots/manual-ped-visible.png` + +`PROBE_TRACE` visual result: the full CJ body, clothes, and attached weapon +render together after spawn. + +The final terminology-only rebuild SHA256 is +`6e26be521f6e3f2a9b464fe6af5d713106628d26db8d932cc026c50ca336a915`. +The full regression artifact +`artifacts/runs/20260727-180200-replacement-all-754589` completed 11 passes, +0 failures, and 11 visual observations. Its trace retained the 3184-triangle +CJ clump, changed skin `0 -> 287 -> 0` through RPC 153, and rebuilt the full +model-0 clump on the return transition without an exception marker. + +## Remaining checks + +`TODO_VERIFY`: + +- Repeat the lifecycle through death/respawn and F4 class selection. +- Include the lazy clothes guard in a GMX/reconnect stress pass. +- Compare a non-model-0 skin and a subsequent RPC 153 skin transition against + an original R5 trace. diff --git a/docs/re/packet200_remote_vehicle_playback_r5_20260727.md b/docs/re/packet200_remote_vehicle_playback_r5_20260727.md new file mode 100644 index 0000000..061a99b --- /dev/null +++ b/docs/re/packet200_remote_vehicle_playback_r5_20260727.md @@ -0,0 +1,119 @@ +# Packet 200 remote-vehicle playback (R5) + +Reference binary: + +- `artifacts/binaries/samp_installer.dll` +- SHA256 `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2` +- PE image base `0x10000000` + +## Static result + +`STATIC_037` was taken from the existing full Ghidra R5 analysis and +cross-checked against direct instruction windows. + +| R5 RVA | Role | +| --- | --- | +| `samp.dll+0xAA10` | Packet 200 handler | +| `samp.dll+0x17340` | Store 63-byte driver sync, mark fresh state | +| `samp.dll+0x168E7` | Fresh normal-driver branch | +| `samp.dll+0x155E0` | Cache target quaternion/position/speed and set exact move speed | +| `samp.dll+0x15140` | Position deadband/correction/snap | +| `samp.dll+0x16E8E` | Every-frame normal-driver branch | +| `samp.dll+0x15460` | Turn clamp and full-attitude feedback | +| `samp.dll+0xAF340` | Copy Packet 200 keys/LR/UD into the remote GTA `CPad` | +| `samp.dll+0xAE270` | Mirror the remote horn key to live `CVehicle+0x514` | +| `samp.dll+0xB7540` | Replace live siren bit `CVehicle+0x42D` bit 7 | +| `samp.dll+0x9EBC0` | Full matrix field copy; no `UpdateRwFrame` | +| `samp.dll+0x9ED10` | Direct CPhysical move-speed setter | +| `samp.dll+0x9EE60` | Direct CPhysical turn-speed setter | +| `samp.dll+0xB5480` | `D3DXQuaternionSlerp` wrapper | +| `samp.dll+0xB5500` | `D3DXQuaternionNormalize` wrapper | + +The normal-driver algorithm is: + +1. Normalize and retain the complete `w,x,y,z` target quaternion, target + position and target move speed. +2. Set the exact target move speed. +3. Position deadband is `0.05` on every axis. +4. Snap at `abs(X/Y error) > 8.0`. The Z limit is `0.5`, or `2.0` for + helicopter, boat and plane subtypes. +5. Otherwise add `position_error * 0.06` to each target-speed component whose + axis error exceeds `0.05`. Write the corrected vector only if at least one + resulting component exceeds `0.01` in magnitude. +6. Every frame, clamp every turn-speed component to `[-0.02, +0.02]`, Slerp + current complete attitude toward the retained target with `t=0.75`, + normalize it, and write all three matrix axes while retaining position. +7. After the optional attitude call, every normal-driver frame loads Packet + 200 UD/LR/keys from remote-player offsets `+0x1D/+0x1B/+0x1F` and calls + `CPlayerPed::SetKeys` at `samp.dll+0xAF340`. Train models skip the attitude + call but still take this key path. It is separate from the on-foot key + cache at `+0xC5/+0xC7/+0xC9`. +8. Immediately afterwards, the call at `samp.dll+0x16FA7` reaches + `samp.dll+0xAE270` and writes exactly one horn byte: + `CVehicle+0x514 = (CPad key index 18 != 0)`. +9. Fresh Packet 200 playback calls `samp.dll+0xB7540` from + `samp.dll+0x16A1E`. It preserves `CVehicle+0x42D` bits 0..6 and replaces + bit 7 with the wire siren bit. + +`CPlayerPed::SetKeys` suppresses Packet key bit 4 only when the live ped is on +foot with an actual `CTaskSimpleJetPack` in `CPed+0x47C/+0x10`. The packet +SpecialAction byte is not sufficient evidence for that decision. + +Packet playback does **not** call `CPhysical::ApplyMoveSpeed`, GTA yaw opcode +`0175`, or `CEntity::UpdateRwFrame`. The optional vehicle ProcessControl +context hook represents the separate R5 path at `samp.dll+0xA3100`. + +## Replacement status + +`reimpl/src/runtime_bridge.c` now implements the normal-driver behavior above. +Passenger and on-foot updates invalidate the retained driver target. Live +driver-pointer, pool, matrix, vtable and finite-value checks guard every raw GTA +access. + +The retained driver controls are advanced once per remote-driver frame, after +the attitude feedback in the same order as R5 and before the class-specific +vehicle `ProcessControl` context consumes the resulting state. This fixes a +previously confirmed replacement divergence where a received steering input +such as `keys=0x0008, LR=-128` was retained in the logical slot but GTA physics +continued with an old or zero `CPad`. Construction no longer advances a fresh +driver or passenger control state a second time before its first frame. + +The replacement also applies the exact live siren bit and one-byte horn latch. +An older inferred RPC 164 path incorrectly treated `CVehicle+0x514` as a +32-bit alternate siren field. That write has been removed. RPC 164's +`addSiren` byte is a constructor capability retained by R5 on its wrapper +(`samp.dll+0xB84AA..+0xB84C5`), not the Packet 200 live siren state; the +remaining constructor-capability path is explicitly deferred. + +Packet 200 now participates in the shared Packet 207/200/209/210/211 +arrival-order cursor with an explicit `APPLIED`/`DEFER`/`DROP` result. A +pending towing vehicle or advertised trailer retains the movement head until +both GTA objects and the live tow-link readback exist. Pool, transform and +opcode-0893 failures are retried rather than being marked consumed. The retry +is bounded to 2000 ms, below the approximate 2.1-second capacity of the +128-entry ring for a normal combined Packet 200/210 stream; an exhausted or +overwritten head is explicitly logged and dropped so it cannot deadlock all +remote players. + +Train models 449, 537 and 538 are intentionally excluded from the normal +per-frame attitude path. R5 uses its separate `samp.dll+0x15650` train routine; +the replacement currently keeps a conservative direct position/speed fallback +and records this as `TODO_VERIFY`. + +## Checks + +- Host quaternion test covers normalization, R5 `t=0.75` Slerp, and the + equivalent `q`/`-q` shortest path. +- Host tests pass `14/14`, including the pure shared-cursor + applied/deferred/retry/timeout/gap/sequence-wrap test. +- Win32 DLL build succeeds; current tested-at-build artifact SHA256 is + `ded014d1775e116dade95f75460d43bef591e4b0ca34f2f1c130fece1d951736`. +- The same hash was deployed to the single replacement prefix. Vehicle + create/enter/spawn-callback/respawn passed 4/4 without a crash in + `artifacts/runs/20260727-231629-replacement-vehicle-1142907`; the runner + still warns that forced shutdown did not expose `process_detach`. +- A comparable live trailer run remains required with the current topology: + original R5 pilot in the single host prefix and native-Windows replacement + observer, retaining Packet 200 target/delta/mode and frame attitude markers. + The Windows host was unreachable (`No route to host`) at the end of this + build, so this hash has not yet received a distributed trailer verdict. diff --git a/docs/re/passenger_bullet_effect_parity_20260727.md b/docs/re/passenger_bullet_effect_parity_20260727.md index 16675a0..4a25ef0 100644 --- a/docs/re/passenger_bullet_effect_parity_20260727.md +++ b/docs/re/passenger_bullet_effect_parity_20260727.md @@ -79,8 +79,10 @@ guarded saved-byte restoration. It can be disabled with - GTA call-site bytes: verified against the reference executable above. - Integrated Win32 compile: passed; final coordinated candidate SHA256 `acb5edd84e5c634309d50cb68213dffd9d575ccb59fae454c66242df4977168b`. -- Paired original/replacement passenger and BulletSync visual runs remain - pending. +- Replacement PassengerSync seating now has a focused original-sender runtime + trace and GTA seat readback in + `docs/re/sync_edge_states_r5_20260727.md`; paired original-observer visuals + and BulletSync visual runs remain pending. - `TODO_VERIFY`: exact RPC 154 timing; passenger drive-by/cuffed/additional-key sources; player-object hit target resolution; untyped/world-hit trajectory; remote ProcessControl timing around recoil/sway/audio. diff --git a/docs/re/pickup_memory_probe_r5_20260728.md b/docs/re/pickup_memory_probe_r5_20260728.md new file mode 100644 index 0000000..c6bd15a --- /dev/null +++ b/docs/re/pickup_memory_probe_r5_20260728.md @@ -0,0 +1,234 @@ +# Original-R5 pickup memory probe — 2026-07-28 + +## Status + +This document records a narrowly scoped pickup probe. Its ordinary type-1 path +is now `OBSERVED_037 + PROBE_TRACE`; the type-14 and dropped branches remain +`STATIC_037 + TODO_VERIFY`. + +Reference binaries: + +- SA-MP 0.3.7-R5 `samp.dll` + SHA256 `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2`; +- GTA-SA 1.0 US `gta_sa.exe` + SHA256 `a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26`. + +The pool hook source module is `tools/asi_probe/src/samp_probe_pickup.c`; its +RakClient observation hook is integrated in `samp_probe_asi.c`. Both remain +inactive unless the focused pickup profile is selected. + +### Original-R5 golden result — 2026-08-02 + +The first prepared run, +`20260802-112137-distributed-sync-pickup-41774`, collected the server-side +pickup event but correctly ended as analyzer `MISMATCH`: the probe had hooked +RakClient vtable slot 26, the extended RPC overload, so it observed no nested +outgoing RPC. + +Direct analysis of the supported R5 DLL then established that the call at +`samp.dll+0x134DE` uses vtable slot 25, whose exact target is +`samp.dll+0x34620` and whose ABI is the short six-argument BitStream overload. +Slot 26 instead targets `samp.dll+0x345B0` and is the extended overload. The +probe now guards both slot 25 and the exact `+0x34620` target. Corrected ASI +SHA256: +`d87fc3c7d91297dc87e1a38c8ec02e5dc4637f256d60a1f638d1c4dadecd7ca9`. + +The identical repeat run, +`20260802-112802-distributed-sync-pickup-57189`, was assessed +`OBSERVED_ORDINARY`: + +- both pickup hooks installed with the exact R5/GTA identities; +- one ordinary slot-0 pickup, model 1240/type 1, used GTA handle generation 2 + and raw index 0; +- `PickedUp` sent RPC 131 with a signed 32-bit slot payload, priority 1, + reliability 9, channel 0, and successful return; +- the notification timer changed from 0 to 15; +- 160 `Process` calls followed the predicted seven-frame gate cadence; +- the bounded ring reported no overflow, orphan RPC, or parse error. + +The fixture destroyed the ordinary pickup immediately after the server +callback. It therefore did not cover the complete 15-tick countdown, +type 14, a dropped pickup, pause behavior, RPC failure, or slot recreation. + +## Static R5 findings + +### Pool layout + +`STATIC_037`, for the supported R5 binary: + +| `CPickupPool` offset | Shape | Static use | +|---|---:|---| +| `+0x0000` | `DWORD` | pool count | +| `+0x0004` | `DWORD[4096]` | full GTA pickup handles | +| `+0x4004` | `DWORD[4096]` | raw GTA pickup indices | +| `+0x8004` | `DWORD[4096]` | notification/debounce counters | +| `+0xC004` | 3 bytes per slot | dropped flag followed by a 16-bit player ID | +| `+0xF004` | `0x14` bytes per slot | model, type, and three position floats | + +The probe logs position values as raw bits so no hook-thread formatting or +floating-point environment changes are introduced. It samples at most eight +active or focused slots per event while still counting all nonzero handles. + +### `CPickupPool::PickedUp` + +`STATIC_037`: `samp.dll+0x00013440` is a `thiscall` method with one 32-bit +stack argument and returns with `ret 4`. + +The method: + +1. searches the `+0x4004` raw-index array for its argument; +2. rejects an absent slot, zero handle, nonzero timer, or dropped pickup; +3. writes the signed 32-bit SA-MP pool slot to a BitStream; +4. sends RPC 131 with priority value 1, reliability value 9, and channel 0; +5. writes `15` to that slot's `+0x8004` counter. + +The numeric RakNet values correspond to +`HIGH_PRIORITY`/`RELIABLE_ORDERED` in the project's validated legacy RakNet +headers. The probe logs both numeric values so the runtime record does not +depend on an enum-name inference. + +Guarded entry bytes at `+0x13440`: + +```text +64 A1 00 00 00 00 +``` + +The complete known return tail begins at `+0x13500`: + +```text +8B 8C 24 20 01 00 00 5F 5E 64 89 0D 00 00 00 00 +81 C4 24 01 00 00 C2 04 00 +``` + +### `CPickupPool::Process` + +`STATIC_037`: `samp.dll+0x00013520` is a no-argument `thiscall` method and +returns with plain `ret`. + +For each active slot: + +- an ordinary non-dropped pickup whose type is not 14 only decrements a + positive notification counter; +- a dropped pickup uses GTA script condition `0x0214` and sends RPC 97 with + the stored 16-bit player ID when collected; +- a non-dropped type-14 pickup uses the same GTA condition and sends RPC 131 + with the signed 32-bit SA-MP pool slot. + +Both `Process` sends use priority value 1, reliability value 10, and channel +0, corresponding to `HIGH_PRIORITY`/`RELIABLE_SEQUENCED`. Thus R5 deliberately +uses a different reliability for ordinary `PickedUp` RPC 131 (value 9) and +type-14 `Process` RPC 131 (value 10). + +The ordinary path does not call `0x0214` inside this method. This distinction +is important: visual collection/respawn is driven by the GTA pickup type, +while the R5 counter suppresses repeated server notification. + +Guarded entry bytes at `+0x13520`: + +```text +64 A1 00 00 00 00 +``` + +The complete known return tail begins at `+0x13655`: + +```text +8B 8C 24 2C 01 00 00 5F 5E 5D 5B 64 89 0D 00 00 +00 00 81 C4 28 01 00 00 C3 +``` + +### Processing cadence + +`STATIC_037`: the caller at `samp.dll+0x00008C8E` compares the global counter +at `samp.dll+0x00118A10` with 5. It calls `CPickupPool::Process` only when the +counter is greater than 5 and then resets the counter. This predicts one +process call per seven caller invocations, not per rendered frame in every +configuration. + +The probe records the raw gate value, GTA frame, process ordinal, elapsed +milliseconds, and frame delta. The ordinary golden run observed all 160 calls +at the predicted seven-frame spacing (approximately 125 to 141 ms in that +fixture). Pause/unpause and other frame-limit configurations remain +`TODO_VERIFY`. + +## Probe contract + +The profile atomically preflights both method entries and both tails before +patching either method. It additionally requires exact PE identity proxies for +R5 and GTA-SA 1.0 US, with GTA loaded at its non-relocatable preferred base. +A partial install restores every owned patch. + +The two method hooks call the original through fixed trampolines and publish +only bounded snapshots to a 256-record ring. The outgoing RakClient vtable +hook observes RPC 131 and RPC 97 through guarded slot 25 +(`samp.dll+0x34620`) after the original short-overload call returns and +publishes into the same event sequence. No hook performs file I/O. + +The worker emits: + +- `pickup_r5`: event order, tick/frame/caller, cadence, RPC payload and QoS; +- `pickup_pool_r5`: pool count, active count, and bounded sample count; +- `pickup_slot_r5`: slot, handle, raw index, timer, dropped/player metadata, + model/type, and position bits. + +The outgoing RakClient hook remains process-bound. Do not hot-unload the ASI +while the game or network thread can execute an installed hook. + +## Activation and remaining golden runs + +Build and deploy the ASI, stop GTA, then select: + +```bash +tools/windows/remote_lab/samp_lab.sh probe-profile pickup-r5 +``` + +The ordinary type-1 fixture is complete. The remaining comparable +original-R5 battery should create separate fixtures for: + +1. an ordinary pickup that is not immediately destroyed by the server; +2. a type-14 pickup; +3. a dropped weapon pickup tied to another player; +4. pause/unpause while standing inside an ordinary pickup; +5. pickup destruction and recreation using the same SA-MP pool slot. + +For each fixture, retain the run ID, server log, `samp_probe.log`, original DLL +hash, FPS limit, and exact pickup model/type. The trace must answer: + +- whether the raw `PickedUp` argument equals the low 16 bits of the full GTA + handle for every tested handle generation; +- the observed frame/time spacing of the 15 process-tick debounce; +- whether timer assignment occurs even if the RPC call reports failure; +- whether the visual GTA pickup disappears independently from the SA-MP slot; +- whether type 14 and dropped pickups bypass the ordinary timer path exactly + as predicted statically. + +Prior runtime evidence in +`docs/traces/special_action_pickup_death_parity_20260726.md` observed repeated +ordinary-pickup callbacks at roughly 1.5-second intervals, but it did not +capture the R5 pool memory transitions. It therefore corroborates the visible +symptom only, not the new field-level claims. + +## Replacement impact + +`STATIC_037 + OBSERVED_037 + PROBE_TRACE`: the replacement now preserves the +RPC 131 call-source distinction. Its ordinary GTA collection hook sends +`RELIABLE_ORDERED` (numeric 9); the stored type-14 `Process` path sends +`RELIABLE_SEQUENCED` (numeric 10). When the GTA collection hook is installed, +generic processing no longer polls ordinary pickups through opcode `0x0214`. + +The replacement retains its prior wall-clock notification guards as a safety +measure. They are not presented as original parity: the ordinary R5 path uses +a 15-Process-call counter and the type-14 branch statically leaves that timer +unchanged. Type-14 runtime behavior remains `TODO_VERIFY` before removing or +retuning those guards. + +## Evidence boundary + +- `STATIC_037`: RVAs, method ABI/tails, pool array offsets, branch structure, + RPC IDs/payload widths/numeric QoS, timer value 15, and the `>5` caller gate. +- `OBSERVED_037 + PROBE_TRACE`: the ordinary type-1 payload/QoS, successful + RPC result, `0->15` timer transition, handle/raw-index relationship for + generation 2/index 0, and seven-frame Process cadence in run + `20260802-112802-distributed-sync-pickup-57189`. +- `TODO_VERIFY`: the complete 15-tick countdown under each FPS/pause state, + RPC-failure timer behavior, additional handle generations, and type-14 or + dropped-pickup state transitions. diff --git a/docs/re/preconnect_clothes_txd_race_20260721.md b/docs/re/preconnect_clothes_txd_race_20260721.md new file mode 100644 index 0000000..305fc6a --- /dev/null +++ b/docs/re/preconnect_clothes_txd_race_20260721.md @@ -0,0 +1,90 @@ +# Pre-connect clothes/TXD race (2026-07-21) + +## Scope + +- Replacement prefix: `/home/chairman/Games/san-andreas-multiplayer-legacy-libsamp` +- GTA executable SHA256: `a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26` +- Fixed replacement DLL SHA256: `01190be87e2a92e52aa1ef069ba54e6b47d7eed719b0aa512afbe86374f5aa24` +- Scenario: ReLoop replacement startup against the local open.mp fixture + +## Failure + +`PROBE_TRACE + STATIC_037 + GTA_REVERSED_REF`: + +- Runs `20260721-190123-replacement-all-171870` and + `20260721-190135-replacement-all-171870` faulted at `gta_sa.exe+0x003F39FB` + (`0x007F39FB`) while reading address `0x00000008`. +- Both register captures had `EAX=0x00000008` and `ESI=0x00000000`. +- The return address `0x005A6116` identifies the first + `RwTexDictionaryFindNamedTexture` call inside + `CClothesBuilder::ConstructTextures` (`0x005A6040`). GTA had obtained a null + source clothes TXD from the TXD pool and passed it to the RenderWare lookup. +- No RakNet connection had started. This was GTA startup/streaming state, not a + server packet or the separately observed IPL fault at `0x00405E15`. + +Reference mappings: + +- `RwTexDictionaryFindNamedTexture`: gta-reversed `rwcore.cpp`, address + `0x007F39F0`. +- `CClothesBuilder::ConstructTextures`: gta-reversed `ClothesBuilder.cpp`, + address `0x005A6040`. + +## Rejected mitigation + +`PROBE_TRACE`: + +A 500 ms delay before the forced `entry=9` transition did not solve the +problem. Runs `20260721-191052`, `20260721-191110`, and `20260721-191128` +still terminated during pre-connect; `20260721-191110` exposed an additional +GTA cleanup fault at `gta_sa.exe+0x001380AF`. A pre-transition sleep only moved +the race and was not retained as the fix. + +The first game-thread handoff change removed concurrent GTA-mutating +`apply_preconnect_frontend_compat()` calls from the monitor thread. That change +is retained because GTA streaming and script commands must remain on the game +thread, but run `20260721-192025-replacement-pvars-183473` reproduced the +original `0x007F39FB` fault and proved that handoff alone was insufficient. + +## Fix + +`PROBE_TRACE + GTA_REVERSED_REF + INFERRED`: + +1. Once the graphics callback is active, the monitor thread no longer calls + the GTA-mutating pre-connect path. It remains only a pre-hook fallback. +2. After `entry 8->9` / `game_started 1->0`, the replacement gives GTA 1000 ms + of normal game-thread frames before issuing its own Santa Maria + `CStreaming::LoadScene` and script-camera operations. This avoids competing + with the local player's clothes/TXD construction window. +3. The post-transition interval is configurable through + `SAMPDLL_PRECONNECT_LOADED_SETTLE_MS` (0..10000 ms); the compatibility + default is 1000 ms. + +The gate logs: + +```text +preconnect_bridge: settling loaded GTA before scene load elapsed_ms=0/1000 ... +preconnect_bridge: monitor handed GTA mutations to graphics callback ... +``` + +## Validation + +`PROBE_TRACE`: + +Seven fresh starts passed consecutively with no `exception_filter` or probe +`exception:` marker: + +- PVars (`3 passes, 0 failures` each): `20260721-192718`, `192828`, `192910`, + `192952`, `193034`, and `193117`. +- Full `all` scenario: `20260721-193213-replacement-all-188928`, + `11 passes, 0 failures`, with 9 expected visual-review observations. + +The built and installed DLL hashes matched after deployment. + +## Remaining risk + +`TODO_VERIFY`: + +The exact original 0.3.7 readiness condition for clothes/TXD completion is not +yet statically identified. The 1000 ms post-transition gate is backed by the +failure window and repeated runtime validation, but it remains a conservative +timing gate rather than an original-DLL semantic marker. diff --git a/docs/re/reloop_streaming_hang_taxonomy_20260727.md b/docs/re/reloop_streaming_hang_taxonomy_20260727.md new file mode 100644 index 0000000..2e2464b --- /dev/null +++ b/docs/re/reloop_streaming_hang_taxonomy_20260727.md @@ -0,0 +1,48 @@ +# ReLoop streaming-hang taxonomy (2026-07-27) + +## Scope + +This note records the evidence used only to classify ReLoop timeouts. It does +not change client runtime behavior. + +## Evidence + +`PROBE_TRACE` from replacement runs: + +- `artifacts/runs/20260727-165414-replacement-ui-680318`: no `RUN_START`, no + exception, and the last scene marker is + `reason=preconnect step=LoadScene phase=begin`. +- `artifacts/runs/20260727-165627-replacement-ui-680318`: no `RUN_START`, no + exception, and the last scene marker is + `reason=preconnect step=LoadSceneCollision phase=begin`. +- `artifacts/runs/20260727-170425-replacement-labels-688825`: the same open + pre-connect `LoadSceneCollision` marker and no RakNet Join/NetGame evidence. +- `artifacts/runs/20260727-170626-replacement-labels-688825`: `ClientJoin` and + `ScrInitGame` are present, followed by an open + `reason=spawn step=LoadScene phase=begin` marker. There is no exception. +- `artifacts/runs/20260727-165838-replacement-ui-680318` and + `artifacts/runs/20260727-170837-replacement-labels-688825` provide successful + controls: their scene `begin` markers have matching `end` markers and the + runs reach `RUN_START`/`RUN_DONE`. + +The runner stops a timed-out Wine process before verdict construction. That +cleanup can yield client return code 0, which previously caused these timeouts +to be labelled `PRECONNECT_CRASH` despite the absence of a crash trace. + +## Conservative classifier + +`HANG_PRECONNECT_STREAMING` requires all of: + +1. the run timed out before `RUN_START`; +2. neither `exception_filter` nor Wine's unhandled-page-fault marker exists; +3. the last parsed `scene_prepare_step` is `phase=begin` with + `reason=preconnect`; +4. no `ClientJoin`/`ScrInitGame`/InitGame state marker exists. + +`HANG_SPAWN_STREAMING` applies the same timeout and exception guards, but +requires Join/NetGame evidence and an open scene call whose reason is `spawn`, +`server_player_pos`, or `class_select_player_pos`. + +A closed scene call, an unknown reason, an explicit crash, `RUN_ABORT`, or a +timeout after `RUN_START` retains the previous verdict path. Both new verdicts +remain in the existing three-attempt retry set. diff --git a/docs/re/remote_movement_ring_capacity_20260727.md b/docs/re/remote_movement_ring_capacity_20260727.md new file mode 100644 index 0000000..b0522f1 --- /dev/null +++ b/docs/re/remote_movement_ring_capacity_20260727.md @@ -0,0 +1,77 @@ +# Shared movement-ring capacity audit (2026-07-27) + +## Scope + +The replacement drains up to 1024 RakNet packets in one runtime pump, then +copies a 128-entry shared movement history into the runtime snapshot. The +history orders packets 207/200/209/210/211 across RakNet channels. + +Without an intermediate snapshot, a burst of more than 128 movement packets +could overwrite the oldest entries before the runtime consumes them. This is +an implementation-level risk, not behavior observed in the original R5 DLL. + +## Windows trailer run + +`PROBE_TRACE`: + +- run: + `artifacts/runs/20260727-204023-windows-sync-edge-trailer-945513` +- replacement DLL SHA256: + `658fb5ec95a3737bf4dbfadb1e81e33b39ccd1f7c8932c023d84adcd599eccbf` +- topology: local original R5 pilot plus native-Windows replacement observer +- server stop marker reported `trailer_updates=505`, and the observer decoded + 505 Packet 210 states; it also decoded 505 Packet 200 states +- the shared movement sequence ended at 1026 after 16 setup movements +- sampled Trailer mappings were exact throughout the run: + `movement_seq = 16 + (2 * trailer_seq)`, including sequence pairs + `18/1`, `144/64`, `272/128`, `400/192`, `528/256`, `656/320`, + `784/384`, `912/448`, and `1026/505` +- no `network_prepare: pump saturated` marker occurred +- the sampled RakNet update telemetry reported at most three messages in one + network update cycle + +Conclusion: this run gives no evidence that the 128-entry ring dropped or +reordered trailer/driver packets. The visibly poor trailer playback must be +explained elsewhere. The capacity mismatch nevertheless remains reachable +under an artificial backlog, long frame stall, or high-player burst. + +## Guard + +`INFERRED`: + +The RakNet adapter now yields a production drain once it has decoded 128 +movement packets during that drain. The runtime snapshots immediately after +the drain, so each batch fits the existing history without changing the public +snapshot layout or expanding all other 128-entry remote-sync arrays. +Non-movement-only drains retain the caller's full packet budget. + +The guard includes sequence-wrap handling and emits +`packet-state movement_drain_yield` when exercised. + +## Deferred runtime head + +`PROBE_TRACE + INFERRED`: + +The runtime can still retain the oldest movement across multiple frames while +a streamed GTA vehicle is pending. That is outside the per-drain guard above. +The shared cursor now retries such a head without advancing either the shared +or packet-specific cursor, but only for 2000 ms. This bound is below the +approximately 2.1-second overwrite window of 128 combined Packet 200/210 +events at 60 events/s. A ring gap is logged and processing resumes from the +oldest available event; an expired dependency is consumed as a drop rather +than globally deadlocking remote movement. + +This protects the focused one-pilot/one-observer trailer topology. Multiple +simultaneous remote drivers can exceed the shared history sooner, so a +runtime-owned deferred queue or larger movement-only snapshot remains a +separate scalability improvement. + +## Remaining verification + +- `TODO_VERIFY`: drive a deterministic synthetic RakNet backlog through the + real `Receive()` loop and confirm two or more lossless snapshot batches. +- `TODO_VERIFY`: introduce a deliberate observer frame stall during a + distributed run and verify the new yield marker plus contiguous consumed + movement sequences. +- This guard does not address vehicle/trailer interpolation, ProcessControl + choreography, transform authority, or trailer physics. diff --git a/docs/re/remove_building_lifecycle_r5_20260727.md b/docs/re/remove_building_lifecycle_r5_20260727.md new file mode 100644 index 0000000..ad15d65 --- /dev/null +++ b/docs/re/remove_building_lifecycle_r5_20260727.md @@ -0,0 +1,122 @@ +# RemoveBuildingForPlayer lifecycle in SA-MP 0.3.7 R5 + +Date: 2026-07-27 + +Reference binary: + +- SA-MP 0.3.7 R5 `samp.dll` +- SHA256: `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2` +- Preferred image base: `0x10000000`; every address below is recorded as an + RVA from `samp.dll`. + +## Evidence scope + +This document began as a static lifecycle/xref audit of the R5 binary. A +populated original-client GMX trace captured on 2026-07-28 now corroborates +the process-lifetime and duplicate-rule behavior: + +- artifact: + `artifacts/runs/20260728-144428-distributed-sync-gmx-1646159`; +- original probe log: + `windows/20260728_144443_dist_sync_gmx_pilot_ee1502b0/latest_log_bytes/samp_probe.log`; +- probe-log SHA256: + `7474fe5d3e5f0b0c1a7ea4d8332daeb446dbbdf119d02e320a8673fc953d3b8f`. + +- `STATIC_037`: instruction flow, global references, and the absence of a + clear/reset reference in the audited R5 paths. +- `INFERRED`: lifecycle behavior derived only from static evidence. +- `OBSERVED_037` / `PROBE_TRACE`: the rule count remained 15 across the + synchronous original-R5 GMX reset; the fixture's repeated rules occupied + separate entries. +- `TODO_VERIFY`: claims that still need a watched original-client run. + +## R5 findings + +| Location | Evidence | Finding | +| --- | --- | --- | +| `samp.dll+0x1D530` | `STATIC_037` | RPC 43 reads the model, position, and radius and enters the remove-building path. | +| `samp.dll+0x9D180`, `+0x9D020`, `+0x9CFF0` | `STATIC_037` | The immediate path scans the relevant GTA pools and marks matching entities removed; the existing focused analysis also identifies the R5 Z displacement used for current entities. | +| `samp.dll+0x9D3D0` | `STATIC_037` | A remove-building rule is appended to the process-global rule store. No equality/deduplication test was found on this append path. | +| `samp.dll+0x14AF68` | `STATIC_037` | Base of the process-global rule storage. A rule occupies 20 bytes: model ID, three position floats, and radius. | +| `samp.dll+0x14FD88` | `STATIC_037` | Global rule count. The 20,000-byte interval from the store base to this count is consistent with storage for about 1,000 rules. This layout-derived capacity is not proof of a checked upper bound. | +| `samp.dll+0xA4A70` | `STATIC_037` | The later IPL/stream-in path checks retained rules and substitutes model 19300 (`blankmodel`) for matching definitions. | +| `samp.dll+0xA540` | `STATIC_037` | The audited GMX handling path contains no remove-building store clear. | + +No count/store reset xref was found in the audited GMX, disconnect, or session +teardown paths. The static result therefore supports the following lifecycle: + +- `INFERRED`: remove-building rules live for the lifetime of the `samp.dll` + process, not only for one network session. +- `INFERRED`: GMX, disconnect, and reconnect do not restore already removed + entities and do not discard the rules used for later IPL stream-ins. +- `INFERRED`: the same retained rule can continue suppressing matching + buildings after a reconnect in the same process. + +The GMX portion is now `OBSERVED_037 + PROBE_TRACE`: the count remained +`15 -> 15` inside `samp.dll+0xA540`. Disconnect/reconnect persistence and the +affected entity state after later IPL streaming remain `TODO_VERIFY`. + +## Replacement correction + +The former replacement reset cleared its retained rules at a session boundary +without restoring the GTA entities already altered by those rules. That +created a hybrid state: current entities remained removed, while future +stream-ins were no longer covered. + +The compatibility reset now preserves the rule count and records for the +process lifetime. On GMX/connect/disconnect it resets only: + +- the adapter-consumer event sequence, so a new adapter snapshot can be + consumed correctly; and +- the periodic scan tick, so the retained rules are reconsidered promptly. + +Fresh-process initialization still zeroes the store normally. The GMX +persistence behavior is now backed by `OBSERVED_037 + PROBE_TRACE`; other +session boundaries remain inferred from `STATIC_037`. + +The replacement also no longer deduplicates equal rules. Each received rule is +appended in arrival order, matching both the absence of an equality branch at +`samp.dll+0x9D3D0` and the original fixture count of 15. Before this correction, +the same fixture collapsed those entries to 9 in: +`artifacts/runs/20260728-145211-distributed-sync-gmx-1657080`. + +`PROBE_TRACE`: the corrected replacement was rebuilt as SHA256 +`693a78e7f40e5d579997f9a126bd0082ddf2495c56d78bce8b657421b259e712` +and rerun in: +`artifacts/runs/20260728-145828-distributed-sync-gmx-1669162`. +The GMX reset now reports `records_persisted=15`, and the full +`InitGame -> GameModeRestart -> InitGame` cycle, Windows rejoin, and post-GMX +spawn completed without a crash. Duplicate-count parity is therefore closed +for this fixture. + +## Remaining differences + +| Replacement behavior | R5 evidence | Status | +| --- | --- | --- | +| Stores at most 256 compatibility rules. | The R5 global layout is consistent with about 1,000 20-byte records. | `STATIC_037`; capacity parity remains open. | +| Reapplies rules by scanning pools every 750 ms. | R5 handles later IPL definitions in the stream-in hook at `+0xA4A70` and substitutes model 19300 before normal creation. | `STATIC_037`; timing and implementation are not exact. | +| Uses conservative bounds and overflow handling. | No equivalent checked upper-bound path was established in the focused R5 append analysis. | `TODO_VERIFY`; unsafe unchecked behavior should not be copied speculatively. | + +The periodic rescan is currently the safer compatibility fallback, but it can +leave a short visible window and cannot exactly reproduce the original IPL +creation-time substitution. + +## Runtime verification still required + +GMX persistence and duplicate append behavior are now closed for the exercised +fixture. Remaining verification should use the same original R5 process for: + +1. joining a fixture that sends one distinctive `RemoveBuildingForPlayer` + rule; +2. watching `samp.dll+0x14FD88` and the first record at + `samp.dll+0x14AF68`. +3. triggering disconnect/reconnect and a later IPL stream-in near the rule; +4. confirming that the count and record survive those remaining session + transitions and that the matching building remains absent; +5. exercising enough unique rules to determine the practical limit around the + layout-derived 1,000-record boundary without allowing an uncontrolled + overwrite. + +Expected evidence tags for those runs are `OBSERVED_037 + PROBE_TRACE`. Exact +capacity, overflow behavior, disconnect/reconnect persistence, and stream-in +timing remain `TODO_VERIFY`. diff --git a/docs/re/rpc48_rpc167_runtime_parity_20260727.md b/docs/re/rpc48_rpc167_runtime_parity_20260727.md new file mode 100644 index 0000000..41b1abc --- /dev/null +++ b/docs/re/rpc48_rpc167_runtime_parity_20260727.md @@ -0,0 +1,93 @@ +# RPC 48, RPC 98, and RPC 167 runtime parity + +Reference DLL: + +- SA-MP 0.3.7-R5 `samp.dll` +- SHA256 `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2` + +## RPC 48 — SetPlayerVirtualWorld + +`STATIC_037`: the registered handler at `samp.dll+0x1DCC0` reads one signed +32-bit virtual-world value. It resolves the local player and calls the setter +at `samp.dll+0x17D10`. + +The setter only changes state when the new world differs: + +- `CLocalPlayer+0x217` receives the new signed 32-bit world; +- `CLocalPlayer+0x220` is cleared; +- `CLocalPlayer+0x221` is cleared. + +The flag identities are corroborated by their R5 consumers: + +- `samp.dll+0xD920` tests `+0x220` before active-player processing and sets it + after the local death path. This is the local wasted/death latch. +- `samp.dll+0xB60E` chooses active-player processing only while `+0x221` is + clear. `samp.dll+0xE570` and the class-selection RPC path set it. This is the + wants-another-class latch. + +There is no direct GTA streaming call in the RPC 48 handler or setter. +Compatibility code must therefore preserve the state transition and let the +normal player/object/vehicle stream RPCs drive entity membership. + +Replacement mapping: + +- the adapter retains the signed value plus a monotonic sequence; +- only a changed value clears its class-selection-after-death request state; +- the game-thread consumer mirrors that transition into the replacement's + local death and F4/class-selection latches; +- no speculative GTA streaming or entity mutation is performed. + +## RPC 98 — SetVehicleTireStatus + +`STATIC_037`: the handler at `samp.dll+0x18B70` reads a 16-bit vehicle ID and +one 8-bit damage mask, resolves the streamed vehicle, and calls the setter at +`samp.dll+0xB7940`. + +The setter recognizes only exact automobile and bike subtypes. Its mask order +is reversed relative to ascending field addresses: + +- automobile bits `0..3` write bytes `+0x5A8, +0x5A7, +0x5A6, +0x5A5`; +- bike bits `0..1` write bytes `+0x65D, +0x65C`. + +Other vehicle subtypes are intentionally unchanged. A zero mask writes zeroes, +so damage from an earlier update cannot remain stale. The replacement stores +the mask in the vehicle slot for both immediate updates and later stream-in. + +## RPC 167 — DisableRemoteVehicleCollisions + +`STATIC_037`: the handler at `samp.dll+0x17DE0` reads one bit and stores it in +`CNetGame+0x232`. + +R5 installs the following guarded GTA hooks: + +- the call at GTA `0x41AF80`, whose stock target is + `CCollision::CheckCameraCollisionVehicles` at `0x41A990`; +- vehicle `ProcessEntityCollision` vtable entries for automobile, helicopter, + plane, quad, bike, BMX, monster truck, and boat. + +When the flag is enabled, the camera wrapper returns false without calling the +stock camera/vehicle collision test. The entity wrappers return zero only when +the other entity is a vehicle model in the inclusive `400..611` range and both +physical entities have a non-null collision-list pointer at `+0x460`. +All other calls retain their exact stock target. + +The replacement hook is restricted to the validated GTA SA 1.0 US executable, +checks each original call/vtable target before writing, and restores only +entries which still point at the replacement wrapper. + +RPC state is reset on GMX, reconnect, and connection loss. The guarded hooks +remain installed but transparent while the flag is clear, matching their +process-lifetime role; their original call displacement and vtable entries are +restored during normal DLL shutdown/rollback. + +## Remaining runtime proof + +- `TODO_VERIFY`: exercise RPC 48 during alive, wasted, and F4 class-selection + states and compare subsequent RPC 52/53/class-request ordering. +- `TODO_VERIFY`: compare two occupied vehicles plus a camera-near-vehicle case + with RPC 167 disabled/enabled. +- `TODO_VERIFY`: exercise RPC 98 masks `0x00`, every single bit, and the full + mask on one automobile and one bike while observing both clients. +- `TODO_VERIFY`: verify the RPC 167 state clears after GMX/disconnect while + the transparent hooks stay installed, and verify every hook is restored on + normal DLL shutdown. diff --git a/docs/re/rpc88_rpc153_runtime_parity_20260727.md b/docs/re/rpc88_rpc153_runtime_parity_20260727.md new file mode 100644 index 0000000..18f21c4 --- /dev/null +++ b/docs/re/rpc88_rpc153_runtime_parity_20260727.md @@ -0,0 +1,164 @@ +# RPC 88 / RPC 153 runtime parity notes — 2026-07-27 + +## Evidence scope + +Reference DLL: + +- SA-MP 0.3.7-R5 `samp.dll` +- SHA256 + `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2` + +The statements tagged `STATIC_037` below were checked against that binary. +Existing runtime evidence is called out separately. No post-change runtime run +yet proves delayed local jetpack task creation or a live RPC 153 remote skin +transition. + +## RPC 88: SetPlayerSpecialAction + +### Original R5 path + +`STATIC_037`: `samp.dll+0x18690` reads one action byte and dispatches it to the +local-player special-action method at `samp.dll+0x30F0`. RPC 88 does not carry a +player ID; remote special actions arrive through player sync. + +For jetpack action 2, the relevant CPlayerPed paths are: + +- `samp.dll+0xACD10`: `StartJetpack` +- `samp.dll+0xACD60`: `StopJetpack` +- `samp.dll+0xACDC0`: `IsInJetpackMode` + +`STATIC_037`: `StartJetpack` requires a live GTA ped and performs this sequence: + +1. It writes the CPlayerPed GTA player-slot byte through the selector pointer + stored at `samp.dll+0x113978`. +2. It reads the current position from the ped matrix at offsets + `+0x30/+0x34/+0x38`. +3. It calls `samp.dll+0x9F040`, whose normal entity path dispatches GTA + `SetPosition` through the entity vtable at `+0x38`. +4. It invokes GTA `CCheat::JetpackCheat` at `0x439600` exactly once. +5. It resets the selected GTA player slot to zero. + +`STATIC_037`: the wrapper does not immediately read the task slot after +`0x439600`. Jetpack task creation is therefore asynchronous from the wrapper's +point of view; successful dispatch is not equivalent to an immediately visible +task. + +`STATIC_037`: `StopJetpack` and `IsInJetpackMode` first reject the task path +while `CPed+0x46C` contains the in-vehicle bit `0x100`. Otherwise they resolve +`CPed+0x47C`, inspect the task pointer at `+0x10`, and require task vtable +`0x8705C4`. `StopJetpack` calls GTA scalar destructor `0x6801D0` with deleting +flag 1 and then clears the `+0x10` task pointer. `StartJetpack` itself has no +equivalent in-vehicle test in the inspected wrapper. + +### Replacement implementation + +`STATIC_037`: + +- The adapter decodes the one-byte RPC payload into + `special_action` plus `special_action_seq`. +- `gta_ped_start_jetpack_compat` mirrors the selector, current-position, + vtable-`+0x38`, GTA `0x439600`, and selector-reset sequence. +- Start now reports dispatch success without requiring an immediate jetpack + task readback. +- Task detection and stop use `CPed+0x47C -> +0x10`, vtable `0x8705C4`, the + in-vehicle guard, destructor `0x6801D0`, and explicit slot clearing. +- Local RPC 88 action 2 uses GTA player index zero. Remote sync action 2 is + applied only to the GTA-player-backed remote route, which has a real GTA + player index. + +`OBSERVED_037 + PROBE_TRACE`: an original client sends on-foot +`special_action=2`, and an earlier replacement observer visibly instantiated +the remote jetpack. + +### Deliberately open + +- `PROBE_TRACE + TODO_VERIFY`: before the asynchronous-dispatch correction, a + replacement local client logged `special_action=jetpack start_failed=1`, + stayed visually without a jetpack, and sent special action 0. A fresh + original/replacement run must now prove that the task appears on a later GTA + tick and that outgoing on-foot sync changes to action 2. +- `TODO_VERIFY`: flight controls, vertical velocity, landing, removal, death, + vehicle-entry, GMX, and observer interpolation still need paired runtime + coverage. +- `TODO_VERIFY`: RPC 88 currently exposes only the latest action and sequence, + not an event ring. Multiple action transitions arriving between two + game-thread snapshot reads can collapse. +- `STATIC_037 + TODO_VERIFY`: R5's dispatcher handles an action-byte table up to + `0x44`. The replacement implements a conservative subset. Unknown or custom + action IDs remain diagnostic-only instead of receiving guessed behavior. +- `TODO_VERIFY`: the actor-based remote fallback is not a CPlayerPed and does + not receive jetpack creation. Only the GTA-player-backed route has the + selector semantics observed in R5. + +## RPC 153: SetPlayerSkin + +### Original R5 path + +`STATIC_037`: `samp.dll+0x19190` reads: + +```text +uint32 player_id +int32 skin +``` + +The handler validates the skin, compares the target against the local SA-MP +player ID, and resolves one of two CPlayerPed paths: + +- local: local-player state followed by its CPlayerPed pointer at `+0x104`; +- remote: an existing remote slot, its active-state guard, then the CPlayerPed + pointer at remote offset `+0x1DD`. + +If no corresponding CPlayerPed exists, the handler returns without applying a +model. The static remote-ID check uses a 16-bit target comparison against 1004. + +`STATIC_037`: both local and remote targets call +`CPlayerPed::SetModelIndex` at `samp.dll+0xAFF50`. That wrapper: + +1. validates the CPlayerPed and requested model; +2. applies the original lazy `CClothes::RebuildPlayer` guard at `0x5A82C0`; +3. stops the follow-task path at `samp.dll+0xAE020`; +4. calls the base model-change path at `samp.dll+0x9EF50`; +5. reinitializes ped audio through GTA `0x4E68D0`. + +### Replacement implementation + +`STATIC_037 + PROBE_TRACE`: + +- The adapter validates the eight-byte payload, a replacement player target + below 1000, and normal ped skins `0..311` excluding 74. +- Local RPC 153 requests/loads the model, applies opcode `09C7` to GTA player + index zero, and resets the local ped audio attributes. +- An active and spawned GTA-player-backed remote is now handled through its + owned GTA player index and opcode `09C7`. +- Before remote mutation, the runtime verifies the SA-MP slot ID, GTA slot + owner, slot range `2..209`, pool-ped identity, exact CPlayerPed vtable, and a + readable PlayerInfo. On success it updates the compatibility slot's skin and + resets that ped's audio attributes. +- A missing/non-streamed remote follows R5's no-CPlayerPed outcome and is + ignored. + +### Deliberately open + +- `STATIC_037 + TODO_VERIFY`: the actor fallback is not the CPlayerPed path + resolved by R5 at `samp.dll+0x19190`. It is logged and left unchanged rather + than being destroyed/recreated or directly patched without evidence. +- `TODO_VERIFY`: RPC 153 also stores only the latest value and sequence. It + needs an event ring if multiple skin changes within one game-thread snapshot + interval must all be observable. +- `STATIC_037 + TODO_VERIFY`: R5 compares remote IDs against 1004, while the + replacement pool currently stops at 1000. Reserved/custom targets + `1000..1003` remain unsupported until their original purpose and layout are + confirmed. +- `STATIC_037`: custom SA-MP object model IDs are not valid player skins. The + accepted skin domain remains `0..311` excluding 74. +- `TODO_VERIFY`: a paired two-client run must exercise local and active remote + skin changes, repeated changes, vehicle occupancy, death/respawn, stream-out, + reconnect, and GMX cleanup before runtime parity is claimed. + +## Current parity status + +- RPC 88 wire decoding and the R5 jetpack dispatch sequence are implemented. + Delayed task appearance and the full flight lifecycle remain runtime-open. +- RPC 153 local behavior and the guarded GTA-player-backed remote path are + implemented. Actor fallback, burst-event preservation, and R5-only remote + IDs remain intentionally open. diff --git a/docs/re/samp_custom_asset_pipeline.md b/docs/re/samp_custom_asset_pipeline.md index 026013b..01d636b 100644 --- a/docs/re/samp_custom_asset_pipeline.md +++ b/docs/re/samp_custom_asset_pipeline.md @@ -1,5 +1,12 @@ # SA-MP Custom Asset Pipeline +> **2026-07-28 correction:** later R5 static analysis proves that the original +> DLL reserves 20,000 `CAtomicModelInfo` entries; 15,417 is the observed +> populated count after the stock `SAMP.ide` pass, not the R5 store capacity. +> See `docs/re/custom_modelinfo_static_r5_20260728.md`. Older 15,417-capacity +> wording below describes replacement experiments and must not be cited as +> `STATIC_037`. + ## Current Evidence - `OBSERVED_037 + PROBE_TRACE`: original 0.3.7-R5 opens `SAMP\samp.IDE`, `SAMP\custom.IDE`, `SAMP\CUSTOM.IMG`, `SAMP\SAMP.IMG`, and `SAMP\SAMPCOL.IMG`. @@ -57,8 +64,10 @@ `SAMP.img` with 1673 entries (`1462` DFF, `208` TXD, `3` IFP), `SAMPCOL.img` with one aggregate `AllSAMPCOLs.col`, and `SAMP.ide` with `1433` `objs`/Atomic rows: `72` in `11682..11753` plus `1361` sparse rows in `18631..19999`; `CUSTOM.ide` is empty. Two additional `anim` rows, `19901` and `19902`, - are clump models and are not part of the 1433-entry Atomic pass. The replacement parser still ignores `anim`, so - those two models remain a separate open point. The low range has no missing DFF or COL names, but `46/72` rows + are clump models and are not part of the 1433-entry Atomic pass. The replacement now indexes those `anim` rows, + but excludes them from the opt-in full-bulk-first eligibility count so the default 1433-entry Atomic/Time pass + remains complete. Their animated-clump/IFP conversion remains a separate `TODO_VERIFY`. The low range has no + missing DFF or COL names, but `46/72` rows reference TXDs that are not in `SAMP.img` because they are vanilla GTA TXDs such as `csmafcouch`, `int_casinoint3`, and `a51_ext`. Replacement diagnostics must therefore treat `txd_file=missing` separately from an actually unavailable `CTxdStore` slot. @@ -201,8 +210,9 @@ guards the atomic/time/clump store counters before calling GTA's `Add*Model` functions. - `OBSERVED_037 + PROBE_TRACE + INFERRED + TODO_VERIFY`: the 2026-07-08 original-R5 run shows broad custom AtomicModelInfo creation first, then `CUSTOM.IMG`/`SAMP.IMG`/`SAMPCOL.IMG` directory loads, then COL binding. The replacement keeps - `SAMPDLL_CUSTOM_ASSET_BULK` enabled and now defaults `SAMPDLL_CUSTOM_ASSET_BULK_LIMIT` to the exact 1433 stock - `objs` rows. With the relocated store active, those sparse indexed rows are registered in IDE parse order before + `SAMPDLL_CUSTOM_ASSET_BULK` as an explicit opt-in and defaults `SAMPDLL_CUSTOM_ASSET_BULK_LIMIT` to the exact 1433 + stock `objs` rows when enabled. With the relocated store active, those sparse indexed rows are registered in IDE + parse order before pending server IDs and before the first archive directory pass. This matches the observed `13984 + 1433 = 15417` Atomic count; the environment limit and targeted low-range helper remain diagnostic fallbacks. - `PROBE_TRACE + GTA_REVERSED_REF + TODO_VERIFY`: replacement build `Jul 10 2026 14:59:51`, @@ -384,8 +394,11 @@ stock test set. 1. `STATIC_037 + PROBE_TRACE + TODO_VERIFY`: keep mapping the original early-init IDE/archive function RVAs so the replacement can move custom model registration from late object fallback toward the original startup path. -2. `PROBE_TRACE + TODO_VERIFY`: verify the default full bulk pass reports `considered=1433`, `skipped=0`, IDE-order - registration, and Atomic store `13984->15417` before the first `archive_dir_batch`. Then rerun +2. `PROBE_TRACE + TODO_VERIFY`: with the opt-in bulk pass enabled, verify the default limit reports + `considered=1433`, `skipped=0`, `bulk_first=1`, and IDE-order registration before the first + `archive_dir_batch`, even though the shared index also contains the two deferred `anim` rows. The default heap + path must leave the native Atomic count unchanged; `13984->15417` applies only to a future verified native-store + implementation. Then rerun `/sampobjscan 11682 5000 3000`; after `11753`, model `18631` and later high IDs must show nonzero DFF `cd_size`, load state `1`, readable `rw_object`, and successful `create_opcode_end`. 3. `PROBE_TRACE + TODO_VERIFY`: if a stock Atomic still logs `streaming_dff_unmapped`, keep it pending and inspect diff --git a/docs/re/scoreboard_gametext_render_semantics_r5_20260728.md b/docs/re/scoreboard_gametext_render_semantics_r5_20260728.md new file mode 100644 index 0000000..e1be3a4 --- /dev/null +++ b/docs/re/scoreboard_gametext_render_semantics_r5_20260728.md @@ -0,0 +1,139 @@ +# Original-R5 scoreboard and GameText render semantics + +Date: 2026-07-28 + +Status: `STATIC_037 + PROBE_TRACE`. The Original-R5 claims below remain based +on static control flow; the final section separately records replacement and +transport probes without promoting them to direct Original-R5 memory +observations. + +## Binary identity + +Analyzed original SA-MP 0.3.7-R5 binary: + +- file: `artifacts/binaries/samp_installer.dll`; +- SHA256: + `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2`; +- preferred base: `0x10000000`. + +All addresses below are module-relative RVAs. + +## Scoreboard is an exclusive render branch + +`STATIC_037`: + +The main R5 render routine starts at `samp.dll+0x75730`. Its scoreboard branch +is controlled by the raw scoreboard-visible DWORD: + +1. `samp.dll+0x7593A` loads the scoreboard object pointer. +2. `samp.dll+0x7593F` compares the DWORD at `CScoreboard+0x0` with zero. +3. When it is nonzero, `samp.dll+0x75944..+0x7594C` calls + `CGame::DisplayHud(0)` at `samp.dll+0xA1DB0`. +4. `samp.dll+0x75951..+0x75957` calls `CScoreboard::Draw` at + `samp.dll+0x6F0B0`. +5. `samp.dll+0x7595C` unconditionally jumps to the shared tail at + `samp.dll+0x75C65`. + +That jump bypasses the normal overlay block. In particular, it skips: + +- `Chat::Draw` at the call site `samp.dll+0x75A81`, targeting + `samp.dll+0x67E00`; +- `ChatInput::Draw` at the call site `samp.dll+0x75A90`, targeting + `samp.dll+0x696F0`. + +The compatibility consequence is exact: while the raw scoreboard-visible +latch is nonzero, R5 hides the GTA HUD and renders the scoreboard instead of +the chat text and chat-input overlay. The chat and chat-input draw calls are +not merely placed behind the scoreboard; they are not executed for that +frame. + +The scoreboard branch contains no chat-buffer clear or chat-input state +transition. Static control flow therefore proves render suppression, not chat +state destruction. Whether any separate path mutates chat state during the +same frame remains `TODO_VERIFY`; the replacement must not infer such a clear +from this render branch. + +## Every accepted GameText globally clears the preceding text + +`STATIC_037`: + +`CGame::DisplayGameText` starts at `samp.dll+0xA0CE0`. For calls that pass its +initial upper-bound guard, the function performs the following sequence: + +1. `samp.dll+0xA0CEC` pushes the script-command descriptor at + `samp.dll+0xEC724` and calls the script-command bridge at + `samp.dll+0xB2310`. +2. The descriptor begins with bytes `BE 00`, the little-endian GTA script + opcode `0x00BE`, `text_clear_all`. +3. Only after that global clear does the function copy and sanitize the new + text. +4. The new GameText dispatch begins at `samp.dll+0xA0D3E`; the actual call at + `samp.dll+0xA0D4F` targets GTA address `0x0069F2B0` with text, duration, + and style/size arguments. + +RPC 73 enters this same path: + +- the RPC 73 handler starts at `samp.dll+0x198F0`; +- after decoding and validating its payload, its call site at + `samp.dll+0x199C8` invokes `CGame::DisplayGameText` at + `samp.dll+0xA0CE0`. + +Consequently, a newly accepted RPC 73 GameText replaces all currently active +GTA game text, including text in a different style slot. R5 does not maintain +independently overlapping GameText entries for successive RPC 73 messages: +the `text_clear_all` command runs before every accepted new message is +installed. + +This evidence does not yet establish the exact runtime result for rejected +payloads, empty strings, zero durations, or extension-specific hide +semantics. Those cases remain `TODO_VERIFY` and must not be generalized from +the proven accepted-message path. + +## Replacement verification + +### Exclusive scoreboard overlay + +`PROBE_TRACE`: + +- replacement build SHA256: + `5f57901401f6d3c337f5c241ea33c1b66842497f449011c7f703f0d74e0d7dfe`; +- run: + `artifacts/runs/20260728-154547-replacement-ui-1741171`; +- the latched scoreboard kept `HUD=0` and `radar_blank=1` while the interaction + driver retained normal GTA movement/input; +- runtime line 899 recorded ten buffered chat lines together with + `normal_overlay_draws=0` in the exclusive scoreboard branch. + +The chat state was therefore retained but not rendered. The focused +interaction analyzer passed every scoreboard state check except the optional +server-side RPC 23 click observation. + +### Global GameText replacement + +The closed fixture in `tools/openmp_rpc73_gametext_fixture/` bypasses +open.mp's GameText-to-TextDraw compatibility conversion and sends two fixed +raw RPC 73 payloads. Fixture SHA256: +`494b1cd647654cd13b2451f80a6e31a13b4c26304bcb2c89fa09980f8243ab51`. + +`PROBE_TRACE` run: +`artifacts/runs/20260728-155306-replacement-pvars-1747209`. + +The observed sequence was: + +1. server lines 180/182 sent style 5 and style 3 with `sent=1`; +2. network lines 495/496 decoded the first raw RPC 73 and lines 518/519 the + second; +3. runtime line 825 installed style 5; +4. runtime line 830 cleared one active GameText before the replacement; +5. runtime line 831 installed style 3. + +`tools/reloop/analyze_gametext_r5.py` evaluates this fixed golden run as +`PASS` with all 13 checks true and no `exception_filter`. + +For transport comparability, Original-R5 run +`artifacts/runs/20260728-155657-original-pvars-1750341` used the original DLL +hash above and received the same two fixed raw payloads from the server. Its +hash-guarded screenshot request was observed, but no image file was produced; +pixel evidence therefore remains `TODO_VERIFY`. The global replacement claim +itself is already fixed by the Original-R5 `text_clear_all` control flow +documented above. diff --git a/docs/re/sync_edge_states_r5_20260727.md b/docs/re/sync_edge_states_r5_20260727.md index f06ded3..1f60b38 100644 --- a/docs/re/sync_edge_states_r5_20260727.md +++ b/docs/re/sync_edge_states_r5_20260727.md @@ -28,6 +28,9 @@ not contain decompiler pseudocode. | Spectator processing | `samp.dll+0x6540`; camera matrix call at `+0x6599`; packet byte at `+0x6628`; payload length at `+0x6632`; send at `+0x6659` | Packet 212, packed 18-byte payload every more than 200 ms, `HIGH_PRIORITY`, `UNRELIABLE`, ordering channel 1. Position is the camera matrix position. | | Unoccupied authority | `samp.dll+0x6E00`; calls to sender at `+0x6F15` and `+0x6FB3`; float at `samp.dll+0xE5908` is 90.0 | R5 checks all eight passenger pointers. The first network-player passenger owns the send; with no player passenger, a nearest-player arbitration path is bounded to 90 units. | | Trailer dispatch | in-car sender `samp.dll+0x7080`; trailer call at `+0x744D` | Trailer sync follows the towing-vehicle sync path when it carries a trailer ID. | +| Unoccupied receive | dispatcher `samp.dll+0xB260`; handler `+0x9A40`; store/apply `+0x158D0` | Packet 209 is player ID plus the raw 67-byte payload. R5 validates its axes, position and speeds, then uses 0.1 no-op, 6/6/3 snap and `delta*0.06` correction thresholds. | +| Trailer receive | dispatcher `samp.dll+0xB260`; handler `+0x9E20`; store/apply `+0x15C90` | Packet 210 is player ID plus the raw 54-byte payload. R5 resolves the remote player's current towing wrapper independently of the cached Packet-200 trailer ID, accepts the nine trailer models or Towtruck model 525, then uses 0.5 no-op, 6/6/3 snap and `delta*0.025` correction thresholds. | +| Passenger receive | dispatcher `samp.dll+0xB260`; handler `+0x9D30`; store `+0x17440` | Packet 211 is player ID plus the raw 24-byte payload. R5 commits passenger state even when its vehicle wrapper cannot yet be resolved. | | Camera backing matrix | copy helper `samp.dll+0x9D460`; constructor literal at `samp.dll+0x9FFAE` contains `0xB6F99C` | The R5 camera wrapper points at GTA SA 1.0 US camera matrix storage; the spectator payload uses matrix position at offset `+0x30`. | The Ghidra function report identifies exact entries for `+0x3730`, `+0x4D30`, @@ -89,12 +92,79 @@ layouts. the first network-player passenger, the driver is absent, the vehicle is moving and the vehicle is not a train. This is deliberately narrower than R5's full authority algorithm. +- `STATIC_037`: inbound packets 209/210/211 are decoded with their exact + 70/57/27-byte server layouts and appended to the runtime snapshot. +- `STATIC_037`: the shared runtime ordering ring now preserves the + `Receive()` arrival order across packets 207, 200, 209, 210 and 211. + Packets 207/200 use channel 0 and 209/210/211 use channel 1; replaying fixed + per-type batches can therefore produce a different final state than RakNet + delivered across the two channels. +- `STATIC_037`: UnoccupiedSync copies the two transmitted matrix axes exactly: + matrix `+0x00` to/from `roll`, matrix `+0x10` to/from `rotation`, while the + matrix `+0x20` axis and flags remain untouched. +- `STATIC_037`: inbound unoccupied and trailer transforms use R5's observed + no-op, correction and snap thresholds. Trailer quaternion order is + `w,x,y,z`. Packet 210 uses the SA-MP vehicle-wrapper matrix setter at + `samp.dll+0x9EBC0`; that R5 setter only copies the 0x40-byte matrix and does + not call GTA `CEntity::UpdateRwFrame` or an extra `ApplyMoveSpeed`. +- `STATIC_037 + ALT_02X_CODE`: PassengerSync caches state before vehicle + resolution, uses seats 1 through 8 without evicting an occupant, and performs + physical seating as a best-effort GTA operation. +- `STATIC_037 + ALT_02X_CODE`: driver/trailer transitions check the live GTA + trailer pointer at `CVehicle+1224`, attach a changed trailer and detach the + previous trailer when packet 200 reports none. +- `STATIC_037`: Packet 210 publishes the owner/towing-slot attachment only + after the live `CVehicle+1224` pointer matches the expected trailer. A + command failure or mismatching/unreadable pointer remains deferred for a + later sync update instead of being reported as applied. +- `STATIC_037`: Packet 210 no longer depends on a previously cached matching + Packet-200 trailer ID or an already-seated remote ped. Packet 209 records + its sender before wrapper resolution and skips physics correction when + `samp.dll+0xB7CF0` resolves a live tractor via `CVehicle+1220`. +- `STATIC_037`: Packet 209 reproduces the narrower `samp.dll+0xB70E0` + occupied-transition check instead of rejecting every non-null GTA driver: + correction is skipped only when driver `+0x46C` bit 8 is set while + driver `+0x598` is null. - `GTA_REVERSED_REF + ALT_02X_CODE + TODO_VERIFY`: vehicle surfing uses `CPed::m_standingOnEntity` at offset `+0x568`, requires an occupied SA-MP vehicle within five units, and sends ped-minus-vehicle offsets. - Runtime counters, first/error traces and session/GMX reset paths were added for the new packet state. +## Runtime evidence: original sender to replacement receiver + +`OBSERVED_037 + PROBE_TRACE`: + +- Artifact: + `artifacts/runs/20260727-193437-sync-edge-all-881592` +- Original R5 pilot: + `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2` +- Replacement observer: + `3f6bce2f8c55b16778e4d528591554f3e9ded4f8c6517a17f74a3dd9acca15f1` +- The server verified Passenger as vehicle 25/seat 1/state 3, Unoccupied as + vehicle 25/seat 1/state 3, and Trailer as vehicle 25/driver seat/state 2 + with trailer 26 actually attached. +- The replacement decoded packets 211, 209 and 210 and consumed all three + packet types through the shared movement ring. Passenger readback reported + `seated=1 seat_read=1`; Unoccupied correction reported successful movement, + turn-speed and GTA transform readback; Trailer reported + `attached=1 readback=1`. +- All three scenario drivers returned zero, no client crash marker was + captured, and all ASI hashes remained unchanged. The clients were terminated + by the harness and did not emit `process_detach`, so this is not evidence of + graceful shutdown. +- The resulting verdict is `TRACE_PASS_VISUAL_UNVERIFIED`. It closes the + original-outbound to replacement-inbound decode/application contract only. + It does not establish pixel-identical passenger placement, matching + interpolation, articulated trailer motion, replacement-outbound parity, or + an original-observer readback. + +Packet 209 arrived once before the passenger setup had settled and was +deferred; later seat-1 updates applied successfully. Consequently this run +does not prove exact first-packet seat-field or authority timing. The original +prefix also used an older, functional control-ASI build than the current +replacement prefix, so the run does not claim control-helper binary identity. + ## Deliberately open - `TODO_VERIFY`: reproduce R5's complete surfing collision query, including @@ -103,14 +173,16 @@ layouts. - `TODO_VERIFY`: implement the no-player-passenger nearest-player authority branch and confirm the exact unoccupied cadence. The present cadence reuses the negotiated in-car interval. -- `TODO_VERIFY`: incoming/remote application of packets 209 and 210 was not - added by this sender-focused change. +- `TODO_VERIFY`: capture paired original-observer/replacement-observer visuals + for all three inbound paths. Passenger seating, driverless correction and + articulated trailer motion now have replacement GTA readback evidence, but + visual and original-observer parity remain open. - `TODO_VERIFY`: the current vehicle-slot convention treats trailer ID zero as "none"; verify whether a server vehicle in slot zero can be a trailer in the original client. -- `TODO_VERIFY`: run original/replacement two-client A/B scenarios for passenger - entry, driverless push, towing, spectator camera motion, surf transitions, - disconnect and GMX. +- `TODO_VERIFY`: run the reverse direction (replacement pilot to original + observer), then extend the two-client matrix to spectator camera motion, + surf transitions, disconnect and GMX. - GTA SA addresses used here target the 1.0 US executable. Record the exact GTA executable hash and RVAs in a future runtime trace before treating those engine addresses as independently observed evidence. @@ -123,7 +195,16 @@ layouts. - Original instruction bytes were checked for IDs 209--212, payload sizes, send arguments, trailer resend suppression, unoccupied call sites and camera matrix copy. -- Coordinated Win32 build passed; final candidate SHA256 - `acb5edd84e5c634309d50cb68213dffd9d575ccb59fae454c66242df4977168b`. -- Host regression passed 12/12 tests. A live original/replacement two-client - A/B for these edge states is still required. +- Coordinated RakNet Win32 build passed and the live-tested candidate SHA256 is + `3f6bce2f8c55b16778e4d528591554f3e9ded4f8c6517a17f74a3dd9acca15f1`. +- Host regression passed 13/13 tests. The new raw edge-sync test covers exact + 209/210/211 sentinel payloads, truncation, accepted trailing bytes, + timestamp normalization, flags, snapshot contents, all-five ordering and + 128-entry ring wrap/reset. +- ReLoop unit regression passed 28/28 tests, including deterministic UDP loss, + reordering and edge-runner evidence cases. +- The no-RakNet host regression passed 7/7 tests and the no-RakNet Win32 DLL + compiled successfully. +- The original-sender/replacement-observer live trace above passed all three + edge states. Reverse-direction, original-observer and visual A/B evidence + remain required. diff --git a/docs/re/ui_glass_surface_style_20260728.md b/docs/re/ui_glass_surface_style_20260728.md new file mode 100644 index 0000000..f46d57e --- /dev/null +++ b/docs/re/ui_glass_surface_style_20260728.md @@ -0,0 +1,168 @@ +# Glass UI, cursor and modal-input parity (2026-07-28) + +## Scope + +This note records the replacement-client UI pass for dialogs, the scoreboard, +class-selection arrows, the visible cursor and modal dialog input ownership. + +Reference binaries: + +- SA-MP 0.3.7-R5 `samp.dll` + SHA256 `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2` +- GTA:SA USA 1.0 `gta_sa.exe` + SHA256 `a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26` +- Live-validated replacement build before restore-path hardening + SHA256 `148d595cd5df69f2d5edb1d4fbf48e3c5259d08b260bf2920814a2f1c0fde6a3` +- Current compiled restore-path hardening build, not yet live-validated + SHA256 `554ff496585e4736518c12a548b67d19bf859e8740a6fdaa6f85d5ad271711a8` + +## Glass surface style + +`USER_REQUESTED + MTA_REF`: + +- Dialog, scoreboard and class-selection fallback controls now share one dark, + semitransparent surface palette. +- Panels use a dark fill, a slightly brighter header/highlight and an explicit + border. Normal, hovered and pressed controls remain distinguishable. +- The square replacement class-selection arrows remain the default fallback. +- This is an intentional modern compatibility style, not an assertion that + R5 used the same pixels. + +MTA was used only as a low-priority conceptual reference for layered alpha +surfaces and state-specific GUI imagery: + +- `Client/gui/CGUIWindow_Impl.cpp`, commit + `a750e5824f3a873a5110548678a67ca52e1bf739` +- `Client/core/Graphics/CGraphics.cpp`, same commit +- `Shared/data/MTA San Andreas/skins/Default 2023/CGUI.lnf.xml`, same commit + +## Cursor + +`OBSERVED_037 + STATIC_037 + PROBE_TRACE + USER_REQUESTED`: + +- The original installation supplies a 32x32 `mouse.png`. +- A D3D hardware cursor can be accepted by Wine/Proton without being composed + into the visible frame. +- The replacement therefore loads the installation's existing `mouse.png` and + draws one textured backbuffer quad after the active UI branch. It does not + commit or redistribute the proprietary asset. +- The old hand-built raster cursor was removed. +- The cursor is emitted once in the exclusive scoreboard branch and once in + the normal overlay branch, never once per child overlay. +- Win32 `IDC_ARROW` is retained only as an explicit fallback when loading + `mouse.png` fails. +- The user visually confirmed the result with: `cursor ist super`. + +Run evidence: + +- `artifacts/runs/20260728-165524-replacement-ui-1832907` +- `samp_runtime.log` records + `ui_cursor: backbuffer texture initialized asset=mouse.png size=32x32 + render_sources=1`. + +## Modal dialog input ownership + +### Static R5 behavior + +`STATIC_037 + GTA_REVERSED_REF`: + +R5 cursor mode 2 does more than suppress keyboard mapping: + +1. `samp.dll+0xA06FF..+0xA071A` patches GTA VA `0x541DF5` + (`gta_sa.exe+0x141DF5`) from `E8 46 F3 FE FF` to five NOPs. +2. `samp.dll+0xA0580` patches GTA VA `0x53F417` + (`gta_sa.exe+0x13F417`) from `E8 B4 7A 20 00` to five NOPs. +3. The same helper changes `0x53F41F..0x53F422` from + `85 C0 0F 8C` to `33 C0 0F 84`. +4. Mode 2 zeros `0xB73424` and `0xB73428`, calls + `CPad::ClearMouseHistory` at `0x541BD0`, then calls + `CPad::UpdatePads` at `0x541DD0`. +5. It changes the first byte of `RsMouseSetPos` at `0x6194A0` from `E9` + to `C3`. + +The first patch suppresses only +`CControllerConfigManager::AffectPadFromKeyBoard`. Without steps 2-4, +`CPad::UpdateMouse` can still receive DirectInput mouse deltas and buttons +behind a modal Win32 overlay. + +R5 close mode 0 with argument 0 arms a ten-tick countdown at +`samp.dll+0xA0861..+0xA0873`. At expiry, `samp.dll+0xA05D0` restores keyboard +and mouse bytes, performs: + +```text +zero movement -> ClearMouseHistory -> UpdatePads +zero movement -> ClearMouseHistory +``` + +and only then restores `RsMouseSetPos` to `E9` and hides the D3D cursor. + +### Replacement behavior + +`STATIC_037 + PROBE_TRACE`: + +- Both DirectInput patch sites are exact-byte validated before mutation. +- Saved bytes and ownership are tracked; changed third-party bytes are never + overwritten during restore. +- Network dialogs and the local F1 help dialog acquire the same mode-2 gate. +- Active dialogs clear/update GTA mouse state on the game/render thread. +- Dialog close uses the R5 ten-tick delayed restore and the two-stage mouse + history clear. +- The bullet-sync callback exits while the dialog gate owns mouse input, so a + stale close-edge shot cannot produce `OnPlayerWeaponShot`. +- Chat mode 1 remains separate; it does not acquire the mode-2 DirectInput + patch. Scoreboard mode 3 is also kept outside this dialog-specific change. + +## Runtime validation + +`PROBE_TRACE + OBSERVED_REPLACEMENT`: + +Scenario: + +1. Start the replacement at `127.0.0.1:7798`. +2. Spawn and open `/tpassword`. +3. Record transform and aim. +4. Hold `W`, inject four DirectInput mouse deltas of `(320, 120)`, hold/release + RMB and LMB. +5. Record transform and aim while the dialog remains active. +6. Close with Escape, wait past the ten-tick restore, then repeat movement and + mouse input. + +Observed while open: + +- GTA input call: `9090909090` +- position delta: `0.000000` +- ped-forward delta: `0.000000` +- aim-vector delta: `0.003751` after a total injected delta of `(1280, 480)`; + this is ordinary sampled camera jitter, not the large rotation reproduced + before the DirectInput patch +- no new server `OnPlayerWeaponShot` +- no new replacement `bullet_impact_hook: send` + +Observed after Escape: + +- GTA input call restored to `e846f3feff` +- subsequent `W` input moved the player `1.794591` units +- subsequent mouse delta changed the aim vector by `1.638225` +- runtime trace recorded `poll=1 result=1 flush=1` + +Automated checks: + +- `python3 -m unittest + tools.reloop.test_dialog_cursor_input_lock_source_contract + tools.reloop.test_ui_surface_style_source_contract -v` +- 24 focused cursor/dialog and UI-surface tests passed. +- Win32 DLL build completed successfully. +- The strict whole-PE identity checker still reports the longstanding + section/import/entry-point structural differences; this UI change does not + claim byte-identical PE layout. + +## Open points + +- Capture an original/replacement screenshot pair on native Windows if exact + cursor hotspot or pixel filtering becomes relevant. +- R5 scoreboard cursor mode 3 also uses the DirectInput mouse gate while + leaving keyboard movement enabled. That is a separate parity item and was + intentionally not folded into the modal-dialog fix. +- One first-start attempt in the UI runner disconnected during preconnect; the + automatic retry completed the full UI scenario. The disconnect occurred + before the dialog patch path and was not reproduced by the final attempt. diff --git a/docs/re/ui_latch_cursor_r5_20260727.md b/docs/re/ui_latch_cursor_r5_20260727.md index 9bf0d06..0696a08 100644 --- a/docs/re/ui_latch_cursor_r5_20260727.md +++ b/docs/re/ui_latch_cursor_r5_20260727.md @@ -43,6 +43,8 @@ selection prematurely and could open GTA's pause menu while cancelling. The replacement now: - keeps selection and cursor ownership after ordinary TextDraw clicks; +- consumes `WM_LBUTTONUP` and submits a valid hovered ID without requiring a + preceding delivered button-down, matching `samp.dll+0x71570`; - consumes both Escape phases; - sends ID `65535` and clears selection on Escape key-up; - clears the adapter's selection snapshot for a cancel even if the network @@ -126,8 +128,9 @@ open while either UI owns input. require the R5 ownership outcomes above. 6. Pause: compare keyboard Escape, controller Start, and an externally opened frontend menu while chat or TextDraw selection is active. -7. Mouse edge case: send `WM_LBUTTONUP` over a selectable TextDraw without a - preceding delivered down. R5's selector has no down latch, while the - replacement currently requires one. +7. Mouse edge case: runtime-confirm that `WM_LBUTTONUP` over a selectable + TextDraw without a preceding delivered down emits the hovered RPC 83. The + replacement now mirrors R5's statically recovered no-latch path; a paired + runtime trace remains `TODO_VERIFY`. 8. Chat: compare held T, F6, key repeat, Home/End/Left/Right, clipboard, non-ASCII input, and focus loss. diff --git a/docs/re/ui_latch_memory_probe_r5_20260728.md b/docs/re/ui_latch_memory_probe_r5_20260728.md new file mode 100644 index 0000000..e40bc4a --- /dev/null +++ b/docs/re/ui_latch_memory_probe_r5_20260728.md @@ -0,0 +1,318 @@ +# Original-R5 AFK, pause, TAB, chat, and cursor memory probe + +Date: 2026-07-28 + +Status: `STATIC_037`; runtime behavior and every field interpretation not +called out as a direct instruction effect remain `TODO_VERIFY`. + +## Scope + +This profile is a passive evidence collector for the original 0.3.7-R5 +client. It is intended to answer: + +- which calls actually open and close the TAB scoreboard; +- whether chat input and the F7 display mode are momentary or latched; +- how R5's cursor/input mode and delayed restore counter change; +- which focus/menu transitions coincide with those changes; +- when a remote player changes between active and AFK display state. + +It does not inject keys, move the cursor, call `ShowCursor`, alter a timer, or +write a log from a hooked thread. The original function always runs through a +trampoline. Hook records go to a fixed ring and the existing worker writes +them later. + +The WndProc at `samp.dll+0x61650` is deliberately not hooked. Its large switch +and many early `ret 0x10` exits are a wider compatibility surface than needed. +The smaller state-transition callees preserve caller RVAs, including WndProc +call sites, while keeping the patch set reviewable. + +## Binary identities + +Original R5: + +- file: `artifacts/binaries/samp_installer.dll`; +- SHA256: + `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2`; +- preferred base: `0x10000000`; +- PE timestamp: `0x6372C39E`; +- entry RVA: `0x000CBC90`; +- image size: `0x0027E000`. + +GTA San Andreas 1.0 US: + +- file: `GTA San Andreas RZ/gta_sa.exe`; +- SHA256: + `a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26`; +- preferred base: `0x00400000`; +- PE timestamp: `0x427101CA`; +- entry RVA: `0x00424570`; +- image size: `0x01177000`; +- PE checksum: `0x00DC5BEA`. + +The code accepts only these PE identity proxies, including machine, +preferred-image-base header, and relocation-table characteristics. GTA must +remain at its fixed preferred base and have relocations stripped. R5 must +retain a non-empty relocation directory but may be loader-relocated: absolute +SA-MP operands covered by the PE relocation table are normalized from +preferred-image VAs to the actual module base before comparison. RVAs, +opcodes, non-relocated bytes, and the GTA absolute operand remain exact. +Hashes above document the analyzed inputs; the probe does not hash files from +inside the game process. + +## Static R5 findings + +### TAB scoreboard + +`STATIC_037`: + +- `samp.dll+0x6F3D0` shows the scoreboard. It tests the DWORD at + `this+0x0`, makes the GUI object at `this+0x34` visible, calls + `CGame::SetCursorMode` with mode `3`, and writes `1` to `this+0x0`. +- `samp.dll+0x6E9E0` hides it. It clears the GUI-visible byte, optionally + calls `CGame::SetCursorMode` with mode `0`, then writes `0` to + `this+0x0`. Its one stack argument is preserved as a raw value. +- The global scoreboard pointer is `samp.dll+0x26EB4C`. +- The WndProc TAB path first requires `uMsg == WM_KEYUP` (`0x101`) at + `samp.dll+0x61785` and `wParam == VK_TAB` (`0x09`) at `+0x6178D`. + It reads the visible DWORD at `CScoreboard+0x0`: a nonzero value calls + hide at `+0x617A0`, while zero calls show at `+0x617B6` when + `CGame+0x24` is zero. Consequently, one completed TAB pulse toggles exactly + one edge; observing show followed by hide requires two key-up pulses. +- WndProc also calls hide at + `samp.dll+0x61B82` for `WM_KILLFOCUS`. These caller RVAs let a runtime trace + distinguish the edges without hooking WndProc. + +The show/hide names are based on the direct visible-state writes. The plain TAB +path is statically assigned to `WM_KEYUP`; right-button interaction and focus +cleanup remain separately identified by their caller RVAs. + +### Chat input and F7 display mode + +`STATIC_037`: + +- the chat object pointer is `samp.dll+0x26EB84`; +- `samp.dll+0x69480` opens input and changes `this+0x14E0` to `1`; +- `samp.dll+0x69580` closes input and changes `this+0x14E0` to `0`; +- the chat-display object pointer is `samp.dll+0x26EB80`; +- `samp.dll+0x612C0` cycles the DWORD at `this+0x8` as + `0 -> 2`, `1 -> 0`, and `2 -> 1`; +- that routine writes `1` to the raw dirty field at `this+0x63DA`. + +The key helper at `samp.dll+0x61360` calls these routines for F6/F7. It is not +hooked because the state-transition callees also capture focus cleanup and +non-keyboard call paths. + +### Cursor/input ownership + +`STATIC_037`: + +- `samp.dll+0xA06F0` is a `thiscall` function with two stack arguments and + `ret 8`; +- it handles raw modes `0..4` and stores the selected mode at + `CGame+0x61`; +- its mode-zero path derives a raw delayed-restore value at `CGame+0x65`; +- `samp.dll+0xA05D0` reads both fields. While the raw restore value is + positive it decrements it. At zero it restores the GTA input-gate code and + related mouse gate. + +The probe therefore logs these as `cursor_mode_raw` and +`cursor_restore_raw`. The second value is a statically confirmed countdown, +not a proven general cursor reference count. + +The following GTA bytes are read alongside each emitted UI record: + +- `0x00541DF5..0x00541DF9`: GTA input-gate call bytes which R5 replaces with + NOPs in cursor modes; +- `0x006194A0`: raw mouse-gate byte toggled by R5. + +They are observations only. The probe never writes either address. + +### Pause/frontend state + +`STATIC_037`: + +- `CGame::IsMenuActive` is `samp.dll+0xA0920`; +- it reads the DWORD at GTA address `0x00BA67A4` and returns whether it is + non-zero; +- R5 render and WndProc paths use that result as a UI gate. + +The hook publishes the first query and later result edges only. It also logs +the raw DWORD and its first three bytes. + +The byte at `0x00B7CB49` is included only as +`pause_raw_b7cb49`. The existing anti-pause reference comes from the legacy +0.2x/GTA-side path, not a proven R5 xref. Its semantic label remains +`TODO_VERIFY`. + +Read-only Win32 observations include `GetForegroundWindow`, `GetFocus`, +`GetCapture`, and `GetCursorInfo.flags`. These are tagged `WIN32_API` and are +not R5 object fields. + +### Remote AFK state + +`STATIC_037`: + +- `CRemotePlayer::Process` is `samp.dll+0x166B0`; +- the sync-state byte is at `CRemotePlayer+0x10A`; +- the last-sync tick is at `CRemotePlayer+0x1B9`; +- the AFK/display state DWORD is at `CRemotePlayer+0x1C5`; +- the remote ped wrapper is at `CRemotePlayer+0x1DD`; +- elapsed time is calculated as `GetTickCount() - last_sync_tick`. + +The guarded transition blocks establish: + +- on-foot at `+0x16DA0`: state `2` after 3000 ms for an all-zero movement + vector, otherwise after 1500 ms; +- driver at `+0x16FE2`: the same 3000/1500 ms split; +- passenger at `+0x1704E`: state `2` after 3000 ms; +- common clear at `+0x170D6`: state `2` returns to `0` when elapsed is below + 1500 ms. + +The public-facing meaning “AFK glyph” is corroborated by the existing R5 +nametag audit, but this profile still records the field numerically. Runtime +must establish exact ordering relative to sync receive and rendering. + +## Hook set and guards + +All nine hooks must pass before the first target is modified: + +| RVA | Static role | Copied entry | Additional guards | +| --- | --- | ---: | --- | +| `+0x612C0` | chat display-mode cycle | 5 bytes | complete remaining body and all returns | +| `+0x69480` | chat open | 6 bytes | final active-state write and epilogue | +| `+0x69580` | chat close | 9 bytes | cursor call, active-state clear, epilogue | +| `+0x6E9E0` | scoreboard hide | 6 bytes | argument branch, cursor call, `ret 4` | +| `+0x6F3D0` | scoreboard show | 6 bytes | cursor call, visible-state write, return | +| `+0xA05D0` | delayed input restore | 7 bytes | both decrement/return forms | +| `+0xA06F0` | cursor mode | 6 bytes | mode `0..4` return forms, all `ret 8` | +| `+0xA0920` | menu query | 6 bytes | boolean conversion and return | +| `+0x166B0` | remote Process/AFK | 6 bytes | AFK blocks and both function tails | + +Each entry span contains whole instructions and no relative branch or call. +The three guarded SA-MP globals at relocation RVAs `+0x695AF`, `+0x6EA11`, +and `+0x6F408` are compared against the actual R5 module base. The menu-query +entry embeds GTA's absolute address and is not an R5 relocation, so the fixed +GTA-base check is still required. + +Installation is all-or-nothing. A failed partial install is rolled back in +reverse order. Shutdown restores a target only if its current bytes are the +exact `E9` plus NOP patch owned by this profile. Trampoline memory remains +process-lifetime storage so an in-flight wrapper cannot jump into freed code. + +## Bounded capture + +Hooked threads allocate no heap memory and perform no file I/O. Records are +published to a fixed 256-entry ring. The worker flushes: + +- `ui_latches_r5`: event, reason bits, tick, GTA frame, thread, caller and + hook RVA, arguments, raw transition values, and remote AFK timing; +- `ui_latch_state_r5`: scoreboard/chat/game pointers and raw fields, + frontend/pause/input-gate bytes, and read-only Win32 focus/cursor state. + +TAB, chat, and cursor-mode calls get pre/post snapshots. Delayed-restore +records are emitted only when a raw counter or input-gate byte changes. Their +changed pre-state fields are retained from the lightweight reads taken before +the original call; unchanged UI/Win32 context is copied from the single post +snapshot so the per-frame no-change path does not perform duplicate queries. +Menu-query records are baseline/edge-only. + +Remote players use a fixed 128-slot tracker. A record is emitted for the first +observation, an AFK/sync-state edge, or a one-second heartbeat. If more than +128 distinct remote objects appear in one process, the worker logs +`remote_tracker_overflow`; heartbeat tracking is suppressed for additional +objects, while direct before/after state changes can still be recorded. + +Reason bits: + +- `0x01`: direct transition-call record; +- `0x02`: first baseline; +- `0x04`: AFK/menu/sync state edge; +- `0x08`: one-second remote heartbeat; +- `0x10`: delayed input-gate edge. + +## Enablement + +Environment: + +```text +SAMP_PROBE_UI_LATCHES_HOOKS=1 +``` + +Flag next to `samp_probe.asi`: + +```text +samp_probe_ui_latches_hooks.flag +``` + +Managed Windows profile: + +```bash +tools/windows/remote_lab/samp_lab.sh probe-profile ui-latches-r5 +``` + +The focused profile skips the probe's normal Winsock/IAT, render, trailer, and +unrelated code-hook sets. + +Prepared bounded runner path: + +```bash +python3 tools/reloop/distributed_sync_runner.py \ + --scenario ui_latches \ + --windows-role pilot \ + --windows-probe-profile ui-latches-r5 +``` + +The path requires Original R5 on Windows and sends only the allowlisted +sequence TAB, TAB, F6, F6, F7, F7, F7. Each TAB pulse is held for 750 ms and +therefore contributes one `WM_KEYUP` toggle; the function keys receive 100 ms +down/up edges. It does not automate ESC, the GTA pause menu, focus loss, or +arbitrary input. A successful runner verdict proves only that the expected +probe calls were recorded without ring overflow; screenshots and exact state +transitions still require review. + +## Original-client runtime matrix + +`OBSERVED_037 + PROBE_TRACE`: + +- run `20260728_150323_dist_sync_ui_latches_pilot_1eeb41f8`, Original R5 + SHA-256 + `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2`, + recorded one `scoreboard_show` from caller `samp.dll+0x617BB` after one + complete TAB pulse and no hide edge; +- the final screenshot retained the visible scoreboard, corroborating the + recorded `scoreboard_visible=1` state; +- chat open/close, three F7 mode edges, all nine hook installations, and no + ring overflow were recorded. The stored failure verdict was therefore a + test-plan error rather than an observed original-client latch defect. + +A controlled original R5 run should additionally cover, in order: + +1. connect and idle for at least five seconds with another streamed player; +2. press and release TAB twice, then repeat with right-button interaction; +3. hold TAB, lose focus, regain focus while TAB is still held, then release; +4. open and close chat with F6 and with the normal chat key; +5. press F7 three times and confirm the raw `0 -> 2 -> 1 -> 0` cycle from the + actual starting state; +6. open and close the GTA pause menu while observing menu-query edges; +7. stop and resume sync from the second player to cross 1500 and 3000 ms; +8. repeat the AFK transition while on foot, driving, and riding as passenger; +9. quit cleanly and verify nine owned hooks were restored. + +Record the exact DLL/GTA hashes, server, gamemode, client role, key sequence, +and latest `process_attach` block. Only after this run may the resulting lines +be tagged `OBSERVED_037` / `PROBE_TRACE`. + +## Open points + +- `TODO_VERIFY`: whether the statically established plain-TAB `WM_KEYUP` + toggle is suppressed by any additional frontend/focus state beyond the + already observed guards. +- `TODO_VERIFY`: exact caller-to-reason mapping for right-button scoreboard + interaction. +- `TODO_VERIFY`: whether `CGame+0x65` has any role beyond the statically seen + delayed input restore. +- `TODO_VERIFY`: semantic meaning of `0x00B7CB49` in the R5 process. +- `TODO_VERIFY`: whether `CRemotePlayer::Process` ever runs outside the main + game thread. +- `TODO_VERIFY`: the exact render frame in which AFK state `2` becomes a + visible nametag glyph. diff --git a/docs/re/vehicle_streamin_paintjob_r5_20260727.md b/docs/re/vehicle_streamin_paintjob_r5_20260727.md new file mode 100644 index 0000000..4bd168e --- /dev/null +++ b/docs/re/vehicle_streamin_paintjob_r5_20260727.md @@ -0,0 +1,63 @@ +# R5 vehicle stream-in paintjob semantics + +Date: 2026-07-27 + +Original binary: + +- `samp.dll` R5 +- SHA256: + `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2` + +## Static R5 result + +`STATIC_037`: the RPC 164 stream-in path does not pass the received paintjob +byte straight to GTA: + +- `samp.dll+0xE820`: clear the temporary integer. +- `samp.dll+0xE822`: load the RPC paintjob byte. +- `samp.dll+0xE826`: test the byte. +- `samp.dll+0xE828`: skip paintjob application when it is zero. +- `samp.dll+0xE82A`: decrement a non-zero byte. +- `samp.dll+0xE82F`: call the vehicle paintjob wrapper at + `samp.dll+0xB80F0`. + +The wrapper at `samp.dll+0xB80F0`: + +- classifies the live GTA vehicle through `samp.dll+0xB61E0`; +- only continues for the automobile subtype; +- accepts logical paintjob IDs `0..3`; +- invokes GTA opcode `0x06ED` through the command descriptor at + `samp.dll+0xED628`. + +Therefore the RPC 164 wire encoding is: + +| Wire byte | R5 behavior | +| --- | --- | +| `0` | no paintjob request; do nothing | +| `1..4` | apply GTA logical paintjob `0..3` to automobiles | +| `>4` | rejected by the wrapper's logical range check | + +This is corroborated by `OPENMP_REF`: open.mp stores vehicle paintjobs as +logical IDs and adds one when constructing the stream-in RPC because zero means +"no paintjob" on that wire path. + +## Legacy distinction + +The 0.2x legacy source also guards its stream-in paintjob call with a non-zero +test. Its older code passes the remaining byte directly to opcode `0x06ED`, +however, so it is not evidence for the R5 byte-to-logical mapping. The R5 +instructions above decide replacement behavior. + +## Current trailer A/B interpretation + +The compared model 435 (`artict1`) stream-in event contains `paintjob=0`. +`STATIC_037` proves that original R5 does not apply opcode `0x06ED` for that +event. The original screenshot's Cok-O-Pops appearance and the replacement's +dark/plain appearance therefore cannot be fixed compatibly by forcing logical +paintjob 0. + +`TODO_VERIFY`: isolate the remaining model-435 visual difference as GTA default +remap/material state. Repeat comparable original/replacement starts while +recording the trailer's live remap texture/material state and creation order. +Lighting and camera angle should be held constant. Do not add a model-specific +paintjob override without such evidence. diff --git a/docs/traces/class_selection_skin298_crash_20260727.md b/docs/traces/class_selection_skin298_crash_20260727.md new file mode 100644 index 0000000..2bf5c75 --- /dev/null +++ b/docs/traces/class_selection_skin298_crash_20260727.md @@ -0,0 +1,65 @@ +# Class-selection skin 298 crash (2026-07-27) + +## Scenario + +- Replacement prefix: + `/home/chairman/Games/san-andreas-multiplayer-legacy-libsamp` +- Server: local UFW, `127.0.0.1:7777`, 49 classes +- Original R5 DLL SHA256: + `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2` +- Reproduction sequence: skin `45 -> 44 -> 45 -> 298` + +## Failure evidence + +`PROBE_TRACE`: the old replacement completed skin 45/44 changes, then stopped +after receiving skin 298. Instrumented reproduction narrowed the stop to: + +1. opcode `0247` / `RequestModel(298)` returned; +2. opcode `038B` / global `LoadAllRequestedModels` never returned; +3. GTA consumed a CPU core until the process died. + +The 290-299 special-character streaming slots have no normal DFF mapping in +this replacement run (`load_state=0`, `cd_size=0`). Calling the global blocking +loader after requesting one of those slots was therefore unsafe. + +## Original R5 evidence + +`STATIC_037`: R5 `CPlayerPed::SetModelIndex` at `samp.dll+0x9EF50` checks model +availability/RW state, requests an unavailable target and bounds its final +availability loop to 200 one-millisecond waits. Its caller at +`samp.dll+0xAFF82..+0xAFF9A` also patches GTA +`CClothes::RebuildPlayer` at `0x5A82C0` from `0x56` to `0xC3` immediately +before changing the local ped model. It does not apply that guard during GTA +startup. + +## Replacement change + +- Apply the observed GTA `0x5A82C0` guard lazily at the first SA-MP model + change, after GTA has built the initial player clothes clump. +- Request only the selected target and let normal GTA streaming ticks finish + it; never enter the unbounded global `038B` path from the graphics callback. +- Keep the R5 200 ms failure budget and leave an unavailable special slot + unapplied instead of blocking/crashing. +- Keep the replacement square class-selection controls as the default. + External R5 `sampgui.png` controls are opt-in through + `SAMPDLL_CLASS_SELECTION_R5_TEXTURE=1`. + +## Verification + +- Candidate/install SHA256: + `3746b0920ee3302b690d6d5cb323c536d981ae42b9488730bf4208ef6a9bb73b` +- `PROBE_TRACE`: the same UFW run reached skin 298 repeatedly, continued + through skins 297, 296 and 295, and stayed responsive for the 120-second + observation window without `exception_filter`. +- Normal skins 45 and 44 followed + `requested -> loaded -> complete (applied=1)`. +- UI trace reported `class_selection ... texture=0`, confirming the square + default. +- Host tests: 12/12 passed. + +## Open parity point + +The crash is closed, but visual parity for GTA special-character slots 290-299 +is still open. They currently retain the last valid visible ped instead of +loading the original named special character. Reproduce R5's special-character +slot population before applying those models. diff --git a/docs/traces/initgame_playerinit_openmp_20260610.md b/docs/traces/initgame_playerinit_openmp_20260610.md new file mode 100644 index 0000000..36b9bd8 --- /dev/null +++ b/docs/traces/initgame_playerinit_openmp_20260610.md @@ -0,0 +1,53 @@ +# InitGame / PlayerInit Trace Note - 2026-06-10 + +## Result + +`RPC 139` from the current open.mp bare run is an open.mp `PlayerInit` +layout, not the sequential 0.3.7 server layout used by the fallback decoder. + +## Evidence + +`PROBE_TRACE`: + +* Prefix GTA run, `samp_net_trace.log`, 2026-06-10 00:22 CEST: + `rpc-in id=139 name=ScrInitGame bits=2243 bytes=281 first=20 00 04 84 38 00 04 62 10 0c 00 00 00 01 00 80 80 00 00 06 05 37 89 01 9e 00 00 00 00 03 c0 00` +* The old decoder produced implausible state: + `local_player=33796`, `tags=0`, `markers=0`, `nametag_dist=0.000`, + bad gravity/send-rate values. + +`OPENMP_REF`: + +* `/home/chairman/Projects/omp-ipv6/open.mp/Shared/NetCode/core.hpp` + `NetCode::RPC::PlayerInit` writes: + zone/CJ/interior/chat flags, chat radius, stunt, name-tag distance, + enter/LOS/manual-vehicle flags, spawn count, player id, name-tag flag, + marker mode, time/weather/gravity, lan/death/instagib, rates, server name, + vehicle models, vehicle-friendly-fire. + +Manual decode of the current trace prefix with that layout gives: + +* `spawns=12` +* `player=1` +* `tags=true` +* `markers=1` +* `time=12` +* `weather=10` +* `gravity=0.0080000004` +* `nametag_dist=70.0` + +`STATIC_037`: + +* Local 0.3.7 cross-check keeps the sequential fallback alive: the first + fallback field is the NetGame spawn-count `int`, followed by player id and + the classic InitGame flags. + +## Expected Next Trace + +After the decoder fix, the replacement DLL should log: + +```text +rpc-state id=139 init_game layout=openmp spawns=12 local_player=1 tags=1 markers=1 ... gravity=0.008000 ... nametag_dist=70.000 +``` + +The runtime line should then apply the same values instead of clamping/falling +back from shifted garbage. diff --git a/docs/traces/windows_trailer_packet210_r5_20260727.md b/docs/traces/windows_trailer_packet210_r5_20260727.md new file mode 100644 index 0000000..2dbe37d --- /dev/null +++ b/docs/traces/windows_trailer_packet210_r5_20260727.md @@ -0,0 +1,195 @@ +# Windows trailer Packet 210 parity, 2026-07-27 + +## Topology + +The stable comparison topology is: + +- one original-R5 pilot in the single active host Wine/Lutris prefix; +- one native-Windows observer; +- original `samp.dll` and replacement `samp.dll` are deployed to the Windows + observer in separate, otherwise comparable runs. + +Two simultaneous host prefixes are not a supported test topology because they +interfere with focus/window scheduling and have produced unstable runs. + +Reference identities: + +- original R5 SHA256 + `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2`; +- GTA SA US 1.0 SHA256 + `a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26`. + +## Comparable runs + +### Original golden + +`OBSERVED_037 + PROBE_TRACE` + +Run: + +`artifacts/runs/20260727-213058-windows-sync-edge-trailer-1001101` + +The focused R5 probe captured 376 complete Packet 210 applications: + +- `noop`: 354; +- `correct`: 22; +- `snap`: 0; +- residual median: `0.300 m`; +- residual p95: `0.563 m`; +- residual maximum: `0.989 m`; +- mean cadence: `27.94 Hz`. + +The first Packet 210 target was approximately +`(205.988, 1894.459, 18.226)`. The live trailer was already at +`(205.954, 1894.468, 18.259)`, only about `0.048 m` away. + +### Bad replacement baseline + +`PROBE_TRACE` + +Run: + +`artifacts/runs/20260727-214215-windows-sync-edge-trailer-1012402` + +This run recorded 99 hard snaps and a maximum residual of `8.788 m`. RakNet +link state, packet order and cadence were healthy, while the tractor generally +remained within roughly `0.3 m`. The failure was therefore local replacement +playback/physics rather than transport loss. + +### Quaternion-corrected replacement + +`PROBE_TRACE` + +Run: + +`artifacts/runs/20260727-221340-windows-sync-edge-trailer-1069028` + +Replacement SHA256: + +`6080acffd529cb9b05547a9ffb809a45a0f65910f7b350f4529245574cb68203` + +Results: + +- complete matrix/quaternion readback stayed at `basis_dot=1.000000`; +- hard snaps fell from 99 to 2; +- snap at Packet 210 sequence 137: residual `5.886 m`; +- snap at sequence 444: residual `6.896 m`; +- sequence 448 recovered to a residual of about `0.177 m`; +- no crash or transport/reassembly fault was observed. + +This is a material improvement but not original parity. In particular, the +first Packet 210 already found the local replacement trailer about `1.259 m` +too high: + +- target Z: approximately `18.238`; +- live Z: `19.497`. + +The tractor and trailer were queued and created in the expected pair order and +RPC 148 attach succeeded before the first Packet 200/210. The remaining first +frame error therefore arises after create/attach and before Packet 210 +correction. At sequence 128 the replacement residual was about `2.328 m`, +versus `0.330 m` in the original. The tractor itself remained comparatively +stable. + +## GTA tow-link static result + +`STATIC_037 + GTA_REVERSED_REF` + +GTA opcode `0893` was followed from its handler-table entry through the +`0x4720BB` case: + +- parameter 1 resolves to the trailer; +- parameter 2 resolves to the tractor; +- the handler invokes the trailer vtable slot `+0xF4` with the tractor and + boolean argument `true`; +- `CTrailer` vtable slot `+0xF4` resolves to + `gta_sa.exe+0x2CFDF0`, `CTrailer::SetTowLink`. + +The replacement's existing opcode call therefore has the correct argument +order and explicitly takes the repositioning branch: + +`trailer->SetTowLink(tractor, true)`. + +This confirms that original RPC 148 and the replacement both select the same +true/`PlaceOnRoadProperly` branch; a wrong replacement boolean does not explain +the `+1.259 m` Z error. The remaining key difference is timing. In the bad +replacement trace, delayed attach replay, the first Packet 200 and the first +Packet 210 all ran in one runtime iteration before the trailer's first +`ProcessControl`. The original fixture leaves about 250 ms between StreamIn +and RPC 148 and then reaches Packet 210 on a later network/game tick. + +A bounded opt-in probe now records the SetTowLink entry/return and the first +64 direct `CTrailer::ProcessControl` frames. It distinguishes an attach-time +position jump from stale Packet 210 replay before the first tow physics frame, +without changing gameplay. Both hooks are installed only after exact GTA US +1.0 identity and entry/tail-byte preflight. A dedicated gateway relocates the +relative call in the ProcessControl prologue. The debug ASI built successfully +at `build-asi-probe-trailer-physics/samp_probe.asi`; the local verification +build SHA256 was +`ffa90f692d7abc69213939888feaf287d304f1a70d0ebb5287047078d1d7154c`. + +## Current interpretation + +`STATIC_037 + PROBE_TRACE` + +The Packet 210 deadband, correction and snap algorithm should not be loosened: +its observed thresholds and writes match R5. The complete quaternion path is +also no longer the primary suspect. + +`INFERRED + TODO_VERIFY` + +The remaining divergence is most likely in remote-driver prediction and/or +the GTA tow-link constraint state: + +1. The replacement previously cached Packet 200 steering without advancing + the remote GTA `CPad` once per driver frame. This can make tractor prediction + disagree with the pilot between packets and destabilize the attached + trailer. +2. The replacement trailer is already vertically wrong immediately after + RPC 148. Original and replacement must be compared directly around GTA + `CTrailer::SetTowLink` (`gta_sa.exe+0x2CFDF0`) and the first + `CTrailer::ProcessControl` frames (`gta_sa.exe+0x2CED20`). + +The replacement's deferred vehicle creation now retains a dependent RPC 148 +at the vehicle-event cursor. It creates no more than one globally oldest +vehicle through the normal hold/100-ms gate, retries the same attach event, +and advances the cursor only once both dependencies are active. This removes +the previous unsafe path that could synchronously drain a large pending pool +or consume the attach before its trailer existed. It is a crash-safety bridge +and remains `TODO_VERIFY` against the synchronous R5 construction timing. + +The first run with the new `trailer-r5` profile will record, before and after +`SetTowLink` and for the first 64 trailer frames: + +- tractor `CVehicle+0x4C8` and trailer `CVehicle+0x4C4` associations; +- both complete matrices and positions; +- move and turn speeds; +- `SetTowLink` arguments/result; +- the trailer state around Packet 210 sequences 128..137. + +The shared movement consumer also previously advanced its cursor after a +runtime `DEFER`. This permanently lost an early Packet 200/209/210 when its +GTA vehicle did not yet exist. The current implementation retains the head, +does not advance the packet-specific cursor, and retries it after creation. +Packet 200 now waits for both its towing vehicle and advertised trailer; +Packet 210 waits for live pool, tow-link, position, quaternion and speed +dependencies. A 2000-ms safety bound and ring-gap trace prevent a permanent +global head-of-line stall. This is `PROBE_TRACE + INFERRED` and still needs the +distributed Windows verdict. + +## Pending distributed verdict + +The current replacement build adds per-frame R5 driver controls, exact +one-byte horn state, exact live siren-bit handling, RPC 164 creation-order +replay with retained dependent events, and exact constructor dirt/door-lock +fields. Attach and Packet 210 diagnostics now include each GTA vehicle's local +activation age so the replay collapse can be measured directly. Its SHA256 is: + +`ded014d1775e116dade95f75460d43bef591e4b0ca34f2f1c130fece1d951736` + +It built successfully, host tests pass `14/14`, and the same deployed hash +passed the single-prefix Vehicle regression 4/4 without a crash in +`artifacts/runs/20260727-231629-replacement-vehicle-1142907`. The native +Windows host remained unreachable with `No route to host`, so neither this DLL +nor the new physics probe has a distributed trailer verdict yet. This is an +infrastructure limitation, not a parity pass. diff --git a/omp-server-bare/filterscripts/sync_pair.pwn b/omp-server-bare/filterscripts/sync_pair.pwn index fc5b86c..7fd008f 100644 --- a/omp-server-bare/filterscripts/sync_pair.pwn +++ b/omp-server-bare/filterscripts/sync_pair.pwn @@ -10,6 +10,8 @@ #define SYNC_PAIR_SAMPLE_MS (250) #define SYNC_PAIR_HEARTBEAT_MS (1000) #define SYNC_PAIR_REQUEST_POLL_MS (250) +#define SYNC_PAIR_EDGE_STREAM_RETRY_LIMIT (40) +#define SYNC_PAIR_AUTOSPAWN_RETRY_LIMIT (12) #define SYNC_PAIR_REQUEST_FILE ("sync_pair_request.txt") #define SYNC_PAIR_RESULT_FILE ("sync_pair_results.log") @@ -17,6 +19,7 @@ #define SYNC_PAIR_Y (1885.0) #define SYNC_PAIR_Z (17.65) #define SYNC_PAIR_TARGET_DISTANCE (25.0) +#define SYNC_PAIR_PASSENGER_G_X_OFFSET (2.4) enum E_SYNC_PAIR_SCENARIO { @@ -24,9 +27,13 @@ enum E_SYNC_PAIR_SCENARIO SYNC_PAIR_ONFOOT, SYNC_PAIR_CAR, SYNC_PAIR_RUSTLER, + SYNC_PAIR_PASSENGER, + SYNC_PAIR_UNOCCUPIED, + SYNC_PAIR_TRAILER, SYNC_PAIR_JETPACK, SYNC_PAIR_PICKUP, - SYNC_PAIR_DEATH + SYNC_PAIR_DEATH, + SYNC_PAIR_PASSENGER_G }; static gSyncPilot = INVALID_PLAYER_ID; @@ -35,6 +42,7 @@ static bool:gSyncSpawned[MAX_PLAYERS]; static E_SYNC_PAIR_SCENARIO:gSyncScenario; static gSyncVehicle = INVALID_VEHICLE_ID; static gSyncTargetVehicle = INVALID_VEHICLE_ID; +static gSyncTrailer = INVALID_VEHICLE_ID; static gSyncPickup = -1; static WEAPON:gSyncWeapon = WEAPON_FIST; static gSyncLastSampleTick; @@ -45,12 +53,22 @@ static gSyncLastLeftRight; static PLAYER_STATE:gSyncLastState = PLAYER_STATE_NONE; static gSyncUpdateCount; static gSyncShotCount; +static gSyncUnoccupiedUpdateCount; +static gSyncTrailerUpdateCount; static gSyncActiveRequestId; static gSyncRequestTimer; +static bool:gSyncPairReadyAnnounced; +static bool:gSyncPassengerEnterSeen; +static gSyncPassengerEnterVehicle = INVALID_VEHICLE_ID; +static gSyncPassengerEnterIsPassenger = -1; +static bool:gSyncPassengerResultEmitted; -forward SyncPairAutoSpawn(playerid); +forward SyncPairAutoSpawn(playerid, attempt); forward SyncPairPollRequest(); forward SyncPairRestorePilotWorld(); +forward SyncPairFinalizeEdgeSetup(expectedScenario, vehicleid, trailerid, attempt); +forward SyncPairVerifyEdgeSetup(expectedScenario, vehicleid, trailerid, attempt); +forward SyncPairVerifyPassengerEntry(vehicleid, requestId, attempt); stock SyncPairScenarioName(E_SYNC_PAIR_SCENARIO:scenario, output[], size) { @@ -59,9 +77,13 @@ stock SyncPairScenarioName(E_SYNC_PAIR_SCENARIO:scenario, output[], size) case SYNC_PAIR_ONFOOT: format(output, size, "onfoot"); case SYNC_PAIR_CAR: format(output, size, "car"); case SYNC_PAIR_RUSTLER: format(output, size, "rustler"); + case SYNC_PAIR_PASSENGER: format(output, size, "passenger"); + case SYNC_PAIR_UNOCCUPIED: format(output, size, "unoccupied"); + case SYNC_PAIR_TRAILER: format(output, size, "trailer"); case SYNC_PAIR_JETPACK: format(output, size, "jetpack"); case SYNC_PAIR_PICKUP: format(output, size, "pickup"); case SYNC_PAIR_DEATH: format(output, size, "death"); + case SYNC_PAIR_PASSENGER_G: format(output, size, "passenger_g"); default: format(output, size, "none"); } return 1; @@ -133,6 +155,10 @@ stock bool:SyncPairScenarioKnown(const scenario[]) !strcmp(scenario, "sniper", true) || !strcmp(scenario, "car", true) || !strcmp(scenario, "rustler", true) || + !strcmp(scenario, "passenger", true) || + !strcmp(scenario, "passenger_g", true) || + !strcmp(scenario, "unoccupied", true) || + !strcmp(scenario, "trailer", true) || !strcmp(scenario, "jetpack", true) || !strcmp(scenario, "pickup", true) || !strcmp(scenario, "death", true) || @@ -150,8 +176,13 @@ stock SyncPairDestroyVehicles() { DestroyVehicle(gSyncTargetVehicle); } + if (gSyncTrailer != INVALID_VEHICLE_ID && IsValidVehicle(gSyncTrailer)) + { + DestroyVehicle(gSyncTrailer); + } gSyncVehicle = INVALID_VEHICLE_ID; gSyncTargetVehicle = INVALID_VEHICLE_ID; + gSyncTrailer = INVALID_VEHICLE_ID; return 1; } @@ -165,6 +196,12 @@ stock SyncPairDestroyPickup() return 1; } +/// Requires both roles to be mutually streamed, not merely spawned. +/// PROBE_TRACE: +/// Run 20260727-194645-sync-edge-all-897587 observed the original pilot's +/// reverse stream-in eleven seconds after the replacement observer spawned. +/// Starting a vehicle scenario at spawn readiness raced that boundary. +/// Reference: https://open.mp/docs/scripting/functions/IsPlayerStreamedIn stock bool:SyncPairReady() { return gSyncPilot != INVALID_PLAYER_ID && @@ -172,7 +209,23 @@ stock bool:SyncPairReady() IsPlayerConnected(gSyncPilot) && IsPlayerConnected(gSyncObserver) && gSyncSpawned[gSyncPilot] && - gSyncSpawned[gSyncObserver]; + gSyncSpawned[gSyncObserver] && + IsPlayerStreamedIn(gSyncPilot, gSyncObserver) && + IsPlayerStreamedIn(gSyncObserver, gSyncPilot); +} + +stock SyncPairAnnounceReady() +{ + if (!SyncPairReady() || gSyncPairReadyAnnounced) + { + return 0; + } + gSyncPairReadyAnnounced = true; + printf("[sync_pair] marker=PAIR_READY pilot=%d observer=%d", + gSyncPilot, gSyncObserver); + SendClientMessage(gSyncPilot, 0x66CCFFFF, + "[sync_pair] Ready: /syncpair pistol | m4 | sniper | car | rustler | passenger | passenger_g | unoccupied | trailer | jetpack | pickup | death | stop"); + return 1; } stock bool:SyncPairPilotReady() @@ -200,6 +253,12 @@ stock SyncPairResetSampling() gSyncLastState = PLAYER_STATE_NONE; gSyncUpdateCount = 0; gSyncShotCount = 0; + gSyncUnoccupiedUpdateCount = 0; + gSyncTrailerUpdateCount = 0; + gSyncPassengerEnterSeen = false; + gSyncPassengerEnterVehicle = INVALID_VEHICLE_ID; + gSyncPassengerEnterIsPassenger = -1; + gSyncPassengerResultEmitted = false; return 1; } @@ -306,6 +365,71 @@ stock SyncPairBegin(E_SYNC_PAIR_SCENARIO:scenario) PutPlayerInVehicle(gSyncPilot, gSyncVehicle, 0); if (SyncPairReady()) SyncPairSetObserverCamera(SYNC_PAIR_Z + 2.5); } + case SYNC_PAIR_PASSENGER: + { + gSyncWeapon = WEAPON_FIST; + gSyncVehicle = CreateVehicle(411, SYNC_PAIR_X, SYNC_PAIR_Y, SYNC_PAIR_Z + 0.5, + 180.0, 1, 1, -1); + if (SyncPairReady()) SyncPairSetObserverCamera(SYNC_PAIR_Z + 1.5); + SetTimerEx("SyncPairFinalizeEdgeSetup", 250, false, "iiii", + _:SYNC_PAIR_PASSENGER, gSyncVehicle, INVALID_VEHICLE_ID, 1); + } + case SYNC_PAIR_PASSENGER_G: + { + /* + * STATIC_037: + * R5's passenger-entry scan rejects vehicles at >= 4.0 units. + * Model 411 has one unambiguous passenger seat, and the pilot is + * placed on its right/east side facing west. No seating native is + * used: the only transition is the client's local G action. + */ + gSyncWeapon = WEAPON_FIST; + SetPlayerPos( + gSyncPilot, + SYNC_PAIR_X + SYNC_PAIR_PASSENGER_G_X_OFFSET, + SYNC_PAIR_Y, + SYNC_PAIR_Z + ); + SetPlayerFacingAngle(gSyncPilot, 90.0); + SetCameraBehindPlayer(gSyncPilot); + gSyncVehicle = CreateVehicle( + 411, + SYNC_PAIR_X, + SYNC_PAIR_Y, + SYNC_PAIR_Z + 0.5, + 0.0, + 1, + 1, + -1 + ); + if (gSyncVehicle != INVALID_VEHICLE_ID) + { + SetVehicleHealth(gSyncVehicle, 5000.0); + } + if (SyncPairReady()) SyncPairSetObserverCamera(SYNC_PAIR_Z + 1.5); + SetTimerEx("SyncPairFinalizeEdgeSetup", 250, false, "iiii", + _:SYNC_PAIR_PASSENGER_G, gSyncVehicle, INVALID_VEHICLE_ID, 1); + } + case SYNC_PAIR_UNOCCUPIED: + { + gSyncWeapon = WEAPON_FIST; + gSyncVehicle = CreateVehicle(411, SYNC_PAIR_X, SYNC_PAIR_Y, SYNC_PAIR_Z + 0.5, + 180.0, 1, 1, -1); + if (SyncPairReady()) SyncPairSetObserverCamera(SYNC_PAIR_Z + 1.5); + SetTimerEx("SyncPairFinalizeEdgeSetup", 250, false, "iiii", + _:SYNC_PAIR_UNOCCUPIED, gSyncVehicle, INVALID_VEHICLE_ID, 1); + } + case SYNC_PAIR_TRAILER: + { + gSyncWeapon = WEAPON_FIST; + gSyncVehicle = CreateVehicle(515, SYNC_PAIR_X, SYNC_PAIR_Y, SYNC_PAIR_Z + 0.5, + 180.0, 1, 1, -1); + gSyncTrailer = CreateVehicle(435, SYNC_PAIR_X, SYNC_PAIR_Y + 8.0, + SYNC_PAIR_Z + 0.5, 180.0, 1, 1, -1); + if (SyncPairReady()) SyncPairSetObserverCamera(SYNC_PAIR_Z + 2.0); + SetTimerEx("SyncPairFinalizeEdgeSetup", 250, false, "iiii", + _:SYNC_PAIR_TRAILER, gSyncVehicle, gSyncTrailer, 1); + } case SYNC_PAIR_JETPACK: { gSyncWeapon = WEAPON_FIST; @@ -337,9 +461,9 @@ stock SyncPairBegin(E_SYNC_PAIR_SCENARIO:scenario) } } - printf("[sync_pair] marker=SCENARIO_START request=%d scenario=%s pilot=%d observer=%d vehicle=%d target_vehicle=%d weapon=%d", + printf("[sync_pair] marker=SCENARIO_START request=%d scenario=%s pilot=%d observer=%d vehicle=%d target_vehicle=%d trailer=%d weapon=%d", gSyncActiveRequestId, scenarioName, gSyncPilot, gSyncObserver, gSyncVehicle, - gSyncTargetVehicle, _:gSyncWeapon); + gSyncTargetVehicle, gSyncTrailer, _:gSyncWeapon); SendClientMessage(gSyncPilot, 0x66FF66FF, "[sync_pair] Scenario ready; host input may start."); if (SyncPairReady()) { @@ -352,9 +476,9 @@ stock SyncPairStop(const reason[]) { new scenarioName[16]; SyncPairScenarioName(gSyncScenario, scenarioName, sizeof(scenarioName)); - printf("[sync_pair] marker=SCENARIO_STOP request=%d scenario=%s reason=%s updates=%d shots=%d weapon=%d", + printf("[sync_pair] marker=SCENARIO_STOP request=%d scenario=%s reason=%s updates=%d shots=%d unoccupied_updates=%d trailer_updates=%d weapon=%d", gSyncActiveRequestId, scenarioName, reason, gSyncUpdateCount, gSyncShotCount, - _:gSyncWeapon); + gSyncUnoccupiedUpdateCount, gSyncTrailerUpdateCount, _:gSyncWeapon); SyncPairDestroyVehicles(); SyncPairDestroyPickup(); if (gSyncPilot != INVALID_PLAYER_ID && IsPlayerConnected(gSyncPilot)) @@ -398,12 +522,329 @@ stock SyncPairBeginNamed(const scenario[]) if (!strcmp(scenario, "sniper", true)) return SyncPairBeginOnFoot(WEAPON_SNIPER); if (!strcmp(scenario, "car", true)) return SyncPairBegin(SYNC_PAIR_CAR); if (!strcmp(scenario, "rustler", true)) return SyncPairBegin(SYNC_PAIR_RUSTLER); + if (!strcmp(scenario, "passenger", true)) return SyncPairBegin(SYNC_PAIR_PASSENGER); + if (!strcmp(scenario, "passenger_g", true)) return SyncPairBegin(SYNC_PAIR_PASSENGER_G); + if (!strcmp(scenario, "unoccupied", true)) return SyncPairBegin(SYNC_PAIR_UNOCCUPIED); + if (!strcmp(scenario, "trailer", true)) return SyncPairBegin(SYNC_PAIR_TRAILER); if (!strcmp(scenario, "jetpack", true)) return SyncPairBegin(SYNC_PAIR_JETPACK); if (!strcmp(scenario, "pickup", true)) return SyncPairBegin(SYNC_PAIR_PICKUP); if (!strcmp(scenario, "death", true)) return SyncPairBegin(SYNC_PAIR_DEATH); return 0; } +/// Completes edge-state seating/attachment only after the involved vehicles +/// are streamed to both roles. +/// References: +/// https://open.mp/docs/scripting/functions/IsVehicleStreamedIn +/// https://open.mp/docs/scripting/functions/PutPlayerInVehicle +/// https://open.mp/docs/scripting/functions/AttachTrailerToVehicle +/// https://open.mp/docs/scripting/functions/SetVehicleVelocity +public SyncPairFinalizeEdgeSetup( + expectedScenario, + vehicleid, + trailerid, + attempt +) +{ + new scenarioName[16]; + new detail[160]; + + if (_:gSyncScenario != expectedScenario || + gSyncVehicle != vehicleid || + !SyncPairReady() || + !IsValidVehicle(vehicleid)) + { + SyncPairScenarioName(E_SYNC_PAIR_SCENARIO:expectedScenario, + scenarioName, sizeof(scenarioName)); + format(detail, sizeof(detail), + "context_lost vehicle=%d trailer=%d attempt=%d", + vehicleid, trailerid, attempt); + SyncPairEmitRequestMarker( + "EDGE_SETUP", + gSyncActiveRequestId, + "FAIL", + scenarioName, + detail + ); + return 0; + } + + new bool:pilotVehicleStreamed = + IsVehicleStreamedIn(vehicleid, gSyncPilot); + new bool:observerVehicleStreamed = + IsVehicleStreamedIn(vehicleid, gSyncObserver); + new bool:pilotTrailerStreamed = true; + new bool:observerTrailerStreamed = true; + new bool:streamed = + pilotVehicleStreamed && observerVehicleStreamed; + if (expectedScenario == _:SYNC_PAIR_TRAILER) + { + pilotTrailerStreamed = + IsVehicleStreamedIn(trailerid, gSyncPilot); + observerTrailerStreamed = + IsVehicleStreamedIn(trailerid, gSyncObserver); + streamed = streamed && + gSyncTrailer == trailerid && + IsValidVehicle(trailerid) && + pilotTrailerStreamed && + observerTrailerStreamed; + } + if (!streamed) + { + if (attempt < SYNC_PAIR_EDGE_STREAM_RETRY_LIMIT) + { + SetTimerEx("SyncPairFinalizeEdgeSetup", 250, false, "iiii", + expectedScenario, vehicleid, trailerid, attempt + 1); + } + else + { + printf("[sync_pair] marker=EDGE_SETUP status=FAIL scenario=%d vehicle=%d trailer=%d reason=stream_timeout pilot_vehicle=%d observer_vehicle=%d pilot_trailer=%d observer_trailer=%d", + expectedScenario, vehicleid, trailerid, pilotVehicleStreamed, + observerVehicleStreamed, pilotTrailerStreamed, + observerTrailerStreamed); + SyncPairScenarioName(gSyncScenario, scenarioName, sizeof(scenarioName)); + format(detail, sizeof(detail), + "stream_timeout vehicle=%d trailer=%d attempts=%d pilot_vehicle=%d observer_vehicle=%d pilot_trailer=%d observer_trailer=%d", + vehicleid, trailerid, attempt, pilotVehicleStreamed, + observerVehicleStreamed, pilotTrailerStreamed, + observerTrailerStreamed); + SyncPairEmitRequestMarker( + "EDGE_SETUP", + gSyncActiveRequestId, + "FAIL", + scenarioName, + detail + ); + } + return 0; + } + + if (expectedScenario == _:SYNC_PAIR_PASSENGER_G) + { + new PLAYER_STATE:actualState = GetPlayerState(gSyncPilot); + new actualVehicle = GetPlayerVehicleID(gSyncPilot); + new actualSeat = GetPlayerVehicleSeat(gSyncPilot); + new Float:distance = GetPlayerDistanceFromPoint( + gSyncPilot, + SYNC_PAIR_X, + SYNC_PAIR_Y, + SYNC_PAIR_Z + 0.5 + ); + new bool:ready = + actualState == PLAYER_STATE_ONFOOT && + actualVehicle == 0 && + distance < 4.0; + + if (!ready && attempt < SYNC_PAIR_EDGE_STREAM_RETRY_LIMIT) + { + SetTimerEx("SyncPairFinalizeEdgeSetup", 250, false, "iiii", + expectedScenario, vehicleid, trailerid, attempt + 1); + return 0; + } + + SyncPairScenarioName(gSyncScenario, scenarioName, sizeof(scenarioName)); + format(detail, sizeof(detail), + "vehicle=%d state=%d seat=%d distance=%.3f action=vk_g attempts=%d", + vehicleid, _:actualState, actualSeat, distance, attempt); + SyncPairEmitRequestMarker( + "EDGE_SETUP", + gSyncActiveRequestId, + ready ? "PASS" : "FAIL", + scenarioName, + detail + ); + return ready; + } + + new bool:putResult; + new bool:velocityResult = true; + new bool:attachResult = true; + if (expectedScenario == _:SYNC_PAIR_PASSENGER || + expectedScenario == _:SYNC_PAIR_UNOCCUPIED) + { + putResult = PutPlayerInVehicle(gSyncPilot, vehicleid, 1); + if (putResult && expectedScenario == _:SYNC_PAIR_UNOCCUPIED) + { + /* + * STATIC_037 + OPENMP_REF + TODO_VERIFY: + * A driverless but moving vehicle with the pilot as its first + * player passenger exercises Packet 209 authority. The official + * SetVehicleVelocity note requires an occupied vehicle; seating + * therefore deliberately precedes velocity. + */ + velocityResult = + SetVehicleVelocity(vehicleid, 0.0, 0.16, 0.0); + } + } + else if (expectedScenario == _:SYNC_PAIR_TRAILER) + { + attachResult = AttachTrailerToVehicle(trailerid, vehicleid); + putResult = PutPlayerInVehicle(gSyncPilot, vehicleid, 0); + } + + if (!putResult || !velocityResult || !attachResult) + { + printf("[sync_pair] marker=EDGE_SETUP status=FAIL scenario=%d vehicle=%d trailer=%d attempts=%d put=%d velocity=%d attach=%d", + expectedScenario, vehicleid, trailerid, attempt, putResult, + velocityResult, attachResult); + SyncPairScenarioName(gSyncScenario, scenarioName, sizeof(scenarioName)); + format(detail, sizeof(detail), + "native_failed vehicle=%d put=%d velocity=%d attach=%d", + vehicleid, putResult, velocityResult, attachResult); + SyncPairEmitRequestMarker( + "EDGE_SETUP", + gSyncActiveRequestId, + "FAIL", + scenarioName, + detail + ); + return 0; + } + + SetTimerEx("SyncPairVerifyEdgeSetup", 250, false, "iiii", + expectedScenario, vehicleid, trailerid, 1); + return 1; +} + +/// Verifies the actual seat/state and trailer relationship after the setup +/// natives have had a server tick to take effect. +/// References: +/// https://open.mp/docs/scripting/functions/GetPlayerVehicleID +/// https://open.mp/docs/scripting/functions/GetPlayerVehicleSeat +/// https://open.mp/docs/scripting/functions/GetPlayerState +/// https://open.mp/docs/scripting/functions/GetVehicleTrailer +public SyncPairVerifyEdgeSetup( + expectedScenario, + vehicleid, + trailerid, + attempt +) +{ + new scenarioName[16]; + new detail[128]; + new actualVehicle; + new actualSeat; + new actualTrailer; + new PLAYER_STATE:actualState; + new bool:verified; + + SyncPairScenarioName(E_SYNC_PAIR_SCENARIO:expectedScenario, + scenarioName, sizeof(scenarioName)); + if (_:gSyncScenario != expectedScenario || + gSyncVehicle != vehicleid || + !SyncPairReady() || + !IsValidVehicle(vehicleid)) + { + format(detail, sizeof(detail), + "verify_context_lost vehicle=%d trailer=%d attempt=%d", + vehicleid, trailerid, attempt); + SyncPairEmitRequestMarker( + "EDGE_SETUP", + gSyncActiveRequestId, + "FAIL", + scenarioName, + detail + ); + return 0; + } + + actualVehicle = GetPlayerVehicleID(gSyncPilot); + actualSeat = GetPlayerVehicleSeat(gSyncPilot); + actualState = GetPlayerState(gSyncPilot); + if (expectedScenario == _:SYNC_PAIR_TRAILER) + { + actualTrailer = GetVehicleTrailer(vehicleid); + verified = + actualVehicle == vehicleid && + actualSeat == 0 && + actualState == PLAYER_STATE_DRIVER && + actualTrailer == trailerid; + } + else + { + verified = + actualVehicle == vehicleid && + actualSeat == 1 && + actualState == PLAYER_STATE_PASSENGER; + } + + if (!verified && attempt < 12) + { + SetTimerEx("SyncPairVerifyEdgeSetup", 250, false, "iiii", + expectedScenario, vehicleid, trailerid, attempt + 1); + return 0; + } + + printf("[sync_pair] marker=EDGE_SETUP status=%s scenario=%d vehicle=%d trailer=%d verify_attempt=%d actual_vehicle=%d actual_seat=%d actual_state=%d actual_trailer=%d", + verified ? "PASS" : "FAIL", expectedScenario, vehicleid, trailerid, + attempt, actualVehicle, actualSeat, _:actualState, actualTrailer); + format(detail, sizeof(detail), + "vehicle=%d seat=%d state=%d trailer=%d verify_attempt=%d", + actualVehicle, actualSeat, _:actualState, actualTrailer, attempt); + SyncPairEmitRequestMarker( + "EDGE_SETUP", + gSyncActiveRequestId, + verified ? "PASS" : "FAIL", + scenarioName, + detail + ); + return verified; +} + +/// Verifies that a client-originated passenger request completed physically. +/// The server callback records the request edge, while state/vehicle/seat prove +/// the later PassengerSync state instead of treating entry animation as success. +/// References: +/// https://open.mp/docs/scripting/callbacks/OnPlayerEnterVehicle +/// https://open.mp/docs/scripting/callbacks/OnPlayerStateChange +/// https://open.mp/docs/scripting/functions/GetPlayerVehicleID +/// https://open.mp/docs/scripting/functions/GetPlayerVehicleSeat +public SyncPairVerifyPassengerEntry(vehicleid, requestId, attempt) +{ + if (gSyncScenario != SYNC_PAIR_PASSENGER_G || + gSyncVehicle != vehicleid || + gSyncActiveRequestId != requestId || + gSyncPassengerResultEmitted || + !IsPlayerConnected(gSyncPilot)) + { + return 0; + } + + new actualVehicle = GetPlayerVehicleID(gSyncPilot); + new actualSeat = GetPlayerVehicleSeat(gSyncPilot); + new PLAYER_STATE:actualState = GetPlayerState(gSyncPilot); + new bool:verified = + gSyncPassengerEnterSeen && + gSyncPassengerEnterVehicle == vehicleid && + gSyncPassengerEnterIsPassenger == 1 && + actualVehicle == vehicleid && + actualSeat == 1 && + actualState == PLAYER_STATE_PASSENGER; + + if (!verified && attempt < 20) + { + SetTimerEx("SyncPairVerifyPassengerEntry", 250, false, "iii", + vehicleid, requestId, attempt + 1); + return 0; + } + + new detail[160]; + format(detail, sizeof(detail), + "rpc=26 enter_seen=%d enter_vehicle=%d is_passenger=%d vehicle=%d seat=%d state=%d verify_attempt=%d", + gSyncPassengerEnterSeen, gSyncPassengerEnterVehicle, + gSyncPassengerEnterIsPassenger, actualVehicle, actualSeat, + _:actualState, attempt); + SyncPairEmitRequestMarker( + "PASSENGER_ENTRY_RESULT", + requestId, + verified ? "PASS" : "FAIL", + "passenger_g", + detail + ); + gSyncPassengerResultEmitted = true; + return verified; +} + /// Loads the isolated two-client fixture. /// Reference: https://open.mp/docs/scripting/callbacks/OnFilterScriptInit public OnFilterScriptInit() @@ -426,11 +867,7 @@ public OnFilterScriptInit() printf("[sync_pair] marker=ROLE_RECOVERED role=%s player=%d spawned=%d", role == 1 ? "pilot" : "observer", playerid, gSyncSpawned[playerid]); } - if (SyncPairReady()) - { - printf("[sync_pair] marker=PAIR_READY pilot=%d observer=%d", - gSyncPilot, gSyncObserver); - } + SyncPairAnnounceReady(); return 1; } @@ -455,9 +892,10 @@ public OnPlayerConnect(playerid) if (role == 1) gSyncPilot = playerid; else gSyncObserver = playerid; gSyncSpawned[playerid] = false; + gSyncPairReadyAnnounced = false; printf("[sync_pair] marker=ROLE_CONNECTED role=%s player=%d", role == 1 ? "pilot" : "observer", playerid); - SetTimerEx("SyncPairAutoSpawn", 1500, false, "i", playerid); + SetTimerEx("SyncPairAutoSpawn", 1500, false, "ii", playerid, 1); return 1; } @@ -470,18 +908,35 @@ public OnPlayerDisconnect(playerid, reason) if (playerid == gSyncPilot) gSyncPilot = INVALID_PLAYER_ID; if (playerid == gSyncObserver) gSyncObserver = INVALID_PLAYER_ID; gSyncSpawned[playerid] = false; + gSyncPairReadyAnnounced = false; } return 1; } /// Forces a fixed test-role spawn without affecting other nicknames. +/// PROBE_TRACE: +/// Run 20260727-194906-sync-edge-all-899837 reached InitGame/class selection +/// after the one-shot 1.5-second SpawnPlayer call and never emitted +/// OnPlayerSpawn. Retry until the callback confirms the spawn so client load +/// time is not confused with a compatibility failure. /// References: https://open.mp/docs/scripting/functions/SetSpawnInfo and /SpawnPlayer -public SyncPairAutoSpawn(playerid) +public SyncPairAutoSpawn(playerid, attempt) { if (!IsPlayerConnected(playerid) || !SyncPairRole(playerid)) return 0; + if (gSyncSpawned[playerid]) return 1; + SetSpawnInfo(playerid, 0, 0, SYNC_PAIR_X, SYNC_PAIR_Y, SYNC_PAIR_Z, 180.0, WEAPON_M4, 500, WEAPON_DEAGLE, 100, WEAPON_KNIFE, 1); - return SpawnPlayer(playerid); + new bool:spawnResult = SpawnPlayer(playerid); + printf("[sync_pair] marker=AUTO_SPAWN player=%d attempt=%d result=%d", + playerid, attempt, spawnResult); + if (!gSyncSpawned[playerid] && + attempt < SYNC_PAIR_AUTOSPAWN_RETRY_LIMIT) + { + SetTimerEx("SyncPairAutoSpawn", 2000, false, "ii", + playerid, attempt + 1); + } + return spawnResult; } /// Records role readiness after the forced spawn. @@ -493,12 +948,7 @@ public OnPlayerSpawn(playerid) gSyncSpawned[playerid] = true; printf("[sync_pair] marker=ROLE_SPAWNED role=%s player=%d", role == 1 ? "pilot" : "observer", playerid); - if (SyncPairReady()) - { - printf("[sync_pair] marker=PAIR_READY pilot=%d observer=%d", gSyncPilot, gSyncObserver); - SendClientMessage(gSyncPilot, 0x66CCFFFF, - "[sync_pair] Ready: /syncpair pistol | m4 | sniper | car | rustler | jetpack | pickup | death | stop"); - } + SyncPairAnnounceReady(); return 1; } @@ -513,6 +963,10 @@ public OnPlayerCommandText(playerid, cmdtext[]) !strcmp(cmdtext, "/syncpair sniper", true) || !strcmp(cmdtext, "/syncpair car", true) || !strcmp(cmdtext, "/syncpair rustler", true) || + !strcmp(cmdtext, "/syncpair passenger", true) || + !strcmp(cmdtext, "/syncpair passenger_g", true) || + !strcmp(cmdtext, "/syncpair unoccupied", true) || + !strcmp(cmdtext, "/syncpair trailer", true) || !strcmp(cmdtext, "/syncpair jetpack", true) || !strcmp(cmdtext, "/syncpair pickup", true) || !strcmp(cmdtext, "/syncpair death", true)) @@ -702,6 +1156,126 @@ public OnPlayerPickUpPickup(playerid, pickupid) return 1; } +/// Records client-originated Packet 209 while returning 1 so open.mp forwards +/// the update to other streamed players. +/// Reference: https://open.mp/docs/scripting/callbacks/OnUnoccupiedVehicleUpdate +public OnUnoccupiedVehicleUpdate( + vehicleid, + playerid, + passenger_seat, + Float:new_x, + Float:new_y, + Float:new_z, + Float:vel_x, + Float:vel_y, + Float:vel_z +) +{ + if (gSyncScenario == SYNC_PAIR_UNOCCUPIED && + playerid == gSyncPilot && + vehicleid == gSyncVehicle) + { + new detail[96]; + gSyncUnoccupiedUpdateCount++; + if (gSyncUnoccupiedUpdateCount <= 3 || + (gSyncUnoccupiedUpdateCount % 20) == 0) + { + printf("[sync_pair] marker=UNOCCUPIED_UPDATE count=%d player=%d vehicle=%d passenger_seat=%d pos=%.3f,%.3f,%.3f vel=%.4f,%.4f,%.4f", + gSyncUnoccupiedUpdateCount, playerid, vehicleid, passenger_seat, + new_x, new_y, new_z, vel_x, vel_y, vel_z); + } + if (gSyncUnoccupiedUpdateCount == 1) + { + format(detail, sizeof(detail), + "packet=209 player=%d vehicle=%d passenger_seat=%d", + playerid, vehicleid, passenger_seat); + SyncPairEmitRequestMarker( + "UNOCCUPIED_UPDATE", + gSyncActiveRequestId, + "PASS", + "unoccupied", + detail + ); + } + } + return 1; +} + +/// Records client-originated Packet 210 while returning 1 so open.mp forwards +/// the trailer update to other streamed players. +/// Reference: https://open.mp/docs/scripting/callbacks/OnTrailerUpdate +public OnTrailerUpdate(playerid, vehicleid) +{ + if (gSyncScenario == SYNC_PAIR_TRAILER && playerid == gSyncPilot) + { + if (vehicleid != gSyncTrailer) + { + printf("[sync_pair] marker=TRAILER_UPDATE_MISMATCH player=%d vehicle=%d expected_trailer=%d", + playerid, vehicleid, gSyncTrailer); + return 1; + } + new detail[80]; + gSyncTrailerUpdateCount++; + if (gSyncTrailerUpdateCount <= 3 || + (gSyncTrailerUpdateCount % 20) == 0) + { + printf("[sync_pair] marker=TRAILER_UPDATE count=%d player=%d vehicle=%d expected_trailer=%d", + gSyncTrailerUpdateCount, playerid, vehicleid, gSyncTrailer); + } + if (gSyncTrailerUpdateCount == 1) + { + format(detail, sizeof(detail), + "packet=210 player=%d vehicle=%d", + playerid, vehicleid); + SyncPairEmitRequestMarker( + "TRAILER_UPDATE", + gSyncActiveRequestId, + "PASS", + "trailer", + detail + ); + } + } + return 1; +} + +/// Records the client-originated passenger-entry request before the ped is +/// seated. STATIC_037 maps this R5 request to RPC 26; the callback itself is +/// the server-side semantic receipt, while raw packet capture remains separate. +/// Reference: https://open.mp/docs/scripting/callbacks/OnPlayerEnterVehicle +public OnPlayerEnterVehicle(playerid, vehicleid, ispassenger) +{ + if (gSyncScenario == SYNC_PAIR_PASSENGER_G && playerid == gSyncPilot) + { + new bool:firstRequest = !gSyncPassengerEnterSeen; + new detail[160]; + new PLAYER_STATE:actualState = GetPlayerState(playerid); + new actualVehicle = GetPlayerVehicleID(playerid); + new actualSeat = GetPlayerVehicleSeat(playerid); + + gSyncPassengerEnterSeen = true; + gSyncPassengerEnterVehicle = vehicleid; + gSyncPassengerEnterIsPassenger = ispassenger; + format(detail, sizeof(detail), + "rpc=26 vehicle=%d expected_vehicle=%d is_passenger=%d state_before=%d current_vehicle=%d seat_before=%d", + vehicleid, gSyncVehicle, ispassenger, _:actualState, + actualVehicle, actualSeat); + SyncPairEmitRequestMarker( + "PASSENGER_ENTER_REQUEST", + gSyncActiveRequestId, + "ACTION", + "passenger_g", + detail + ); + if (firstRequest && !gSyncPassengerResultEmitted) + { + SetTimerEx("SyncPairVerifyPassengerEntry", 250, false, "iii", + gSyncVehicle, gSyncActiveRequestId, 1); + } + } + return 1; +} + /// Records driver/on-foot transitions for packet-path comparison. /// Reference: https://open.mp/docs/scripting/callbacks/OnPlayerStateChange public OnPlayerStateChange(playerid, PLAYER_STATE:newstate, PLAYER_STATE:oldstate) @@ -738,6 +1312,7 @@ public OnPlayerStreamIn(playerid, forplayerid) (playerid == gSyncObserver && forplayerid == gSyncPilot)) { printf("[sync_pair] marker=STREAM_IN player=%d for=%d", playerid, forplayerid); + SyncPairAnnounceReady(); } return 1; } @@ -750,6 +1325,7 @@ public OnPlayerStreamOut(playerid, forplayerid) (playerid == gSyncObserver && forplayerid == gSyncPilot)) { printf("[sync_pair] marker=STREAM_OUT player=%d for=%d", playerid, forplayerid); + gSyncPairReadyAnnounced = false; } return 1; } diff --git a/reimpl/CMakeLists.txt b/reimpl/CMakeLists.txt index 848a4c1..a541292 100644 --- a/reimpl/CMakeLists.txt +++ b/reimpl/CMakeLists.txt @@ -203,6 +203,42 @@ if(SAMPDLL_BUILD_TESTS) add_test(NAME gta_quaternion_compat COMMAND test_gta_quaternion_compat) + add_executable(test_gta_camera_aim_compat + tests/test_gta_camera_aim_compat.c + ) + target_include_directories(test_gta_camera_aim_compat + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ) + if(NOT WIN32) + target_link_libraries(test_gta_camera_aim_compat PRIVATE m) + endif() + + add_test(NAME gta_camera_aim_compat COMMAND test_gta_camera_aim_compat) + + add_executable(test_custom_asset_bulk_compat + tests/test_custom_asset_bulk_compat.c + ) + + add_test(NAME custom_asset_bulk_compat COMMAND test_custom_asset_bulk_compat) + + add_executable(test_pickup_pool_compat + tests/test_pickup_pool_compat.c + ) + + add_test(NAME pickup_pool_compat COMMAND test_pickup_pool_compat) + + add_executable(test_remote_edge_cursor_compat + tests/test_remote_edge_cursor_compat.c + ) + target_include_directories(test_remote_edge_cursor_compat + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ) + + add_test(NAME remote_edge_cursor_compat + COMMAND test_remote_edge_cursor_compat) + add_executable(test_saa tests/test_saa.c ) @@ -291,6 +327,18 @@ if(SAMPDLL_BUILD_TESTS) add_test(NAME raknet_bridge COMMAND test_raknet_bridge) + add_executable(test_raknet_remote_edge_sync + tests/test_raknet_remote_edge_sync.cpp + ) + target_include_directories(test_raknet_remote_edge_sync + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src/net + ) + target_link_libraries(test_raknet_remote_edge_sync PRIVATE sampdll_net) + + add_test(NAME raknet_remote_edge_sync + COMMAND test_raknet_remote_edge_sync) + if(NOT WIN32) add_executable(test_raknet_offline_status_delivery tests/test_raknet_offline_status_delivery.cpp diff --git a/reimpl/README.md b/reimpl/README.md index 4ae171e..c38c9ac 100644 --- a/reimpl/README.md +++ b/reimpl/README.md @@ -117,7 +117,17 @@ can over-expand existing `GDI32`/`KERNEL32`/`WSOCK32` groups. tools/compare_runtime_traces.sh /path/to/reference.log /path/to/rebuild.log ``` -Use this to compare normalized event sequences and event-count deltas between reference and rebuild runs. +Use this to compare normalized event sequences and event-count deltas between reference and rebuild runs. The command exits with status `5` when a critical or high-severity runtime check fails. Use `--report-only` to retain report generation with a successful exit status: + +```bash +tools/compare_runtime_traces.sh --report-only /path/to/reference.log /path/to/rebuild.log +``` + +The SA-MP module base is derived from Wine's native `build_module` load event, so relocated traces do not require manual adjustment. Run the small fixture regression test with: + +```bash +bash tools/tests/test_compare_runtime_traces.sh +``` ## ABI Notes diff --git a/reimpl/include/sampdll/net/raknet_client_adapter.h b/reimpl/include/sampdll/net/raknet_client_adapter.h index b468aca..90bba82 100644 --- a/reimpl/include/sampdll/net/raknet_client_adapter.h +++ b/reimpl/include/sampdll/net/raknet_client_adapter.h @@ -25,6 +25,8 @@ int samp_raknet_client_send_spawn_notification_for_seq(void *client, uint32_t sp int samp_raknet_client_send_respawn_notification(void *client); int samp_raknet_client_send_death_notification(void *client, uint8_t death_reason, uint16_t responsible_player); int samp_raknet_client_send_pickup_notification(void *client, int32_t pickup_id); +int samp_raknet_client_send_pickup_process_notification(void *client, + int32_t pickup_id); int samp_raknet_client_send_textdraw_click(void *client, uint16_t textdraw_id); int samp_raknet_client_send_player_click(void *client, uint16_t player_id, uint8_t source); int samp_raknet_client_send_menu_select(void *client, uint8_t row); @@ -331,6 +333,7 @@ typedef struct samp_raknet_join_profile { #define SAMP_RAKNET_VEHICLE_ACTION_LINK_INTERIOR 11u #define SAMP_RAKNET_VEHICLE_ACTION_SET_NUMBER_PLATE 12u #define SAMP_RAKNET_VEHICLE_ACTION_SET_PARAMS_FOR_PLAYER 13u +#define SAMP_RAKNET_VEHICLE_ACTION_SET_TYRE_STATUS 14u #define SAMP_RAKNET_VEHICLE_PARAM_BYTES 16u #define SAMP_RAKNET_VEHICLE_NUMBER_PLATE_BYTES 33u #define SAMP_RAKNET_ANIM_LIB_BYTES 64u @@ -438,6 +441,52 @@ typedef struct samp_raknet_remote_bullet_sync { float offset[3]; } samp_raknet_remote_bullet_sync; +/* + * STATIC_037: + * R5 handlers at samp.dll+0x9A40/+0x9E20/+0x9D30 read packet ID, the + * authoritative uint16 player ID, then the original packed 67/54/24-byte + * client payload respectively. Binary SHA256: + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + * Keep the packed payload nested so its wire layout remains tied to the + * sender-side ABI assertions above. + */ +typedef struct samp_raknet_remote_unoccupied_sync { + uint32_t seq; + uint16_t player_id; + samp_raknet_unoccupied_sync sync; +} samp_raknet_remote_unoccupied_sync; + +typedef struct samp_raknet_remote_trailer_sync { + uint32_t seq; + uint16_t player_id; + samp_raknet_trailer_sync sync; +} samp_raknet_remote_trailer_sync; + +typedef struct samp_raknet_remote_passenger_sync { + uint32_t seq; + uint16_t player_id; + samp_raknet_passenger_sync sync; +} samp_raknet_remote_passenger_sync; + +#define SAMP_RAKNET_REMOTE_MOVEMENT_ONFOOT 1u +#define SAMP_RAKNET_REMOTE_MOVEMENT_DRIVER 2u +#define SAMP_RAKNET_REMOTE_MOVEMENT_PASSENGER 3u +#define SAMP_RAKNET_REMOTE_MOVEMENT_UNOCCUPIED 4u +#define SAMP_RAKNET_REMOTE_MOVEMENT_TRAILER 5u + +typedef struct samp_raknet_remote_movement_sync { + uint32_t seq; + uint8_t type; + uint8_t reserved[3]; + union { + samp_raknet_remote_onfoot_sync onfoot; + samp_raknet_remote_vehicle_sync driver; + samp_raknet_remote_passenger_sync passenger; + samp_raknet_remote_unoccupied_sync unoccupied; + samp_raknet_remote_trailer_sync trailer; + } state; +} samp_raknet_remote_movement_sync; + typedef struct samp_raknet_map_icon_event { uint32_t seq; uint8_t action; @@ -1015,6 +1064,41 @@ typedef struct samp_raknet_rpc_probe_snapshot { * executes GTA opcode 03FD with the received uint32 level. */ uint32_t legacy_drunk_handling_seq; uint32_t legacy_drunk_handling_level; + /* + * Appended for ABI stability. + * STATIC_037: RPC 48 at samp.dll+0x1DCC0 / local-player setter +0x17D10. + */ + uint32_t virtual_world_seq; + int32_t virtual_world; + /* + * STATIC_037: RPC 167 at samp.dll+0x17DE0 and collision wrappers + * +0xA5AC0/+0xA5B10/+0xA5BB0/+0xA5C50/+0xA5CF0. + */ + uint32_t remote_vehicle_collisions_disabled_seq; + uint8_t remote_vehicle_collisions_disabled; + uint8_t remote_vehicle_collisions_reserved[3]; + /* + * Appended for ABI stability. STATIC_037: R5 packet handlers 209/210/211 at + * samp.dll+0x9A40/+0x9E20/+0x9D30 and stores at + * +0x158D0/+0x15C90/+0x17440. + */ + uint32_t remote_unoccupied_sync_count; + uint32_t remote_trailer_sync_count; + uint32_t remote_passenger_sync_count; + samp_raknet_remote_unoccupied_sync + remote_unoccupied_syncs[SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING]; + samp_raknet_remote_trailer_sync + remote_trailer_syncs[SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING]; + samp_raknet_remote_passenger_sync + remote_passenger_syncs[SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING]; + /* + * Appended for ABI stability. This ring preserves Receive() order across + * OnFoot/Driver (channel 0) and Passenger/Unoccupied/Trailer (channel 1) + * packet types. + */ + uint32_t remote_movement_sync_count; + samp_raknet_remote_movement_sync + remote_movement_syncs[SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING]; } samp_raknet_rpc_probe_snapshot; /* @@ -1032,6 +1116,15 @@ int samp_raknet_client_drain_packets(void *client, int max_packets); int samp_raknet_client_drain_packets_autojoin(void *client, int max_packets, const samp_raknet_join_profile *profile, int *out_connected, int *out_join_sent, int *out_last_packet_id); +/* + * Formats the connected peer's RakNet transport counters using the verbose + * layout consumed by SA-MP 0.3.7-R5's F5 overlay. The two rate outputs are + * sampled one-second byte deltas and are optional. + */ +int samp_raknet_client_format_transport_statistics( + void *client, char *out_text, uint32_t out_text_size, + double *out_download_kbytes_per_second, + double *out_upload_kbytes_per_second); int samp_raknet_client_get_rpc_probe_snapshot(void *client, samp_raknet_rpc_probe_snapshot *out_snapshot); int samp_raknet_client_get_actor_state(void *client, uint16_t actor_id, samp_raknet_actor_state *out_state); int samp_raknet_client_get_object_material(void *client, uint16_t object_id, uint32_t object_generation, diff --git a/reimpl/src/custom_asset_bulk_compat.h b/reimpl/src/custom_asset_bulk_compat.h new file mode 100644 index 0000000..89eeb13 --- /dev/null +++ b/reimpl/src/custom_asset_bulk_compat.h @@ -0,0 +1,59 @@ +#ifndef SAMPDLL_CUSTOM_ASSET_BULK_COMPAT_H +#define SAMPDLL_CUSTOM_ASSET_BULK_COMPAT_H + +#include +#include + +#define SAMP_ASSET_IDE_SECTION_UNKNOWN 0u +#define SAMP_ASSET_IDE_SECTION_OBJS 1u +#define SAMP_ASSET_IDE_SECTION_TOBJ 2u +#define SAMP_ASSET_IDE_SECTION_ANIM 3u + +typedef struct samp_asset_prearchive_bulk_plan_compat { + uint32_t model_info_count; + uint32_t deferred_anim_count; +} samp_asset_prearchive_bulk_plan_compat; + +static inline int samp_asset_ide_section_is_prearchive_model_info_compat( + uint8_t section) { + /* OBSERVED_037 + PROBE_TRACE: + * R5's pre-archive stock pass covers the `objs` Atomic rows, while the two + * stock `anim` rows are outside that 1,433-entry pass. + * GTA_REVERSED_REF + INFERRED + TODO_VERIFY: parsed `tobj` rows use the + * corresponding AddTimeModel phase; animated clump/IFP conversion remains + * separate in the replacement. + */ + return section == SAMP_ASSET_IDE_SECTION_OBJS || + section == SAMP_ASSET_IDE_SECTION_TOBJ; +} + +static inline void samp_asset_prearchive_bulk_plan_reset_compat( + samp_asset_prearchive_bulk_plan_compat *plan) { + if (plan == NULL) { + return; + } + plan->model_info_count = 0u; + plan->deferred_anim_count = 0u; +} + +static inline void samp_asset_prearchive_bulk_plan_add_section_compat( + samp_asset_prearchive_bulk_plan_compat *plan, uint8_t section) { + if (plan == NULL) { + return; + } + + if (samp_asset_ide_section_is_prearchive_model_info_compat(section)) { + ++plan->model_info_count; + } else if (section == SAMP_ASSET_IDE_SECTION_ANIM) { + ++plan->deferred_anim_count; + } +} + +static inline int samp_asset_prearchive_full_bulk_ready_compat( + int bulk_enabled, int model_info_path_ready, uint32_t bulk_limit, + const samp_asset_prearchive_bulk_plan_compat *plan) { + return bulk_enabled && model_info_path_ready && plan != NULL && + plan->model_info_count > 0u && bulk_limit >= plan->model_info_count; +} + +#endif diff --git a/reimpl/src/gta_camera_aim_compat.h b/reimpl/src/gta_camera_aim_compat.h new file mode 100644 index 0000000..3607ec4 --- /dev/null +++ b/reimpl/src/gta_camera_aim_compat.h @@ -0,0 +1,75 @@ +#ifndef SAMPDLL_GTA_CAMERA_AIM_COMPAT_H +#define SAMPDLL_GTA_CAMERA_AIM_COMPAT_H + +#include +#include +#include + +/* + * OBSERVED_037 + PROBE_TRACE: + * Original R5 stores each 0x30-byte camera-aim context as + * [front, position, position, up]. Across the non-degenerate contexts from + * artifacts/runs/20260728-aim-r5-memory-observer, the up vector is: + * + * h = sqrt(front.x^2 + front.y^2) + * up = (-front.x * front.z / h, + * -front.y * front.z / h, + * h) + * + * This is also the exact context copied by samp.dll+0x9C9C0 and restored by + * +0x9C960 (R5 SHA256 + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2). + * + * TODO_VERIFY: + * No exactly vertical front vector was captured. Reject that boundary rather + * than inventing a basis which could differ from R5. + */ +static inline int samp_gta_camera_aim_build_r5( + const float front[3], const float position[3], float out_front[3], + float out_position1[3], float out_position2[3], float out_up[3]) { + float built[12]; + float length_sq = 0.0f; + float horizontal = 0.0f; + unsigned int i = 0u; + + if (front == NULL || position == NULL || out_front == NULL || + out_position1 == NULL || out_position2 == NULL || out_up == NULL) { + return 0; + } + + for (i = 0u; i < 3u; ++i) { + if (!isfinite(front[i]) || !isfinite(position[i]) || + fabsf(front[i]) > 2.0f || fabsf(position[i]) > 50000.0f) { + return 0; + } + length_sq += front[i] * front[i]; + } + if (!isfinite(length_sq) || length_sq < 0.01f || length_sq > 4.0f) { + return 0; + } + + horizontal = sqrtf( + (front[0] * front[0]) + (front[1] * front[1])); + if (!isfinite(horizontal) || horizontal <= 0.000001f) { + return 0; + } + + memcpy(&built[0], front, sizeof(float) * 3u); + memcpy(&built[3], position, sizeof(float) * 3u); + memcpy(&built[6], position, sizeof(float) * 3u); + built[9] = -(front[0] * front[2]) / horizontal; + built[10] = -(front[1] * front[2]) / horizontal; + built[11] = horizontal; + if (!isfinite(built[9]) || !isfinite(built[10]) || + !isfinite(built[11])) { + return 0; + } + + memcpy(out_front, &built[0], sizeof(float) * 3u); + memcpy(out_position1, &built[3], sizeof(float) * 3u); + memcpy(out_position2, &built[6], sizeof(float) * 3u); + memcpy(out_up, &built[9], sizeof(float) * 3u); + return 1; +} + +#endif diff --git a/reimpl/src/gta_quaternion_compat.h b/reimpl/src/gta_quaternion_compat.h index e6d51dd..24badcc 100644 --- a/reimpl/src/gta_quaternion_compat.h +++ b/reimpl/src/gta_quaternion_compat.h @@ -3,6 +3,127 @@ #include #include +#include + +static inline int samp_gta_quaternion_normalize_wxyz( + const float input[4], float output[4]) { + float length_sq = 0.0f; + float inverse_length = 0.0f; + unsigned int i = 0u; + + if (input == NULL || output == NULL) { + return 0; + } + for (i = 0u; i < 4u; ++i) { + if (!isfinite(input[i])) { + return 0; + } + length_sq += input[i] * input[i]; + } + if (!isfinite(length_sq) || length_sq <= 0.000001f) { + return 0; + } + inverse_length = 1.0f / sqrtf(length_sq); + for (i = 0u; i < 4u; ++i) { + output[i] = input[i] * inverse_length; + } + return 1; +} + +/* + * STATIC_037 + GTA_REVERSED_REF: + * R5's matrix-to-wire path at samp.dll+0xB52B0/+0xB4D70 is the inverse of + * its wire-to-matrix path at +0xB6A80/+0xB4F10. GTA CQuaternion::Set stores + * x,y,z,w, while SA-MP sends the conjugated quaternion as w,-x,-y,-z. + * The original converter takes the positive square-root branch for W, so + * canonicalize the otherwise equivalent global sign after normalization. + */ +static inline int samp_gta_matrix_quaternion_xyzw_to_wire_wxyz( + const float gta_xyzw[4], float wire_wxyz[4]) { + float converted[4]; + + if (gta_xyzw == NULL || wire_wxyz == NULL) { + return 0; + } + converted[0] = gta_xyzw[3]; + converted[1] = -gta_xyzw[0]; + converted[2] = -gta_xyzw[1]; + converted[3] = -gta_xyzw[2]; + if (!samp_gta_quaternion_normalize_wxyz(converted, wire_wxyz)) { + return 0; + } + if (wire_wxyz[0] < 0.0f) { + wire_wxyz[0] = -wire_wxyz[0]; + wire_wxyz[1] = -wire_wxyz[1]; + wire_wxyz[2] = -wire_wxyz[2]; + wire_wxyz[3] = -wire_wxyz[3]; + } + return 1; +} + +/* + * STATIC_037: + * R5 remote-vehicle playback calls D3DXQuaternionSlerp through + * samp.dll+0xB5480 and normalizes through +0xB5500. Inputs and outputs here + * use SA-MP's w,x,y,z ordering. + */ +static inline int samp_gta_quaternion_slerp_wxyz( + const float current[4], const float target[4], float amount, + float output[4]) { + float normalized_current[4]; + float normalized_target[4]; + float adjusted_target[4]; + float dot = 0.0f; + float theta = 0.0f; + float sin_theta = 0.0f; + float current_weight = 0.0f; + float target_weight = 0.0f; + unsigned int i = 0u; + + if (!isfinite(amount) || amount < 0.0f || amount > 1.0f || + !samp_gta_quaternion_normalize_wxyz( + current, normalized_current) || + !samp_gta_quaternion_normalize_wxyz( + target, normalized_target)) { + return 0; + } + for (i = 0u; i < 4u; ++i) { + dot += normalized_current[i] * normalized_target[i]; + } + if (dot < 0.0f) { + dot = -dot; + for (i = 0u; i < 4u; ++i) { + adjusted_target[i] = -normalized_target[i]; + } + } else { + memcpy(adjusted_target, normalized_target, sizeof(adjusted_target)); + } + if (dot > 1.0f) { + dot = 1.0f; + } + + if (dot > 0.9995f) { + for (i = 0u; i < 4u; ++i) { + output[i] = normalized_current[i] + + amount * (adjusted_target[i] - normalized_current[i]); + } + return samp_gta_quaternion_normalize_wxyz(output, output); + } + + theta = acosf(dot); + sin_theta = sinf(theta); + if (!isfinite(theta) || !isfinite(sin_theta) || + fabsf(sin_theta) <= 0.000001f) { + return 0; + } + current_weight = sinf((1.0f - amount) * theta) / sin_theta; + target_weight = sinf(amount * theta) / sin_theta; + for (i = 0u; i < 4u; ++i) { + output[i] = current_weight * normalized_current[i] + + target_weight * adjusted_target[i]; + } + return samp_gta_quaternion_normalize_wxyz(output, output); +} /* * OBSERVED_037 + PROBE_TRACE + OPENMP_REF: diff --git a/reimpl/src/net/raknet_client_adapter.cpp b/reimpl/src/net/raknet_client_adapter.cpp index 7673d61..fe52c36 100644 --- a/reimpl/src/net/raknet_client_adapter.cpp +++ b/reimpl/src/net/raknet_client_adapter.cpp @@ -1,9 +1,12 @@ #include "sampdll/net/raknet_client_adapter.h" #include "../gta_quaternion_compat.h" +#include "../pickup_pool_compat.h" +#include "../vehicle_attach_timing_compat.h" #include "death_message_codec.h" #include "openmp_compressed_vector_compat.h" #include "raknet_client_adapter_internal.h" +#include "raknet_client_adapter_test.h" #include "raknet_offline_status_observer.h" #include @@ -177,6 +180,14 @@ static_assert(offsetof(samp_raknet_actor_state, skin) == 8U, "actor state skin A static_assert(offsetof(samp_raknet_actor_state, pos) == 12U, "actor state position ABI offset"); static_assert(offsetof(samp_raknet_actor_state, animation_lib) == 44U, "actor state animation ABI offset"); static_assert(offsetof(samp_raknet_actor_state, animation_name) == 300U, "actor state animation name ABI offset"); +static_assert(sizeof(samp_raknet_vehicle_event) == 132U, + "internal vehicle event ABI must remain stable"); +static_assert(offsetof(samp_raknet_vehicle_event, model) == 20U, + "vehicle event model ABI offset"); +static_assert(offsetof(samp_raknet_vehicle_event, component) == 84U, + "vehicle event component ABI offset"); +static_assert(offsetof(samp_raknet_vehicle_event, number_plate) == 96U, + "vehicle event number plate ABI offset"); bool packet_resets_session_state(unsigned char packet_id) { return packet_id == static_cast(RakNet::ID_DISCONNECTION_NOTIFICATION) || @@ -356,6 +367,8 @@ struct RpcProbeState { unsigned int spectate_toggle_seq; unsigned int spectate_player_seq; unsigned int spectate_vehicle_seq; + unsigned int virtual_world_seq; + unsigned int remote_vehicle_collisions_disabled_seq; unsigned int world_visual_event_seq; unsigned int client_check_response_count; unsigned char player_controllable; @@ -422,6 +435,8 @@ struct RpcProbeState { float camera_interpolate_to[3]; std::int32_t camera_interpolate_time_ms; unsigned char special_action; + std::int32_t virtual_world; + unsigned char remote_vehicle_collisions_disabled; std::int32_t spectate_toggle; unsigned short spectate_player_id; unsigned short spectate_vehicle_id; @@ -474,6 +489,10 @@ struct RpcProbeState { ObjectMaterialSlotState *object_material_slots[SAMP_RAKNET_MAX_OBJECTS][SAMP_RAKNET_OBJECT_MATERIAL_SLOTS]; unsigned int vehicle_event_seq; samp_raknet_vehicle_event vehicle_events[SAMP_RAKNET_VEHICLE_EVENT_RING]; + RakNet::RakNetTime + vehicle_create_observed_ticks[SAMP_RAKNET_MAX_VEHICLES]; + unsigned char + vehicle_create_observed_valid[SAMP_RAKNET_MAX_VEHICLES]; unsigned int remote_player_event_seq; samp_raknet_remote_player_event remote_player_events[SAMP_RAKNET_REMOTE_PLAYER_EVENT_RING]; unsigned int remote_player_sync_seq; @@ -484,6 +503,14 @@ struct RpcProbeState { samp_raknet_remote_aim_sync remote_aim_syncs[SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING]; unsigned int remote_bullet_sync_seq; samp_raknet_remote_bullet_sync remote_bullet_syncs[SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING]; + unsigned int remote_unoccupied_sync_seq; + samp_raknet_remote_unoccupied_sync remote_unoccupied_syncs[SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING]; + unsigned int remote_trailer_sync_seq; + samp_raknet_remote_trailer_sync remote_trailer_syncs[SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING]; + unsigned int remote_passenger_sync_seq; + samp_raknet_remote_passenger_sync remote_passenger_syncs[SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING]; + unsigned int remote_movement_sync_seq; + samp_raknet_remote_movement_sync remote_movement_syncs[SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING]; unsigned int map_icon_event_seq; samp_raknet_map_icon_event map_icon_events[SAMP_RAKNET_MAP_ICON_EVENT_RING]; unsigned int gang_zone_event_seq; @@ -687,8 +714,8 @@ const RpcMeta kRpcMeta[] = { {45U, "ScrSetObjectPos", kRpcLocalImplemented, "PROBE_TRACE"}, {46U, "ScrSetObjectRot", kRpcLocalImplemented, "PROBE_TRACE"}, {47U, "ScrDestroyObject", kRpcLocalImplemented, "PROBE_TRACE"}, - {48U, "ScrSetPlayerVirtualWorld", kRpcLocalDecoded, - "STATIC_037:samp.dll+0x1DCC0,OPENMP_REF,TODO_VERIFY"}, + {48U, "ScrSetPlayerVirtualWorld", kRpcLocalImplemented, + "STATIC_037:samp.dll+0x1DCC0,samp.dll+0x17D10"}, {50U, "ServerCommand", kRpcLocalOutgoing, "OPENMP_REF"}, {52U, "Spawn", kRpcLocalOutgoing, "OPENMP_REF"}, {53U, "Death", kRpcLocalOutgoing, "OPENMP_REF"}, @@ -742,8 +769,8 @@ const RpcMeta kRpcMeta[] = { {95U, "ScrCreatePickup", kRpcLocalImplemented, "STATIC_037:samp.dll+0xF080"}, {96U, "ScmEvent", kRpcLocalOutgoing, "OPENMP_REF"}, {97U, "WeaponPickupDestroy", kRpcLocalOutgoing, "OPENMP_REF"}, - {98U, "ScrSetVehicleTireStatus", kRpcLocalDecoded, - "STATIC_037:samp.dll+0x18B70,ALT_02X_CODE,TODO_VERIFY"}, + {98U, "ScrSetVehicleTireStatus", kRpcLocalImplemented, + "STATIC_037:samp.dll+0x18B70,samp.dll+0xB7940"}, {99U, "ScrMoveObject", kRpcLocalImplemented, "PROBE_TRACE"}, {101U, "Chat", kRpcLocalImplemented, "INFERRED,OPENMP_REF,TODO_VERIFY"}, {102U, "ServerNetStats", kRpcLocalOutgoing, "OPENMP_REF"}, @@ -806,8 +833,8 @@ const RpcMeta kRpcMeta[] = { {164U, "ScrWorldVehicleAdd", kRpcLocalImplemented, "PROBE_TRACE"}, {165U, "ScrWorldVehicleRemove", kRpcLocalImplemented, "PROBE_TRACE"}, {166U, "ScrWorldPlayerDeath", kRpcLocalImplemented, "SAMPFUNCS_037,PROBE_TRACE"}, - {167U, "ScrDisableRemoteVehicleCollisions", kRpcLocalDecoded, - "STATIC_037:samp.dll+0x17DE0,OPENMP_REF,TODO_VERIFY"}, + {167U, "ScrDisableRemoteVehicleCollisions", kRpcLocalImplemented, + "STATIC_037:samp.dll+0x17DE0,samp.dll+0xA5AC0..+0xA5CF0"}, {169U, "ScrSetActorInvulnerableLegacy", kRpcLocalDecoded, "STATIC_037:samp.dll+0x1C170,OPENMP_REF,TODO_VERIFY"}, {171U, "ScrShowActor", kRpcLocalImplemented, @@ -1321,6 +1348,8 @@ void reset_rpc_probe_runtime(RakNet::RakClientInterface *client) { g_rpc_probe.spectate_toggle_seq = 0U; g_rpc_probe.spectate_player_seq = 0U; g_rpc_probe.spectate_vehicle_seq = 0U; + g_rpc_probe.virtual_world_seq = 0U; + g_rpc_probe.remote_vehicle_collisions_disabled_seq = 0U; g_rpc_probe.world_visual_event_seq = 0U; g_rpc_probe.client_check_response_count = 0U; g_rpc_probe.game_text_event_seq = 0U; @@ -1396,6 +1425,8 @@ void reset_rpc_probe_runtime(RakNet::RakClientInterface *client) { std::memset(g_rpc_probe.camera_interpolate_to, 0, sizeof(g_rpc_probe.camera_interpolate_to)); g_rpc_probe.camera_interpolate_time_ms = 0; g_rpc_probe.special_action = 0U; + g_rpc_probe.virtual_world = 0; + g_rpc_probe.remote_vehicle_collisions_disabled = 0U; g_rpc_probe.spectate_toggle = 0; g_rpc_probe.spectate_player_id = 0U; g_rpc_probe.spectate_vehicle_id = 0U; @@ -1448,6 +1479,10 @@ void reset_rpc_probe_runtime(RakNet::RakClientInterface *client) { std::memset(g_rpc_probe.object_material_slots, 0, sizeof(g_rpc_probe.object_material_slots)); g_rpc_probe.vehicle_event_seq = 0U; std::memset(g_rpc_probe.vehicle_events, 0, sizeof(g_rpc_probe.vehicle_events)); + std::memset(g_rpc_probe.vehicle_create_observed_ticks, 0, + sizeof(g_rpc_probe.vehicle_create_observed_ticks)); + std::memset(g_rpc_probe.vehicle_create_observed_valid, 0, + sizeof(g_rpc_probe.vehicle_create_observed_valid)); g_rpc_probe.remote_player_event_seq = 0U; std::memset(g_rpc_probe.remote_player_events, 0, sizeof(g_rpc_probe.remote_player_events)); g_rpc_probe.remote_player_sync_seq = 0U; @@ -1458,6 +1493,18 @@ void reset_rpc_probe_runtime(RakNet::RakClientInterface *client) { std::memset(g_rpc_probe.remote_aim_syncs, 0, sizeof(g_rpc_probe.remote_aim_syncs)); g_rpc_probe.remote_bullet_sync_seq = 0U; std::memset(g_rpc_probe.remote_bullet_syncs, 0, sizeof(g_rpc_probe.remote_bullet_syncs)); + g_rpc_probe.remote_unoccupied_sync_seq = 0U; + std::memset(g_rpc_probe.remote_unoccupied_syncs, 0, + sizeof(g_rpc_probe.remote_unoccupied_syncs)); + g_rpc_probe.remote_trailer_sync_seq = 0U; + std::memset(g_rpc_probe.remote_trailer_syncs, 0, + sizeof(g_rpc_probe.remote_trailer_syncs)); + g_rpc_probe.remote_passenger_sync_seq = 0U; + std::memset(g_rpc_probe.remote_passenger_syncs, 0, + sizeof(g_rpc_probe.remote_passenger_syncs)); + g_rpc_probe.remote_movement_sync_seq = 0U; + std::memset(g_rpc_probe.remote_movement_syncs, 0, + sizeof(g_rpc_probe.remote_movement_syncs)); g_rpc_probe.map_icon_event_seq = 0U; std::memset(g_rpc_probe.map_icon_events, 0, sizeof(g_rpc_probe.map_icon_events)); g_rpc_probe.gang_zone_event_seq = 0U; @@ -2462,7 +2509,8 @@ samp_raknet_vehicle_event *queue_vehicle_event(unsigned char action, unsigned sh return event; } -bool decode_vehicle_add_payload(const unsigned char *data, unsigned int bytes) { +bool decode_vehicle_add_payload(const unsigned char *data, unsigned int bytes, + RakNet::RakNetTime observed_tick) { unsigned short vehicle_id = 0U; std::int32_t model = 0; float pos[3] = {0.0f, 0.0f, 0.0f}; @@ -2496,6 +2544,8 @@ bool decode_vehicle_add_payload(const unsigned char *data, unsigned int bytes) { } event = queue_vehicle_event(SAMP_RAKNET_VEHICLE_ACTION_CREATE, vehicle_id); + g_rpc_probe.vehicle_create_observed_ticks[vehicle_id] = observed_tick; + g_rpc_probe.vehicle_create_observed_valid[vehicle_id] = 1U; event->model = model; std::memcpy(event->pos, pos, sizeof(event->pos)); event->rotation = rotation; @@ -2544,6 +2594,8 @@ bool decode_vehicle_remove_payload(const unsigned char *data, unsigned int bytes } event = queue_vehicle_event(SAMP_RAKNET_VEHICLE_ACTION_DESTROY, vehicle_id); + g_rpc_probe.vehicle_create_observed_ticks[vehicle_id] = 0U; + g_rpc_probe.vehicle_create_observed_valid[vehicle_id] = 0U; trace_netf("vehicle-decode: destroy seq=%u id=%u", event->seq, static_cast(vehicle_id)); return true; } @@ -2611,7 +2663,9 @@ bool decode_vehicle_z_angle_payload(const unsigned char *data, unsigned int byte return true; } -bool decode_attach_trailer_payload(const unsigned char *data, unsigned int bytes) { +bool decode_attach_trailer_payload(const unsigned char *data, + unsigned int bytes, + RakNet::RakNetTime observed_tick) { if (data == nullptr || bytes < 4U) { return false; } @@ -2622,9 +2676,30 @@ bool decode_attach_trailer_payload(const unsigned char *data, unsigned int bytes } samp_raknet_vehicle_event *event = queue_vehicle_event(SAMP_RAKNET_VEHICLE_ACTION_ATTACH_TRAILER, vehicle_id); + const std::uint16_t towing_elapsed_ms = + samp_vehicle_attach_source_elapsed_ms( + static_cast( + g_rpc_probe.vehicle_create_observed_ticks[vehicle_id]), + static_cast(observed_tick), + g_rpc_probe.vehicle_create_observed_valid[vehicle_id] != 0U); + const std::uint16_t trailer_elapsed_ms = + samp_vehicle_attach_source_elapsed_ms( + static_cast( + g_rpc_probe.vehicle_create_observed_ticks[trailer_id]), + static_cast(observed_tick), + g_rpc_probe.vehicle_create_observed_valid[trailer_id] != 0U); event->related_vehicle_id = trailer_id; - trace_netf("vehicle-decode: attach_trailer seq=%u vehicle=%u trailer=%u evidence=STATIC_037", - event->seq, static_cast(vehicle_id), static_cast(trailer_id)); + event->component = static_cast( + samp_vehicle_attach_source_ages_pack(towing_elapsed_ms, + trailer_elapsed_ms)); + trace_netf( + "vehicle-decode: attach_trailer seq=%u vehicle=%u trailer=%u " + "source_elapsed_ms=(%u,%u) evidence=STATIC_037,PROBE_TRACE," + "INFERRED:receive_timing", + event->seq, static_cast(vehicle_id), + static_cast(trailer_id), + static_cast(towing_elapsed_ms), + static_cast(trailer_elapsed_ms)); return true; } @@ -2975,6 +3050,24 @@ unsigned int bump_seq(unsigned int *seq) { return *seq; } +unsigned int remote_movement_events_since(unsigned int sequence_before, + unsigned int current_sequence) { + if (current_sequence >= sequence_before) { + return current_sequence - sequence_before; + } + /* + * bump_seq() reserves zero. When the counter wraps, UINT_MAX -> 1 is one + * event rather than two. + */ + return current_sequence + (~0U - sequence_before); +} + +bool remote_movement_drain_should_yield(unsigned int sequence_before, + unsigned int current_sequence) { + return remote_movement_events_since(sequence_before, current_sequence) >= + SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; +} + bool copy_bounded_bytes(char *out, size_t out_size, const unsigned char *data, unsigned int len) { if (out == nullptr || out_size == 0U) { return false; @@ -3418,6 +3511,67 @@ unsigned char decode_health_armour_nibble(unsigned char nibble) { return static_cast(nibble * 7U); } +void queue_remote_movement_sync( + unsigned char type, const void *sync, std::size_t sync_size) { + unsigned int seq = 0U; + unsigned int index = 0U; + samp_raknet_remote_movement_sync *event = nullptr; + void *destination = nullptr; + std::size_t destination_size = 0U; + + if (sync == nullptr) { + return; + } + switch (type) { + case SAMP_RAKNET_REMOTE_MOVEMENT_ONFOOT: + destination_size = sizeof(samp_raknet_remote_onfoot_sync); + break; + case SAMP_RAKNET_REMOTE_MOVEMENT_DRIVER: + destination_size = sizeof(samp_raknet_remote_vehicle_sync); + break; + case SAMP_RAKNET_REMOTE_MOVEMENT_PASSENGER: + destination_size = sizeof(samp_raknet_remote_passenger_sync); + break; + case SAMP_RAKNET_REMOTE_MOVEMENT_UNOCCUPIED: + destination_size = sizeof(samp_raknet_remote_unoccupied_sync); + break; + case SAMP_RAKNET_REMOTE_MOVEMENT_TRAILER: + destination_size = sizeof(samp_raknet_remote_trailer_sync); + break; + default: + return; + } + if (sync_size != destination_size) { + return; + } + seq = bump_seq(&g_rpc_probe.remote_movement_sync_seq); + index = (seq - 1U) % SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + event = &g_rpc_probe.remote_movement_syncs[index]; + std::memset(event, 0, sizeof(*event)); + switch (type) { + case SAMP_RAKNET_REMOTE_MOVEMENT_ONFOOT: + destination = &event->state.onfoot; + break; + case SAMP_RAKNET_REMOTE_MOVEMENT_DRIVER: + destination = &event->state.driver; + break; + case SAMP_RAKNET_REMOTE_MOVEMENT_PASSENGER: + destination = &event->state.passenger; + break; + case SAMP_RAKNET_REMOTE_MOVEMENT_UNOCCUPIED: + destination = &event->state.unoccupied; + break; + case SAMP_RAKNET_REMOTE_MOVEMENT_TRAILER: + destination = &event->state.trailer; + break; + default: + return; + } + event->seq = seq; + event->type = type; + std::memcpy(destination, sync, destination_size); +} + void queue_remote_onfoot_sync(const samp_raknet_remote_onfoot_sync *sync) { const unsigned int seq = bump_seq(&g_rpc_probe.remote_player_sync_seq); const unsigned int index = (seq - 1U) % SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; @@ -3505,6 +3659,9 @@ bool decode_remote_onfoot_sync_packet(const unsigned char *data, unsigned int by } queue_remote_onfoot_sync(&sync); + sync.seq = g_rpc_probe.remote_player_sync_seq; + queue_remote_movement_sync(SAMP_RAKNET_REMOTE_MOVEMENT_ONFOOT, &sync, + sizeof(sync)); if (g_rpc_probe.remote_player_sync_seq <= 3U || (g_rpc_probe.remote_player_sync_seq % 64U) == 0U) { trace_netf("packet-state id=207 remote_onfoot seq=%u player=%u pos=%.3f %.3f %.3f rot=%.3f hp=%u ar=%u weapon=%u", g_rpc_probe.remote_player_sync_seq, static_cast(sync.player_id), @@ -3581,6 +3738,9 @@ bool decode_remote_vehicle_sync_packet(const unsigned char *data, unsigned int b } queue_remote_vehicle_sync(&sync); + sync.seq = g_rpc_probe.remote_vehicle_sync_seq; + queue_remote_movement_sync(SAMP_RAKNET_REMOTE_MOVEMENT_DRIVER, &sync, + sizeof(sync)); if (g_rpc_probe.remote_vehicle_sync_seq <= 3U || (g_rpc_probe.remote_vehicle_sync_seq % 64U) == 0U) { trace_netf("packet-state id=200 remote_vehicle seq=%u player=%u vehicle=%u pos=%.3f %.3f %.3f " "speed=%.3f %.3f %.3f vehicle_hp=%u player_hp=%u armour=%u keys=0x%04x lr=%d ud=%d", @@ -3686,6 +3846,208 @@ bool decode_remote_bullet_sync_packet(const unsigned char *data, unsigned int by return true; } +void queue_remote_unoccupied_sync( + const samp_raknet_remote_unoccupied_sync *sync) { + const unsigned int seq = + bump_seq(&g_rpc_probe.remote_unoccupied_sync_seq); + const unsigned int index = + (seq - 1U) % SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + if (sync == nullptr) { + return; + } + std::memcpy(&g_rpc_probe.remote_unoccupied_syncs[index], sync, + sizeof(*sync)); + g_rpc_probe.remote_unoccupied_syncs[index].seq = seq; +} + +bool decode_remote_unoccupied_sync_packet(const unsigned char *data, + unsigned int bytes) { + unsigned char packet_id = 0U; + samp_raknet_remote_unoccupied_sync sync; + RakNet::BitStream bs(const_cast(data), bytes, false); + + std::memset(&sync, 0, sizeof(sync)); + /* + * STATIC_037: + * R5 handler samp.dll+0x9A40 reads packet ID, uint16 player ID, then the + * byte-exact 67-byte payload and stores it through samp.dll+0x158D0. + * SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + */ + if (data == nullptr || bytes < 3U + sizeof(sync.sync) || + !bs.Read(packet_id) || packet_id != kPacketUnoccupiedSync || + !bs.Read(sync.player_id) || + !bs.Read(reinterpret_cast(&sync.sync), + static_cast(sizeof(sync.sync)))) { + return false; + } + + queue_remote_unoccupied_sync(&sync); + sync.seq = g_rpc_probe.remote_unoccupied_sync_seq; + queue_remote_movement_sync(SAMP_RAKNET_REMOTE_MOVEMENT_UNOCCUPIED, &sync, + sizeof(sync)); + if (g_rpc_probe.remote_unoccupied_sync_seq <= 3U || + (g_rpc_probe.remote_unoccupied_sync_seq % 64U) == 0U) { + trace_netf( + "packet-state id=209 remote_unoccupied seq=%u player=%u vehicle=%u " + "seat=%u pos=%.3f %.3f %.3f speed=%.3f %.3f %.3f hp=%.3f " + "evidence=STATIC_037:samp.dll+0x9A40", + g_rpc_probe.remote_unoccupied_sync_seq, + static_cast(sync.player_id), + static_cast(sync.sync.vehicle_id), + static_cast(sync.sync.seat_id), + static_cast(sync.sync.position[0]), + static_cast(sync.sync.position[1]), + static_cast(sync.sync.position[2]), + static_cast(sync.sync.move_speed[0]), + static_cast(sync.sync.move_speed[1]), + static_cast(sync.sync.move_speed[2]), + static_cast(sync.sync.vehicle_health)); + } + return true; +} + +void queue_remote_trailer_sync(const samp_raknet_remote_trailer_sync *sync) { + const unsigned int seq = bump_seq(&g_rpc_probe.remote_trailer_sync_seq); + const unsigned int index = + (seq - 1U) % SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + if (sync == nullptr) { + return; + } + std::memcpy(&g_rpc_probe.remote_trailer_syncs[index], sync, sizeof(*sync)); + g_rpc_probe.remote_trailer_syncs[index].seq = seq; +} + +bool decode_remote_trailer_sync_packet(const unsigned char *data, + unsigned int bytes) { + unsigned char packet_id = 0U; + samp_raknet_remote_trailer_sync sync; + RakNet::BitStream bs(const_cast(data), bytes, false); + + std::memset(&sync, 0, sizeof(sync)); + /* + * STATIC_037: + * R5 handler samp.dll+0x9E20 reads packet ID, uint16 player ID, then the + * byte-exact 54-byte payload and stores it through samp.dll+0x15C90. + * SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + */ + if (data == nullptr || bytes < 3U + sizeof(sync.sync) || + !bs.Read(packet_id) || packet_id != kPacketTrailerSync || + !bs.Read(sync.player_id) || + !bs.Read(reinterpret_cast(&sync.sync), + static_cast(sizeof(sync.sync)))) { + return false; + } + + queue_remote_trailer_sync(&sync); + sync.seq = g_rpc_probe.remote_trailer_sync_seq; + queue_remote_movement_sync(SAMP_RAKNET_REMOTE_MOVEMENT_TRAILER, &sync, + sizeof(sync)); + if (g_rpc_probe.remote_trailer_sync_seq <= 3U || + (g_rpc_probe.remote_trailer_sync_seq % 64U) == 0U) { + trace_netf( + "packet-state id=210 remote_trailer seq=%u player=%u vehicle=%u " + "pos=%.3f %.3f %.3f speed=%.3f %.3f %.3f " + "evidence=STATIC_037:samp.dll+0x9E20", + g_rpc_probe.remote_trailer_sync_seq, + static_cast(sync.player_id), + static_cast(sync.sync.vehicle_id), + static_cast(sync.sync.position[0]), + static_cast(sync.sync.position[1]), + static_cast(sync.sync.position[2]), + static_cast(sync.sync.move_speed[0]), + static_cast(sync.sync.move_speed[1]), + static_cast(sync.sync.move_speed[2])); + } + return true; +} + +void queue_remote_passenger_sync( + const samp_raknet_remote_passenger_sync *sync) { + const unsigned int seq = bump_seq(&g_rpc_probe.remote_passenger_sync_seq); + const unsigned int index = + (seq - 1U) % SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + if (sync == nullptr) { + return; + } + std::memcpy(&g_rpc_probe.remote_passenger_syncs[index], sync, sizeof(*sync)); + g_rpc_probe.remote_passenger_syncs[index].seq = seq; +} + +bool decode_remote_passenger_sync_packet(const unsigned char *data, + unsigned int bytes) { + unsigned char packet_id = 0U; + samp_raknet_remote_passenger_sync sync; + RakNet::BitStream bs(const_cast(data), bytes, false); + + std::memset(&sync, 0, sizeof(sync)); + /* + * STATIC_037: + * R5 handler samp.dll+0x9D30 reads packet ID, uint16 player ID, then the + * byte-exact 24-byte payload and stores it through samp.dll+0x17440. + * SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + */ + if (data == nullptr || bytes < 3U + sizeof(sync.sync) || + !bs.Read(packet_id) || packet_id != kPacketPassengerSync || + !bs.Read(sync.player_id) || + !bs.Read(reinterpret_cast(&sync.sync), + static_cast(sizeof(sync.sync)))) { + return false; + } + + queue_remote_passenger_sync(&sync); + sync.seq = g_rpc_probe.remote_passenger_sync_seq; + queue_remote_movement_sync(SAMP_RAKNET_REMOTE_MOVEMENT_PASSENGER, &sync, + sizeof(sync)); + if (g_rpc_probe.remote_passenger_sync_seq <= 3U || + (g_rpc_probe.remote_passenger_sync_seq % 64U) == 0U) { + trace_netf( + "packet-state id=211 remote_passenger seq=%u player=%u vehicle=%u " + "seat=%u drive_by=%u cuffed=%u weapon=%u pos=%.3f %.3f %.3f " + "evidence=STATIC_037:samp.dll+0x9D30", + g_rpc_probe.remote_passenger_sync_seq, + static_cast(sync.player_id), + static_cast(sync.sync.vehicle_id), + static_cast(sync.sync.seat_flags & 0x3FU), + static_cast((sync.sync.seat_flags >> 6U) & 1U), + static_cast((sync.sync.seat_flags >> 7U) & 1U), + static_cast(sync.sync.additional_key_weapon & 0x3FU), + static_cast(sync.sync.position[0]), + static_cast(sync.sync.position[1]), + static_cast(sync.sync.position[2])); + } + return true; +} + +bool decode_remote_edge_sync_packet_buffer(const unsigned char *data, + unsigned int bytes) { + unsigned int sync_offset = 0U; + unsigned char packet_id = kPacketIdInvalid; + + if (data == nullptr || bytes == 0U) { + return false; + } + if (data[0] == RakNet::ID_TIMESTAMP) { + sync_offset = static_cast(sizeof(RakNet::RakNetTime)) + 1U; + } + if (sync_offset >= bytes) { + return false; + } + packet_id = data[sync_offset]; + switch (packet_id) { + case kPacketUnoccupiedSync: + return decode_remote_unoccupied_sync_packet( + data + sync_offset, bytes - sync_offset); + case kPacketTrailerSync: + return decode_remote_trailer_sync_packet( + data + sync_offset, bytes - sync_offset); + case kPacketPassengerSync: + return decode_remote_passenger_sync_packet( + data + sync_offset, bytes - sync_offset); + default: + return false; + } +} + bool decode_update_scores_pings_payload(const unsigned char *data, unsigned int bytes) { unsigned int entry_size = 0U; unsigned int count = 0U; @@ -4005,9 +4367,24 @@ bool decode_legacy_registered_rpc_payload(unsigned int rpc_id, const unsigned ch return false; } if (rpc_id == 48U && bytes >= 4U) { - trace_netf("rpc-state id=48 virtual_world=%d decoded=1 apply_pending=0 " - "evidence=STATIC_037:samp.dll+0x1DCC0,OPENMP_REF,TODO_VERIFY", - static_cast(static_cast(read_le32(data)))); + const std::int32_t virtual_world = static_cast(read_le32(data)); + const bool changed = g_rpc_probe.virtual_world != virtual_world; + if (changed) { + /* + * STATIC_037: + * CLocalPlayer::SetVirtualWorld at samp.dll+0x17D10 clears the wasted + * and wants-another-class latches only when the signed world changes. + * The runtime bridge owns the wasted latch; these are the adapter-side + * equivalents of the class-selection latch. + */ + g_rpc_probe.class_selection_after_death_requested = 0; + g_rpc_probe.class_selection_after_death_consumed = 0; + } + g_rpc_probe.virtual_world = virtual_world; + const unsigned int seq = bump_seq(&g_rpc_probe.virtual_world_seq); + trace_netf("rpc-state id=48 virtual_world_seq=%u virtual_world=%d changed=%d apply_pending=1 " + "evidence=STATIC_037:samp.dll+0x1DCC0,samp.dll+0x17D10", + seq, static_cast(virtual_world), changed ? 1 : 0); return true; } if ((rpc_id == 92U || rpc_id == 150U) && bytes >= 4U) { @@ -4032,9 +4409,20 @@ bool decode_legacy_registered_rpc_payload(unsigned int rpc_id, const unsigned ch return true; } if (rpc_id == 98U && bytes >= 3U) { - trace_netf("rpc-state id=98 tire_status vehicle=%u status=%u decoded=1 apply_pending=0 " - "evidence=STATIC_037:samp.dll+0x18B70,ALT_02X_CODE,TODO_VERIFY", - read_le16(data), static_cast(data[2U])); + const unsigned short vehicle_id = read_le16(data); + if (!vehicle_id_valid(vehicle_id)) { + trace_netf("rpc-state id=98 tire_status vehicle=%u invalid=1 ignored=1 bytes=%u " + "evidence=STATIC_037:samp.dll+0x18B70", + static_cast(vehicle_id), bytes); + return false; + } + samp_raknet_vehicle_event *event = + queue_vehicle_event(SAMP_RAKNET_VEHICLE_ACTION_SET_TYRE_STATUS, vehicle_id); + event->tyre_damage = data[2U]; + trace_netf("rpc-state id=98 tire_status seq=%u vehicle=%u mask=0x%02x apply_pending=1 " + "evidence=STATIC_037:samp.dll+0x18B70,samp.dll+0xB7940", + event->seq, static_cast(vehicle_id), + static_cast(event->tyre_damage)); return true; } if (rpc_id == 111U && bytes >= 1U) { @@ -4047,9 +4435,14 @@ bool decode_legacy_registered_rpc_payload(unsigned int rpc_id, const unsigned ch } if (rpc_id == 167U && bits >= 1U) { const unsigned int disabled = (data[0U] & 0x80U) != 0U ? 1U : 0U; - trace_netf("rpc-state id=167 remote_vehicle_collisions_disabled=%u decoded=1 apply_pending=0 bits=%u " - "evidence=STATIC_037:samp.dll+0x17DE0,OPENMP_REF,TODO_VERIFY", - disabled, bits); + g_rpc_probe.remote_vehicle_collisions_disabled = + disabled != 0U ? 1U : 0U; + const unsigned int seq = + bump_seq(&g_rpc_probe.remote_vehicle_collisions_disabled_seq); + trace_netf("rpc-state id=167 remote_vehicle_collisions_seq=%u disabled=%u " + "apply_pending=1 bits=%u evidence=STATIC_037:samp.dll+0x17DE0," + "samp.dll+0xA5AC0..+0xA5CF0", + seq, disabled, bits); return true; } if (rpc_id == 169U && bytes >= 2U) { @@ -6523,6 +6916,7 @@ void rpc_observer(RakNet::RPCParameters *rpc_params, void *extra) { unsigned int rpc_id = 0; unsigned int bytes = 0; unsigned int prefix_bytes = 0; + const RakNet::RakNetTime observed_tick = RakNet::GetTime(); char prefix[kRpcTraceMaxBytes * 3U + 1U] = {0}; if (extra != nullptr) { @@ -6703,7 +7097,7 @@ void rpc_observer(RakNet::RPCParameters *rpc_params, void *extra) { } else if (rpc_id == 63U) { if (rpc_params != nullptr && bytes >= 4U) { const std::int32_t pickup_id = static_cast(read_le32(rpc_params->input)); - if (pickup_id >= 0 && pickup_id < 4096) { + if (samp_pickup_pool_id_valid(pickup_id)) { const unsigned int seq = bump_seq(&g_rpc_probe.pickup_event_seq); samp_raknet_pickup_event &event = g_rpc_probe.pickup_events[(seq - 1U) % SAMP_RAKNET_PICKUP_EVENT_RING]; @@ -6743,7 +7137,7 @@ void rpc_observer(RakNet::RPCParameters *rpc_params, void *extra) { const std::int32_t type = static_cast(read_le32(rpc_params->input + 8U)); float pos[3]; read_vec3(rpc_params->input + 12U, pos); - if (pickup_id >= 0 && pickup_id < 4096 && model > 0 && model <= 20000 && type >= 0 && type <= 255 && + if (samp_pickup_pool_id_valid(pickup_id) && model > 0 && model <= 20000 && type >= 0 && type <= 255 && std::isfinite(pos[0]) && std::isfinite(pos[1]) && std::isfinite(pos[2])) { const unsigned int seq = bump_seq(&g_rpc_probe.pickup_event_seq); samp_raknet_pickup_event &event = @@ -6908,7 +7302,9 @@ void rpc_observer(RakNet::RPCParameters *rpc_params, void *extra) { trace_netf("rpc-state id=122 object_stop decode_failed bytes=%u", bytes); } } else if (rpc_id == 164U) { - if (rpc_params == nullptr || !decode_vehicle_add_payload(rpc_params->input, bytes)) { + if (rpc_params == nullptr || + !decode_vehicle_add_payload(rpc_params->input, bytes, + observed_tick)) { trace_netf("rpc-state id=164 vehicle_add decode_failed bytes=%u", bytes); } } else if (rpc_id == 165U) { @@ -6925,7 +7321,9 @@ void rpc_observer(RakNet::RPCParameters *rpc_params, void *extra) { trace_netf("rpc-state id=147 vehicle_health decode_failed bytes=%u", bytes); } } else if (rpc_id == 148U) { - if (rpc_params == nullptr || !decode_attach_trailer_payload(rpc_params->input, bytes)) { + if (rpc_params == nullptr || + !decode_attach_trailer_payload(rpc_params->input, bytes, + observed_tick)) { trace_netf("rpc-state id=148 attach_trailer decode_failed bytes=%u", bytes); } } else if (rpc_id == 149U) { @@ -7614,6 +8012,8 @@ int send_client_join(RakNet::RakClientInterface *rak_client, const RakNet::Packe int drain_packets_internal(void *client, int max_packets, const samp_raknet_join_profile *profile, int autojoin, int *out_connected, int *out_join_sent, int *out_last_packet_id) { static RakNet::RakNetTime last_transport_stats_tick = 0U; + unsigned int movement_sequence_before_drain = + g_rpc_probe.remote_movement_sync_seq; int drained = 0; int join_sent = 0; int last_packet_id = -1; @@ -7624,7 +8024,18 @@ int drain_packets_internal(void *client, int max_packets, const samp_raknet_join } rak_client = static_cast(client); - while (drained < max_packets) { + /* + * INFERRED: + * The runtime asks for as many as 1024 packets and snapshots only after this + * call, while the shared cross-channel movement history has 128 entries. + * Yield after filling that history so a burst cannot overwrite movement + * packets before the caller gets its snapshot. Non-movement-only drains keep + * the caller-provided max_packets budget. + */ + while (drained < max_packets && + !remote_movement_drain_should_yield( + movement_sequence_before_drain, + g_rpc_probe.remote_movement_sync_seq)) { RakNet::RakNetStatisticsStruct stats_before = {}; RakNet::RakNetStatisticsStruct *stats_ptr = rak_client->GetStatistics(); if (stats_ptr != nullptr) { @@ -7719,6 +8130,31 @@ int drain_packets_internal(void *client, int max_packets, const samp_raknet_join prefix_bytes > 0U ? prefix : "-"); } } + if ((packet_id == kPacketUnoccupiedSync || + packet_id == kPacketTrailerSync || + packet_id == kPacketPassengerSync) && + packet->data != nullptr && packet->length > 0U) { + /* + * STATIC_037 + INFERRED: + * R5's dispatcher recognizes a timestamp-wrapped edge-sync packet but + * passes the unshifted buffer to these handlers. Normal open.mp sends + * 209/210/211 without timestamps. We deliberately normalize the prefix + * here so a wrapped packet is decoded safely instead of reproducing the + * original handler's misaligned read. + */ + unsigned int sync_offset = 0U; + if (packet->data[0] == RakNet::ID_TIMESTAMP) { + sync_offset = static_cast(sizeof(RakNet::RakNetTime)) + 1U; + } + if (!decode_remote_edge_sync_packet_buffer(packet->data, + packet->length)) { + trace_netf( + "packet-state id=%u remote_edge decode_failed bytes=%u " + "offset=%u evidence=STATIC_037,INFERRED", + static_cast(packet_id), + static_cast(packet->length), sync_offset); + } + } if (packet_id == kPacketAimSync && packet->data != nullptr && packet->length > 0U) { unsigned int sync_offset = 0U; if (packet->data[0] == RakNet::ID_TIMESTAMP) { @@ -7746,12 +8182,27 @@ int drain_packets_internal(void *client, int max_packets, const samp_raknet_join if (reset_session) { trace_netf("packet-state id=%d reset_rpc_probe reason=disconnect", last_packet_id); reset_rpc_probe_runtime(rak_client); + movement_sequence_before_drain = + g_rpc_probe.remote_movement_sync_seq; g_offline_status_observer.Reset(); } else { service_rpc_probe_actions(rak_client); } ++drained; } + if (drained < max_packets && + remote_movement_drain_should_yield( + movement_sequence_before_drain, + g_rpc_probe.remote_movement_sync_seq)) { + trace_netf( + "packet-state movement_drain_yield drained=%d movement_events=%u " + "ring=%u requested=%d evidence=INFERRED", + drained, + remote_movement_events_since(movement_sequence_before_drain, + g_rpc_probe.remote_movement_sync_seq), + static_cast(SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING), + max_packets); + } /* * PROBE_TRACE + INFERRED: @@ -7787,6 +8238,19 @@ int drain_packets_internal(void *client, int max_packets, const samp_raknet_join } } // namespace +int samp_raknet_test_ingest_remote_edge_sync(const unsigned char *data, + unsigned int bytes) { + return decode_remote_edge_sync_packet_buffer(data, bytes) ? 1 : 0; +} + +int samp_raknet_test_remote_movement_drain_should_yield( + unsigned int sequence_before_drain, unsigned int current_sequence) { + return remote_movement_drain_should_yield(sequence_before_drain, + current_sequence) + ? 1 + : 0; +} + int samp_raknet_client_available(void) { return 1; } int samp_raknet_client_create(void **out_client) { @@ -8533,6 +8997,245 @@ int samp_raknet_client_drain_packets_autojoin(void *client, int max_packets, con return drain_packets_internal(client, max_packets, profile, 1, out_connected, out_join_sent, out_last_packet_id); } +int samp_raknet_client_format_transport_statistics( + void *client, char *out_text, uint32_t out_text_size, + double *out_download_kbytes_per_second, + double *out_upload_kbytes_per_second) { + static void *sample_client = nullptr; + static RakNet::RakNetTime sample_connection_start = 0U; + static RakNet::RakNetTime sample_tick = 0U; + static unsigned long long sample_sent_bytes = 0U; + static unsigned long long sample_received_bytes = 0U; + static double sampled_download_rate = 0.0; + static double sampled_upload_rate = 0.0; + RakNet::RakClientInterface *rak_client = nullptr; + RakNet::RakNetStatisticsStruct *stats = nullptr; + RakNet::RakNetTime now = 0U; + RakNet::RakNetTime sample_elapsed = 0U; + unsigned long long sent_bytes = 0U; + unsigned long long received_bits = 0U; + unsigned long long received_bytes = 0U; + unsigned long long rate_received_bytes = 0U; + unsigned long long resend_total_bits = 0U; + unsigned long long resend_data_bits = 0U; + unsigned int messages_sent = 0U; + unsigned int messages_received_total = 0U; + unsigned int packets_received_total = 0U; + unsigned int acknowledgements_received_total = 0U; + double packet_loss = 0.0; + double elapsed_seconds = 0.0; + double sent_kbits_per_second = 0.0; + double received_kbits_per_second = 0.0; + int written = 0; + unsigned int i = 0U; + + if (out_download_kbytes_per_second != nullptr) { + *out_download_kbytes_per_second = 0.0; + } + if (out_upload_kbytes_per_second != nullptr) { + *out_upload_kbytes_per_second = 0.0; + } + if (out_text == nullptr || out_text_size == 0U) { + return -1; + } + out_text[0] = '\0'; + if (client == nullptr || client != g_rpc_probe.client) { + return -1; + } + + rak_client = static_cast(client); + stats = rak_client->GetStatistics(); + if (stats == nullptr) { + return -2; + } + now = RakNet::GetTime(); + sent_bytes = stats->totalBitsSent >> 3U; + received_bits = + static_cast(stats->bitsReceived) + + static_cast(stats->bitsWithBadCRCReceived); + received_bytes = received_bits >> 3U; + rate_received_bytes = + static_cast(stats->bitsReceived) >> 3U; + + /* + * STATIC_037: + * samp.dll R5 SHA256= + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + * F5 formatter samp.dll+0x60D70 retains sent/received byte totals and updates + * its displayed KB/s deltas after GetTickCount advances beyond 1000 ms. + */ + if (sample_client != client || + sample_connection_start != stats->connectionStartTime) { + sample_client = client; + sample_connection_start = stats->connectionStartTime; + sample_tick = stats->connectionStartTime; + sample_sent_bytes = 0U; + sample_received_bytes = 0U; + sampled_download_rate = 0.0; + sampled_upload_rate = 0.0; + } + sample_elapsed = now - sample_tick; + if (sample_elapsed > 1000U) { + sampled_upload_rate = + sent_bytes >= sample_sent_bytes + ? static_cast(sent_bytes - sample_sent_bytes) / 1024.0 + : 0.0; + sampled_download_rate = + rate_received_bytes >= sample_received_bytes + ? static_cast(rate_received_bytes - sample_received_bytes) / + 1024.0 + : 0.0; + sample_tick = now; + sample_sent_bytes = sent_bytes; + sample_received_bytes = rate_received_bytes; + } + if (out_download_kbytes_per_second != nullptr) { + *out_download_kbytes_per_second = sampled_download_rate; + } + if (out_upload_kbytes_per_second != nullptr) { + *out_upload_kbytes_per_second = sampled_upload_rate; + } + + for (i = 0U; i < 4U; ++i) { + messages_sent += stats->messagesSent[i]; + } + messages_received_total = + stats->messagesReceived + stats->invalidMessagesReceived + + stats->duplicateMessagesReceived; + packets_received_total = + stats->packetsReceived + stats->packetsWithBadCRCReceived; + acknowledgements_received_total = + stats->acknowlegementsReceived + + stats->duplicateAcknowlegementsReceived; + if (messages_sent != 0U || stats->messageResends != 0U) { + packet_loss = + (100.0 * static_cast(stats->messageResends)) / + static_cast(messages_sent + stats->messageResends); + } + elapsed_seconds = + static_cast(now - stats->connectionStartTime) / 1000.0; + if (elapsed_seconds > 0.0) { + sent_kbits_per_second = + static_cast(stats->totalBitsSent) * 0.001 / elapsed_seconds; + received_kbits_per_second = + static_cast(received_bits) * 0.001 / elapsed_seconds; + } + resend_total_bits = stats->messagesTotalBitsResent; + resend_data_bits = stats->messageDataBitsResent; + + /* + * STATIC_037: + * The spelling, priority order, tabs and line order below are the exact + * verbosity-4 format at samp.dll+0x36610 / string samp.dll+0xE8220. + */ + written = std::snprintf( + out_text, static_cast(out_text_size), + "Bytes sent:\t\t\t\t%u\n" + "Messages in send buffer:\t\tSP:%u HP:%u MP:%u LP:%u\n" + "Messages sent:\t\t\t\tSP:%u HP:%u MP:%u LP:%u\n" + "Message data bytes sent:\t\tSP:%u HP:%u MP:%u LP:%u\n" + "Message header bytes sent:\t\tSP:%u HP:%u MP:%u LP:%u\n" + "Message total bytes sent:\t\tSP:%u HP:%u MP:%u LP:%u\n" + "Bytes received:\t\t\t\tTtl:%u Good:%u Bad:%u\n" + "Packets received:\t\t\tTtl:%u Good:%u Bad:%u\n" + "Acks received:\t\t\t\tTtl:%u Good:%u Dup:%u\n" + "Messages received:\t\t\tTotal:%u Valid:%u Invalid:%u Dup:%u\n" + "Packetloss:\t\t\t\t%.1f%%\n" + "Packets sent:\t\t\t\t%u\n" + "Acks sent:\t\t\t\t%u\n" + "Acks in send buffer:\t\t\t%u\n" + "Messages waiting for ack:\t\t%u\n" + "Ack bytes sent:\t\t\t\t%u\n" + "Sent packets containing only acks:\t%u\n" + "Sent packets w/only acks and resends:\t%u\n" + "Reliable messages resent:\t\t%u\n" + "Reliable message data bytes resent:\t%u\n" + "Reliable message header bytes resent:\t%u\n" + "Reliable message total bytes resent:\t%u\n" + "Number of messages split:\t\t%u\n" + "Number of messages unsplit:\t\t%u\n" + "Message splits performed:\t\t%u\n" + "Additional encryption bytes:\t\t%u\n" + "Sequenced messages out of order:\t%u\n" + "Sequenced messages in order:\t\t%u\n" + "Ordered messages out of order:\t\t%u\n" + "Ordered messages in of order:\t\t%u\n" + "Split messages waiting for reassembly:\t%u\n" + "Messages in internal output queue:\t%u\n" + "Inst KBits per second:\t\t\t%.1f\n" + "Elapsed time (sec):\t\t\t%.1f\n" + "KBits per second sent:\t\t\t%.1f\n" + "KBits per second received:\t\t%.1f\n", + static_cast(sent_bytes), + stats->messageSendBuffer[0], stats->messageSendBuffer[1], + stats->messageSendBuffer[2], stats->messageSendBuffer[3], + stats->messagesSent[0], stats->messagesSent[1], + stats->messagesSent[2], stats->messagesSent[3], + static_cast(stats->messageDataBitsSent[0] >> 3U), + static_cast(stats->messageDataBitsSent[1] >> 3U), + static_cast(stats->messageDataBitsSent[2] >> 3U), + static_cast(stats->messageDataBitsSent[3] >> 3U), + static_cast( + (stats->messageTotalBitsSent[0] - + stats->messageDataBitsSent[0]) >> + 3U), + static_cast( + (stats->messageTotalBitsSent[1] - + stats->messageDataBitsSent[1]) >> + 3U), + static_cast( + (stats->messageTotalBitsSent[2] - + stats->messageDataBitsSent[2]) >> + 3U), + static_cast( + (stats->messageTotalBitsSent[3] - + stats->messageDataBitsSent[3]) >> + 3U), + static_cast(stats->messageTotalBitsSent[0] >> 3U), + static_cast(stats->messageTotalBitsSent[1] >> 3U), + static_cast(stats->messageTotalBitsSent[2] >> 3U), + static_cast(stats->messageTotalBitsSent[3] >> 3U), + static_cast(received_bytes), + static_cast(stats->bitsReceived >> 3U), + static_cast(stats->bitsWithBadCRCReceived >> 3U), + packets_received_total, stats->packetsReceived, + stats->packetsWithBadCRCReceived, acknowledgements_received_total, + stats->acknowlegementsReceived, + stats->duplicateAcknowlegementsReceived, messages_received_total, + stats->messagesReceived, stats->invalidMessagesReceived, + stats->duplicateMessagesReceived, packet_loss, stats->packetsSent, + stats->acknowlegementsSent, stats->acknowlegementsPending, + stats->messagesOnResendQueue, + static_cast(stats->acknowlegementBitsSent >> 3U), + stats->packetsContainingOnlyAcknowlegements, + stats->packetsContainingOnlyAcknowlegementsAndResends, + stats->messageResends, + static_cast(resend_data_bits >> 3U), + static_cast( + (resend_total_bits >= resend_data_bits + ? resend_total_bits - resend_data_bits + : 0U) >> + 3U), + static_cast(resend_total_bits >> 3U), + stats->numberOfSplitMessages, stats->numberOfUnsplitMessages, + stats->totalSplits, + static_cast(stats->encryptionBitsSent >> 3U), + stats->sequencedMessagesOutOfOrder, stats->sequencedMessagesInOrder, + stats->orderedMessagesOutOfOrder, stats->orderedMessagesInOrder, + stats->messagesWaitingForReassembly, stats->internalOutputQueueSize, + stats->bitsPerSecond * 0.001, elapsed_seconds, + sent_kbits_per_second, received_kbits_per_second); + if (written < 0) { + out_text[0] = '\0'; + return -2; + } + if (static_cast(written) >= out_text_size) { + out_text[out_text_size - 1U] = '\0'; + return -3; + } + return 0; +} + int samp_raknet_client_get_rpc_probe_snapshot(void *client, samp_raknet_rpc_probe_snapshot *out_snapshot) { uint32_t flags = 0U; @@ -8614,7 +9317,9 @@ int samp_raknet_client_get_rpc_probe_snapshot(void *client, samp_raknet_rpc_prob g_rpc_probe.game_mode_restart_seq > 0U || g_rpc_probe.force_class_selection_seq > 0U || g_rpc_probe.camera_attach_object_seq > 0U || g_rpc_probe.camera_interpolate_seq > 0U || g_rpc_probe.special_action_seq > 0U || g_rpc_probe.spectate_toggle_seq > 0U || - g_rpc_probe.spectate_player_seq > 0U || g_rpc_probe.spectate_vehicle_seq > 0U) { + g_rpc_probe.spectate_player_seq > 0U || g_rpc_probe.spectate_vehicle_seq > 0U || + g_rpc_probe.virtual_world_seq > 0U || + g_rpc_probe.remote_vehicle_collisions_disabled_seq > 0U) { flags |= SAMP_RAKNET_RPC_FLAG_PLAYER_SCRIPT_EVENT; } if (g_rpc_probe.world_visual_event_seq > 0U) { @@ -8639,7 +9344,10 @@ int samp_raknet_client_get_rpc_probe_snapshot(void *client, samp_raknet_rpc_prob flags |= SAMP_RAKNET_RPC_FLAG_REMOTE_PLAYER_EVENT; } if (g_rpc_probe.remote_player_sync_seq > 0U || g_rpc_probe.remote_vehicle_sync_seq > 0U || - g_rpc_probe.remote_aim_sync_seq > 0U || g_rpc_probe.remote_bullet_sync_seq > 0U) { + g_rpc_probe.remote_aim_sync_seq > 0U || g_rpc_probe.remote_bullet_sync_seq > 0U || + g_rpc_probe.remote_unoccupied_sync_seq > 0U || + g_rpc_probe.remote_trailer_sync_seq > 0U || + g_rpc_probe.remote_passenger_sync_seq > 0U) { flags |= SAMP_RAKNET_RPC_FLAG_REMOTE_PLAYER_SYNC; } if (g_rpc_probe.player_pool_event_seq > 0U) { @@ -8670,6 +9378,10 @@ int samp_raknet_client_get_rpc_probe_snapshot(void *client, samp_raknet_rpc_prob out_snapshot->remote_vehicle_sync_count = 0U; out_snapshot->remote_aim_sync_count = 0U; out_snapshot->remote_bullet_sync_count = 0U; + out_snapshot->remote_unoccupied_sync_count = 0U; + out_snapshot->remote_trailer_sync_count = 0U; + out_snapshot->remote_passenger_sync_count = 0U; + out_snapshot->remote_movement_sync_count = 0U; out_snapshot->map_icon_event_count = 0U; out_snapshot->gang_zone_event_count = 0U; out_snapshot->gang_zone_state_seq = g_rpc_probe.gang_zone_state_seq; @@ -8931,6 +9643,14 @@ int samp_raknet_client_get_rpc_probe_snapshot(void *client, samp_raknet_rpc_prob out_snapshot->widescreen_enabled = g_rpc_probe.widescreen_enabled; out_snapshot->legacy_drunk_handling_seq = g_rpc_probe.legacy_drunk_handling_seq; out_snapshot->legacy_drunk_handling_level = g_rpc_probe.legacy_drunk_handling_level; + out_snapshot->virtual_world_seq = g_rpc_probe.virtual_world_seq; + out_snapshot->virtual_world = g_rpc_probe.virtual_world; + out_snapshot->remote_vehicle_collisions_disabled_seq = + g_rpc_probe.remote_vehicle_collisions_disabled_seq; + out_snapshot->remote_vehicle_collisions_disabled = + g_rpc_probe.remote_vehicle_collisions_disabled; + std::memset(out_snapshot->remote_vehicle_collisions_reserved, 0, + sizeof(out_snapshot->remote_vehicle_collisions_reserved)); out_snapshot->play_sound_id = g_rpc_probe.play_sound_id; std::memcpy(out_snapshot->play_sound_pos, g_rpc_probe.play_sound_pos, sizeof(out_snapshot->play_sound_pos)); out_snapshot->player_color_player_id = g_rpc_probe.player_color_player_id; @@ -9263,6 +9983,94 @@ int samp_raknet_client_get_rpc_probe_snapshot(void *client, samp_raknet_rpc_prob } } } + std::memset(out_snapshot->remote_unoccupied_syncs, 0, + sizeof(out_snapshot->remote_unoccupied_syncs)); + if (g_rpc_probe.remote_unoccupied_sync_seq > 0U) { + const unsigned int available = + g_rpc_probe.remote_unoccupied_sync_seq < + SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING + ? g_rpc_probe.remote_unoccupied_sync_seq + : SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + const unsigned int first_seq = + g_rpc_probe.remote_unoccupied_sync_seq - available + 1U; + for (unsigned int i = 0U; i < available; ++i) { + const unsigned int seq = first_seq + i; + const unsigned int slot = + (seq - 1U) % SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + if (g_rpc_probe.remote_unoccupied_syncs[slot].seq == seq) { + out_snapshot + ->remote_unoccupied_syncs + [out_snapshot->remote_unoccupied_sync_count++] = + g_rpc_probe.remote_unoccupied_syncs[slot]; + } + } + } + std::memset(out_snapshot->remote_trailer_syncs, 0, + sizeof(out_snapshot->remote_trailer_syncs)); + if (g_rpc_probe.remote_trailer_sync_seq > 0U) { + const unsigned int available = + g_rpc_probe.remote_trailer_sync_seq < + SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING + ? g_rpc_probe.remote_trailer_sync_seq + : SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + const unsigned int first_seq = + g_rpc_probe.remote_trailer_sync_seq - available + 1U; + for (unsigned int i = 0U; i < available; ++i) { + const unsigned int seq = first_seq + i; + const unsigned int slot = + (seq - 1U) % SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + if (g_rpc_probe.remote_trailer_syncs[slot].seq == seq) { + out_snapshot + ->remote_trailer_syncs + [out_snapshot->remote_trailer_sync_count++] = + g_rpc_probe.remote_trailer_syncs[slot]; + } + } + } + std::memset(out_snapshot->remote_passenger_syncs, 0, + sizeof(out_snapshot->remote_passenger_syncs)); + if (g_rpc_probe.remote_passenger_sync_seq > 0U) { + const unsigned int available = + g_rpc_probe.remote_passenger_sync_seq < + SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING + ? g_rpc_probe.remote_passenger_sync_seq + : SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + const unsigned int first_seq = + g_rpc_probe.remote_passenger_sync_seq - available + 1U; + for (unsigned int i = 0U; i < available; ++i) { + const unsigned int seq = first_seq + i; + const unsigned int slot = + (seq - 1U) % SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + if (g_rpc_probe.remote_passenger_syncs[slot].seq == seq) { + out_snapshot + ->remote_passenger_syncs + [out_snapshot->remote_passenger_sync_count++] = + g_rpc_probe.remote_passenger_syncs[slot]; + } + } + } + std::memset(out_snapshot->remote_movement_syncs, 0, + sizeof(out_snapshot->remote_movement_syncs)); + if (g_rpc_probe.remote_movement_sync_seq > 0U) { + const unsigned int available = + g_rpc_probe.remote_movement_sync_seq < + SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING + ? g_rpc_probe.remote_movement_sync_seq + : SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + const unsigned int first_seq = + g_rpc_probe.remote_movement_sync_seq - available + 1U; + for (unsigned int i = 0U; i < available; ++i) { + const unsigned int seq = first_seq + i; + const unsigned int slot = + (seq - 1U) % SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + if (g_rpc_probe.remote_movement_syncs[slot].seq == seq) { + out_snapshot + ->remote_movement_syncs + [out_snapshot->remote_movement_sync_count++] = + g_rpc_probe.remote_movement_syncs[slot]; + } + } + } std::memset(out_snapshot->player_pool_events, 0, sizeof(out_snapshot->player_pool_events)); if (g_rpc_probe.player_pool_event_seq > 0U) { const unsigned int available = g_rpc_probe.player_pool_event_seq < SAMP_RAKNET_PLAYER_POOL_EVENT_RING @@ -9469,33 +10277,63 @@ int samp_raknet_client_send_death_notification(void *client, uint8_t death_reaso return sent ? 0 : -2; } -int samp_raknet_client_send_pickup_notification(void *client, int32_t pickup_id) { +static int send_pickup_notification_compat( + void *client, int32_t pickup_id, + samp_pickup_rpc_source_compat source) { RakNet::BitStream bs_send; int sent = 0; + const unsigned int reliability = samp_pickup_rpc_reliability_r5(source); + const char *evidence = + source == SAMP_PICKUP_RPC_SOURCE_ORDINARY_PICKED_UP + ? "STATIC_037,OBSERVED_037,PROBE_TRACE" + : "STATIC_037,TODO_VERIFY"; - if (client == nullptr || client != g_rpc_probe.client || pickup_id < 0) { + if (client == nullptr || client != g_rpc_probe.client || + !samp_pickup_pool_id_valid(pickup_id) || reliability == 0U) { return -1; } /* - * ALT_02X_CODE + OPENMP_REF + TODO_VERIFY: - * CPickupPool::PickedUp writes a signed 32-bit pool index and sends RPC 131 - * HIGH_PRIORITY/RELIABLE_SEQUENCED on channel zero. Runtime comparison - * against R5 remains part of the pickup golden-trace scenario. + * STATIC_037: + * R5 CPickupPool::PickedUp at samp.dll+0x13440 writes a signed 32-bit pool + * index and sends RPC 131 HIGH_PRIORITY/RELIABLE_ORDERED on channel zero. + * The separate type-14 CPickupPool::Process branch at +0x13520 uses + * RELIABLE_SEQUENCED. + * OBSERVED_037 + PROBE_TRACE: + * Original-R5 run + * 20260802-112802-distributed-sync-pickup-57189 corroborates the ordinary + * reliability value 9, priority 1, channel 0 and 32-bit payload. */ bs_send.Write(static_cast(pickup_id)); sent = static_cast(client) ->RPC(kRpcPickedUpPickup, &bs_send, RakNet::HIGH_PRIORITY, - RakNet::RELIABLE_SEQUENCED, 0, false, + static_cast(reliability), 0, false, RakNet::UNASSIGNED_NETWORK_ID, nullptr) ? 1 : 0; trace_netf("rpc-auto-out id=131 name=PickedUpPickup pickup=%d sent=%d " - "evidence=ALT_02X_CODE,OPENMP_REF,TODO_VERIFY", - static_cast(pickup_id), sent); + "source=%s priority=1 reliability=%u channel=0 " + "evidence=%s", + static_cast(pickup_id), sent, + source == SAMP_PICKUP_RPC_SOURCE_ORDINARY_PICKED_UP + ? "ordinary_picked_up" + : "process", + reliability, evidence); return sent ? 0 : -2; } +int samp_raknet_client_send_pickup_notification(void *client, + int32_t pickup_id) { + return send_pickup_notification_compat( + client, pickup_id, SAMP_PICKUP_RPC_SOURCE_ORDINARY_PICKED_UP); +} + +int samp_raknet_client_send_pickup_process_notification( + void *client, int32_t pickup_id) { + return send_pickup_notification_compat( + client, pickup_id, SAMP_PICKUP_RPC_SOURCE_PROCESS); +} + int samp_raknet_client_mark_class_selection_after_death(void *client) { if (client == nullptr || client != g_rpc_probe.client) { return -1; diff --git a/reimpl/src/net/raknet_client_adapter_stub.c b/reimpl/src/net/raknet_client_adapter_stub.c index 689e42c..3f07f01 100644 --- a/reimpl/src/net/raknet_client_adapter_stub.c +++ b/reimpl/src/net/raknet_client_adapter_stub.c @@ -140,6 +140,23 @@ int samp_raknet_client_drain_packets_autojoin(void *client, int max_packets, con return 0; } +int samp_raknet_client_format_transport_statistics( + void *client, char *out_text, uint32_t out_text_size, + double *out_download_kbytes_per_second, + double *out_upload_kbytes_per_second) { + (void)client; + if (out_text != 0 && out_text_size != 0u) { + out_text[0] = '\0'; + } + if (out_download_kbytes_per_second != 0) { + *out_download_kbytes_per_second = 0.0; + } + if (out_upload_kbytes_per_second != 0) { + *out_upload_kbytes_per_second = 0.0; + } + return -1; +} + int samp_raknet_client_get_rpc_probe_snapshot(void *client, samp_raknet_rpc_probe_snapshot *out_snapshot) { (void)client; if (out_snapshot == 0) { @@ -236,6 +253,13 @@ int samp_raknet_client_send_pickup_notification(void *client, int32_t pickup_id) return -1; } +int samp_raknet_client_send_pickup_process_notification(void *client, + int32_t pickup_id) { + (void)client; + (void)pickup_id; + return -1; +} + int samp_raknet_client_send_onfoot_sync(void *client, const samp_raknet_onfoot_sync *sync) { (void)client; (void)sync; diff --git a/reimpl/src/net/raknet_client_adapter_test.h b/reimpl/src/net/raknet_client_adapter_test.h new file mode 100644 index 0000000..67fdd51 --- /dev/null +++ b/reimpl/src/net/raknet_client_adapter_test.h @@ -0,0 +1,27 @@ +#ifndef SAMPDLL_NET_RAKNET_CLIENT_ADAPTER_TEST_H +#define SAMPDLL_NET_RAKNET_CLIENT_ADAPTER_TEST_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Internal host-test seam. This accepts the same complete packet buffer as + * RakClientInterface::Receive(), including an optional ID_TIMESTAMP prefix. + * It is intentionally kept outside the public include tree. + */ +int samp_raknet_test_ingest_remote_edge_sync(const unsigned char *data, + unsigned int bytes); + +/* + * Internal test seam for the production drain guard. Returns non-zero once a + * single drain has filled the shared movement snapshot ring. + */ +int samp_raknet_test_remote_movement_drain_should_yield( + unsigned int sequence_before_drain, unsigned int current_sequence); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/reimpl/src/pickup_pool_compat.h b/reimpl/src/pickup_pool_compat.h new file mode 100644 index 0000000..f42e8b7 --- /dev/null +++ b/reimpl/src/pickup_pool_compat.h @@ -0,0 +1,48 @@ +#ifndef SAMPDLL_PICKUP_POOL_COMPAT_H +#define SAMPDLL_PICKUP_POOL_COMPAT_H + +#include + +/* + * STATIC_037: + * SA-MP 0.3.7-R5 CPickupPool owns 4096 indexed slots. The corresponding + * methods are New at samp.dll+0x13270, Destroy at +0x13320, PickedUp at + * +0x13440 and Process at +0x13520. + * Binary SHA256: + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + */ +#define SAMP_PICKUP_POOL_CAPACITY_037 4096u + +typedef enum samp_pickup_rpc_source_compat { + SAMP_PICKUP_RPC_SOURCE_INVALID = 0, + SAMP_PICKUP_RPC_SOURCE_ORDINARY_PICKED_UP, + SAMP_PICKUP_RPC_SOURCE_PROCESS +} samp_pickup_rpc_source_compat; + +static inline int samp_pickup_pool_id_valid(int32_t pickup_id) { + return pickup_id >= 0 && + (uint32_t)pickup_id < SAMP_PICKUP_POOL_CAPACITY_037; +} + +/* + * STATIC_037: + * R5 CPickupPool::PickedUp at samp.dll+0x13440 sends RPC 131 with numeric + * reliability 9 (RELIABLE_ORDERED). The type-14 and dropped branches in + * CPickupPool::Process at +0x13520 use numeric reliability 10 + * (RELIABLE_SEQUENCED). + * OBSERVED_037 + PROBE_TRACE: + * Run 20260802-112802-distributed-sync-pickup-57189 corroborates the ordinary + * value on the original R5 DLL. The Process sources remain TODO_VERIFY. + */ +static inline unsigned int samp_pickup_rpc_reliability_r5( + samp_pickup_rpc_source_compat source) { + if (source == SAMP_PICKUP_RPC_SOURCE_ORDINARY_PICKED_UP) { + return 9u; + } + if (source == SAMP_PICKUP_RPC_SOURCE_PROCESS) { + return 10u; + } + return 0u; +} + +#endif diff --git a/reimpl/src/remote_edge_cursor_compat.h b/reimpl/src/remote_edge_cursor_compat.h new file mode 100644 index 0000000..3956114 --- /dev/null +++ b/reimpl/src/remote_edge_cursor_compat.h @@ -0,0 +1,84 @@ +#ifndef SAMPDLL_REMOTE_EDGE_CURSOR_COMPAT_H +#define SAMPDLL_REMOTE_EDGE_CURSOR_COMPAT_H + +#include + +/* + * INFERRED: + * The shared 128-entry ring carries both Packet 200 and Packet 210, normally + * about 56--60 events/s for one towing player. Keep the retry below that + * ring's roughly 2.1-second overwrite window. + */ +#define SAMP_REMOTE_EDGE_DEFER_MAX_MS 2000u + +typedef enum samp_remote_edge_apply_result_compat { + SAMP_REMOTE_EDGE_APPLIED = 0, + SAMP_REMOTE_EDGE_DEFER = 1, + SAMP_REMOTE_EDGE_DROP = 2 +} samp_remote_edge_apply_result_compat; + +typedef struct samp_remote_edge_cursor_decision_compat { + uint32_t next_cursor; + samp_remote_edge_apply_result_compat effective_result; + int consume; + int stop; +} samp_remote_edge_cursor_decision_compat; + +static inline int samp_remote_edge_seq_is_newer(uint32_t cursor, + uint32_t candidate) { + if (candidate == 0u) { + return 0; + } + if (cursor == 0u) { + return 1; + } + return (int32_t)(candidate - cursor) > 0; +} + +static inline uint32_t samp_remote_edge_seq_distance(uint32_t cursor, + uint32_t candidate) { + uint32_t distance = candidate - cursor; + if (candidate < cursor && distance > 0u) { + --distance; + } + return distance; +} + +/* + * PROBE_TRACE + INFERRED: + * A runtime dependency may lag network delivery while GTA entities are + * created. Retain the shared arrival-order head for a bounded interval, then + * consume it as a drop so one invalid live pointer cannot block every remote + * player behind the global movement stream. + */ +static inline samp_remote_edge_cursor_decision_compat +samp_remote_edge_cursor_decide(uint32_t cursor, uint32_t movement_seq, + samp_remote_edge_apply_result_compat result, + uint32_t defer_age_ms) { + samp_remote_edge_cursor_decision_compat decision; + + decision.next_cursor = cursor; + decision.effective_result = result; + decision.consume = 0; + decision.stop = 0; + + if (!samp_remote_edge_seq_is_newer(cursor, movement_seq)) { + return decision; + } + if (result == SAMP_REMOTE_EDGE_DEFER && + defer_age_ms < SAMP_REMOTE_EDGE_DEFER_MAX_MS) { + decision.stop = 1; + return decision; + } + if (result == SAMP_REMOTE_EDGE_DEFER) { + decision.effective_result = SAMP_REMOTE_EDGE_DROP; + } else if (result != SAMP_REMOTE_EDGE_APPLIED && + result != SAMP_REMOTE_EDGE_DROP) { + decision.effective_result = SAMP_REMOTE_EDGE_DROP; + } + decision.next_cursor = movement_seq; + decision.consume = 1; + return decision; +} + +#endif diff --git a/reimpl/src/remote_vehicle_playback_compat.h b/reimpl/src/remote_vehicle_playback_compat.h new file mode 100644 index 0000000..254a942 --- /dev/null +++ b/reimpl/src/remote_vehicle_playback_compat.h @@ -0,0 +1,146 @@ +#ifndef SAMPDLL_REMOTE_VEHICLE_PLAYBACK_COMPAT_H +#define SAMPDLL_REMOTE_VEHICLE_PLAYBACK_COMPAT_H + +#include +#include +#include + +#define SAMP_REMOTE_VEHICLE_POSITION_EPSILON 0.05f +#define SAMP_REMOTE_VEHICLE_POSITION_SNAP_XY 8.0f +#define SAMP_REMOTE_VEHICLE_POSITION_SNAP_Z 0.5f +#define SAMP_REMOTE_VEHICLE_POSITION_SNAP_Z_AIR_WATER 2.0f +#define SAMP_REMOTE_VEHICLE_POSITION_CORRECTION 0.06f +#define SAMP_REMOTE_VEHICLE_CORRECTED_SPEED_EPSILON 0.01f +#define SAMP_REMOTE_VEHICLE_TURN_SPEED_LIMIT 0.02f +#define SAMP_REMOTE_VEHICLE_QUATERNION_SLERP 0.75f +#define SAMP_REMOTE_TRAILER_POSITION_EPSILON 0.5f +#define SAMP_REMOTE_TRAILER_POSITION_SNAP_XY 6.0f +#define SAMP_REMOTE_TRAILER_POSITION_SNAP_Z 3.0f + +typedef enum samp_remote_vehicle_position_decision_compat { + SAMP_REMOTE_VEHICLE_DECISION_INVALID = 0, + SAMP_REMOTE_VEHICLE_DECISION_NOOP, + SAMP_REMOTE_VEHICLE_DECISION_CORRECT, + SAMP_REMOTE_VEHICLE_DECISION_SNAP +} samp_remote_vehicle_position_decision_compat; + +typedef enum samp_remote_trailer_position_decision_compat { + SAMP_REMOTE_TRAILER_DECISION_INVALID = 0, + SAMP_REMOTE_TRAILER_DECISION_TRANSITION, + SAMP_REMOTE_TRAILER_DECISION_NOOP, + SAMP_REMOTE_TRAILER_DECISION_CORRECT, + SAMP_REMOTE_TRAILER_DECISION_SNAP +} samp_remote_trailer_position_decision_compat; + +/* + * OBSERVED_037 + PROBE_TRACE: + * A Packet-210 association transition is distinct from ordinary positional + * correction. R5 calls SetTowLink and then publishes the exact Packet-210 + * transform even when the position sampled before SetTowLink was inside the + * normal 0.5-m deadband. + */ +static inline samp_remote_trailer_position_decision_compat +samp_remote_trailer_position_decision_r5( + const float current_position[3], const float target_position[3], + int association_transition, float delta_out[3]) { + unsigned int i = 0u; + + if (current_position == NULL || target_position == NULL || + delta_out == NULL) { + return SAMP_REMOTE_TRAILER_DECISION_INVALID; + } + for (i = 0u; i < 3u; ++i) { + if (!isfinite(current_position[i]) || !isfinite(target_position[i])) { + return SAMP_REMOTE_TRAILER_DECISION_INVALID; + } + delta_out[i] = target_position[i] - current_position[i]; + } + if (association_transition) { + return SAMP_REMOTE_TRAILER_DECISION_TRANSITION; + } + if (fabsf(delta_out[0]) <= SAMP_REMOTE_TRAILER_POSITION_EPSILON && + fabsf(delta_out[1]) <= SAMP_REMOTE_TRAILER_POSITION_EPSILON && + fabsf(delta_out[2]) <= SAMP_REMOTE_TRAILER_POSITION_EPSILON) { + return SAMP_REMOTE_TRAILER_DECISION_NOOP; + } + if (fabsf(delta_out[0]) > SAMP_REMOTE_TRAILER_POSITION_SNAP_XY || + fabsf(delta_out[1]) > SAMP_REMOTE_TRAILER_POSITION_SNAP_XY || + fabsf(delta_out[2]) > SAMP_REMOTE_TRAILER_POSITION_SNAP_Z) { + return SAMP_REMOTE_TRAILER_DECISION_SNAP; + } + return SAMP_REMOTE_TRAILER_DECISION_CORRECT; +} + +static inline const char *samp_remote_trailer_position_decision_name( + samp_remote_trailer_position_decision_compat decision) { + switch (decision) { + case SAMP_REMOTE_TRAILER_DECISION_TRANSITION: + return "transition"; + case SAMP_REMOTE_TRAILER_DECISION_NOOP: + return "noop"; + case SAMP_REMOTE_TRAILER_DECISION_CORRECT: + return "correct"; + case SAMP_REMOTE_TRAILER_DECISION_SNAP: + return "snap"; + default: + return "invalid"; + } +} + +/* + * STATIC_037: + * Pure math extracted from samp.dll+0x15140. The caller owns the preceding + * exact move-speed setter and the not-added/full-matrix branches. + */ +static inline samp_remote_vehicle_position_decision_compat +samp_remote_vehicle_position_decision_r5( + const float current_position[3], const float target_position[3], + const float target_speed[3], float snap_z, float delta_out[3], + float corrected_speed_out[3], int *write_corrected_speed_out) { + unsigned int i = 0u; + int write_corrected_speed = 0; + + if (current_position == NULL || target_position == NULL || + target_speed == NULL || delta_out == NULL || + corrected_speed_out == NULL || + write_corrected_speed_out == NULL || !isfinite(snap_z) || + snap_z <= 0.0f) { + return SAMP_REMOTE_VEHICLE_DECISION_INVALID; + } + for (i = 0u; i < 3u; ++i) { + if (!isfinite(current_position[i]) || + !isfinite(target_position[i]) || !isfinite(target_speed[i])) { + return SAMP_REMOTE_VEHICLE_DECISION_INVALID; + } + delta_out[i] = target_position[i] - current_position[i]; + } + memcpy(corrected_speed_out, target_speed, + sizeof(float) * 3u); + *write_corrected_speed_out = 0; + + if (fabsf(delta_out[0]) <= SAMP_REMOTE_VEHICLE_POSITION_EPSILON && + fabsf(delta_out[1]) <= SAMP_REMOTE_VEHICLE_POSITION_EPSILON && + fabsf(delta_out[2]) <= SAMP_REMOTE_VEHICLE_POSITION_EPSILON) { + return SAMP_REMOTE_VEHICLE_DECISION_NOOP; + } + if (fabsf(delta_out[0]) > SAMP_REMOTE_VEHICLE_POSITION_SNAP_XY || + fabsf(delta_out[1]) > SAMP_REMOTE_VEHICLE_POSITION_SNAP_XY || + fabsf(delta_out[2]) > snap_z) { + return SAMP_REMOTE_VEHICLE_DECISION_SNAP; + } + + for (i = 0u; i < 3u; ++i) { + if (fabsf(delta_out[i]) > SAMP_REMOTE_VEHICLE_POSITION_EPSILON) { + corrected_speed_out[i] += + delta_out[i] * SAMP_REMOTE_VEHICLE_POSITION_CORRECTION; + } + if (fabsf(corrected_speed_out[i]) > + SAMP_REMOTE_VEHICLE_CORRECTED_SPEED_EPSILON) { + write_corrected_speed = 1; + } + } + *write_corrected_speed_out = write_corrected_speed; + return SAMP_REMOTE_VEHICLE_DECISION_CORRECT; +} + +#endif diff --git a/reimpl/src/runtime_bridge.c b/reimpl/src/runtime_bridge.c index 5292169..0732c46 100644 --- a/reimpl/src/runtime_bridge.c +++ b/reimpl/src/runtime_bridge.c @@ -13,7 +13,13 @@ #include #include "actor_facing_compat.h" +#include "custom_asset_bulk_compat.h" +#include "gta_camera_aim_compat.h" #include "gta_quaternion_compat.h" +#include "pickup_pool_compat.h" +#include "remote_edge_cursor_compat.h" +#include "remote_vehicle_playback_compat.h" +#include "vehicle_attach_timing_compat.h" #include "net/raknet_client_adapter_internal.h" #include "sampdll/archive/win32_archive_fs.h" #include "sampdll/net/dual_stack.h" @@ -86,6 +92,9 @@ #define SAMP_ADDR_LOADING_SCREEN_NAME_B 0x866CCCu #define SAMP_ADDR_GAME_PROCESS_HOOK_INSTALL 0x58C246u #define SAMP_ADDR_GAME_PROCESS_HOOK_STORAGE 0x53BED1u +#define SAMP_ADDR_CAMERA_COLLISION_VEHICLES_CALL 0x41AF80u +#define SAMP_ADDR_CAMERA_COLLISION_VEHICLES_CALL_DISP 0x41AF81u +#define SAMP_GTA_FUNC_CAMERA_COLLISION_VEHICLES 0x41A990u #define SAMP_ADDR_SCRIPT_PROCESS_GATE 0x469EF5u #define SAMP_ADDR_SCRIPT_PROCESS_GATE2 0x469EF6u #define SAMP_ADDR_SCRIPT_PROCESS_CALL_DISP 0x53BFC8u @@ -133,6 +142,7 @@ #define SAMP_ADDR_CAMERA_MOUSE_ACCEL_VERTICAL 0xB6EC1Cu #define SAMP_ADDR_CAMERA_USE_MOUSE_3RD_PERSON 0xB6EC2Eu #define SAMP_ADDR_CAMERA_EXT_ZOOM 0xB6F250u +#define SAMP_ADDR_CAMERA_EXT_ASPECT 0xC3EFA4u #define SAMP_ADDR_CAMERA_INTERNAL_AIM 0xB6F32Cu #define SAMP_ADDR_CAMERA_MATRIX 0xB6F99Cu #define SAMP_ADDR_CAMERA_MODE 0xB6F1A8u @@ -184,6 +194,15 @@ #define SAMP_ADDR_GAME_INPUT_UPDATE_CALL 0x541DF5u #define SAMP_GAME_INPUT_UPDATE_CALL_SIZE 5u #define SAMP_GAME_INPUT_RELEASE_FRAMES 2 +#define SAMP_ADDR_GAME_MOUSE_POLL_CALL 0x53F417u +#define SAMP_GAME_MOUSE_POLL_CALL_SIZE 5u +#define SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH 0x53F41Fu +#define SAMP_GAME_MOUSE_RESULT_BRANCH_SIZE 4u +#define SAMP_ADDR_GAME_MOUSE_MOVE_X 0xB73424u +#define SAMP_ADDR_GAME_MOUSE_MOVE_Y 0xB73428u +#define SAMP_GTA_FUNC_CLEAR_MOUSE_HISTORY 0x541BD0u +#define SAMP_GTA_FUNC_UPDATE_PADS 0x541DD0u +#define SAMP_DIALOG_INPUT_RELEASE_FRAMES 10 #define SAMP_ADDR_GANG_ZONE_DRAW 0x5853D0u #define SAMP_ADDR_GANG_ZONE_RADAR_CALL 0x5869BFu #define SAMP_ADDR_GANG_ZONE_RADAR_CALL_DISP (SAMP_ADDR_GANG_ZONE_RADAR_CALL + 1u) @@ -273,9 +292,8 @@ #define SAMP_ADDR_STREAMING_REQUEST_MODEL 0x4087E0u #define SAMP_ADDR_STREAMING_LOAD_ALL_REQUESTED 0x40EA10u #define SAMP_ADDR_CENTITY_UPDATE_RW_FRAME 0x532B00u +#define SAMP_ADDR_CLOTHES_REBUILD_PLAYER 0x5A82C0u #define SAMP_ADDR_STREAMING_SET_MODEL_IS_DELETABLE 0x409C10u -#define SAMP_ADDR_STREAMING_LOAD_SCENE 0x40EB70u -#define SAMP_ADDR_STREAMING_LOAD_SCENE_COLLISION 0x40ED80u #define SAMP_ADDR_STREAMING_LOAD_CD_DIRECTORY 0x5B6170u #define SAMP_ADDR_STREAMING_FILE_LIST 0x8E48D8u #define SAMP_STREAMING_FILE_COUNT 8u @@ -480,6 +498,7 @@ #define SAMP_LOCAL_STREAM_REFRESH_DISTANCE 35.0f #define SAMP_PED_OFFSET_MATRIX 20u #define SAMP_PED_OFFSET_STATE_FLAGS 1132u +#define SAMP_PED_OFFSET_VISIBILITY_FLAGS 1140u #define SAMP_PED_OFFSET_INTELLIGENCE 1148u #define SAMP_PED_OFFSET_PLAYER_INFO 1152u #define SAMP_PLAYER_INFO_OFFSET_CLOTHES_DESC 4u @@ -487,6 +506,7 @@ #define SAMP_PLAYER_INFO_OFFSET_AIM_Z 84u #define SAMP_GTA_VTBL_CPLAYERPED 0x0086D168u #define SAMP_GTA_FUNC_CPLAYERPED_SCALAR_DTOR 0x0060A9A0u +#define SAMP_GTA_FUNC_CPLAYERPED_SET_INITIAL_STATE 0x0060CD20u #define SAMP_GTA_FUNC_CPLAYERPED_PROCESS_CONTROL 0x0060EA90u #define SAMP_GTA_CPLAYERPED_PROCESS_CONTROL_SLOT 0x0086D190u #define SAMP_CLOTHES_DESC_OFFSET_FAT_STAT 0x70u @@ -499,6 +519,8 @@ #define SAMP_PED_OFFSET_HEALTH 1344u #define SAMP_PED_OFFSET_ARMOUR 1352u #define SAMP_PED_OFFSET_AUDIO_ENTITY 660u +#define SAMP_PED_OFFSET_UNOCCUPIED_BLOCK_FLAGS 0x46Cu +#define SAMP_PED_OFFSET_UNOCCUPIED_BLOCK_LINK 0x598u #define SAMP_PED_OFFSET_ROTATION1 1368u #define SAMP_PED_OFFSET_ROTATION2 1372u #define SAMP_PED_OFFSET_STANDING_ENTITY 0x568u @@ -508,6 +530,7 @@ #define SAMP_PED_OFFSET_CURRENT_WEAPON_SLOT 1816u #define SAMP_PED_OFFSET_ENTRY_EXIT 0x78Cu #define SAMP_PED_INTELLIGENCE_TASK_MANAGER_OFFSET 4u +#define SAMP_PED_INTELLIGENCE_FOLLOW_TASK_OFFSET 0x2Cu #define SAMP_TASK_MANAGER_PRIMARY_COUNT 5u #define SAMP_TASK_MANAGER_ROOT_COUNT 11u #define SAMP_TASK_MANAGER_SECONDARY_ATTACK 0u @@ -523,20 +546,31 @@ #define SAMP_PED_STATE_IN_VEHICLE 0x100u #define SAMP_PED_ACTION_DEATH 54u #define SAMP_PED_ACTION_WASTED 55u +#define SAMP_GTA_FUNC_CTASK_COMPLEX_FOLLOW_NODE_SCALAR_DTOR 0x00639330u +#define SAMP_CENTITY_DESTROY_RW_VTABLE_OFFSET 0x20u +#define SAMP_CENTITY_SET_MODEL_VTABLE_OFFSET 0x14u +#define SAMP_ENTITY_OFFSET_RENDER_FLAGS 0x1Cu #define SAMP_VEHICLE_SCANNER_DISTANCE 200.0f -#define SAMP_VEHICLE_CREATE_Z_BIAS 0.1f +#define SAMP_VEHICLE_CREATE_POST_Z_BIAS 0.25f #define SAMP_VEHICLE_OFFSET_COLOR1 1076u #define SAMP_VEHICLE_OFFSET_COLOR2 1077u #define SAMP_VEHICLE_OFFSET_SIREN_FLAGS 1069u #define SAMP_VEHICLE_OFFSET_DRIVER 1120u #define SAMP_VEHICLE_OFFSET_PASSENGERS 1124u #define SAMP_VEHICLE_PASSENGER_COUNT 8u +#define SAMP_VEHICLE_OFFSET_TRACTOR 1220u +#define SAMP_VEHICLE_OFFSET_TRAILER 1224u +#define SAMP_PHYSICAL_OFFSET_FAKE_PHYSICS 0xB8u +#define SAMP_PHYSICAL_OFFSET_COLLISION_LIST 0x460u #define SAMP_VEHICLE_OFFSET_HEALTH 1216u -#define SAMP_VEHICLE_OFFSET_SIREN_ALT 1300u +#define SAMP_VEHICLE_OFFSET_DIRT_LEVEL 0x4B0u +#define SAMP_VEHICLE_OFFSET_DOOR_LOCK 0x4F8u +#define SAMP_VEHICLE_OFFSET_HORN_STATE 0x514u #define SAMP_VEHICLE_OFFSET_AUDIO_ENTITY 312u #define SAMP_VEHICLE_OFFSET_PROCESS_FLAGS 0x428u #define SAMP_ENTITY_OFFSET_CONTROL_FLAGS 54u #define SAMP_VEHICLE_OFFSET_CAR_WHEELS 1445u +#define SAMP_VEHICLE_OFFSET_BIKE_WHEELS 1628u #define SAMP_VEHICLE_OFFSET_DOOR_DAMAGE 1449u #define SAMP_VEHICLE_OFFSET_LIGHT_DAMAGE 1456u #define SAMP_VEHICLE_OFFSET_PANEL_DAMAGE 1460u @@ -553,6 +587,7 @@ #define SAMP_ENTITY_OFFSET_MODEL_INDEX 34u #define SAMP_ENTITY_OFFSET_MOVE_SPEED 68u #define SAMP_ENTITY_OFFSET_TURN_SPEED 80u +#define SAMP_ENTITY_OFFSET_WORLD_LINK 0xB4u #define SAMP_RW_TYPE_ATOMIC 1u #define SAMP_RW_TYPE_CLUMP 2u #define SAMP_MATRIX_OFFSET_RIGHT 0u @@ -575,7 +610,6 @@ #define SAMP_REMOVE_BUILDING_RESCAN_INTERVAL_MS 750u #define SAMP_GTA_ACTOR_LOCAL_ID 1 #define SAMP_GTA_PLAYER_LOCAL_ID 0 -#define SAMP_PICKUP_COMPAT_MAX 4096u #define SAMP_PI 3.14159265358979323846f #define SAMP_DEG_TO_RAD 0.01745329251994329577f #define SAMP_CHAT_COMPAT_MAX_LINES 20 @@ -589,13 +623,31 @@ #define SAMP_CHAT_COMPAT_MIN_FONT_ADJUSTMENT -3 #define SAMP_CHAT_COMPAT_MAX_FONT_ADJUSTMENT 5 #define SAMP_CHAT_COMPAT_COLOR_INFO 0xFFECECECu -#define SAMP_MODERN_UI_PANEL_COLOR 0x70000000u -#define SAMP_MODERN_UI_PANEL_FALLBACK 0xB0000000u -#define SAMP_MODERN_UI_HEADER_COLOR 0x90000000u -#define SAMP_MODERN_UI_BORDER_COLOR 0xFFFFFFFFu -#define SAMP_MODERN_UI_DIVIDER_COLOR 0xFF707070u +#define SAMP_CHAT_COMPAT_DISPLAY_HIDDEN 0 +#define SAMP_CHAT_COMPAT_DISPLAY_COLORED 1 +#define SAMP_CHAT_COMPAT_DISPLAY_PLAIN 2 +#define SAMP_DIALOG_COMPAT_LOCAL_NONE 0 +#define SAMP_DIALOG_COMPAT_LOCAL_HELP 1 +#define SAMP_NETSTATS_COMPAT_TEXT_BYTES 8192u +#define SAMP_NETSTATS_COMPAT_TITLE_COLOR 0xFF8888EEu +#define SAMP_MODERN_UI_SHADOW_COLOR 0x70000000u +#define SAMP_MODERN_UI_PANEL_COLOR 0xCC0A0F14u +#define SAMP_MODERN_UI_PANEL_FALLBACK 0xE00A0F14u +#define SAMP_MODERN_UI_HEADER_COLOR 0xD8141C25u +#define SAMP_MODERN_UI_BODY_COLOR 0xA80B1118u +#define SAMP_MODERN_UI_BORDER_COLOR 0xA06A8294u +#define SAMP_MODERN_UI_INNER_BORDER_COLOR 0x50566B7Au +#define SAMP_MODERN_UI_DIVIDER_COLOR 0x80627583u +#define SAMP_MODERN_UI_ACCENT_COLOR 0xFF4EA6D1u +#define SAMP_MODERN_UI_ACCENT_SOFT_COLOR 0x98508FB0u +#define SAMP_MODERN_UI_CONTROL_COLOR 0xB8131B23u +#define SAMP_MODERN_UI_CONTROL_HOVER_COLOR 0xD0265268u +#define SAMP_MODERN_UI_CONTROL_PRESSED_COLOR 0xE01B3F52u +#define SAMP_MODERN_UI_CONTROL_BORDER_COLOR 0x905F7788u +#define SAMP_MODERN_UI_CONTROL_HOVER_BORDER_COLOR 0xD06FC4E8u +#define SAMP_MODERN_UI_CONTROL_PRESSED_BORDER_COLOR 0xF04EA6D1u #define SAMP_MODERN_UI_TEXT_COLOR 0xFFFFFFFFu -#define SAMP_MODERN_UI_MUTED_COLOR 0xFFB8B8B8u +#define SAMP_MODERN_UI_MUTED_COLOR 0xFFD0D8DEu #define SAMP_MENU_SCRIPT_WIDTH 640.0f #define SAMP_MENU_SCRIPT_HEIGHT 460.0f #define SAMP_MENU_TITLE_HEIGHT 28 @@ -617,7 +669,9 @@ #define SAMP_SCOREBOARD_COLOR_GRID SAMP_MODERN_UI_DIVIDER_COLOR #define SAMP_SCOREBOARD_COLOR_LABEL SAMP_MODERN_UI_TEXT_COLOR #define SAMP_SCOREBOARD_COLOR_MUTED SAMP_MODERN_UI_MUTED_COLOR -#define SAMP_SCOREBOARD_COLOR_HOVER 0x60FFFFFFu +#define SAMP_SCOREBOARD_COLOR_HOVER SAMP_MODERN_UI_ACCENT_SOFT_COLOR +#define SAMP_SCOREBOARD_COLOR_LOCAL 0x70426374u +#define SAMP_SCOREBOARD_COLOR_ALTERNATE 0x38000000u #define SAMP_CHAT_INPUT_MAX 128 #define SAMP_CHAT_INPUT_HISTORY 10 #define SAMP_CHAT_INPUT_DRAW_BYTES 160 @@ -632,6 +686,7 @@ #define SAMP_D3D9_END_SCENE_INDEX 42u #define SAMP_D3D9_SET_CURSOR_POSITION_INDEX 11u #define SAMP_D3D9_SHOW_CURSOR_INDEX 12u +#define SAMP_D3D9_GET_RENDER_TARGET_DATA_INDEX 32u #define SAMP_D3D9_GET_FRONT_BUFFER_DATA_INDEX 33u #define SAMP_D3D9_CREATE_OFFSCREEN_PLAIN_SURFACE_INDEX 36u #define SAMP_D3D9_SET_RENDER_TARGET_INDEX 37u @@ -654,10 +709,15 @@ #define SAMP_D3DTS_VIEW 2u #define SAMP_D3DTS_PROJECTION 3u #define SAMP_D3DRS_ZENABLE 7u +#define SAMP_D3DRS_FILLMODE 8u #define SAMP_D3DRS_ALPHATESTENABLE 15u #define SAMP_D3DRS_SRCBLEND 19u #define SAMP_D3DRS_DESTBLEND 20u +#define SAMP_D3DRS_CULLMODE 22u #define SAMP_D3DRS_ALPHABLENDENABLE 27u +#define SAMP_D3DRS_COLORWRITEENABLE 168u +#define SAMP_D3DRS_BLENDOP 171u +#define SAMP_D3DRS_SCISSORTESTENABLE 174u #define SAMP_D3DSAMP_MAGFILTER 5u #define SAMP_D3DSAMP_MINFILTER 6u #define SAMP_D3DTEXF_LINEAR 2u @@ -667,29 +727,44 @@ #define SAMP_D3DTSS_ALPHAOP 4u #define SAMP_D3DTSS_ALPHAARG1 5u #define SAMP_D3DTSS_ALPHAARG2 6u +#define SAMP_D3DTOP_DISABLE 1u #define SAMP_D3DTOP_SELECTARG1 2u #define SAMP_D3DTOP_MODULATE 4u #define SAMP_D3DTA_DIFFUSE 0u #define SAMP_D3DTA_TEXTURE 2u #define SAMP_D3DBLEND_SRCALPHA 5u #define SAMP_D3DBLEND_INVSRCALPHA 6u +#define SAMP_D3DBLENDOP_ADD 1u +#define SAMP_D3DFILL_SOLID 3u +#define SAMP_D3DCULL_NONE 1u +#define SAMP_D3DCOLORWRITE_ALL 0x0000000Fu #define SAMP_D3DPT_TRIANGLESTRIP 5u #define SAMP_D3DFVF_XYZRHW_DIFFUSE 0x00000044u #define SAMP_D3DFVF_XYZRHW_DIFFUSE_TEX1 0x00000144u #define SAMP_D3DCLEAR_TARGET 0x00000001u +#define SAMP_UI_CURSOR_WIDTH 32 +#define SAMP_UI_CURSOR_HEIGHT 32 +#define SAMP_D3DUSAGE_RENDERTARGET 0x00000001u #define SAMP_D3DFMT_A8R8G8B8 21u +#define SAMP_D3DPOOL_DEFAULT 0u +#define SAMP_D3DPOOL_SYSTEMMEM 2u #define SAMP_D3DPOOL_SCRATCH 3u +#define SAMP_D3DMULTISAMPLE_NONE 0u +#define SAMP_D3DLOCK_READONLY 0x00000010u +#define SAMP_D3D9_SURFACE_GET_DESC_INDEX 12u +#define SAMP_D3D9_SURFACE_LOCK_RECT_INDEX 13u +#define SAMP_D3D9_SURFACE_UNLOCK_RECT_INDEX 14u #define SAMP_D3DXIFF_PNG 3u #define SAMP_CHAT_INPUT_BOX_COLOR 0x33000000u #define SAMP_CHAT_INPUT_BORDER_COLOR 0xA0FFFFFFu #define SAMP_DIALOG_COMPAT_MAX_VISIBLE_ITEMS 8 #define SAMP_DIALOG_COMPAT_MAX_ITEM_BYTES 256 -#define SAMP_DIALOG_COMPAT_COLOR_PANEL 0xA0000000u -#define SAMP_DIALOG_COMPAT_COLOR_HEADER 0x90000000u -#define SAMP_DIALOG_COMPAT_COLOR_BODY 0x70000000u -#define SAMP_DIALOG_COMPAT_COLOR_SELECTED 0xFF9E1B1Bu -#define SAMP_DIALOG_COMPAT_COLOR_PRIMARY_BUTTON 0xA0000000u -#define SAMP_DIALOG_COMPAT_COLOR_BUTTON 0x80000000u +#define SAMP_DIALOG_COMPAT_COLOR_PANEL SAMP_MODERN_UI_PANEL_COLOR +#define SAMP_DIALOG_COMPAT_COLOR_HEADER SAMP_MODERN_UI_HEADER_COLOR +#define SAMP_DIALOG_COMPAT_COLOR_BODY SAMP_MODERN_UI_BODY_COLOR +#define SAMP_DIALOG_COMPAT_COLOR_SELECTED SAMP_MODERN_UI_ACCENT_SOFT_COLOR +#define SAMP_DIALOG_COMPAT_COLOR_PRIMARY_BUTTON SAMP_MODERN_UI_CONTROL_HOVER_COLOR +#define SAMP_DIALOG_COMPAT_COLOR_BUTTON SAMP_MODERN_UI_CONTROL_COLOR #define SAMP_DIALOG_COMPAT_COLOR_TEXT 0xFFFFFFFFu #define SAMP_DIALOG_COMPAT_COLOR_MUTED 0xFFB8B8B8u #define SAMP_DIALOG_INPUT_BUTTON_WIDTH 96 @@ -703,9 +778,9 @@ #define SAMP_DIALOG_INPUT_TEXT_INSET 9 #define SAMP_DIALOG_INPUT_CARET_WIDTH 2 #define SAMP_DIALOG_INPUT_CARET_COLOR 0xFF969696u -#define SAMP_DIALOG_INPUT_BORDER_COLOR 0xFFF0F0F0u +#define SAMP_DIALOG_INPUT_BORDER_COLOR SAMP_MODERN_UI_CONTROL_HOVER_BORDER_COLOR #define SAMP_DIALOG_INPUT_INFO_COLOR 0xFFA9C4E4u -#define SAMP_DIALOG_INPUT_PANEL_COLOR 0xC80A0A0Au +#define SAMP_DIALOG_INPUT_PANEL_COLOR SAMP_MODERN_UI_PANEL_COLOR #define SAMP_DIALOG_INPUT_FONT0_BASE_HEIGHT 20 #define SAMP_DIALOG_INPUT_FONT1_BASE_HEIGHT 18 #define SAMP_CLASS_SELECTION_DIALOG_WIDTH 310 @@ -717,7 +792,7 @@ #define SAMP_CLASS_SELECTION_BUTTON_Y 5 #define SAMP_CLASS_SELECTION_BUTTON_WIDTH 90 #define SAMP_CLASS_SELECTION_BUTTON_HEIGHT 30 -#define SAMP_CLASS_SELECTION_DIALOG_COLOR 0x960A0A0Au +#define SAMP_CLASS_SELECTION_DIALOG_COLOR SAMP_MODERN_UI_PANEL_COLOR #define SAMP_CLASS_SELECTION_TEXTURE_WIDTH 512.0f #define SAMP_CLASS_SELECTION_TEXTURE_HEIGHT 512.0f #define SAMP_CLASS_SELECTION_BUTTON_SRC_LEFT 0 @@ -989,6 +1064,7 @@ #define SAMP_STREAMING_RESOURCE_COUNT 26316 #define SAMP_LOCAL_SPAWN_SKIN_APPLY_ENV "SAMPDLL_LOCAL_SPAWN_SKIN_APPLY" #define SAMP_CLASS_SELECTION_SKIN_APPLY_ENV "SAMPDLL_CLASS_SELECTION_SKIN_APPLY" +#define SAMP_CLASS_SELECTION_R5_TEXTURE_ENV "SAMPDLL_CLASS_SELECTION_R5_TEXTURE" #define SAMP_VEHICLE_COMPAT_MODEL_LOAD_FLAGS 0x06 #define SAMP_VEHICLE_COMPAT_CREATE_BUDGET 1u #define SAMP_VEHICLE_COMPAT_CREATE_INTERVAL_MS 100u @@ -1088,6 +1164,8 @@ #define SAMP_DEATH_WINDOW_REASON_SUICIDE 255u #define SAMP_DEATH_REPORT_REASON_UNKNOWN 255u #define SAMP_DEATH_REPORT_RESPONSIBLE_NONE 65535u +#define SAMP_LOCAL_RESPAWN_PENDING_APPLY 1 +#define SAMP_LOCAL_RESPAWN_PENDING_RPC 2 #define SAMP_DEATH_WINDOW_WIDTH 340 #define SAMP_DEATH_WINDOW_NAME_FALLBACK_WIDTH 118 #define SAMP_DEATH_WINDOW_WEAPON_WIDTH 30 @@ -1132,10 +1210,6 @@ #define SAMP_ASSET_IDE_SOURCE_UNKNOWN 0u #define SAMP_ASSET_IDE_SOURCE_SAMP 1u #define SAMP_ASSET_IDE_SOURCE_CUSTOM 2u -#define SAMP_ASSET_IDE_SECTION_UNKNOWN 0u -#define SAMP_ASSET_IDE_SECTION_OBJS 1u -#define SAMP_ASSET_IDE_SECTION_TOBJ 2u -#define SAMP_ASSET_IDE_SECTION_ANIM 3u #define SAMP_RAKNET_RPC_FLAG_GAME_STATE_MASK \ (SAMP_RAKNET_RPC_FLAG_PLAYER_POS | SAMP_RAKNET_RPC_FLAG_PLAYER_FACING | SAMP_RAKNET_RPC_FLAG_WEATHER | \ SAMP_RAKNET_RPC_FLAG_WORLD_TIME | SAMP_RAKNET_RPC_FLAG_SET_TIME_EX | SAMP_RAKNET_RPC_FLAG_TOGGLE_CLOCK | \ @@ -1297,6 +1371,7 @@ typedef struct samp_d3d_matrix_compat { typedef void(__cdecl *samp_script_process_fn)(void); typedef void(__cdecl *samp_gta_draw_gang_zone_fn)(float *, uint32_t *, uint8_t); +typedef void(__cdecl *samp_gta_void_fn)(void); typedef HANDLE(WINAPI *samp_create_file_a_fn)(LPCSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE); typedef BOOL(WINAPI *samp_read_file_fn)(HANDLE, LPVOID, DWORD, LPDWORD, LPOVERLAPPED); typedef DWORD(WINAPI *samp_get_file_size_fn)(HANDLE, LPDWORD); @@ -1317,6 +1392,7 @@ typedef HRESULT(WINAPI *samp_d3d9_set_fvf_fn)(void *, DWORD); typedef HRESULT(WINAPI *samp_d3d9_draw_primitive_up_fn)(void *, DWORD, unsigned int, const void *, unsigned int); typedef HRESULT(WINAPI *samp_d3d9_get_front_buffer_data_fn)(void *, UINT, void *); typedef HRESULT(WINAPI *samp_d3d9_create_offscreen_plain_surface_fn)(void *, UINT, UINT, DWORD, DWORD, void **, void *); +typedef HRESULT(WINAPI *samp_d3d9_get_render_target_data_fn)(void *, void *, void *); typedef HRESULT(WINAPI *samp_d3d9_set_render_target_fn)(void *, DWORD, void *); typedef HRESULT(WINAPI *samp_d3d9_get_render_target_fn)(void *, DWORD, void **); typedef HRESULT(WINAPI *samp_d3d9_set_depth_stencil_surface_fn)(void *, void *); @@ -1327,6 +1403,30 @@ typedef HRESULT(WINAPI *samp_d3d9_get_viewport_fn)(void *, samp_d3d9_viewport_co typedef void(WINAPI *samp_d3d9_set_cursor_position_fn)(void *, int, int, DWORD); typedef BOOL(WINAPI *samp_d3d9_show_cursor_fn)(void *, BOOL); typedef ULONG(WINAPI *samp_unknown_release_fn)(void *); + +typedef struct samp_d3d9_surface_desc_compat { + DWORD format; + DWORD type; + DWORD usage; + DWORD pool; + DWORD multi_sample_type; + DWORD multi_sample_quality; + UINT width; + UINT height; +} samp_d3d9_surface_desc_compat; + +typedef struct samp_d3d9_locked_rect_compat { + INT pitch; + void *bits; +} samp_d3d9_locked_rect_compat; + +typedef HRESULT(WINAPI *samp_d3d9_surface_get_desc_fn)(void *, samp_d3d9_surface_desc_compat *); +typedef HRESULT(WINAPI *samp_d3d9_surface_lock_rect_fn)(void *, samp_d3d9_locked_rect_compat *, const RECT *, + DWORD); +typedef HRESULT(WINAPI *samp_d3d9_surface_unlock_rect_fn)(void *); + +_Static_assert(sizeof(samp_d3d9_surface_desc_compat) == 0x20u, "D3DSURFACE_DESC ABI"); + typedef BOOL(WINAPI *samp_bass_init_fn)(int, DWORD, DWORD, HWND, const GUID *); typedef BOOL(WINAPI *samp_bass_free_fn)(void); typedef BOOL(WINAPI *samp_bass_set_config_fn)(DWORD, DWORD); @@ -1658,8 +1758,12 @@ typedef struct samp_remote_player_slot_compat { uint16_t up_down_keys; uint16_t keys; uint16_t vehicle_id; + uint16_t trailer_id; uint8_t in_vehicle; uint8_t vehicle_seat; + uint8_t drive_by; + uint8_t cuffed; + uint8_t additional_key; uint8_t gta_player_index; uint8_t gta_player_backed; int32_t skin; @@ -1671,6 +1775,11 @@ typedef struct samp_remote_player_slot_compat { float pos[3]; float rotation; float move_speed[3]; + float vehicle_target_quaternion[4]; + float vehicle_target_position[3]; + float vehicle_target_speed[3]; + uint32_t vehicle_target_frames; + uint8_t vehicle_target_valid; samp_raknet_remote_aim_sync aim_sync; DWORD last_sync_tick; } samp_remote_player_slot_compat; @@ -2139,8 +2248,16 @@ typedef void(SAMP_THISCALL *gta_task_manager_set_secondary_fn)( void *manager, void *task, uint32_t task_index); typedef void *(SAMP_THISCALL *gta_scalar_deleting_dtor_fn)( void *object, uint32_t delete_memory); +typedef void(SAMP_THISCALL *gta_cplayerped_set_initial_state_fn)( + void *ped, uint32_t player_number); typedef void(SAMP_THISCALL *gta_cplayerped_process_control_fn)(void *ped); typedef void(SAMP_THISCALL *gta_vehicle_process_control_fn)(void *vehicle); +typedef int32_t(SAMP_THISCALL *gta_vehicle_process_entity_collision_fn)( + void *vehicle, void *entity, void *col_points); +typedef int(__cdecl *gta_camera_collision_vehicles_fn)( + int32_t sector_x, int32_t sector_y, const void *bounds, + const void *scan_sphere, const void *camera_sphere, + const void *next_camera_sphere, const void *player_vehicle_velocity); typedef void(SAMP_THISCALL *gta_cquaternion_set_matrix_fn)( void *quaternion, const void *matrix); typedef void *(SAMP_THISCALL *gta_cped_get_bone_position_fn)( @@ -2235,6 +2352,7 @@ typedef struct samp_vehicle_slot_compat { uint32_t seq; int32_t model; uint32_t gta_id; + DWORD activated_tick; uint32_t marker_id; float marker_pos[3]; int32_t invulnerable_state; @@ -2253,6 +2371,16 @@ typedef struct samp_vehicle_slot_compat { uint32_t panel_damage; uint16_t trailer_vehicle_id; uint8_t trailer_attached; + uint32_t trailer_attach_timing_seq; + uint16_t trailer_attach_timing_vehicle_id; + uint16_t trailer_attach_towing_age_ms; + uint16_t trailer_attach_trailer_age_ms; + uint8_t trailer_attach_timing_logged; + uint32_t trailer_transition_seq; + uint16_t trailer_transition_vehicle_id; + uint8_t trailer_transition_pending; + uint8_t remote_unoccupied; + uint16_t unoccupied_owner_id; uint8_t params[SAMP_RAKNET_VEHICLE_PARAM_BYTES]; int32_t body_color1; int32_t body_color2; @@ -2364,6 +2492,9 @@ typedef struct samp_runtime_state { char module_path[MAX_PATH]; char module_dir[MAX_PATH]; char archive_path[MAX_PATH]; + char samp_user_dir[MAX_PATH]; + char screenshot_dir[MAX_PATH]; + char chatlog_path[MAX_PATH]; DWORD module_path_len; int archive_present; @@ -2427,6 +2558,7 @@ typedef struct samp_runtime_state { LONG chat_overlay_line_count; LONG chat_client_message_seq; LONG chat_page_size; + LONG chat_display_mode; LONG chat_font_adjustment; LONG chat_timestamp_enabled; LONG audio_messages_enabled; @@ -2463,7 +2595,9 @@ typedef struct samp_runtime_state { LONG client_debug_last_net_state; DWORD client_debug_last_tick; LONG scoreboard_offset; + LONG scoreboard_visible; LONG scoreboard_logged; + LONG scoreboard_exclusive_overlay_logged; LONG scoreboard_d3d_font_fail_logged; LONG scoreboard_player_pool_event_seq; LONG scoreboard_score_ping_seq; @@ -2523,8 +2657,9 @@ typedef struct samp_runtime_state { LONG mp_session_applied_checkpoint_event_seq; LONG mp_session_applied_pickup_event_seq; LONG mp_session_applied_explosion_event_seq; - uint32_t pickup_handles[SAMP_PICKUP_COMPAT_MAX]; - DWORD pickup_notify_after_tick[SAMP_PICKUP_COMPAT_MAX]; + uint32_t pickup_handles[SAMP_PICKUP_POOL_CAPACITY_037]; + uint8_t pickup_types[SAMP_PICKUP_POOL_CAPACITY_037]; + DWORD pickup_notify_after_tick[SAMP_PICKUP_POOL_CAPACITY_037]; LONG checkpoint_enabled; float checkpoint_pos[3]; float checkpoint_size; @@ -2548,7 +2683,11 @@ typedef struct samp_runtime_state { LONG mp_session_script_failures; LONG mp_session_spawn_finalized; LONG mp_session_finalized_spawn_seq; + LONG mp_session_class_selection_initial_state_seq; LONG mp_session_class_selection_skin_seq; + LONG mp_session_class_selection_skin_request_seq; + LONG mp_session_class_selection_skin_request_model; + LONG mp_session_class_selection_skin_request_tick; LONG class_selection_mouse_mode; LONG class_selection_mouse_down; LONG class_selection_overlay_logged; @@ -2556,6 +2695,8 @@ typedef struct samp_runtime_state { LONG class_selection_after_death_consumed; LONG class_selection_after_death_seen_dead; LONG class_selection_forced_local; + LONG local_virtual_world; + LONG local_virtual_world_seq; LONG client_spectate_active; LONG client_spectate_type; LONG client_spectate_id; @@ -2565,6 +2706,7 @@ typedef struct samp_runtime_state { LONG client_camera_attach_seq; LONG client_camera_interp_seq; LONG local_death_reported; + LONG local_respawn_pending; LONG mp_session_post_spawn_camera_restored; LONG mp_session_frontend_hold_logged; LONG time_passing_patch_applied; @@ -2787,6 +2929,7 @@ typedef struct samp_runtime_state { LONG dialog_overlay_input_len; LONG dialog_overlay_logged; LONG dialog_overlay_response_count; + LONG dialog_overlay_local_kind; LONG menu_event_seq; LONG menu_overlay_active; LONG menu_overlay_selected; @@ -2805,6 +2948,14 @@ typedef struct samp_runtime_state { LONG dialog_mouse_raw_x; LONG dialog_mouse_raw_y; LONG dialog_mouse_logged; + LONG dialog_game_mouse_patch_applied; + LONG dialog_game_mouse_release_frames; + LONG dialog_game_mouse_flush_pending; + LONG dialog_game_mouse_flush_fail_logged; + LONG dialog_game_mouse_thread_skip_logged; + uint8_t dialog_game_mouse_poll_saved[SAMP_GAME_MOUSE_POLL_CALL_SIZE]; + uint8_t dialog_game_mouse_result_saved[SAMP_GAME_MOUSE_RESULT_BRANCH_SIZE]; + uint8_t dialog_game_mouse_patch_owned; LONG rs_mouse_set_pos_saved_valid; LONG rs_mouse_set_pos_cursor_patch_applied; uint8_t rs_mouse_set_pos_saved_byte; @@ -2832,6 +2983,13 @@ typedef struct samp_runtime_state { LONG remote_vehicle_sync_seq; LONG remote_aim_sync_seq; LONG remote_bullet_sync_seq; + LONG remote_unoccupied_sync_seq; + LONG remote_trailer_sync_seq; + LONG remote_passenger_sync_seq; + LONG remote_movement_sync_seq; + LONG remote_movement_defer_logged_seq; + LONG remote_movement_defer_first_tick; + LONG remote_movement_defer_attempts; LONG remote_player_name_tag_event_seq; LONG text_label_event_seq; LONG remote_player_active_count; @@ -2852,6 +3010,15 @@ typedef struct samp_runtime_state { LONG remote_vehicle_process_control_calls; LONG remote_vehicle_process_control_remote_calls; uint32_t remote_vehicle_process_control_hook_mask; + LONG remote_vehicle_collision_hook_attempted; + LONG remote_vehicle_collision_hook_installed; + LONG remote_vehicle_collision_camera_hook_installed; + LONG remote_vehicle_collisions_disabled; + LONG remote_vehicle_collisions_disabled_seq; + LONG remote_vehicle_collision_skip_count; + uint32_t remote_vehicle_collision_hook_mask; + uintptr_t remote_vehicle_collision_camera_original; + uint8_t remote_vehicle_collision_camera_saved_disp[4]; LONG remote_player_name_tags_logged; LONG remote_player_name_tags_empty_logged; LONG actor_event_seq; @@ -3009,6 +3176,13 @@ typedef struct samp_runtime_state { samp_id3dx_font_compat *class_selection_d3dx_font; void *class_selection_d3d_device; void *class_selection_texture; + void *ui_cursor_texture; + void *ui_cursor_d3d_device; + LONG ui_cursor_texture_ready; + LONG ui_cursor_texture_fail_logged; + LONG ui_cursor_draw_fail_logged; + LONG ui_cursor_win32_visibility_known; + LONG ui_cursor_win32_visible; LONG class_selection_font_fail_logged; LONG class_selection_texture_fail_logged; samp_id3dx_font_compat *scoreboard_d3dx_font; @@ -3115,7 +3289,14 @@ static void chat_compat_viewport_rect(int *out_x, int *out_y, int *out_w, int *o static void dialog_compat_release_font(void); static int dialog_compat_ensure_font(void *device); static void dialog_compat_set_mouse_mode(int enabled); +static int ui_cursor_compat_prepare_d3d(void); +static void ui_cursor_compat_release_texture(void); +static int ui_compat_draw_cursor(void *device); +static int scoreboard_compat_visible_latched(void); static int scoreboard_compat_active(void); +static int scoreboard_compat_hud_owned(void); +static int scoreboard_compat_cursor_owned(void); +static int scoreboard_compat_write_hud_if_unowned(uint8_t hud, uint8_t radar_blank); static void scoreboard_compat_update_hud(void); static void scoreboard_compat_restore_hud(const char *reason); static int scoreboard_compat_draw_d3dx_overlay(void *device); @@ -3127,7 +3308,6 @@ static int scoreboard_compat_hit_test(int x, int y, uint16_t *out_player_id); static int scoreboard_compat_local_id(void); static void scoreboard_compat_release_font(void); static void scoreboard_compat_update_from_snapshot(const samp_raknet_rpc_probe_snapshot *snapshot); -static void ui_compat_draw_cursor(void *device, int x, int y); static void death_window_compat_update_from_snapshot(const samp_raknet_rpc_probe_snapshot *snapshot); static int death_window_compat_active(void); static int death_window_compat_handle_key(UINT msg, WPARAM wparam, LPARAM lparam); @@ -3187,6 +3367,12 @@ static void visible_cursor_recenter_patch_restore_compat(const char *reason); static int chat_input_game_controls_apply_compat(const char *reason); static void chat_input_game_controls_update_compat(void); static void chat_input_game_controls_restore_compat(const char *reason); +static int dialog_game_mouse_controls_apply_compat(const char *reason); +static int dialog_game_mouse_controls_activate_compat(const char *reason); +static int dialog_game_mouse_controls_flush_compat(const char *reason); +static int dialog_game_mouse_controls_restore_compat(const char *reason); +static void dialog_game_mouse_controls_restore_shutdown_compat( + const char *reason); static void center_hwnd_cursor_compat(HWND hwnd); static void d3d9_cursor_show_compat(int show, const char *reason); static void d3d9_cursor_sync_client_compat(HWND hwnd, int client_x, int client_y, const char *reason); @@ -3197,6 +3383,9 @@ static void class_selection_compat_update_mouse_mode(void); static int class_selection_compat_request_f4(void); static int local_death_compat_process_report(uintptr_t ped, int spawn_ready); static int class_selection_compat_process_after_death_latch(uintptr_t ped, int spawn_ready); +static int gta_player_ped_set_initial_state_compat(uintptr_t ped, const char *reason); +static int gta_player_ped_set_loaded_model_direct_compat(uintptr_t ped, int32_t model, + const char *reason); static void object_compat_update_from_snapshot(const samp_raknet_rpc_probe_snapshot *snapshot); static int object_compat_post_spawn_scene_ready(void); static int object_compat_scene_ready(void); @@ -3224,6 +3413,8 @@ static void gang_zone_compat_reset(const char *reason); static void gang_zone_compat_install_render_hooks(void); static void gang_zone_compat_uninstall_render_hooks(void); static void remote_player_compat_update_from_snapshot(const samp_raknet_rpc_probe_snapshot *snapshot); +static void remote_edge_sync_update_from_snapshot( + const samp_raknet_rpc_probe_snapshot *snapshot); static void remote_player_compat_reset_pool(const char *reason); static void install_remote_bullet_los_hooks_compat(void); static void uninstall_remote_bullet_los_hooks_compat(void); @@ -3272,20 +3463,38 @@ static int local_weapon_is_firearm_compat(uint8_t weapon); static float local_weapon_damage_compat(uint8_t weapon); static int gta_reset_ped_audio_attributes_compat(uintptr_t ped, const char *reason); static int gta_entity_direct_position_compat(uintptr_t entity, float x, float y, float z); +static int gta_entity_write_position_preserve_speed_compat( + uintptr_t entity, const float position[3]); static int gta_entity_read_position_compat(uintptr_t entity, float *x, float *y, float *z); static uint32_t gta_remove_building_event_compat(const samp_raknet_remove_building_event *event); static void remove_building_compat_update_from_snapshot(const samp_raknet_rpc_probe_snapshot *snapshot); -static void remove_building_compat_reset(void); +static void remove_building_compat_reset(const char *reason); static int gta_entity_read_move_speed_compat(uintptr_t entity, float out_speed[3]); +static int gta_entity_read_turn_speed_compat( + uintptr_t entity, float out_speed[3]); +static int gta_entity_read_unoccupied_axes_compat( + uintptr_t entity, float roll[3], float rotation[3]); static int gta_entity_write_move_speed_compat(uintptr_t entity, const float speed[3]); +static int gta_entity_write_turn_speed_compat(uintptr_t entity, + const float speed[3]); +static int gta_entity_write_unoccupied_axes_compat( + uintptr_t entity, const float right[3], const float up[3]); +static int gta_entity_write_quaternion_compat( + uintptr_t entity, const float quaternion[4]); +static int gta_entity_read_full_quaternion_compat( + uintptr_t entity, float quaternion[4], float fallback_angle_rad); static int gta_entity_apply_move_speed_compat(uintptr_t entity); static int gta_entity_teleport_compat(uintptr_t entity, float x, float y, float z); static int valid_world_position_compat(float x, float y, float z); static int gta_ped_read_vehicle_compat(uintptr_t ped, uintptr_t *vehicle); static uintptr_t remote_player_compat_game_pool_get_at(uint32_t gta_id); static uintptr_t remote_player_compat_resolve_ped(samp_remote_player_slot_compat *slot); +static int remote_edge_speed_plausible_compat(const float speed[3], + float limit); static void remote_player_compat_ped_identity( uintptr_t ped, uintptr_t *vtable_out, uintptr_t *player_info_out); +static int gta_ped_jetpack_task_compat( + uintptr_t ped, uintptr_t *out_task_manager, uintptr_t *out_task); static int mp_session_resolve_animation_actor_compat(uint16_t player_id, uint32_t *out_actor_id); static void attached_object_compat_update_from_snapshot(const samp_raknet_rpc_probe_snapshot *snapshot); static void attached_object_compat_destroy_player(uint16_t player_id, const char *reason); @@ -3308,6 +3517,8 @@ static void remote_player_compat_install_process_control_hook(void); static void remote_player_compat_uninstall_process_control_hook(void); static void remote_vehicle_compat_install_process_control_hooks(void); static void remote_vehicle_compat_uninstall_process_control_hooks(void); +static void remote_vehicle_collision_install_hooks_compat(void); +static void remote_vehicle_collision_uninstall_hooks_compat(void); static void install_pickup_collect_hook_compat(void); static void uninstall_pickup_collect_hook_compat(void); static void install_menu_gxt_hook_compat(void); @@ -3423,8 +3634,18 @@ static void runtime_trace_file_line(const char *line) { fputs(line, file); fputc('\n', file); ++buffered_lines; + /* + * PROBE_TRACE: + * Native-Windows lab runs may end through Stop-Process after the final + * artifact collection. Keep the large general-purpose buffer, but make + * each deliberately sampled remote-bullet result durable so a short burst + * cannot look like a queue/cursor loss merely because the CRT tail was + * never flushed. + */ if (buffered_lines >= 64u || strstr(line, "exception_filter") != NULL || - strstr(line, "process_detach") != NULL || strstr(line, "disconnect") != NULL) { + strstr(line, "process_detach") != NULL || + strstr(line, "disconnect") != NULL || + strstr(line, "remote_bullet: effect") != NULL) { fflush(file); buffered_lines = 0u; } @@ -3454,6 +3675,86 @@ static void runtime_tracef(const char *fmt, ...) { } } +static int directory_exists_or_create_compat(const char *path) { + DWORD attributes = INVALID_FILE_ATTRIBUTES; + + if (path == NULL || path[0] == '\0') { + return 0; + } + attributes = GetFileAttributesA(path); + if (attributes != INVALID_FILE_ATTRIBUTES) { + return (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0u; + } + if (CreateDirectoryA(path, NULL)) { + return 1; + } + return GetLastError() == ERROR_ALREADY_EXISTS; +} + +static int samp_user_paths_init_compat(void) { + typedef HRESULT(WINAPI *sh_get_folder_path_a_fn)(HWND, int, HANDLE, DWORD, LPSTR); + HMODULE shell32 = NULL; + sh_get_folder_path_a_fn get_folder_path = NULL; + char documents[MAX_PATH]; + char user_files[MAX_PATH]; + int written = 0; + + g_runtime.samp_user_dir[0] = '\0'; + g_runtime.screenshot_dir[0] = '\0'; + g_runtime.chatlog_path[0] = '\0'; + documents[0] = '\0'; + user_files[0] = '\0'; + + shell32 = LoadLibraryA("shell32.dll"); + if (shell32 != NULL) { + get_folder_path = + (sh_get_folder_path_a_fn)(uintptr_t)GetProcAddress(shell32, "SHGetFolderPathA"); + if (get_folder_path != NULL) { + (void)get_folder_path(NULL, 0x0005 /* CSIDL_PERSONAL */, NULL, 0u, documents); + } + FreeLibrary(shell32); + } + if (documents[0] == '\0') { + DWORD chars = GetEnvironmentVariableA("USERPROFILE", documents, (DWORD)sizeof(documents)); + if (chars == 0u || chars >= sizeof(documents)) { + return 0; + } + written = snprintf(documents + chars, sizeof(documents) - chars, "\\Documents"); + if (written <= 0 || (size_t)written >= sizeof(documents) - chars) { + return 0; + } + } + + written = snprintf(user_files, sizeof(user_files), "%s\\GTA San Andreas User Files", documents); + if (written <= 0 || (size_t)written >= sizeof(user_files) || + !directory_exists_or_create_compat(user_files)) { + return 0; + } + written = snprintf(g_runtime.samp_user_dir, sizeof(g_runtime.samp_user_dir), "%s\\SAMP", user_files); + if (written <= 0 || (size_t)written >= sizeof(g_runtime.samp_user_dir) || + !directory_exists_or_create_compat(g_runtime.samp_user_dir)) { + g_runtime.samp_user_dir[0] = '\0'; + return 0; + } + written = snprintf(g_runtime.screenshot_dir, sizeof(g_runtime.screenshot_dir), "%s\\screens", + g_runtime.samp_user_dir); + if (written <= 0 || (size_t)written >= sizeof(g_runtime.screenshot_dir) || + !directory_exists_or_create_compat(g_runtime.screenshot_dir)) { + g_runtime.screenshot_dir[0] = '\0'; + return 0; + } + written = snprintf(g_runtime.chatlog_path, sizeof(g_runtime.chatlog_path), "%s\\chatlog.txt", + g_runtime.samp_user_dir); + if (written <= 0 || (size_t)written >= sizeof(g_runtime.chatlog_path)) { + g_runtime.chatlog_path[0] = '\0'; + return 0; + } + + runtime_tracef("user_paths: samp='%s' screenshots='%s' chatlog='%s' evidence=STATIC_037", + g_runtime.samp_user_dir, g_runtime.screenshot_dir, g_runtime.chatlog_path); + return 1; +} + static void archive_fs_trace_bridge(const char *line) { if (line != NULL) { runtime_tracef("archive_fs: %s", line); @@ -3787,6 +4088,45 @@ static void chat_compat_strip_samp_color_tags(char *line) { *write_cursor = '\0'; } +static void chat_compat_append_log_line(const char *timestamp, const char *text) { + static LONG failure_logged = 0; + char plain[SAMP_CHAT_COMPAT_LINE_BYTES]; + char record[SAMP_CHAT_COMPAT_LINE_BYTES + 16u]; + FILE *file = NULL; + size_t bytes_written = 0u; + int written = 0; + + if (timestamp == NULL || text == NULL || text[0] == '\0' || + g_runtime.chatlog_path[0] == '\0') { + return; + } + strncpy(plain, text, sizeof(plain) - 1u); + plain[sizeof(plain) - 1u] = '\0'; + chat_compat_strip_samp_color_tags(plain); + written = snprintf(record, sizeof(record), "%s %s\r\n", timestamp, plain); + if (written <= 0 || (size_t)written >= sizeof(record)) { + return; + } + + file = fopen(g_runtime.chatlog_path, "ab"); + if (file == NULL) { + if (InterlockedCompareExchange(&failure_logged, 1, 0) == 0) { + runtime_tracef("chatlog: open failed path='%s' error=%lu", g_runtime.chatlog_path, + (unsigned long)GetLastError()); + } + return; + } + bytes_written = fwrite(record, 1u, (size_t)written, file); + if (bytes_written != (size_t)written) { + if (InterlockedCompareExchange(&failure_logged, 1, 0) == 0) { + runtime_tracef("chatlog: write failed path='%s'", g_runtime.chatlog_path); + } + } else { + InterlockedExchange(&failure_logged, 0); + } + fclose(file); +} + static void chat_compat_add_colored_line(DWORD argb_color, const char *text) { char line[SAMP_CHAT_COMPAT_LINE_BYTES]; char timestamp[11]; @@ -3817,6 +4157,14 @@ static void chat_compat_add_colored_line(DWORD argb_color, const char *text) { return; } + /* + * STATIC_037: + * R5 retains the per-user SAMP directory and references "%s\\chatlog.txt". + * The on-disk log always carries its own timestamp, independent of whether + * /timestamp is enabled for the in-game overlay. + */ + chat_compat_append_log_line(timestamp, line); + if (count < SAMP_CHAT_COMPAT_MAX_LINES) { index = InterlockedIncrement(&g_runtime.chat_overlay_line_count) - 1; if (index < 0 || index >= SAMP_CHAT_COMPAT_MAX_LINES) { @@ -3903,13 +4251,19 @@ static int game_window_foreground_compat(void) { static int game_window_key_down_compat(int virtual_key) { /* - * STATIC_037 + INFERRED + TODO_VERIFY: + * STATIC_037 + PROBE_TRACE: * Original gameplay/menu paths consume GTA CPad state, which is scoped to * the active game window. GetAsyncKeyState is process-independent, so the * replacement must reject keys typed into another foreground application * while still allowing its network/sync loop to run in the background. + * Dialog cursor mode 2 at samp.dll+0xA06F3..+0xA073C blocks and resets GTA + * input; mirror that ownership for replacement-only key/sync pollers so a + * dialog click cannot leak FIRE/ACTION or synthetic melee damage. The R5 + * scoreboard's mode 3 deliberately remains outside this gate. */ - if (!game_window_foreground_compat()) { + if (!game_window_foreground_compat() || + InterlockedCompareExchange(&g_runtime.chat_input_active, 0, 0) != 0 || + InterlockedCompareExchange(&g_runtime.dialog_overlay_active, 0, 0) != 0) { return 0; } return (GetAsyncKeyState(virtual_key) & 0x8000) != 0; @@ -4168,7 +4522,7 @@ static void chat_input_close_compat(void) { InterlockedCompareExchange(&g_runtime.edit_active, 0, 0) != 0 || InterlockedCompareExchange(&g_runtime.textdraw_select_active, 0, 0) != 0 || InterlockedCompareExchange(&g_runtime.class_selection_mouse_mode, 0, 0) != 0 || - InterlockedCompareExchange(&g_runtime.scoreboard_mouse_mode, 0, 0) != 0; + scoreboard_compat_cursor_owned(); if (!other_mouse_owner) { dialog_compat_set_mouse_mode(0); @@ -4909,42 +5263,16 @@ static void client_fps_restore_original_gta_state_compat(void) { } static int client_fps_config_path_compat(char *out_path, size_t out_size) { - typedef HRESULT(WINAPI *sh_get_folder_path_a_fn)(HWND, int, HANDLE, DWORD, - LPSTR); - HMODULE shell32 = NULL; - sh_get_folder_path_a_fn get_folder_path = NULL; - char documents[MAX_PATH]; int written = 0; if (out_path == NULL || out_size == 0u) { return 0; } out_path[0] = '\0'; - documents[0] = '\0'; - shell32 = LoadLibraryA("shell32.dll"); - if (shell32 != NULL) { - get_folder_path = (sh_get_folder_path_a_fn)(uintptr_t) - GetProcAddress(shell32, "SHGetFolderPathA"); - if (get_folder_path != NULL) { - (void)get_folder_path(NULL, 0x0005 /* CSIDL_PERSONAL */, NULL, 0u, - documents); - } - FreeLibrary(shell32); - } - if (documents[0] == '\0') { - DWORD chars = GetEnvironmentVariableA("USERPROFILE", documents, - (DWORD)sizeof(documents)); - if (chars == 0u || chars >= sizeof(documents)) { - return 0; - } - written = snprintf(out_path, out_size, - "%s\\Documents\\GTA San Andreas User Files\\SAMP\\sa-mp.cfg", - documents); - } else { - written = snprintf(out_path, out_size, - "%s\\GTA San Andreas User Files\\SAMP\\sa-mp.cfg", - documents); + if (g_runtime.samp_user_dir[0] == '\0' && !samp_user_paths_init_compat()) { + return 0; } + written = snprintf(out_path, out_size, "%s\\sa-mp.cfg", g_runtime.samp_user_dir); return written > 0 && (size_t)written < out_size; } @@ -5556,6 +5884,53 @@ static int dialog_compat_get_line(const char *text, int index, char *out_line, s return 0; } +static int dialog_compat_get_raw_line(const char *text, int index, char *out_line, + size_t out_size) { + const char *cursor = text; + const char *line_start = NULL; + const char *line_end = NULL; + size_t written = 0u; + int current = 0; + + if (out_line == NULL || out_size == 0u) { + return 0; + } + out_line[0] = '\0'; + if (text == NULL || index < 0) { + return 0; + } + + while (*cursor != '\0') { + line_start = cursor; + while (*cursor != '\0' && *cursor != '\n' && *cursor != '\r') { + ++cursor; + } + line_end = cursor; + if (current == index) { + while (line_start < line_end && written + 1u < out_size) { + if (*line_start == '\t') { + int spaces = 0; + while (spaces++ < 4 && written + 1u < out_size) { + out_line[written++] = ' '; + } + } else if ((unsigned char)*line_start >= (unsigned char)' ') { + out_line[written++] = *line_start; + } else { + out_line[written++] = ' '; + } + ++line_start; + } + out_line[written] = '\0'; + return 1; + } + while (*cursor == '\n' || *cursor == '\r') { + ++cursor; + } + ++current; + } + return 0; +} + static int dialog_compat_line_count(const char *text) { const char *cursor = text; int count = 0; @@ -5585,6 +5960,40 @@ static int dialog_compat_item_count(void) { return count < 0 ? 0 : count; } +static void ui_cursor_compat_set_win32_visible(int visible) { + int i = 0; + const LONG desired = visible ? 1 : 0; + + if (InterlockedCompareExchange( + &g_runtime.ui_cursor_win32_visibility_known, 0, 0) != 0 && + InterlockedCompareExchange(&g_runtime.ui_cursor_win32_visible, 0, 0) == + desired) { + SetCursor(visible ? LoadCursorA(NULL, IDC_ARROW) : NULL); + return; + } + if (visible) { + if (g_runtime.bootstrap_shims.show_cursor != NULL) { + while (i++ < 8 && g_runtime.bootstrap_shims.show_cursor(TRUE) < 0) { + } + } else { + while (i++ < 8 && ShowCursor(TRUE) < 0) { + } + } + SetCursor(LoadCursorA(NULL, IDC_ARROW)); + } else { + if (g_runtime.bootstrap_shims.show_cursor != NULL) { + while (i++ < 8 && g_runtime.bootstrap_shims.show_cursor(FALSE) >= 0) { + } + } else { + while (i++ < 8 && ShowCursor(FALSE) >= 0) { + } + } + SetCursor(NULL); + } + InterlockedExchange(&g_runtime.ui_cursor_win32_visible, desired); + InterlockedExchange(&g_runtime.ui_cursor_win32_visibility_known, 1); +} + static void dialog_compat_set_mouse_mode(int enabled) { HWND hwnd = NULL; POINT cursor; @@ -5594,11 +6003,14 @@ static void dialog_compat_set_mouse_mode(int enabled) { int viewport_h = 0; int mouse_x = 0; int mouse_y = 0; - int i = 0; + int texture_cursor_ready = 0; if (enabled) { if (InterlockedExchange(&g_runtime.dialog_mouse_mode, 1) != 0) { - SetCursor(LoadCursorA(NULL, IDC_ARROW)); + SetCursor(InterlockedCompareExchange( + &g_runtime.ui_cursor_texture_ready, 0, 0) != 0 + ? NULL + : LoadCursorA(NULL, IDC_ARROW)); return; } @@ -5618,18 +6030,26 @@ static void dialog_compat_set_mouse_mode(int enabled) { InterlockedExchange(&g_runtime.dialog_mouse_raw_y, mouse_y); ClipCursor(NULL); - d3d9_cursor_show_compat(1, "dialog_mouse_enable"); - d3d9_cursor_sync_client_compat(hwnd, mouse_x, mouse_y, "dialog_mouse_enable"); - if (g_runtime.bootstrap_shims.show_cursor != NULL) { - while (i++ < 8 && g_runtime.bootstrap_shims.show_cursor(TRUE) < 0) { - } - } else { - while (i++ < 8 && ShowCursor(TRUE) < 0) { - } + texture_cursor_ready = ui_cursor_compat_prepare_d3d(); + /* + * OBSERVED_037 + STATIC_037 + PROBE_TRACE: + * R5 supplies mouse.png to the D3D9 hardware-cursor path. Wine/Proton can + * accept that setup while never composing the cursor. Keep D3D and Win32 + * cursor sources hidden when the texture is available; the same asset is + * drawn once into the backbuffer at the end of our overlay frame. + */ + d3d9_cursor_show_compat(0, "dialog_mouse_enable_backbuffer"); + if (texture_cursor_ready) { + d3d9_cursor_sync_client_compat(hwnd, mouse_x, mouse_y, + "dialog_mouse_enable"); } - SetCursor(LoadCursorA(NULL, IDC_ARROW)); + ui_cursor_compat_set_win32_visible(!texture_cursor_ready); if (InterlockedCompareExchange(&g_runtime.dialog_mouse_logged, 1, 0) == 0) { - runtime_tracef("dialog_overlay: mouse mode enabled hwnd=0x%08lx", (unsigned long)(uintptr_t)hwnd); + runtime_tracef("dialog_overlay: mouse mode enabled hwnd=0x%08lx " + "render=%s sources=1", + (unsigned long)(uintptr_t)hwnd, + texture_cursor_ready ? "backbuffer_mouse.png" + : "win32_fallback"); } return; } @@ -5639,17 +6059,24 @@ static void dialog_compat_set_mouse_mode(int enabled) { } InterlockedExchange(&g_runtime.dialog_mouse_down, 0); center_hwnd_cursor_compat(read_game_hwnd_compat()); - visible_cursor_recenter_patch_restore_compat("dialog_mouse_disable"); - d3d9_cursor_show_compat(0, "dialog_mouse_disable"); - ClipCursor(NULL); - i = 0; - if (g_runtime.bootstrap_shims.show_cursor != NULL) { - while (i++ < 8 && g_runtime.bootstrap_shims.show_cursor(FALSE) >= 0) { - } + /* + * STATIC_037: + * Cursor mode 0 only arms the ten-tick countdown. R5 leaves the + * RsMouseSetPos RET at gta_sa.exe+0x2194A0 in place until the delayed + * +0xA05D0 restore has flushed CPad's mouse history. + */ + if (InterlockedCompareExchange( + &g_runtime.dialog_game_mouse_patch_applied, 0, 0) == 0) { + visible_cursor_recenter_patch_restore_compat("dialog_mouse_disable"); } else { - while (i++ < 8 && ShowCursor(FALSE) >= 0) { - } + runtime_tracef("dialog_overlay: recenter restore deferred ticks=%ld " + "evidence=STATIC_037:samp.dll+0xA05D0,+0xA0861", + (long)InterlockedCompareExchange( + &g_runtime.dialog_game_mouse_release_frames, 0, 0)); } + d3d9_cursor_show_compat(0, "dialog_mouse_disable"); + ClipCursor(NULL); + ui_cursor_compat_set_win32_visible(0); runtime_tracef("dialog_overlay: mouse mode disabled"); } @@ -5690,15 +6117,88 @@ static void ui_compat_release_game_mouse(const char *reason) { } static void dialog_compat_close(void) { + LONG was_active = InterlockedExchange(&g_runtime.dialog_overlay_active, 0); + if (InterlockedCompareExchange(&g_runtime.textdraw_select_active, 0, 0) == 0 && - InterlockedCompareExchange(&g_runtime.class_selection_mouse_mode, 0, 0) == 0) { + InterlockedCompareExchange(&g_runtime.class_selection_mouse_mode, 0, 0) == 0 && + !scoreboard_compat_cursor_owned()) { dialog_compat_set_mouse_mode(0); } - InterlockedExchange(&g_runtime.dialog_overlay_active, 0); + /* + * USER_REQUESTED + STATIC_037 + PROBE_TRACE: + * The replacement's dialog WndProc consumed Win32 mouse messages, but GTA's + * DirectInput/CPad update still ran behind the modal overlay. Keep the + * shared input-update patch through the close edge so the button that closed + * the dialog cannot leak into gameplay. Original R5 closes mode 2 at + * samp.dll+0x6F860..+0x6F86D and arms a ten-tick restore countdown in + * CGame::SetCursorMode at samp.dll+0xA0861..+0xA0873. + */ + if (was_active != 0) { + InterlockedExchange(&g_runtime.dialog_game_mouse_flush_pending, 0); + InterlockedExchange(&g_runtime.dialog_game_mouse_release_frames, + SAMP_DIALOG_INPUT_RELEASE_FRAMES); + if (InterlockedCompareExchange(&g_runtime.chat_input_active, 0, 0) == 0) { + InterlockedExchange(&g_runtime.chat_game_input_release_frames, + SAMP_DIALOG_INPUT_RELEASE_FRAMES); + } + } + InterlockedExchange(&g_runtime.dialog_overlay_local_kind, SAMP_DIALOG_COMPAT_LOCAL_NONE); InterlockedExchange(&g_runtime.dialog_overlay_input_len, 0); g_runtime.dialog_overlay_input[0] = '\0'; } +static void dialog_compat_show_help(void) { + static const char kHelpText[] = + "{BB9999}F1\t{BBBBBB}Display this help dialog\n" + "{BB9999}Tab\t{BBBBBB}Display the scoreboard\n" + "{BB9999}F4\t{BBBBBB}Allows you to change class next time you respawn\n" + "{BB9999}F5\t{BBBBBB}Show bandwidth statistics\n" + "{BB9999}F7\t{BBBBBB}Toggle the chat box\n" + "{BB9999}F8\t{BBBBBB}Take a screenshot\n" + "{BB9999}F9\t{BBBBBB}Toggle the deathwindow\n" + "{BB9999}T/F6\t{BBBBBB}Allows you to enter a chat message\n" + "{BB9999}G\t{BBBBBB}Enter vehicle as passenger"; + + if (dialog_compat_active()) { + return; + } + + /* + * STATIC_037: + * Original SA-MP 0.3.7-R5 SHA256= + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + * samp.dll+0x6BB30 opens the generic dialog with the exact title/body above, + * one "OK" button and style 0. The process path at +0x759DE polls VK_F1. + */ + chat_input_close_compat(); + InterlockedExchange(&g_runtime.dialog_overlay_id, 0); + InterlockedExchange(&g_runtime.dialog_overlay_style, 0); + InterlockedExchange(&g_runtime.dialog_overlay_selected, -1); + InterlockedExchange(&g_runtime.dialog_overlay_scroll, 0); + InterlockedExchange(&g_runtime.dialog_overlay_input_len, 0); + InterlockedExchange(&g_runtime.dialog_overlay_logged, 1); + strncpy(g_runtime.dialog_overlay_title, "SA-MP Keys:", + sizeof(g_runtime.dialog_overlay_title) - 1u); + g_runtime.dialog_overlay_title[sizeof(g_runtime.dialog_overlay_title) - 1u] = '\0'; + strncpy(g_runtime.dialog_overlay_info, kHelpText, + sizeof(g_runtime.dialog_overlay_info) - 1u); + g_runtime.dialog_overlay_info[sizeof(g_runtime.dialog_overlay_info) - 1u] = '\0'; + strncpy(g_runtime.dialog_overlay_button1, "OK", + sizeof(g_runtime.dialog_overlay_button1) - 1u); + g_runtime.dialog_overlay_button1[sizeof(g_runtime.dialog_overlay_button1) - 1u] = '\0'; + g_runtime.dialog_overlay_button2[0] = '\0'; + g_runtime.dialog_overlay_input[0] = '\0'; + InterlockedExchange(&g_runtime.dialog_overlay_local_kind, + SAMP_DIALOG_COMPAT_LOCAL_HELP); + InterlockedExchange(&g_runtime.dialog_overlay_active, 1); + InterlockedExchange(&g_runtime.dialog_game_mouse_flush_pending, 1); + (void)chat_input_game_controls_apply_compat("help_dialog_open"); + (void)dialog_game_mouse_controls_activate_compat("help_dialog_open"); + dialog_compat_set_mouse_mode(1); + runtime_tracef("help_dialog: opened title='SA-MP Keys:' lines=9 " + "evidence=STATIC_037:samp.dll+0x6BB30,+0x759DE"); +} + static void dialog_compat_measure_text(samp_id3dx_font_compat *font, const char *text, int *out_w, int *out_h) { RECT rect; size_t line_len = 0u; @@ -5933,7 +6433,10 @@ static void dialog_compat_record_mouse(HWND hwnd, LPARAM lparam) { Treat that warp back to screen center as bookkeeping, not user input. */ if (raw_near_center && !last_near_center) { if (hwnd != NULL) { - SetCursor(LoadCursorA(NULL, IDC_ARROW)); + SetCursor(InterlockedCompareExchange( + &g_runtime.ui_cursor_texture_ready, 0, 0) != 0 + ? NULL + : LoadCursorA(NULL, IDC_ARROW)); } d3d9_cursor_sync_client_compat(hwnd, virtual_x, virtual_y, "dialog_mouse_center_warp"); return; @@ -5959,7 +6462,10 @@ static void dialog_compat_record_mouse(HWND hwnd, LPARAM lparam) { InterlockedExchange(&g_runtime.dialog_mouse_y, virtual_y); } if (hwnd != NULL) { - SetCursor(LoadCursorA(NULL, IDC_ARROW)); + SetCursor(InterlockedCompareExchange( + &g_runtime.ui_cursor_texture_ready, 0, 0) != 0 + ? NULL + : LoadCursorA(NULL, IDC_ARROW)); } d3d9_cursor_sync_client_compat(hwnd, virtual_x, virtual_y, "dialog_mouse_record"); } @@ -6144,11 +6650,22 @@ static void dialog_compat_submit(unsigned char button) { LONG style = InterlockedCompareExchange(&g_runtime.dialog_overlay_style, 0, 0); LONG dialog_id = InterlockedCompareExchange(&g_runtime.dialog_overlay_id, 0, 0); LONG selected = InterlockedCompareExchange(&g_runtime.dialog_overlay_selected, 0, 0); + LONG local_kind = InterlockedCompareExchange(&g_runtime.dialog_overlay_local_kind, 0, 0); int16_t listitem = -1; char input[SAMP_RAKNET_DIALOG_INPUT_BYTES]; int result = -1; - if (!dialog_compat_active() || dialog_id < 0 || dialog_id > 65535) { + if (!dialog_compat_active()) { + return; + } + if (local_kind != SAMP_DIALOG_COMPAT_LOCAL_NONE) { + runtime_tracef("help_dialog: closed button=%u local_kind=%ld network_response=0 " + "evidence=STATIC_037:samp.dll+0x6BB30", + (unsigned)button, (long)local_kind); + dialog_compat_close(); + return; + } + if (dialog_id < 0 || dialog_id > 65535) { return; } input[0] = '\0'; @@ -6187,6 +6704,7 @@ static void dialog_compat_submit(unsigned char button) { static void dialog_compat_update_from_snapshot(const samp_raknet_rpc_probe_snapshot *snapshot) { LONG current_id = InterlockedCompareExchange(&g_runtime.dialog_overlay_id, 0, 0); + LONG local_kind = InterlockedCompareExchange(&g_runtime.dialog_overlay_local_kind, 0, 0); int new_dialog = 0; /* PROBE_TRACE + OPENMP_REF: @@ -6196,6 +6714,9 @@ static void dialog_compat_update_from_snapshot(const samp_raknet_rpc_probe_snaps */ if (snapshot == NULL || (snapshot->flags & SAMP_RAKNET_RPC_FLAG_DIALOG) == 0u || snapshot->last_dialog_id == 0xFFFFu) { + if (local_kind != SAMP_DIALOG_COMPAT_LOCAL_NONE && dialog_compat_active()) { + return; + } if (dialog_compat_active()) { runtime_tracef("dialog_overlay: closed by rpc state id=%u", snapshot != NULL ? (unsigned)snapshot->last_dialog_id : 0u); @@ -6204,8 +6725,28 @@ static void dialog_compat_update_from_snapshot(const samp_raknet_rpc_probe_snaps return; } + if (local_kind != SAMP_DIALOG_COMPAT_LOCAL_NONE) { + current_id = -1; + InterlockedExchange(&g_runtime.dialog_overlay_local_kind, + SAMP_DIALOG_COMPAT_LOCAL_NONE); + } new_dialog = !dialog_compat_active() || current_id != (LONG)snapshot->last_dialog_id; InterlockedExchange(&g_runtime.dialog_overlay_active, 1); + if (new_dialog) { + InterlockedExchange(&g_runtime.dialog_game_mouse_flush_pending, 1); + } + /* + * USER_REQUESTED + STATIC_037 + PROBE_TRACE: + * Modal dialogs own both the visible cursor and GTA gameplay input. The + * WndProc branch below handles UI clicks; this validated GTA 1.0 call-site + * patch prevents DirectInput mouse-look/fire from bypassing that branch. + * R5 enters cursor mode 2 at samp.dll+0x6F8C6..+0x6F8D0; mode 2 NOPs + * gta_sa.exe+0x141DF5 at samp.dll+0xA06F3..+0xA071A. + */ + (void)chat_input_game_controls_apply_compat( + new_dialog ? "dialog_open" : "dialog_active"); + (void)dialog_game_mouse_controls_activate_compat( + new_dialog ? "dialog_open" : "dialog_active"); dialog_compat_set_mouse_mode(1); InterlockedExchange(&g_runtime.dialog_overlay_id, (LONG)snapshot->last_dialog_id); InterlockedExchange(&g_runtime.dialog_overlay_style, (LONG)snapshot->last_dialog_style); @@ -6604,7 +7145,8 @@ static void edit_state_compat_set_mouse_mode(int enabled) { dialog_compat_set_mouse_mode(1); } else if (InterlockedCompareExchange(&g_runtime.dialog_overlay_active, 0, 0) == 0 && InterlockedCompareExchange(&g_runtime.textdraw_select_active, 0, 0) == 0 && - InterlockedCompareExchange(&g_runtime.class_selection_mouse_mode, 0, 0) == 0) { + InterlockedCompareExchange(&g_runtime.class_selection_mouse_mode, 0, 0) == 0 && + !scoreboard_compat_cursor_owned()) { dialog_compat_set_mouse_mode(0); } } @@ -6945,7 +7487,7 @@ static void textdraw_compat_update_from_snapshot(const samp_raknet_rpc_probe_sna } else if (InterlockedCompareExchange(&g_runtime.textdraw_select_active, 0, 0) != 0) { InterlockedExchange(&g_runtime.textdraw_select_active, 0); InterlockedExchange(&g_runtime.textdraw_mouse_down, 0); - if (!dialog_compat_active()) { + if (!dialog_compat_active() && !scoreboard_compat_cursor_owned()) { dialog_compat_set_mouse_mode(0); } runtime_tracef("textdraw: select mode disabled by rpc state"); @@ -8870,17 +9412,49 @@ static void vehicle_compat_disable_marker(samp_vehicle_slot_compat *slot) { } static void remote_player_compat_destroy_slot(uint16_t player_id, const char *reason); +static void vehicle_compat_clear_attach_timing( + samp_vehicle_slot_compat *towing_slot); +static void vehicle_compat_clear_trailer_transition( + samp_vehicle_slot_compat *towing_slot); static void vehicle_compat_destroy_slot(uint16_t vehicle_id, const char *reason) { samp_vehicle_slot_compat *slot = NULL; uint32_t gta_id = 0u; int was_pending = 0; + unsigned int towing_id = 0u; if (!vehicle_compat_id_valid(vehicle_id)) { return; } slot = &g_runtime.vehicle_slots[vehicle_id]; + /* + * INFERRED: + * Vehicle IDs are reusable. Remove reverse logical/timing references before + * clearing the target slot so a later RPC164 with the same ID cannot inherit + * an old trailer attach gate or transition. + */ + for (towing_id = 0u; towing_id < SAMP_RAKNET_MAX_VEHICLES; + ++towing_id) { + samp_vehicle_slot_compat *towing_slot = + &g_runtime.vehicle_slots[towing_id]; + if (towing_slot->trailer_vehicle_id == vehicle_id && + (InterlockedCompareExchange(&towing_slot->active, 0, 0) != 0 || + InterlockedCompareExchange(&towing_slot->pending, 0, 0) != 0 || + towing_slot->trailer_attached != 0u)) { + towing_slot->trailer_vehicle_id = 0u; + towing_slot->trailer_attached = 0u; + vehicle_compat_clear_trailer_transition(towing_slot); + } + if (towing_slot->trailer_attach_timing_seq != 0u && + towing_slot->trailer_attach_timing_vehicle_id == vehicle_id) { + vehicle_compat_clear_attach_timing(towing_slot); + } + if (towing_slot->trailer_transition_pending != 0u && + towing_slot->trailer_transition_vehicle_id == vehicle_id) { + vehicle_compat_clear_trailer_transition(towing_slot); + } + } was_pending = InterlockedExchange(&slot->pending, 0) != 0; if (was_pending) { vehicle_compat_decrement_pending_count(); @@ -9065,12 +9639,72 @@ static int vehicle_compat_read_float(uintptr_t vehicle, uint32_t offset, float * return 1; } +static void vehicle_compat_trace_lifecycle_state( + const char *stage, uint32_t source_seq, uint16_t vehicle_id, + uintptr_t vehicle) { + uint32_t flags = 0u; + uint32_t world_link = 0u; + uint32_t driver = 0u; + uint32_t tractor = 0u; + uint32_t trailer = 0u; + uint8_t status = 0u; + uint8_t fake_physics = 0u; + uint8_t process_flags = 0u; + uint32_t valid = 0u; + + valid |= vehicle_compat_read_u32( + vehicle, SAMP_ENTITY_OFFSET_RENDER_FLAGS, &flags) + ? 0x001u + : 0u; + valid |= vehicle_compat_read_u8( + vehicle, SAMP_ENTITY_OFFSET_CONTROL_FLAGS, &status) + ? 0x002u + : 0u; + valid |= vehicle_compat_read_u8( + vehicle, SAMP_PHYSICAL_OFFSET_FAKE_PHYSICS, &fake_physics) + ? 0x004u + : 0u; + valid |= vehicle_compat_read_u32( + vehicle, SAMP_ENTITY_OFFSET_WORLD_LINK, &world_link) + ? 0x008u + : 0u; + valid |= vehicle_compat_read_u8( + vehicle, SAMP_VEHICLE_OFFSET_PROCESS_FLAGS, &process_flags) + ? 0x010u + : 0u; + valid |= vehicle_compat_read_u32( + vehicle, SAMP_VEHICLE_OFFSET_DRIVER, &driver) + ? 0x020u + : 0u; + valid |= vehicle_compat_read_u32( + vehicle, SAMP_VEHICLE_OFFSET_TRACTOR, &tractor) + ? 0x040u + : 0u; + valid |= vehicle_compat_read_u32( + vehicle, SAMP_VEHICLE_OFFSET_TRAILER, &trailer) + ? 0x080u + : 0u; + runtime_tracef( + "vehicle_lifecycle: stage=%s seq=%lu id=%u ptr=0x%08lx " + "valid=0x%03lx flags=0x%08lx status=0x%02x fake=%u " + "world=0x%08lx process=0x%02x driver=0x%08lx " + "tractor=0x%08lx trailer=0x%08lx " + "evidence=PROBE_TRACE,TODO_VERIFY:original_lifecycle_diff", + stage != NULL ? stage : "unknown", (unsigned long)source_seq, + (unsigned)vehicle_id, (unsigned long)vehicle, (unsigned long)valid, + (unsigned long)flags, (unsigned)status, (unsigned)fake_physics, + (unsigned long)world_link, (unsigned)process_flags, + (unsigned long)driver, (unsigned long)tractor, + (unsigned long)trailer); +} + static int vehicle_compat_read_live_health(uintptr_t vehicle, float *health) { return vehicle_compat_read_float(vehicle, SAMP_VEHICLE_OFFSET_HEALTH, health); } static int vehicle_compat_write_u8(uintptr_t vehicle, uint32_t offset, uint8_t value) { - if (vehicle < 0x10000u || !memory_is_readable_compat((const void *)(vehicle + offset), sizeof(value))) { + if (vehicle < 0x10000u || + !memory_is_writable_compat((void *)(vehicle + offset), sizeof(value))) { return 0; } memcpy((void *)(vehicle + offset), &value, sizeof(value)); @@ -9078,7 +9712,8 @@ static int vehicle_compat_write_u8(uintptr_t vehicle, uint32_t offset, uint8_t v } static int vehicle_compat_write_u32(uintptr_t vehicle, uint32_t offset, uint32_t value) { - if (vehicle < 0x10000u || !memory_is_readable_compat((const void *)(vehicle + offset), sizeof(value))) { + if (vehicle < 0x10000u || + !memory_is_writable_compat((void *)(vehicle + offset), sizeof(value))) { return 0; } memcpy((void *)(vehicle + offset), &value, sizeof(value)); @@ -9086,7 +9721,8 @@ static int vehicle_compat_write_u32(uintptr_t vehicle, uint32_t offset, uint32_t } static int vehicle_compat_write_float(uintptr_t vehicle, uint32_t offset, float value) { - if (vehicle < 0x10000u || !memory_is_readable_compat((const void *)(vehicle + offset), sizeof(value))) { + if (vehicle < 0x10000u || + !memory_is_writable_compat((void *)(vehicle + offset), sizeof(value))) { return 0; } memcpy((void *)(vehicle + offset), &value, sizeof(value)); @@ -9140,6 +9776,114 @@ static int vehicle_compat_is_automobile(uintptr_t vehicle) { return vehicle_compat_read_vtable(vehicle, &vtable) && vtable == (uintptr_t)SAMP_GTA_VTBL_CAUTOMOBILE ? 1 : 0; } +static int vehicle_compat_apply_stream_in_paintjob(uint16_t vehicle_id, + const samp_vehicle_slot_compat *slot, + uintptr_t vehicle) { + unsigned int logical_paintjob = 0u; + int applied = 0; + + if (slot == NULL || vehicle == 0u) { + return 0; + } + + /* + * STATIC_037: + * RPC 164's byte is not GTA's logical paintjob ID. R5 tests the wire byte + * for zero at samp.dll+0xE826 and skips the call at +0xE828. For a non-zero + * byte it decrements at +0xE82A and calls CVehicle::SetPaintjob at +0xB80F0. + * That wrapper accepts logical IDs 0..3 and only applies them to an + * automobile. Consequently wire 0 means "no paintjob", while wire 1..4 + * maps to GTA paintjob 0..3. + * + * Original samp.dll R5 SHA256: + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2 + */ + if (slot->paintjob == 0u) { + return 1; + } + if (slot->paintjob > 4u) { + runtime_tracef("vehicle: paintjob_skip id=%u ptr=0x%08lx wire=%u reason=out_of_range_r5 " + "evidence=STATIC_037:samp.dll+0xE820,+0xB80F0", + (unsigned)vehicle_id, (unsigned long)vehicle, (unsigned)slot->paintjob); + return 1; + } + if (!vehicle_compat_is_automobile(vehicle)) { + runtime_tracef("vehicle: paintjob_skip id=%u ptr=0x%08lx wire=%u reason=non_automobile_r5 " + "evidence=STATIC_037:samp.dll+0xB80F0", + (unsigned)vehicle_id, (unsigned long)vehicle, (unsigned)slot->paintjob); + return 1; + } + + logical_paintjob = (unsigned int)slot->paintjob - 1u; + applied = gta_script_command_compat(0x06EDu, "ii", (int)slot->gta_id, + (int)logical_paintjob); + runtime_tracef("vehicle: paintjob_%s id=%u gta=%lu ptr=0x%08lx wire=%u logical=%u " + "evidence=STATIC_037:samp.dll+0xE820,+0xB80F0", + applied ? "apply" : "apply_failed", (unsigned)vehicle_id, + (unsigned long)slot->gta_id, (unsigned long)vehicle, + (unsigned)slot->paintjob, logical_paintjob); + return applied; +} + +static int vehicle_compat_apply_tyre_status(uint16_t vehicle_id, uintptr_t vehicle, + uint8_t tyre_mask, const char *reason) { + uintptr_t vtable = 0u; + static const uint32_t automobile_offsets[4] = { + SAMP_VEHICLE_OFFSET_CAR_WHEELS + 3u, SAMP_VEHICLE_OFFSET_CAR_WHEELS + 2u, + SAMP_VEHICLE_OFFSET_CAR_WHEELS + 1u, SAMP_VEHICLE_OFFSET_CAR_WHEELS}; + static const uint32_t bike_offsets[2] = { + SAMP_VEHICLE_OFFSET_BIKE_WHEELS + 1u, SAMP_VEHICLE_OFFSET_BIKE_WHEELS}; + const uint32_t *offsets = NULL; + unsigned int count = 0u; + unsigned int i = 0u; + int applied = 1; + const char *subtype = "unsupported"; + + if (!vehicle_compat_read_vtable(vehicle, &vtable)) { + runtime_tracef("vehicle: tyre_apply_failed id=%u ptr=0x%08lx mask=0x%02x reason=unreadable", + (unsigned)vehicle_id, (unsigned long)vehicle, (unsigned)tyre_mask); + return 0; + } + + /* + * STATIC_037: + * R5's CVehicle::SetTireStatus at samp.dll+0xB7940 classifies the GTA + * vehicle through samp.dll+0xB61E0. Only exact CAutomobile (subtype 1) and + * CBike (subtype 2) vtables receive writes. The bit order is deliberately + * opposite to ascending field addresses: + * + * automobile bits 0..3 -> +0x5A8, +0x5A7, +0x5A6, +0x5A5 + * bike bits 0..1 -> +0x65D, +0x65C + * + * SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + */ + if (vtable == (uintptr_t)SAMP_GTA_VTBL_CAUTOMOBILE) { + offsets = automobile_offsets; + count = 4u; + subtype = "automobile"; + } else if (vtable == (uintptr_t)SAMP_GTA_VTBL_CBIKE) { + offsets = bike_offsets; + count = 2u; + subtype = "bike"; + } else { + runtime_tracef("vehicle: tyre_apply_skip id=%u ptr=0x%08lx vtable=0x%08lx mask=0x%02x " + "reason=unsupported_r5_subtype source=%s evidence=STATIC_037", + (unsigned)vehicle_id, (unsigned long)vehicle, (unsigned long)vtable, + (unsigned)tyre_mask, reason != NULL ? reason : "unknown"); + return 1; + } + + for (i = 0u; i < count; ++i) { + applied &= vehicle_compat_write_u8(vehicle, offsets[i], (uint8_t)((tyre_mask >> i) & 1u)); + } + runtime_tracef("vehicle: tyre_%s id=%u ptr=0x%08lx subtype=%s mask=0x%02x source=%s " + "evidence=STATIC_037:samp.dll+0xB7940", + applied ? "apply" : "apply_failed", (unsigned)vehicle_id, + (unsigned long)vehicle, subtype, (unsigned)tyre_mask, + reason != NULL ? reason : "unknown"); + return applied; +} + static int vehicle_compat_apply_damage(uint16_t vehicle_id, const samp_vehicle_slot_compat *slot, uintptr_t vehicle) { uint32_t current_panel = 0u; @@ -9204,6 +9948,14 @@ static void vehicle_compat_apply_created_state(uint16_t vehicle_id, const samp_v return; } + /* + * STATIC_037: + * RPC 164 applies components, then the paintjob at samp.dll+0xE800..0xE82F, + * and only then the two colors at +0xE834..0xE84A. Components remain a + * separate TODO, but keep the observed paintjob-before-color order here. + */ + (void)vehicle_compat_apply_stream_in_paintjob(vehicle_id, slot, vehicle); + if (slot->color1 != 255u || slot->color2 != 255u) { int color_ok = vehicle_compat_write_u8(vehicle, SAMP_VEHICLE_OFFSET_COLOR1, slot->color1) && vehicle_compat_write_u8(vehicle, SAMP_VEHICLE_OFFSET_COLOR2, slot->color2); @@ -9214,29 +9966,23 @@ static void vehicle_compat_apply_created_state(uint16_t vehicle_id, const samp_v (void)vehicle_compat_apply_damage(vehicle_id, slot, vehicle); - if (slot->tyre_damage != 0u && vehicle_compat_is_automobile(vehicle)) { - unsigned int i = 0u; - for (i = 0u; i < 4u; ++i) { - (void)vehicle_compat_write_u8(vehicle, SAMP_VEHICLE_OFFSET_CAR_WHEELS + i, - (uint8_t)((slot->tyre_damage >> i) & 1u)); - } - runtime_tracef("vehicle: tyre_apply id=%u ptr=0x%08lx tyre_mask=0x%02x evidence=INFERRED,OPENMP_REF,TODO_VERIFY", - (unsigned)vehicle_id, (unsigned long)vehicle, (unsigned)slot->tyre_damage); - } else if (slot->tyre_damage != 0u) { - runtime_tracef("vehicle: tyre_deferred id=%u ptr=0x%08lx tyre_mask=0x%02x reason=non_automobile TODO_VERIFY=1", - (unsigned)vehicle_id, (unsigned long)vehicle, (unsigned)slot->tyre_damage); - } + (void)vehicle_compat_apply_tyre_status(vehicle_id, vehicle, slot->tyre_damage, + "stream_in"); if (slot->siren != 0u) { - uint8_t siren_flags = 0u; - uint32_t siren_alt = 1u; - if (vehicle_compat_read_u8(vehicle, SAMP_VEHICLE_OFFSET_SIREN_FLAGS, &siren_flags)) { - siren_flags = (uint8_t)(siren_flags | 0x80u); - (void)vehicle_compat_write_u8(vehicle, SAMP_VEHICLE_OFFSET_SIREN_FLAGS, siren_flags); - } - (void)vehicle_compat_write_u32(vehicle, SAMP_VEHICLE_OFFSET_SIREN_ALT, siren_alt); - runtime_tracef("vehicle: siren_apply id=%u ptr=0x%08lx siren=%u evidence=INFERRED,TODO_VERIFY", - (unsigned)vehicle_id, (unsigned long)vehicle, (unsigned)slot->siren); + /* + * STATIC_037 + TODO_VERIFY: + * RPC 164's add-siren byte is passed into the R5 vehicle constructor and + * retained on its wrapper at +0x8B (samp.dll+0xB84AA..+0xB84C5). It is + * not the live siren state and must not write GTA CVehicle+0x42D or + * +0x514 here. The latter is the one-byte horn state used by +0xAE270. + */ + runtime_tracef( + "vehicle: add_siren_deferred id=%u ptr=0x%08lx add_siren=%u " + "reason=wrapper_creation_path_pending " + "evidence=STATIC_037:samp.dll+0xB84AA,+0xB84C5,TODO_VERIFY", + (unsigned)vehicle_id, (unsigned long)vehicle, + (unsigned)slot->siren); } } @@ -9266,12 +10012,18 @@ static int vehicle_compat_apply_pending_slot(uint16_t vehicle_id, samp_vehicle_s return 0; } - create_z = slot->pos[2] + SAMP_VEHICLE_CREATE_Z_BIAS; - /* STATIC_037 + OPENMP_REF + INFERRED: - * open.mp streams RPC 164 before/around spawn; observed compatible client behavior - * creates vanilla GTA vehicles with create_car at z+0.1f, then set_car_z_angle. - * We defer this until the MP session is spawned, matching the existing object - * bridge gate to avoid CStreaming work during dialogs/loading. + create_z = slot->pos[2]; + /* + * STATIC_037: + * R5 samp.dll+0xB84CB..+0xB84F6 passes the raw RPC-164 X/Y/Z to + * create_car. It restores the complete freshly-created matrix at + * +0xB85A1..+0xB85F7 after set_car_z_angle and the three compatibility + * script commands below. Original SHA256: + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + * + * We still defer creation until the MP session is spawned, matching the + * existing object bridge gate to avoid CStreaming work during + * dialogs/loading. */ runtime_tracef("vehicle: create_begin seq=%lu id=%u model=%ld pos=(%.3f,%.3f,%.3f) create_z=%.3f rot=%.3f", (unsigned long)slot->seq, (unsigned)vehicle_id, (long)slot->model, (double)slot->pos[0], @@ -9298,18 +10050,64 @@ static int vehicle_compat_apply_pending_slot(uint16_t vehicle_id, samp_vehicle_s (void)gta_script_command_compat(0x09C4u, "ii", (int)gta_id, 0); (void)gta_script_command_compat(0x07FFu, "ii", (int)gta_id, 0); (void)gta_script_command_compat(0x053Fu, "ii", (int)gta_id, 0); - /* STATIC_037 + TODO_VERIFY: - * CVehicle sets dwDoorsLocked=0 after create_car and SetLockedState(false) uses - * lock_car(gta_id, 0). We do not write VEHICLE_TYPE directly yet, so use the - * script command side first. - */ - (void)gta_script_command_compat(0x0519u, "ii", (int)gta_id, 0); if (slot->interior != 0u) { (void)gta_script_command_compat(0x0840u, "ii", (int)gta_id, (int)slot->interior); } slot->gta_id = gta_id; vehicle = vehicle_compat_game_pool_get_at(gta_id); if (vehicle != 0u) { + float final_position[3] = {slot->pos[0], slot->pos[1], + slot->pos[2] + + SAMP_VEHICLE_CREATE_POST_Z_BIAS}; + uintptr_t vtable = 0u; + int vtable_ok = vehicle_compat_read_vtable(vehicle, &vtable); + int position_ok = 0; + int constructor_fields_ok = 0; + + vehicle_compat_trace_lifecycle_state( + "rpc164_post_commands", slot->seq, vehicle_id, vehicle); + + /* + * STATIC_037: + * The R5 constructor writes CVehicle+0x4F8=0 and +0x4B0=0.0 directly at + * samp.dll+0xB8588/+0xB8595. It does not issue opcode 0519 here. Keep the + * exact constructor fields and avoid the extra script-side lock behavior. + */ + constructor_fields_ok = + vehicle_compat_write_u32(vehicle, SAMP_VEHICLE_OFFSET_DOOR_LOCK, 0u) && + vehicle_compat_write_float(vehicle, SAMP_VEHICLE_OFFSET_DIRT_LEVEL, + 0.0f); + runtime_tracef( + "vehicle: constructor_fields id=%u gta=%lu ptr=0x%08lx " + "door_lock=0 dirt=0.000 write=%d " + "evidence=STATIC_037:samp.dll+0xB8588,+0xB8595", + (unsigned)vehicle_id, (unsigned long)gta_id, + (unsigned long)vehicle, constructor_fields_ok); + + /* + * STATIC_037: + * samp.dll+0xB85A8 calls the R5 subtype classifier. Only exact bike + * (subtype 2) and BMX (subtype 6) keep raw Z; all other subtypes, + * including CTrailer (subtype 0), use RPC Z + 0.25 before the full + * matrix write at +0xB85DE. The basis already comes from + * set_car_z_angle, so changing only its position while preserving + * CPhysical velocity is equivalent here. + */ + if (vtable_ok && + (vtable == (uintptr_t)SAMP_GTA_VTBL_CBIKE || + vtable == (uintptr_t)SAMP_GTA_VTBL_CBMX)) { + final_position[2] = slot->pos[2]; + } + position_ok = + gta_entity_write_position_preserve_speed_compat(vehicle, final_position); + runtime_tracef( + "vehicle: create_matrix_restore id=%u gta=%lu ptr=0x%08lx " + "vtable=0x%08lx vtable_ok=%d pos=(%.3f,%.3f,%.3f) write=%d " + "evidence=STATIC_037", + (unsigned)vehicle_id, (unsigned long)gta_id, (unsigned long)vehicle, + (unsigned long)vtable, vtable_ok, (double)final_position[0], + (double)final_position[1], (double)final_position[2], position_ok); + float live_health = slot->health; /* * INFERRED + GTA_REVERSED_REF + PROBE_TRACE + TODO_VERIFY: @@ -9328,15 +10126,20 @@ static int vehicle_compat_apply_pending_slot(uint16_t vehicle_id, samp_vehicle_s runtime_tracef("vehicle: created_state_deferred id=%u gta=%lu reason=pool_lookup", (unsigned)vehicle_id, (unsigned long)gta_id); } - if (slot->paintjob != 255u || vehicle_compat_count_mods(slot->mods) != 0u) { - runtime_tracef("vehicle: extra_state_deferred id=%u gta=%lu paintjob=%u mods=%u reason=mods_paintjob_todo_verify", - (unsigned)vehicle_id, (unsigned long)gta_id, (unsigned)slot->paintjob, + if (vehicle_compat_count_mods(slot->mods) != 0u) { + runtime_tracef("vehicle: extra_state_deferred id=%u gta=%lu mods=%u reason=mods_todo_verify", + (unsigned)vehicle_id, (unsigned long)gta_id, vehicle_compat_count_mods(slot->mods)); } + slot->activated_tick = GetTickCount(); InterlockedExchange(&slot->active, 1); vehicle_compat_clear_pending_slot(slot); InterlockedIncrement(&g_runtime.vehicle_active_count); + if (vehicle != 0u) { + vehicle_compat_trace_lifecycle_state( + "rpc164_active_publish", slot->seq, vehicle_id, vehicle); + } { float actual_x = 0.0f; float actual_y = 0.0f; @@ -9539,6 +10342,7 @@ static void vehicle_compat_apply_pending_local_player_put(void) { LONG seq = 0; LONG vehicle_id = 0; LONG seat_id = 0; + samp_vehicle_slot_compat *slot = NULL; if (InterlockedCompareExchange(&g_runtime.vehicle_pending_local_put_valid, 0, 0) == 0) { return; @@ -9551,21 +10355,75 @@ static void vehicle_compat_apply_pending_local_player_put(void) { (uint32_t)applied_player_pos_seq < (uint32_t)required_player_pos_seq) { return; } - if (InterlockedExchange(&g_runtime.vehicle_pending_local_put_valid, 0) == 0) { - return; - } seq = InterlockedCompareExchange(&g_runtime.vehicle_pending_local_put_seq, 0, 0); vehicle_id = InterlockedCompareExchange(&g_runtime.vehicle_pending_local_put_vehicle_id, 0, 0); seat_id = InterlockedCompareExchange(&g_runtime.vehicle_pending_local_put_seat_id, 0, 0); + if (!vehicle_compat_id_valid((uint16_t)vehicle_id)) { + InterlockedExchange(&g_runtime.vehicle_pending_local_put_valid, 0); + return; + } + slot = &g_runtime.vehicle_slots[(uint16_t)vehicle_id]; + if ((InterlockedCompareExchange(&slot->active, 0, 0) == 0 || + slot->gta_id == 0u) && + !vehicle_compat_ensure_active((uint16_t)vehicle_id)) { + if (InterlockedCompareExchange(&slot->pending, 0, 0) != 0) { + return; + } + runtime_tracef( + "vehicle: put_local_player_order_drop seq=%ld id=%ld seat=%ld " + "reason=dependency_gone evidence=INFERRED,TODO_VERIFY", + (long)seq, (long)vehicle_id, (long)seat_id); + InterlockedExchange(&g_runtime.vehicle_pending_local_put_valid, 0); + return; + } + if (InterlockedExchange(&g_runtime.vehicle_pending_local_put_valid, 0) == 0) { + return; + } runtime_tracef("vehicle: put_local_player_order_ready seq=%ld id=%ld seat=%ld player_pos_seq=%ld " "evidence=PROBE_TRACE", (long)seq, (long)vehicle_id, (long)seat_id, (long)required_player_pos_seq); vehicle_compat_put_local_player((uint16_t)vehicle_id, (uint32_t)seq, (uint8_t)seat_id); } +static int vehicle_compat_oldest_pending_id( + uint32_t maximum_seq, uint16_t *vehicle_id_out) { + uint32_t oldest_seq = UINT32_MAX; + uint32_t i = 0u; + uint16_t oldest_id = 0u; + int found = 0; + + if (vehicle_id_out == NULL) { + return 0; + } + for (i = 0u; i < SAMP_RAKNET_MAX_VEHICLES; ++i) { + samp_vehicle_slot_compat *candidate = &g_runtime.vehicle_slots[i]; + uint32_t candidate_seq = candidate->seq; + + if (InterlockedCompareExchange(&candidate->pending, 0, 0) == 0 || + InterlockedCompareExchange(&candidate->active, 0, 0) != 0 || + candidate_seq == 0u || + (maximum_seq != 0u && candidate_seq > maximum_seq)) { + continue; + } + if (!found || candidate_seq < oldest_seq || + (candidate_seq == oldest_seq && i < oldest_id)) { + oldest_seq = candidate_seq; + oldest_id = (uint16_t)i; + found = 1; + } + } + if (found) { + *vehicle_id_out = oldest_id; + } + return found; +} + static int vehicle_compat_ensure_active(uint16_t vehicle_id) { samp_vehicle_slot_compat *slot = NULL; + uint16_t oldest_pending_id = 0u; + DWORD now = 0u; + DWORD hold_until = 0u; if (!vehicle_compat_id_valid(vehicle_id)) { return 0; @@ -9575,15 +10433,146 @@ static int vehicle_compat_ensure_active(uint16_t vehicle_id) { return 1; } if (InterlockedCompareExchange(&slot->pending, 0, 0) != 0) { - /* INFERRED + PROBE_TRACE: - * Dependent RPCs may follow StreamInVehicle in the same snapshot. Create - * that specific dependency now instead of consuming the later RPC first. + /* + * STATIC_037 + INFERRED: + * RPC 164 constructs synchronously at samp.dll+0xE75F -> +0x1F080, so + * every older StreamInVehicle has already completed before a dependent + * packet resolves this slot. The replacement deliberately paces creation + * for crash safety, so only the globally oldest dependency may leave the + * queue here and it must obey the same hold/interval gate as the normal + * flush. The vehicle-event cursor retains dependent script events until + * their pending slots become active. */ - (void)vehicle_compat_apply_pending_slot(vehicle_id, slot); + if (!vehicle_compat_oldest_pending_id(0u, &oldest_pending_id) || + oldest_pending_id != vehicle_id || + !vehicle_compat_can_flush_pending()) { + return 0; + } + now = GetTickCount(); + hold_until = g_runtime.vehicle_create_hold_until_tick; + if ((hold_until != 0u && (LONG)(now - hold_until) < 0) || + (g_runtime.vehicle_create_last_tick != 0u && + (DWORD)(now - g_runtime.vehicle_create_last_tick) < + SAMP_VEHICLE_COMPAT_CREATE_INTERVAL_MS)) { + return 0; + } + if (vehicle_compat_apply_pending_slot(vehicle_id, slot)) { + g_runtime.vehicle_create_last_tick = now; + } else if (InterlockedCompareExchange(&slot->pending, 0, 0) != 0 && + InterlockedCompareExchange(&slot->active, 0, 0) == 0) { + runtime_tracef( + "vehicle: pending_order_blocked requested=%u requested_seq=%lu " + "oldest=%u source=ensure_active " + "evidence=STATIC_037:samp.dll+0xE75F,+0x1F080", + (unsigned)vehicle_id, (unsigned long)slot->seq, + (unsigned)oldest_pending_id); + } } return InterlockedCompareExchange(&slot->active, 0, 0) != 0 && slot->gta_id != 0u; } +static void vehicle_compat_clear_attach_timing( + samp_vehicle_slot_compat *towing_slot) { + if (towing_slot == NULL) { + return; + } + towing_slot->trailer_attach_timing_seq = 0u; + towing_slot->trailer_attach_timing_vehicle_id = 0u; + towing_slot->trailer_attach_towing_age_ms = 0u; + towing_slot->trailer_attach_trailer_age_ms = 0u; + towing_slot->trailer_attach_timing_logged = 0u; +} + +static void vehicle_compat_clear_trailer_transition( + samp_vehicle_slot_compat *towing_slot) { + if (towing_slot == NULL) { + return; + } + towing_slot->trailer_transition_seq = 0u; + towing_slot->trailer_transition_vehicle_id = 0u; + towing_slot->trailer_transition_pending = 0u; +} + +static void vehicle_compat_stage_trailer_transition( + samp_vehicle_slot_compat *towing_slot, uint16_t trailer_id, + uint32_t source_seq) { + if (towing_slot == NULL || !vehicle_compat_id_valid(trailer_id)) { + return; + } + towing_slot->trailer_transition_seq = source_seq; + towing_slot->trailer_transition_vehicle_id = trailer_id; + towing_slot->trailer_transition_pending = 1u; +} + +static int vehicle_compat_trailer_transition_pending( + const samp_vehicle_slot_compat *towing_slot, uint16_t trailer_id) { + return towing_slot != NULL && + towing_slot->trailer_transition_pending != 0u && + towing_slot->trailer_transition_vehicle_id == trailer_id; +} + +static void vehicle_compat_stage_attach_timing( + const samp_raknet_vehicle_event *event) { + samp_vehicle_slot_compat *towing_slot = NULL; + + if (event == NULL || + event->action != SAMP_RAKNET_VEHICLE_ACTION_ATTACH_TRAILER || + !vehicle_compat_id_valid(event->vehicle_id) || + !vehicle_compat_id_valid(event->related_vehicle_id)) { + return; + } + towing_slot = &g_runtime.vehicle_slots[event->vehicle_id]; + if (towing_slot->trailer_attach_timing_seq == event->seq && + towing_slot->trailer_attach_timing_vehicle_id == + event->related_vehicle_id) { + return; + } + towing_slot->trailer_attach_timing_seq = event->seq; + towing_slot->trailer_attach_timing_vehicle_id = + event->related_vehicle_id; + towing_slot->trailer_attach_towing_age_ms = + samp_vehicle_attach_source_towing_age_unpack( + (uint32_t)event->component); + towing_slot->trailer_attach_trailer_age_ms = + samp_vehicle_attach_source_trailer_age_unpack( + (uint32_t)event->component); + towing_slot->trailer_attach_timing_logged = 0u; +} + +static int vehicle_compat_attach_timing_pending( + const samp_vehicle_slot_compat *towing_slot, + const samp_vehicle_slot_compat *trailer_slot, uint16_t trailer_id, + DWORD now, DWORD *towing_age_ms_out, DWORD *trailer_age_ms_out) { + DWORD towing_age_ms = 0u; + DWORD trailer_age_ms = 0u; + + if (towing_slot == NULL || trailer_slot == NULL || + towing_slot->trailer_attach_timing_seq == 0u || + towing_slot->trailer_attach_timing_vehicle_id != trailer_id) { + /* + * Preserve caller-sampled live ages when no source-timing barrier exists. + * Packet-210 diagnostics use those values after a later natural tow-link + * loss; clearing them here made a mature vehicle pair look newly active. + */ + return 0; + } + towing_age_ms = towing_slot->activated_tick != 0u + ? now - towing_slot->activated_tick + : 0u; + trailer_age_ms = trailer_slot->activated_tick != 0u + ? now - trailer_slot->activated_tick + : 0u; + if (towing_age_ms_out != NULL) { + *towing_age_ms_out = towing_age_ms; + } + if (trailer_age_ms_out != NULL) { + *trailer_age_ms_out = trailer_age_ms; + } + return samp_vehicle_attach_source_wait_pending( + towing_slot->trailer_attach_towing_age_ms, towing_age_ms, + towing_slot->trailer_attach_trailer_age_ms, trailer_age_ms); +} + static int vehicle_compat_apply_script_event(const samp_raknet_vehicle_event *event) { samp_vehicle_slot_compat *slot = NULL; samp_vehicle_slot_compat *related = NULL; @@ -9611,6 +10600,27 @@ static int vehicle_compat_apply_script_event(const samp_raknet_vehicle_event *ev return applied; } if (event->action == SAMP_RAKNET_VEHICLE_ACTION_ATTACH_TRAILER) { + uintptr_t towing_vehicle = 0u; + uintptr_t trailer_vehicle = 0u; + float towing_pre[3] = {NAN, NAN, NAN}; + float towing_post[3] = {NAN, NAN, NAN}; + float trailer_pre[3] = {NAN, NAN, NAN}; + float trailer_post[3] = {NAN, NAN, NAN}; + float trailer_move_pre[3] = {NAN, NAN, NAN}; + float trailer_move_post[3] = {NAN, NAN, NAN}; + float trailer_turn_pre[3] = {NAN, NAN, NAN}; + float trailer_turn_post[3] = {NAN, NAN, NAN}; + uint32_t towing_link_pre = 0u; + uint32_t towing_link_post = 0u; + uint32_t trailer_link_pre = 0u; + uint32_t trailer_link_post = 0u; + DWORD attach_tick = 0u; + DWORD towing_age_ms = 0u; + DWORD trailer_age_ms = 0u; + int applied = 0; + int attach_commanded = 0; + int link_readback_ok = 0; + if (!vehicle_compat_id_valid(event->related_vehicle_id)) { return 0; } @@ -9618,9 +10628,93 @@ static int vehicle_compat_apply_script_event(const samp_raknet_vehicle_event *ev return 0; } related = &g_runtime.vehicle_slots[event->related_vehicle_id]; - if (gta_script_command_compat(0x0893u, "ii", (int)related->gta_id, (int)slot->gta_id)) { + attach_tick = GetTickCount(); + towing_age_ms = + slot->activated_tick != 0u ? attach_tick - slot->activated_tick : 0u; + trailer_age_ms = related->activated_tick != 0u + ? attach_tick - related->activated_tick + : 0u; + towing_vehicle = vehicle_compat_game_pool_get_at(slot->gta_id); + trailer_vehicle = vehicle_compat_game_pool_get_at(related->gta_id); + (void)gta_entity_read_position_compat( + towing_vehicle, &towing_pre[0], &towing_pre[1], &towing_pre[2]); + (void)gta_entity_read_position_compat( + trailer_vehicle, &trailer_pre[0], &trailer_pre[1], + &trailer_pre[2]); + (void)gta_entity_read_move_speed_compat( + trailer_vehicle, trailer_move_pre); + (void)gta_entity_read_turn_speed_compat( + trailer_vehicle, trailer_turn_pre); + (void)vehicle_compat_read_u32( + towing_vehicle, SAMP_VEHICLE_OFFSET_TRAILER, &towing_link_pre); + (void)vehicle_compat_read_u32( + trailer_vehicle, SAMP_VEHICLE_OFFSET_TRACTOR, &trailer_link_pre); + + if ((uintptr_t)towing_link_pre == trailer_vehicle && + (uintptr_t)trailer_link_pre == towing_vehicle) { + applied = 1; + } else { + attach_commanded = 1; + applied = gta_script_command_compat( + 0x0893u, "ii", (int)related->gta_id, (int)slot->gta_id); + } + + (void)gta_entity_read_position_compat( + towing_vehicle, &towing_post[0], &towing_post[1], + &towing_post[2]); + (void)gta_entity_read_position_compat( + trailer_vehicle, &trailer_post[0], &trailer_post[1], + &trailer_post[2]); + (void)gta_entity_read_move_speed_compat( + trailer_vehicle, trailer_move_post); + (void)gta_entity_read_turn_speed_compat( + trailer_vehicle, trailer_turn_post); + (void)vehicle_compat_read_u32( + towing_vehicle, SAMP_VEHICLE_OFFSET_TRAILER, &towing_link_post); + (void)vehicle_compat_read_u32( + trailer_vehicle, SAMP_VEHICLE_OFFSET_TRACTOR, &trailer_link_post); + link_readback_ok = + (uintptr_t)towing_link_post == trailer_vehicle && + (uintptr_t)trailer_link_post == towing_vehicle; + runtime_tracef( + "vehicle: tow_link_trace seq=%lu towing=%u trailer=%u " + "gta=(%lu,%lu) ptr=(0x%08lx,0x%08lx) commanded=%d applied=%d " + "active_age_ms=(%lu,%lu) " + "source_age_ms=(%u,%u) " + "towing_pos=(%.3f,%.3f,%.3f)->(%.3f,%.3f,%.3f) " + "trailer_pos=(%.3f,%.3f,%.3f)->(%.3f,%.3f,%.3f) " + "trailer_move=(%.4f,%.4f,%.4f)->(%.4f,%.4f,%.4f) " + "trailer_turn=(%.4f,%.4f,%.4f)->(%.4f,%.4f,%.4f) " + "links=(0x%08lx,0x%08lx)->(0x%08lx,0x%08lx) link_readback=%d " + "evidence=PROBE_TRACE,TODO_VERIFY:gta_sa.exe+0x2CFDF0", + (unsigned long)event->seq, (unsigned)event->vehicle_id, + (unsigned)event->related_vehicle_id, (unsigned long)slot->gta_id, + (unsigned long)related->gta_id, (unsigned long)towing_vehicle, + (unsigned long)trailer_vehicle, attach_commanded, applied, + (unsigned long)towing_age_ms, (unsigned long)trailer_age_ms, + (unsigned)slot->trailer_attach_towing_age_ms, + (unsigned)slot->trailer_attach_trailer_age_ms, + (double)towing_pre[0], + (double)towing_pre[1], (double)towing_pre[2], + (double)towing_post[0], (double)towing_post[1], + (double)towing_post[2], (double)trailer_pre[0], + (double)trailer_pre[1], (double)trailer_pre[2], + (double)trailer_post[0], (double)trailer_post[1], + (double)trailer_post[2], (double)trailer_move_pre[0], + (double)trailer_move_pre[1], (double)trailer_move_pre[2], + (double)trailer_move_post[0], (double)trailer_move_post[1], + (double)trailer_move_post[2], (double)trailer_turn_pre[0], + (double)trailer_turn_pre[1], (double)trailer_turn_pre[2], + (double)trailer_turn_post[0], (double)trailer_turn_post[1], + (double)trailer_turn_post[2], (unsigned long)towing_link_pre, + (unsigned long)trailer_link_pre, (unsigned long)towing_link_post, + (unsigned long)trailer_link_post, link_readback_ok); + if (applied && link_readback_ok) { slot->trailer_vehicle_id = event->related_vehicle_id; slot->trailer_attached = 1u; + vehicle_compat_stage_trailer_transition( + slot, event->related_vehicle_id, event->seq); + vehicle_compat_clear_attach_timing(slot); return 1; } return 0; @@ -9635,6 +10729,8 @@ static int vehicle_compat_apply_script_event(const samp_raknet_vehicle_event *ev } slot->trailer_vehicle_id = 0u; slot->trailer_attached = 0u; + vehicle_compat_clear_attach_timing(slot); + vehicle_compat_clear_trailer_transition(slot); return applied; } if (event->action == SAMP_RAKNET_VEHICLE_ACTION_SET_PARAMS_EX) { @@ -9680,52 +10776,143 @@ static int vehicle_compat_apply_script_event(const samp_raknet_vehicle_event *ev } return applied; } + if (event->action == SAMP_RAKNET_VEHICLE_ACTION_SET_TYRE_STATUS) { + uintptr_t vehicle = vehicle_compat_game_pool_get_at(slot->gta_id); + int applied = vehicle_compat_apply_tyre_status(event->vehicle_id, vehicle, + event->tyre_damage, "rpc98"); + if (applied) { + slot->tyre_damage = event->tyre_damage; + } + return applied; + } return 0; } -static void vehicle_compat_apply_event(const samp_raknet_vehicle_event *event) { +typedef enum samp_vehicle_event_apply_result_compat { + SAMP_VEHICLE_EVENT_APPLIED = 0, + SAMP_VEHICLE_EVENT_DEFER = 1, + SAMP_VEHICLE_EVENT_DROP = 2 +} samp_vehicle_event_apply_result_compat; + +static int vehicle_compat_event_has_pending_dependency( + const samp_raknet_vehicle_event *event) { + if (event == NULL || !vehicle_compat_id_valid(event->vehicle_id)) { + return 0; + } + if (InterlockedCompareExchange( + &g_runtime.vehicle_slots[event->vehicle_id].pending, 0, 0) != 0) { + return 1; + } + if (event->action == SAMP_RAKNET_VEHICLE_ACTION_ATTACH_TRAILER && + vehicle_compat_id_valid(event->related_vehicle_id)) { + const samp_vehicle_slot_compat *towing_slot = + &g_runtime.vehicle_slots[event->vehicle_id]; + return InterlockedCompareExchange( + &g_runtime.vehicle_slots[event->related_vehicle_id].pending, + 0, 0) != 0 || + (towing_slot->trailer_attach_timing_seq == event->seq && + towing_slot->trailer_attach_timing_vehicle_id == + event->related_vehicle_id); + } + return 0; +} + +static samp_vehicle_event_apply_result_compat vehicle_compat_apply_event( + const samp_raknet_vehicle_event *event) { if (event == NULL || event->seq == 0u || !vehicle_compat_id_valid(event->vehicle_id)) { - return; + return SAMP_VEHICLE_EVENT_DROP; } if (event->action == SAMP_RAKNET_VEHICLE_ACTION_CREATE) { - (void)vehicle_compat_create_slot(event); - return; + return vehicle_compat_create_slot(event) + ? SAMP_VEHICLE_EVENT_APPLIED + : SAMP_VEHICLE_EVENT_DROP; } if (event->action == SAMP_RAKNET_VEHICLE_ACTION_DESTROY) { vehicle_compat_destroy_slot(event->vehicle_id, "rpc"); - return; + return SAMP_VEHICLE_EVENT_APPLIED; } if (event->action == SAMP_RAKNET_VEHICLE_ACTION_SET_HEALTH) { vehicle_compat_set_health(event->vehicle_id, event->seq, event->health); - return; + return SAMP_VEHICLE_EVENT_APPLIED; } if (event->action == SAMP_RAKNET_VEHICLE_ACTION_PUT_LOCAL_PLAYER) { + samp_vehicle_slot_compat *slot = + &g_runtime.vehicle_slots[event->vehicle_id]; + if ((InterlockedCompareExchange(&slot->active, 0, 0) == 0 || + slot->gta_id == 0u) && + !vehicle_compat_ensure_active(event->vehicle_id)) { + return InterlockedCompareExchange(&slot->pending, 0, 0) != 0 + ? SAMP_VEHICLE_EVENT_DEFER + : SAMP_VEHICLE_EVENT_DROP; + } vehicle_compat_put_local_player(event->vehicle_id, event->seq, event->seat_id); - return; + return SAMP_VEHICLE_EVENT_APPLIED; + } + + if (event->action == SAMP_RAKNET_VEHICLE_ACTION_ATTACH_TRAILER && + vehicle_compat_id_valid(event->related_vehicle_id)) { + samp_vehicle_slot_compat *towing_slot = + &g_runtime.vehicle_slots[event->vehicle_id]; + samp_vehicle_slot_compat *trailer_slot = + &g_runtime.vehicle_slots[event->related_vehicle_id]; + + vehicle_compat_stage_attach_timing(event); + if (vehicle_compat_ensure_active(event->vehicle_id) && + vehicle_compat_ensure_active(event->related_vehicle_id)) { + DWORD now = GetTickCount(); + DWORD towing_age_ms = 0u; + DWORD trailer_age_ms = 0u; + if (vehicle_compat_attach_timing_pending( + towing_slot, trailer_slot, event->related_vehicle_id, now, + &towing_age_ms, &trailer_age_ms)) { + if (towing_slot->trailer_attach_timing_logged == 0u) { + towing_slot->trailer_attach_timing_logged = 1u; + runtime_tracef( + "vehicle: attach_timing_deferred seq=%lu towing=%u trailer=%u " + "active_age_ms=(%lu,%lu) required_age_ms=(%u,%u) " + "evidence=PROBE_TRACE,INFERRED:preserve_rpc164_to_rpc148", + (unsigned long)event->seq, (unsigned)event->vehicle_id, + (unsigned)event->related_vehicle_id, + (unsigned long)towing_age_ms, (unsigned long)trailer_age_ms, + (unsigned)towing_slot->trailer_attach_towing_age_ms, + (unsigned)towing_slot->trailer_attach_trailer_age_ms); + } + return SAMP_VEHICLE_EVENT_DEFER; + } + } } if (event->action >= SAMP_RAKNET_VEHICLE_ACTION_SET_POS && - event->action <= SAMP_RAKNET_VEHICLE_ACTION_SET_PARAMS_FOR_PLAYER) { + event->action <= SAMP_RAKNET_VEHICLE_ACTION_SET_TYRE_STATUS) { int applied = vehicle_compat_apply_script_event(event); + samp_vehicle_event_apply_result_compat result = + applied ? SAMP_VEHICLE_EVENT_APPLIED + : (vehicle_compat_event_has_pending_dependency(event) + ? SAMP_VEHICLE_EVENT_DEFER + : SAMP_VEHICLE_EVENT_DROP); runtime_tracef("vehicle: script_event action=%u seq=%lu id=%u related=%u pos=(%.3f,%.3f,%.3f) " - "rotation=%.3f applied=%d evidence=STATIC_037", + "rotation=%.3f applied=%d result=%s evidence=STATIC_037", (unsigned)event->action, (unsigned long)event->seq, (unsigned)event->vehicle_id, (unsigned)event->related_vehicle_id, (double)event->pos[0], (double)event->pos[1], - (double)event->pos[2], (double)event->rotation, applied); - return; + (double)event->pos[2], (double)event->rotation, applied, + result == SAMP_VEHICLE_EVENT_APPLIED + ? "applied" + : (result == SAMP_VEHICLE_EVENT_DEFER ? "defer" + : "drop")); + return result; } runtime_tracef("vehicle: ignored action=%u id=%u seq=%lu reason=unknown_action", (unsigned)event->action, (unsigned)event->vehicle_id, (unsigned long)event->seq); + return SAMP_VEHICLE_EVENT_DROP; } static void vehicle_compat_flush_pending(uint32_t budget) { uint32_t applied = 0u; - uint32_t i = 0u; LONG pending = 0; DWORD now = 0u; DWORD hold_until = 0u; @@ -9744,17 +10931,29 @@ static void vehicle_compat_flush_pending(uint32_t budget) { return; } - for (i = 0u; i < SAMP_RAKNET_MAX_VEHICLES; ++i) { - samp_vehicle_slot_compat *slot = &g_runtime.vehicle_slots[i]; - if (InterlockedCompareExchange(&slot->pending, 0, 0) != 0 && + while (applied < budget) { + uint16_t pending_id = 0u; + samp_vehicle_slot_compat *slot = NULL; + LONG pending_before = 0; + + if (!vehicle_compat_oldest_pending_id(0u, &pending_id)) { + break; + } + slot = &g_runtime.vehicle_slots[pending_id]; + pending_before = InterlockedCompareExchange(&slot->pending, 0, 0); + if (vehicle_compat_apply_pending_slot(pending_id, slot)) { + g_runtime.vehicle_create_last_tick = now; + ++applied; + continue; + } + if (InterlockedCompareExchange(&slot->pending, 0, 0) == + pending_before && InterlockedCompareExchange(&slot->active, 0, 0) == 0) { - if (vehicle_compat_apply_pending_slot((uint16_t)i, slot)) { - g_runtime.vehicle_create_last_tick = now; - ++applied; - if (applied >= budget) { - break; - } - } + runtime_tracef( + "vehicle: pending_order_blocked oldest=%u oldest_seq=%lu " + "source=flush evidence=STATIC_037:samp.dll+0xE75F,+0x1F080", + (unsigned)pending_id, (unsigned long)slot->seq); + break; } } @@ -10085,15 +11284,36 @@ static void vehicle_compat_update_from_snapshot(const samp_raknet_rpc_probe_snap for (i = 0u; i < count; ++i) { const samp_raknet_vehicle_event *event = &snapshot->vehicle_events[i]; if (event->seq != 0u && event->seq > previous_seq) { + samp_vehicle_event_apply_result_compat result = + SAMP_VEHICLE_EVENT_APPLIED; LONG applied_player_pos_seq = InterlockedCompareExchange(&g_runtime.mp_session_applied_player_pos_seq, 0, 0); + if (event->seq > latest_seq + 1u) { + runtime_tracef( + "vehicle: event_ring_gap expected=%lu actual=%lu count=%lu " + "reason=cursor_fell_behind evidence=INFERRED,TODO_VERIFY", + (unsigned long)(latest_seq + 1u), (unsigned long)event->seq, + (unsigned long)count); + } if (event->action == SAMP_RAKNET_VEHICLE_ACTION_PUT_LOCAL_PLAYER && (snapshot->flags & SAMP_RAKNET_RPC_FLAG_PLAYER_POS) != 0u && snapshot->player_pos_seq != 0u && snapshot->player_pos_seq != (uint32_t)applied_player_pos_seq) { vehicle_compat_defer_local_player_put(event, snapshot->player_pos_seq); } else { - vehicle_compat_apply_event(event); + result = vehicle_compat_apply_event(event); + } + if (result == SAMP_VEHICLE_EVENT_DEFER) { + runtime_tracef( + "vehicle: event_deferred seq=%lu action=%u id=%u related=%u " + "cursor=%lu pending=%ld evidence=INFERRED,TODO_VERIFY", + (unsigned long)event->seq, (unsigned)event->action, + (unsigned)event->vehicle_id, + (unsigned)event->related_vehicle_id, + (unsigned long)latest_seq, + (long)InterlockedCompareExchange( + &g_runtime.vehicle_pending_count, 0, 0)); + break; } latest_seq = event->seq; } @@ -10304,6 +11524,30 @@ static int video_mode_toggle_original_compat(void) { return 1; } +static LONG chat_compat_toggle_display_mode(void) { + LONG old_mode = InterlockedCompareExchange(&g_runtime.chat_display_mode, 0, 0); + LONG new_mode = SAMP_CHAT_COMPAT_DISPLAY_PLAIN; + + /* + * STATIC_037: + * Original SA-MP 0.3.7-R5 SHA256= + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + * samp.dll+0x612C0 marks the chat dirty and cycles 0->2, 1->0, 2->1. + * CChat::Draw at +0x67E00 hides mode 0; the line path at +0x67470 keeps + * inline colours in mode 1 and strips valid {RRGGBB} tags in mode 2. + */ + if (old_mode == SAMP_CHAT_COMPAT_DISPLAY_COLORED) { + new_mode = SAMP_CHAT_COMPAT_DISPLAY_HIDDEN; + } else if (old_mode == SAMP_CHAT_COMPAT_DISPLAY_PLAIN) { + new_mode = SAMP_CHAT_COMPAT_DISPLAY_COLORED; + } + InterlockedExchange(&g_runtime.chat_display_mode, new_mode); + runtime_tracef("chat_display: F7 old=%ld new=%ld cycle=0>2>1>0 " + "evidence=STATIC_037:samp.dll+0x612C0,+0x67470,+0x67E00", + (long)old_mode, (long)new_mode); + return new_mode; +} + static LRESULT CALLBACK chat_input_wndproc_compat(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { const int active = InterlockedCompareExchange(&g_runtime.chat_input_active, 0, 0) != 0; const int dialog_active = dialog_compat_active(); @@ -10323,6 +11567,7 @@ static LRESULT CALLBACK chat_input_wndproc_compat(HWND hwnd, UINT msg, WPARAM wp * open dialog or cancel TextDraw selection in this branch. */ InterlockedExchange(&g_runtime.scoreboard_focus_release_latched, 1); + InterlockedExchange(&g_runtime.scoreboard_visible, 0); scoreboard_compat_restore_hud("wm_killfocus"); if (active) { chat_input_close_compat(); @@ -10376,6 +11621,22 @@ static LRESULT CALLBACK chat_input_wndproc_compat(HWND hwnd, UINT msg, WPARAM wp return chat_input_call_original_compat(hwnd, msg, wparam, lparam); } + if ((msg == WM_KEYDOWN || msg == WM_KEYUP) && wparam == VK_F1) { + if (msg == WM_KEYDOWN && (lparam & 0x40000000L) == 0 && + !dialog_active && !textdraw_select_active && + !edit_state_compat_active()) { + dialog_compat_show_help(); + } + return 0; + } + + if ((msg == WM_KEYDOWN || msg == WM_KEYUP) && wparam == VK_F7) { + if (msg == WM_KEYDOWN && (lparam & 0x40000000L) == 0) { + (void)chat_compat_toggle_display_mode(); + } + return 0; + } + if (msg == WM_KEYDOWN && wparam == VK_F4 && (lparam & 0x40000000L) == 0) { if (class_selection_compat_request_f4()) { return 0; @@ -10390,6 +11651,17 @@ static LRESULT CALLBACK chat_input_wndproc_compat(HWND hwnd, UINT msg, WPARAM wp return 0; } } + if (msg == WM_RBUTTONDOWN || msg == WM_RBUTTONUP || + msg == WM_RBUTTONDBLCLK || msg == WM_MBUTTONDOWN || + msg == WM_MBUTTONUP || msg == WM_MBUTTONDBLCLK || + msg == WM_XBUTTONDOWN || msg == WM_XBUTTONUP || + msg == WM_XBUTTONDBLCLK || msg == WM_MOUSEWHEEL || + msg == WM_MOUSEHWHEEL) { + /* USER_REQUESTED: a modal dialog consumes non-action mouse buttons and + * wheel input as well; none may fall through to GTA aim/fire/weapon + * handling while the dialog cursor is active. */ + return 0; + } if (msg == WM_KEYUP) { return 0; @@ -10734,7 +12006,8 @@ static void d3d9_cursor_sync_client_compat(HWND hwnd, int client_x, int client_y * INFERRED + TODO_VERIFY: * The compatibility DXUT path mirrors the Win32 cursor into IDirect3DDevice9::SetCursorPosition * and toggles IDirect3DDevice9::ShowCursor for GUI input. Keep this secondary to the - * software cursor draw path; it exists to match D3D9 cursor state while RsMouseSetPos is patched. + * backbuffer cursor draw path; it exists to match D3D9 cursor state while + * RsMouseSetPos is patched. */ device = read_game_d3d_device_compat(); set_cursor_position = (samp_d3d9_set_cursor_position_fn)method; @@ -10757,9 +12030,242 @@ static void screenshot_compat_release_unknown(void *unknown) { (void)release_fn(unknown); } +static HRESULT screenshot_compat_surface_get_desc(void *surface, samp_d3d9_surface_desc_compat *out_desc) { + void **vtbl = NULL; + samp_d3d9_surface_get_desc_fn get_desc_fn = NULL; + + if (surface == NULL || out_desc == NULL || !memory_is_readable_compat(surface, sizeof(void **))) { + return E_INVALIDARG; + } + memset(out_desc, 0, sizeof(*out_desc)); + vtbl = *(void ***)surface; + if (vtbl == NULL || + !memory_is_readable_compat(&vtbl[SAMP_D3D9_SURFACE_GET_DESC_INDEX], sizeof(void *)) || + vtbl[SAMP_D3D9_SURFACE_GET_DESC_INDEX] == NULL) { + return E_FAIL; + } + get_desc_fn = (samp_d3d9_surface_get_desc_fn)vtbl[SAMP_D3D9_SURFACE_GET_DESC_INDEX]; + return get_desc_fn(surface, out_desc); +} + +/* + * PROBE_TRACE + INFERRED: + * GE-Proton10-34/DXVK returned D3D_OK from GetFrontBufferData while the + * successfully encoded client crop contained only black RGB pixels. Inspect + * only a validated, lockable A8R8G8B8 crop; an indeterminate surface must keep + * the original R5 save path rather than guessing. + */ +static int screenshot_compat_surface_crop_is_black(void *surface, const RECT *crop, int *out_is_black) { + samp_d3d9_surface_desc_compat desc; + samp_d3d9_locked_rect_compat locked; + samp_d3d9_surface_lock_rect_fn lock_rect_fn = NULL; + samp_d3d9_surface_unlock_rect_fn unlock_rect_fn = NULL; + void **vtbl = NULL; + RECT lock_rect; + const RECT *lock_rect_ptr = NULL; + HRESULT hr = E_FAIL; + HRESULT unlock_hr = E_FAIL; + size_t crop_width = 0u; + size_t crop_height = 0u; + size_t row_bytes = 0u; + size_t pitch = 0u; + size_t y = 0u; + int all_black = 1; + int determined = 0; + + if (out_is_black == NULL) { + return 0; + } + *out_is_black = 0; + if (FAILED(screenshot_compat_surface_get_desc(surface, &desc)) || + desc.format != SAMP_D3DFMT_A8R8G8B8 || desc.width == 0u || desc.height == 0u) { + return 0; + } + + if (crop != NULL) { + if (crop->left < 0 || crop->top < 0 || crop->right <= crop->left || crop->bottom <= crop->top || + (uint64_t)crop->right > (uint64_t)desc.width || + (uint64_t)crop->bottom > (uint64_t)desc.height) { + runtime_tracef("screenshot: frontbuffer crop validation failed rect=%ld,%ld-%ld,%ld surface=%ux%u", + (long)crop->left, (long)crop->top, (long)crop->right, (long)crop->bottom, + (unsigned)desc.width, (unsigned)desc.height); + return 0; + } + lock_rect = *crop; + lock_rect_ptr = &lock_rect; + crop_width = (size_t)((uint64_t)crop->right - (uint64_t)crop->left); + crop_height = (size_t)((uint64_t)crop->bottom - (uint64_t)crop->top); + } else { + crop_width = (size_t)desc.width; + crop_height = (size_t)desc.height; + } + if (crop_width == 0u || crop_height == 0u || crop_width > SIZE_MAX / 4u) { + return 0; + } + row_bytes = crop_width * 4u; + + if (!memory_is_readable_compat(surface, sizeof(void **))) { + return 0; + } + vtbl = *(void ***)surface; + if (vtbl == NULL || + !memory_is_readable_compat(&vtbl[SAMP_D3D9_SURFACE_LOCK_RECT_INDEX], sizeof(void *)) || + !memory_is_readable_compat(&vtbl[SAMP_D3D9_SURFACE_UNLOCK_RECT_INDEX], sizeof(void *)) || + vtbl[SAMP_D3D9_SURFACE_LOCK_RECT_INDEX] == NULL || + vtbl[SAMP_D3D9_SURFACE_UNLOCK_RECT_INDEX] == NULL) { + return 0; + } + lock_rect_fn = (samp_d3d9_surface_lock_rect_fn)vtbl[SAMP_D3D9_SURFACE_LOCK_RECT_INDEX]; + unlock_rect_fn = (samp_d3d9_surface_unlock_rect_fn)vtbl[SAMP_D3D9_SURFACE_UNLOCK_RECT_INDEX]; + memset(&locked, 0, sizeof(locked)); + hr = lock_rect_fn(surface, &locked, lock_rect_ptr, SAMP_D3DLOCK_READONLY); + if (FAILED(hr) || locked.bits == NULL || locked.pitch <= 0) { + runtime_tracef("screenshot: frontbuffer crop LockRect failed hr=0x%08lx pitch=%d bits=0x%08lx", + (unsigned long)hr, (int)locked.pitch, (unsigned long)(uintptr_t)locked.bits); + if (SUCCEEDED(hr)) { + unlock_hr = unlock_rect_fn(surface); + if (FAILED(unlock_hr)) { + runtime_tracef("screenshot: invalid crop UnlockRect failed hr=0x%08lx", + (unsigned long)unlock_hr); + } + } + return 0; + } + + pitch = (size_t)locked.pitch; + if (pitch >= row_bytes && + (crop_height <= 1u || (crop_height - 1u) <= (SIZE_MAX - (uintptr_t)locked.bits) / pitch)) { + determined = 1; + for (y = 0u; y < crop_height && all_black; ++y) { + size_t x = 0u; + uintptr_t row_address = (uintptr_t)locked.bits + y * pitch; + const uint8_t *row = (const uint8_t *)row_address; + + if (!memory_is_readable_compat(row, row_bytes)) { + determined = 0; + break; + } + for (x = 0u; x < row_bytes; x += 4u) { + if ((row[x] | row[x + 1u] | row[x + 2u]) != 0u) { + all_black = 0; + break; + } + } + } + } + + unlock_hr = unlock_rect_fn(surface); + if (FAILED(unlock_hr)) { + runtime_tracef("screenshot: frontbuffer crop UnlockRect failed hr=0x%08lx", (unsigned long)unlock_hr); + return 0; + } + if (!determined) { + runtime_tracef("screenshot: frontbuffer crop scan rejected pitch=%d row=%lu height=%lu bits=0x%08lx", + (int)locked.pitch, (unsigned long)row_bytes, (unsigned long)crop_height, + (unsigned long)(uintptr_t)locked.bits); + return 0; + } + + *out_is_black = all_black; + return 1; +} + +static HRESULT screenshot_compat_save_render_target_fallback(void *device, const char *path) { + void **vtbl = NULL; + samp_d3d9_get_render_target_fn get_render_target_fn = NULL; + samp_d3d9_create_offscreen_plain_surface_fn create_surface_fn = NULL; + samp_d3d9_get_render_target_data_fn get_render_target_data_fn = NULL; + samp_d3d9_surface_desc_compat desc; + void *render_target = NULL; + void *system_surface = NULL; + HRESULT hr = E_FAIL; + const char *stage = "validate"; + + if (device == NULL || path == NULL || path[0] == '\0' || + !memory_is_readable_compat(device, sizeof(void **))) { + return E_INVALIDARG; + } + memset(&desc, 0, sizeof(desc)); + vtbl = *(void ***)device; + if (vtbl == NULL || + !memory_is_readable_compat(&vtbl[SAMP_D3D9_GET_RENDER_TARGET_DATA_INDEX], sizeof(void *)) || + !memory_is_readable_compat(&vtbl[SAMP_D3D9_CREATE_OFFSCREEN_PLAIN_SURFACE_INDEX], + sizeof(void *)) || + !memory_is_readable_compat(&vtbl[SAMP_D3D9_GET_RENDER_TARGET_INDEX], sizeof(void *)) || + vtbl[SAMP_D3D9_GET_RENDER_TARGET_DATA_INDEX] == NULL || + vtbl[SAMP_D3D9_CREATE_OFFSCREEN_PLAIN_SURFACE_INDEX] == NULL || + vtbl[SAMP_D3D9_GET_RENDER_TARGET_INDEX] == NULL) { + return E_FAIL; + } + + get_render_target_data_fn = + (samp_d3d9_get_render_target_data_fn)vtbl[SAMP_D3D9_GET_RENDER_TARGET_DATA_INDEX]; + create_surface_fn = + (samp_d3d9_create_offscreen_plain_surface_fn)vtbl[SAMP_D3D9_CREATE_OFFSCREEN_PLAIN_SURFACE_INDEX]; + get_render_target_fn = (samp_d3d9_get_render_target_fn)vtbl[SAMP_D3D9_GET_RENDER_TARGET_INDEX]; + + stage = "GetRenderTarget"; + hr = get_render_target_fn(device, 0u, &render_target); + if (FAILED(hr) || render_target == NULL) { + if (SUCCEEDED(hr)) { + hr = E_FAIL; + } + goto done; + } + stage = "GetDesc"; + hr = screenshot_compat_surface_get_desc(render_target, &desc); + if (FAILED(hr)) { + goto done; + } + if (desc.width == 0u || desc.height == 0u || desc.format == 0u || + desc.pool != SAMP_D3DPOOL_DEFAULT || + (desc.usage & SAMP_D3DUSAGE_RENDERTARGET) == 0u || + desc.multi_sample_type != SAMP_D3DMULTISAMPLE_NONE) { + runtime_tracef("screenshot: render-target fallback rejected format=%lu size=%ux%u usage=0x%08lx " + "pool=%lu multisample=%lu", + (unsigned long)desc.format, (unsigned)desc.width, (unsigned)desc.height, + (unsigned long)desc.usage, (unsigned long)desc.pool, + (unsigned long)desc.multi_sample_type); + hr = E_FAIL; + stage = "validate_desc"; + goto done; + } + + stage = "CreateOffscreenPlainSurface"; + hr = create_surface_fn(device, desc.width, desc.height, desc.format, SAMP_D3DPOOL_SYSTEMMEM, + &system_surface, NULL); + if (FAILED(hr) || system_surface == NULL) { + if (SUCCEEDED(hr)) { + hr = E_FAIL; + } + goto done; + } + stage = "GetRenderTargetData"; + hr = get_render_target_data_fn(device, render_target, system_surface); + if (FAILED(hr)) { + goto done; + } + stage = "D3DXSaveSurfaceToFileA"; + hr = g_runtime.d3dx_save_surface_to_file_a(path, SAMP_D3DXIFF_PNG, system_surface, NULL, NULL); + +done: + screenshot_compat_release_unknown(system_surface); + screenshot_compat_release_unknown(render_target); + if (FAILED(hr)) { + runtime_tracef("screenshot: render-target fallback failed stage=%s hr=0x%08lx", + stage, (unsigned long)hr); + } else { + runtime_tracef("screenshot: render-target fallback copied format=%lu size=%ux%u", + (unsigned long)desc.format, (unsigned)desc.width, (unsigned)desc.height); + } + return hr; +} + static int screenshot_compat_next_path(char *out_path, size_t out_size) { LONG start = InterlockedCompareExchange(&g_runtime.screenshot_count, 0, 0); LONG candidate = 0; + const char *directory = NULL; + const char *separator = NULL; int written = 0; int i = 0; @@ -10767,10 +12273,18 @@ static int screenshot_compat_next_path(char *out_path, size_t out_size) { return 0; } out_path[0] = '\0'; + directory = g_runtime.screenshot_dir[0] != '\0' ? g_runtime.screenshot_dir : g_runtime.module_dir; + if (directory[0] == '\0') { + return 0; + } + separator = (directory[strlen(directory) - 1u] == '\\' || + directory[strlen(directory) - 1u] == '/') + ? "" + : "\\"; for (i = 0; i < 1000; ++i) { candidate = (start + i) % 1000; - written = snprintf(out_path, out_size, "%ssa-mp-%03ld.png", g_runtime.module_dir, (long)candidate); + written = snprintf(out_path, out_size, "%s%ssa-mp-%03ld.png", directory, separator, (long)candidate); if (written <= 0 || (size_t)written >= out_size) { return 0; } @@ -10781,7 +12295,7 @@ static int screenshot_compat_next_path(char *out_path, size_t out_size) { } candidate = start % 1000; - written = snprintf(out_path, out_size, "%ssa-mp-%03ld.png", g_runtime.module_dir, (long)candidate); + written = snprintf(out_path, out_size, "%s%ssa-mp-%03ld.png", directory, separator, (long)candidate); if (written <= 0 || (size_t)written >= out_size) { return 0; } @@ -10802,6 +12316,8 @@ static void screenshot_compat_capture_if_requested(void *device) { RECT *save_rect = NULL; POINT point; HWND hwnd = NULL; + const char *backend = "frontbuffer"; + int frontbuffer_crop_black = 0; if (InterlockedExchange(&g_runtime.screenshot_requested, 0) == 0) { return; @@ -10861,18 +12377,37 @@ static void screenshot_compat_capture_if_requested(void *device) { save_rect = ▭ } } - hr = g_runtime.d3dx_save_surface_to_file_a(path, SAMP_D3DXIFF_PNG, surface, NULL, save_rect); + /* + * STATIC_037: + * The primary path above remains the R5 sequence at samp.dll+0x755C0: + * desktop scratch surface, GetFrontBufferData, client-screen crop, PNG. + * PROBE_TRACE: + * DXVK can report D3D_OK here while returning an entirely black crop. + * Fall back only when LockRect proves that exact crop has no non-black RGB + * pixel; failed/unsafe inspection deliberately retains the original path. + */ + if (screenshot_compat_surface_crop_is_black(surface, save_rect, &frontbuffer_crop_black) && + frontbuffer_crop_black) { + backend = "render_target_fallback"; + runtime_tracef("screenshot: frontbuffer crop is fully black; trying backend=%s " + "evidence=PROBE_TRACE,INFERRED", + backend); + hr = screenshot_compat_save_render_target_fallback(device, path); + } else { + hr = g_runtime.d3dx_save_surface_to_file_a(path, SAMP_D3DXIFF_PNG, surface, NULL, save_rect); + } } screenshot_compat_release_unknown(surface); if (SUCCEEDED(hr)) { InterlockedExchange(&g_runtime.screenshot_fail_logged, 0); chat_compat_add_message("Screenshot Taken - %s", path); - runtime_tracef("screenshot: saved '%s'", path); + runtime_tracef("screenshot: saved '%s' backend=%s", path, backend); } else { chat_compat_add_message("Unable to save screenshot."); if (InterlockedCompareExchange(&g_runtime.screenshot_fail_logged, 1, 0) == 0) { - runtime_tracef("screenshot: save failed hr=0x%08lx path='%s'", (unsigned long)hr, path); + runtime_tracef("screenshot: save failed hr=0x%08lx path='%s' backend=%s", + (unsigned long)hr, path, backend); } } } @@ -10970,6 +12505,7 @@ static void chat_compat_release_d3dx_font(void) { remote_player_compat_release_name_tag_status_fonts(); textdraw_compat_release_fonts(); loading_screen_compat_release_texture(); + ui_cursor_compat_release_texture(); class_selection_compat_release_resources(); } @@ -11048,6 +12584,244 @@ static int loading_screen_compat_resolve_d3dx_create_texture(void) { return 1; } +static void ui_cursor_compat_release_texture(void) { + if (g_runtime.ui_cursor_texture != NULL) { + screenshot_compat_release_unknown(g_runtime.ui_cursor_texture); + } + g_runtime.ui_cursor_texture = NULL; + g_runtime.ui_cursor_d3d_device = NULL; + InterlockedExchange(&g_runtime.ui_cursor_texture_ready, 0); +} + +static int ui_cursor_compat_prepare_d3d(void) { + void *device = read_game_d3d_device_compat(); + char path[MAX_PATH]; + int written = 0; + HRESULT hr = E_FAIL; + + if (device == NULL || !memory_is_readable_compat(device, sizeof(void *))) { + return 0; + } + if (g_runtime.ui_cursor_d3d_device != NULL && + g_runtime.ui_cursor_d3d_device != device) { + ui_cursor_compat_release_texture(); + } + if (InterlockedCompareExchange(&g_runtime.ui_cursor_texture_ready, 0, 0) != + 0 && + g_runtime.ui_cursor_texture != NULL && + g_runtime.ui_cursor_d3d_device == device) { + return 1; + } + if (!loading_screen_compat_resolve_d3dx_create_texture() || + g_runtime.module_dir[0] == '\0') { + return 0; + } + + written = snprintf(path, sizeof(path), "%smouse.png", g_runtime.module_dir); + if (written <= 0 || (size_t)written >= sizeof(path) || + GetFileAttributesA(path) == INVALID_FILE_ATTRIBUTES) { + if (InterlockedCompareExchange(&g_runtime.ui_cursor_texture_fail_logged, 1, + 0) == 0) { + runtime_tracef("ui_cursor: external mouse.png missing fallback=win32 " + "evidence=OBSERVED_037,USER_REQUESTED"); + } + return 0; + } + + hr = g_runtime.d3dx_create_texture_from_file_a( + device, path, &g_runtime.ui_cursor_texture); + if (FAILED(hr) || g_runtime.ui_cursor_texture == NULL || + !memory_is_readable_compat(g_runtime.ui_cursor_texture, + sizeof(void *))) { + g_runtime.ui_cursor_texture = NULL; + if (InterlockedCompareExchange(&g_runtime.ui_cursor_texture_fail_logged, 1, + 0) == 0) { + runtime_tracef("ui_cursor: mouse.png texture load failed hr=0x%08lx " + "fallback=win32", + (unsigned long)hr); + } + return 0; + } + + g_runtime.ui_cursor_d3d_device = device; + InterlockedExchange(&g_runtime.ui_cursor_texture_ready, 1); + InterlockedExchange(&g_runtime.ui_cursor_texture_fail_logged, 0); + InterlockedExchange(&g_runtime.ui_cursor_draw_fail_logged, 0); + runtime_tracef("ui_cursor: backbuffer texture initialized asset=mouse.png " + "size=32x32 render_sources=1 " + "evidence=OBSERVED_037,STATIC_037,PROBE_TRACE," + "USER_REQUESTED"); + return 1; +} + +static int ui_compat_draw_cursor(void *device) { + void **vtbl = NULL; + void **state_block_vtbl = NULL; + void *state_block = NULL; + samp_d3d9_create_state_block_fn create_state_block = NULL; + samp_d3d9_state_block_apply_fn apply_state_block = NULL; + samp_d3d9_set_render_state_fn set_render_state = NULL; + samp_d3d9_set_texture_fn set_texture = NULL; + samp_d3d9_set_texture_stage_state_fn set_texture_stage_state = NULL; + samp_d3d9_set_sampler_state_fn set_sampler_state = NULL; + samp_d3d9_set_fvf_fn set_fvf = NULL; + samp_d3d9_draw_primitive_up_fn draw_primitive_up = NULL; + samp_d3d9_textured_vertex_compat vertices[4]; + LONG x = 0; + LONG y = 0; + HRESULT draw_hr = E_FAIL; + + if (device == NULL || + InterlockedCompareExchange(&g_runtime.dialog_mouse_mode, 0, 0) == 0 || + !memory_is_readable_compat(device, sizeof(void *))) { + return 0; + } + if (InterlockedCompareExchange(&g_runtime.ui_cursor_texture_ready, 0, 0) == + 0 || + g_runtime.ui_cursor_texture == NULL || + g_runtime.ui_cursor_d3d_device != device) { + if (!ui_cursor_compat_prepare_d3d() || + g_runtime.ui_cursor_d3d_device != device) { + ui_cursor_compat_set_win32_visible(1); + return 0; + } + d3d9_cursor_show_compat(0, "ui_cursor_backbuffer_recreate"); + ui_cursor_compat_set_win32_visible(0); + } + + vtbl = *(void ***)device; + if (vtbl == NULL || + !memory_is_readable_compat( + &vtbl[SAMP_D3D9_CREATE_STATE_BLOCK_INDEX], sizeof(void *)) || + !memory_is_readable_compat( + &vtbl[SAMP_D3D9_SET_RENDER_STATE_INDEX], sizeof(void *)) || + !memory_is_readable_compat(&vtbl[SAMP_D3D9_SET_TEXTURE_INDEX], + sizeof(void *)) || + !memory_is_readable_compat( + &vtbl[SAMP_D3D9_SET_TEXTURE_STAGE_STATE_INDEX], sizeof(void *)) || + !memory_is_readable_compat( + &vtbl[SAMP_D3D9_SET_SAMPLER_STATE_INDEX], sizeof(void *)) || + !memory_is_readable_compat(&vtbl[SAMP_D3D9_SET_FVF_INDEX], + sizeof(void *)) || + !memory_is_readable_compat( + &vtbl[SAMP_D3D9_DRAW_PRIMITIVE_UP_INDEX], sizeof(void *)) || + vtbl[SAMP_D3D9_CREATE_STATE_BLOCK_INDEX] == NULL || + vtbl[SAMP_D3D9_SET_RENDER_STATE_INDEX] == NULL || + vtbl[SAMP_D3D9_SET_TEXTURE_INDEX] == NULL || + vtbl[SAMP_D3D9_SET_TEXTURE_STAGE_STATE_INDEX] == NULL || + vtbl[SAMP_D3D9_SET_SAMPLER_STATE_INDEX] == NULL || + vtbl[SAMP_D3D9_SET_FVF_INDEX] == NULL || + vtbl[SAMP_D3D9_DRAW_PRIMITIVE_UP_INDEX] == NULL) { + ui_cursor_compat_set_win32_visible(1); + return 0; + } + + create_state_block = (samp_d3d9_create_state_block_fn) + vtbl[SAMP_D3D9_CREATE_STATE_BLOCK_INDEX]; + set_render_state = + (samp_d3d9_set_render_state_fn)vtbl[SAMP_D3D9_SET_RENDER_STATE_INDEX]; + set_texture = + (samp_d3d9_set_texture_fn)vtbl[SAMP_D3D9_SET_TEXTURE_INDEX]; + set_texture_stage_state = (samp_d3d9_set_texture_stage_state_fn) + vtbl[SAMP_D3D9_SET_TEXTURE_STAGE_STATE_INDEX]; + set_sampler_state = (samp_d3d9_set_sampler_state_fn) + vtbl[SAMP_D3D9_SET_SAMPLER_STATE_INDEX]; + set_fvf = (samp_d3d9_set_fvf_fn)vtbl[SAMP_D3D9_SET_FVF_INDEX]; + draw_primitive_up = (samp_d3d9_draw_primitive_up_fn) + vtbl[SAMP_D3D9_DRAW_PRIMITIVE_UP_INDEX]; + + if (FAILED(create_state_block(device, SAMP_D3DSBT_ALL, &state_block)) || + state_block == NULL || + !memory_is_readable_compat(state_block, sizeof(void *))) { + screenshot_compat_release_unknown(state_block); + ui_cursor_compat_set_win32_visible(1); + return 0; + } + state_block_vtbl = *(void ***)state_block; + if (state_block_vtbl == NULL || + !memory_is_readable_compat( + &state_block_vtbl[SAMP_D3D9_STATE_BLOCK_APPLY_INDEX], + sizeof(void *)) || + state_block_vtbl[SAMP_D3D9_STATE_BLOCK_APPLY_INDEX] == NULL) { + screenshot_compat_release_unknown(state_block); + ui_cursor_compat_set_win32_visible(1); + return 0; + } + apply_state_block = (samp_d3d9_state_block_apply_fn) + state_block_vtbl[SAMP_D3D9_STATE_BLOCK_APPLY_INDEX]; + + x = InterlockedCompareExchange(&g_runtime.dialog_mouse_x, 0, 0); + y = InterlockedCompareExchange(&g_runtime.dialog_mouse_y, 0, 0); + vertices[0] = (samp_d3d9_textured_vertex_compat){ + (float)x - 0.5f, (float)(y + SAMP_UI_CURSOR_HEIGHT) - 0.5f, + 0.1f, 1.0f, 0xFFFFFFFFu, 0.0f, 1.0f}; + vertices[1] = (samp_d3d9_textured_vertex_compat){ + (float)x - 0.5f, (float)y - 0.5f, 0.1f, 1.0f, 0xFFFFFFFFu, + 0.0f, 0.0f}; + vertices[2] = (samp_d3d9_textured_vertex_compat){ + (float)(x + SAMP_UI_CURSOR_WIDTH) + 0.5f, + (float)(y + SAMP_UI_CURSOR_HEIGHT) - 0.5f, 0.1f, 1.0f, + 0xFFFFFFFFu, 1.0f, 1.0f}; + vertices[3] = (samp_d3d9_textured_vertex_compat){ + (float)(x + SAMP_UI_CURSOR_WIDTH) + 0.5f, (float)y - 0.5f, + 0.1f, 1.0f, 0xFFFFFFFFu, 1.0f, 0.0f}; + + (void)set_render_state(device, SAMP_D3DRS_ZENABLE, 0u); + (void)set_render_state(device, SAMP_D3DRS_ALPHATESTENABLE, 0u); + (void)set_render_state(device, SAMP_D3DRS_ALPHABLENDENABLE, 1u); + (void)set_render_state(device, SAMP_D3DRS_SRCBLEND, + SAMP_D3DBLEND_SRCALPHA); + (void)set_render_state(device, SAMP_D3DRS_DESTBLEND, + SAMP_D3DBLEND_INVSRCALPHA); + (void)set_render_state(device, SAMP_D3DRS_FILLMODE, SAMP_D3DFILL_SOLID); + (void)set_render_state(device, SAMP_D3DRS_CULLMODE, SAMP_D3DCULL_NONE); + (void)set_render_state(device, SAMP_D3DRS_COLORWRITEENABLE, + SAMP_D3DCOLORWRITE_ALL); + (void)set_render_state(device, SAMP_D3DRS_BLENDOP, SAMP_D3DBLENDOP_ADD); + (void)set_render_state(device, SAMP_D3DRS_SCISSORTESTENABLE, 0u); + (void)set_texture(device, 0u, g_runtime.ui_cursor_texture); + (void)set_texture(device, 1u, NULL); + (void)set_texture_stage_state(device, 0u, SAMP_D3DTSS_COLOROP, + SAMP_D3DTOP_MODULATE); + (void)set_texture_stage_state(device, 0u, SAMP_D3DTSS_COLORARG1, + SAMP_D3DTA_TEXTURE); + (void)set_texture_stage_state(device, 0u, SAMP_D3DTSS_COLORARG2, + SAMP_D3DTA_DIFFUSE); + (void)set_texture_stage_state(device, 0u, SAMP_D3DTSS_ALPHAOP, + SAMP_D3DTOP_MODULATE); + (void)set_texture_stage_state(device, 0u, SAMP_D3DTSS_ALPHAARG1, + SAMP_D3DTA_TEXTURE); + (void)set_texture_stage_state(device, 0u, SAMP_D3DTSS_ALPHAARG2, + SAMP_D3DTA_DIFFUSE); + (void)set_texture_stage_state(device, 1u, SAMP_D3DTSS_COLOROP, + SAMP_D3DTOP_DISABLE); + (void)set_texture_stage_state(device, 1u, SAMP_D3DTSS_ALPHAOP, + SAMP_D3DTOP_DISABLE); + (void)set_sampler_state(device, 0u, SAMP_D3DSAMP_MINFILTER, + SAMP_D3DTEXF_LINEAR); + (void)set_sampler_state(device, 0u, SAMP_D3DSAMP_MAGFILTER, + SAMP_D3DTEXF_LINEAR); + (void)set_fvf(device, SAMP_D3DFVF_XYZRHW_DIFFUSE_TEX1); + draw_hr = draw_primitive_up(device, SAMP_D3DPT_TRIANGLESTRIP, 2u, vertices, + sizeof(vertices[0])); + (void)set_texture(device, 0u, NULL); + (void)apply_state_block(state_block); + screenshot_compat_release_unknown(state_block); + + if (FAILED(draw_hr)) { + if (InterlockedCompareExchange(&g_runtime.ui_cursor_draw_fail_logged, 1, + 0) == 0) { + runtime_tracef("ui_cursor: backbuffer draw failed hr=0x%08lx " + "fallback=win32 evidence=PROBE_TRACE,TODO_VERIFY", + (unsigned long)draw_hr); + } + ui_cursor_compat_set_win32_visible(1); + return 0; + } + ui_cursor_compat_set_win32_visible(0); + return 1; +} + static int chat_compat_font_size(void) { const char *value = getenv("SAMPDLL_CHAT_FONT_SIZE"); char *endptr = NULL; @@ -11898,16 +13672,24 @@ static int dialog_compat_d3d_alpha_rect(void *device, int x, int y, int w, int h * vary with the previously rendered primitive. Preserve the complete state * so UI panels do not contaminate the following D3DX text pass either. */ - if (SUCCEEDED(create_state_block(device, SAMP_D3DSBT_ALL, &state_block)) && state_block != NULL && - memory_is_readable_compat(state_block, sizeof(void **))) { - state_block_vtbl = *(void ***)state_block; - if (state_block_vtbl != NULL && - memory_is_readable_compat(&state_block_vtbl[SAMP_D3D9_STATE_BLOCK_APPLY_INDEX], sizeof(void *)) && - state_block_vtbl[SAMP_D3D9_STATE_BLOCK_APPLY_INDEX] != NULL) { - apply_state_block = - (samp_d3d9_state_block_apply_fn)state_block_vtbl[SAMP_D3D9_STATE_BLOCK_APPLY_INDEX]; - } + if (FAILED(create_state_block(device, SAMP_D3DSBT_ALL, &state_block)) || + state_block == NULL || + !memory_is_readable_compat(state_block, sizeof(void **))) { + screenshot_compat_release_unknown(state_block); + return 0; + } + state_block_vtbl = *(void ***)state_block; + if (state_block_vtbl == NULL || + !memory_is_readable_compat( + &state_block_vtbl[SAMP_D3D9_STATE_BLOCK_APPLY_INDEX], + sizeof(void *)) || + state_block_vtbl[SAMP_D3D9_STATE_BLOCK_APPLY_INDEX] == NULL) { + screenshot_compat_release_unknown(state_block); + return 0; } + apply_state_block = + (samp_d3d9_state_block_apply_fn) + state_block_vtbl[SAMP_D3D9_STATE_BLOCK_APPLY_INDEX]; (void)set_render_state(device, SAMP_D3DRS_ZENABLE, 0u); /* PROBE_TRACE + INFERRED: * The initial class-selection scene can leave GTA's alpha test enabled with @@ -11920,6 +13702,22 @@ static int dialog_compat_d3d_alpha_rect(void *device, int x, int y, int w, int h (void)set_render_state(device, SAMP_D3DRS_ALPHABLENDENABLE, 1u); (void)set_render_state(device, SAMP_D3DRS_SRCBLEND, SAMP_D3DBLEND_SRCALPHA); (void)set_render_state(device, SAMP_D3DRS_DESTBLEND, SAMP_D3DBLEND_INVSRCALPHA); + /* + * USER_REQUESTED + PROBE_TRACE + MTA_REF: + * GTA/RenderWare may leave EndScene with scissoring, culling, wireframe or a + * partial colour-write mask. DrawPrimitiveUP still returns success in those + * states even when the UI quad contributes no pixels, which previously made + * panels appear completely transparent and suppressed the visible fallback. + * Normalize the complete immediate-mode surface state, then restore the + * captured block. MTA's straight-alpha GUI path uses the same blend factors; + * no MTA code or assets are used here. + */ + (void)set_render_state(device, SAMP_D3DRS_FILLMODE, SAMP_D3DFILL_SOLID); + (void)set_render_state(device, SAMP_D3DRS_CULLMODE, SAMP_D3DCULL_NONE); + (void)set_render_state(device, SAMP_D3DRS_COLORWRITEENABLE, + SAMP_D3DCOLORWRITE_ALL); + (void)set_render_state(device, SAMP_D3DRS_BLENDOP, SAMP_D3DBLENDOP_ADD); + (void)set_render_state(device, SAMP_D3DRS_SCISSORTESTENABLE, 0u); (void)set_texture(device, 0u, NULL); (void)set_texture_stage_state(device, 0u, SAMP_D3DTSS_COLOROP, SAMP_D3DTOP_SELECTARG1); (void)set_texture_stage_state(device, 0u, SAMP_D3DTSS_COLORARG1, SAMP_D3DTA_DIFFUSE); @@ -11927,13 +13725,111 @@ static int dialog_compat_d3d_alpha_rect(void *device, int x, int y, int w, int h (void)set_texture_stage_state(device, 0u, SAMP_D3DTSS_ALPHAARG1, SAMP_D3DTA_DIFFUSE); (void)set_fvf(device, SAMP_D3DFVF_XYZRHW_DIFFUSE); draw_hr = draw_primitive_up(device, SAMP_D3DPT_TRIANGLESTRIP, 2u, vertices, sizeof(vertices[0])); - if (apply_state_block != NULL) { - (void)apply_state_block(state_block); - } + (void)apply_state_block(state_block); screenshot_compat_release_unknown(state_block); return SUCCEEDED(draw_hr); } +static int ui_compat_draw_blended_rect(void *device, int x, int y, int w, + int h, DWORD argb_color) { + const DWORD alpha = argb_color & 0xFF000000u; + + if (device == NULL || w <= 0 || h <= 0 || x < 0 || y < 0 || + alpha == 0u) { + return 0; + } + if (alpha == 0xFF000000u) { + dialog_compat_d3d_fill_rect(device, x, y, w, h, argb_color); + return 1; + } + if (dialog_compat_d3d_alpha_rect(device, x, y, w, h, argb_color)) { + return 1; + } + + /* + * PROBE_TRACE: + * A failed state-block/quad path must remain visible. Clear does not blend, + * so force an opaque copy of the intended dark RGB as the conservative + * fallback instead of silently returning a text-only UI. + */ + dialog_compat_d3d_fill_rect(device, x, y, w, h, + argb_color | 0xFF000000u); + return 1; +} + +static void ui_compat_draw_glass_panel(void *device, int x, int y, int w, + int h, int header_h) { + if (device == NULL || w <= 0 || h <= 0) { + return; + } + if (header_h < 0) { + header_h = 0; + } + if (header_h > h) { + header_h = h; + } + + /* + * USER_REQUESTED + MTA_REF: + * MTA's current GUI gets its glass-like depth from a dark translucent + * window, separately layered frame/title states and a restrained highlight, + * not from a framebuffer blur. Keep each layer's alpha independent so text + * and controls are not dimmed again by inherited parent alpha. + */ + (void)ui_compat_draw_blended_rect(device, x - 3, y + 4, w + 6, h, + SAMP_MODERN_UI_SHADOW_COLOR); + (void)ui_compat_draw_blended_rect(device, x, y, w, h, + SAMP_MODERN_UI_PANEL_COLOR); + if (header_h > 0) { + (void)ui_compat_draw_blended_rect(device, x + 1, y + 1, w - 2, + header_h - 1, + SAMP_MODERN_UI_HEADER_COLOR); + (void)ui_compat_draw_blended_rect(device, x + 1, + y + header_h - 1, w - 2, 1, + SAMP_MODERN_UI_DIVIDER_COLOR); + } + (void)ui_compat_draw_blended_rect(device, x, y, w, 1, + SAMP_MODERN_UI_BORDER_COLOR); + (void)ui_compat_draw_blended_rect(device, x, y + h - 1, w, 1, + SAMP_MODERN_UI_BORDER_COLOR); + (void)ui_compat_draw_blended_rect(device, x, y, 1, h, + SAMP_MODERN_UI_BORDER_COLOR); + (void)ui_compat_draw_blended_rect(device, x + w - 1, y, 1, h, + SAMP_MODERN_UI_BORDER_COLOR); + if (w > 2 && h > 2) { + (void)ui_compat_draw_blended_rect(device, x + 1, y + 1, w - 2, 1, + SAMP_MODERN_UI_INNER_BORDER_COLOR); + } +} + +static void ui_compat_draw_glass_control(void *device, int x, int y, int w, + int h, int emphasized, int hovered, + int pressed) { + DWORD fill = SAMP_MODERN_UI_CONTROL_COLOR; + DWORD border = SAMP_MODERN_UI_BORDER_COLOR; + + if (pressed) { + fill = SAMP_MODERN_UI_CONTROL_PRESSED_COLOR; + border = SAMP_MODERN_UI_CONTROL_PRESSED_BORDER_COLOR; + } else if (hovered || emphasized) { + fill = SAMP_MODERN_UI_CONTROL_HOVER_COLOR; + border = hovered ? SAMP_MODERN_UI_CONTROL_HOVER_BORDER_COLOR + : SAMP_MODERN_UI_CONTROL_BORDER_COLOR; + } + + (void)ui_compat_draw_blended_rect(device, x, y, w, h, fill); + (void)ui_compat_draw_blended_rect(device, x, y, w, 1, border); + (void)ui_compat_draw_blended_rect(device, x, y + h - 1, w, 1, border); + (void)ui_compat_draw_blended_rect(device, x, y, 1, h, border); + (void)ui_compat_draw_blended_rect(device, x + w - 1, y, 1, h, border); + if (!pressed && w > 2) { + (void)ui_compat_draw_blended_rect( + device, x + 1, y + 1, w - 2, 1, + hovered ? SAMP_MODERN_UI_ACCENT_COLOR + : SAMP_MODERN_UI_INNER_BORDER_COLOR); + } +} + static void textdraw_compat_d3d_fill_rect(void *device, int x, int y, int w, int h, DWORD argb_color) { DWORD alpha = argb_color & 0xFF000000u; @@ -11972,8 +13868,19 @@ static int scoreboard_compat_available(void) { return 1; } +static int scoreboard_compat_visible_latched(void) { + /* + * STATIC_037: + * The main R5 render path checks CScoreboard+0x0 directly at + * samp.dll+0x7593A..+0x7593F. When set, it hides the GTA HUD, draws only + * the scoreboard, and jumps past Chat::Draw/ChatInput::Draw. + */ + return InterlockedCompareExchange(&g_runtime.scoreboard_visible, 0, 0) != 0; +} + static int scoreboard_compat_active(void) { if (!scoreboard_compat_available()) { + InterlockedExchange(&g_runtime.scoreboard_visible, 0); return 0; } if (InterlockedCompareExchange(&g_runtime.chat_input_active, 0, 0) != 0 || dialog_compat_active() || @@ -11990,7 +13897,38 @@ static int scoreboard_compat_active(void) { } InterlockedExchange(&g_runtime.scoreboard_focus_release_latched, 0); } - return game_window_key_down_compat(VK_TAB); + return scoreboard_compat_visible_latched(); +} + +static int scoreboard_compat_hud_owned(void) { + return InterlockedCompareExchange(&g_runtime.scoreboard_visible, 0, 0) != 0 || + InterlockedCompareExchange(&g_runtime.scoreboard_hud_hidden, 0, 0) != 0; +} + +static int scoreboard_compat_cursor_owned(void) { + return InterlockedCompareExchange(&g_runtime.scoreboard_visible, 0, 0) != 0 || + InterlockedCompareExchange(&g_runtime.scoreboard_mouse_mode, 0, 0) != 0; +} + +static int scoreboard_compat_write_hud_if_unowned(uint8_t hud, uint8_t radar_blank) { + if (scoreboard_compat_hud_owned()) { + return 0; + } + + write_game_u8(SAMP_ADDR_ENABLE_HUD, hud); + write_game_u8(SAMP_ADDR_RADAR_BLANK, radar_blank); + /* + * OBSERVED_037 + PROBE_TRACE + INFERRED: + * Show publishes the raw visibility latch before acquiring the HUD bytes. + * Recheck after this monitor/session write so an interleaved Show edge + * cannot leave a one-frame HUD=1/radar_blank=0 window behind it. + */ + if (scoreboard_compat_hud_owned()) { + write_game_u8(SAMP_ADDR_ENABLE_HUD, 0u); + write_game_u8(SAMP_ADDR_RADAR_BLANK, 1u); + return 0; + } + return 1; } static void scoreboard_compat_clear_mouse_mode(const char *reason) { @@ -12037,17 +13975,26 @@ static void scoreboard_compat_restore_hud(const char *reason) { } static void scoreboard_compat_update_hud(void) { - int active = scoreboard_compat_active() && !gta_frontend_menu_active_compat(); - - if (!active) { + if (!scoreboard_compat_available()) { + InterlockedExchange(&g_runtime.scoreboard_visible, 0); + } + /* + * STATIC_037 + PROBE_TRACE: + * Show (+0x6F3D0) acquires cursor mode 3 and then publishes visible=1; + * Hide (+0x6E9E0) owns the inverse transition. Render suppression by a + * different overlay is not a Hide edge, so it must not restore shared + * cursor/HUD ownership while the raw visibility latch remains set. + */ + if (InterlockedCompareExchange(&g_runtime.scoreboard_visible, 0, 0) == 0) { scoreboard_compat_restore_hud("tab_release"); return; } /* * OBSERVED_037 + PROBE_TRACE: - * Three R5 runs kept gta_sa.exe+0x141DF5 at its original CALL bytes while - * TAB alone was held, and W moved the local player about 3.08 units. Do not - * reuse chat's game-input patch or enable the cursor merely for TAB. + * Run 20260728-151746-original-ui-1697969 kept + * gta_sa.exe+0x141DF5 at its original CALL bytes while the key-up-latched + * scoreboard was visible, including after RMB, and W moved the local + * player about 3.08 units. Do not reuse chat's game-input patch. */ if (!memory_is_readable_compat((const void *)(uintptr_t)SAMP_ADDR_ENABLE_HUD, 1u) || !memory_is_readable_compat((const void *)(uintptr_t)SAMP_ADDR_RADAR_BLANK, 1u)) { @@ -12069,10 +14016,57 @@ static void scoreboard_compat_update_hud(void) { static int scoreboard_compat_handle_key(UINT msg, WPARAM wparam) { if (msg == WM_KEYUP && wparam == VK_TAB) { int consume = scoreboard_compat_available() || + InterlockedCompareExchange(&g_runtime.scoreboard_visible, 0, 0) != 0 || InterlockedCompareExchange(&g_runtime.scoreboard_hud_hidden, 0, 0) != 0 || InterlockedCompareExchange(&g_runtime.scoreboard_mouse_mode, 0, 0) != 0; - InterlockedExchange(&g_runtime.scoreboard_focus_release_latched, 0); - scoreboard_compat_restore_hud("tab_keyup"); + LONG focus_release = + InterlockedExchange(&g_runtime.scoreboard_focus_release_latched, 0); + + if (focus_release != 0 || !scoreboard_compat_available()) { + InterlockedExchange(&g_runtime.scoreboard_visible, 0); + scoreboard_compat_restore_hud( + focus_release != 0 ? "tab_keyup_after_focus_loss" : "tab_keyup_unavailable"); + return consume; + } + if (InterlockedCompareExchange(&g_runtime.chat_input_active, 0, 0) != 0 || + dialog_compat_active() || menu_compat_active() || + InterlockedCompareExchange(&g_runtime.textdraw_select_active, 0, 0) != 0) { + return consume; + } + + /* + * OBSERVED_037 + PROBE_TRACE + STATIC_037: + * Original R5 WndProc checks WM_KEYUP/VK_TAB at + * samp.dll+0x61785..+0x61790, then calls Show at samp.dll+0x617B6 + * when CScoreboard+0x0 is zero or Hide at samp.dll+0x617A0 when it + * is nonzero. + * Runs 20260728_150323_* and 20260728_151023_* corroborate that one + * complete key pulse leaves the scoreboard visible and a second pulse + * hides it. Do not derive visibility from the physical TAB-down state. + */ + if (InterlockedCompareExchange(&g_runtime.scoreboard_visible, 0, 0) != 0) { + InterlockedExchange(&g_runtime.scoreboard_visible, 0); + scoreboard_compat_restore_hud("tab_keyup_hide"); + runtime_tracef("scoreboard: keyup visible=1->0 " + "evidence=OBSERVED_037,PROBE_TRACE,STATIC_037:" + "samp.dll+0x61785,+0x617a0"); + } else { + InterlockedExchange(&g_runtime.scoreboard_exclusive_overlay_logged, 0); + InterlockedExchange(&g_runtime.scoreboard_visible, 1); + /* + * OBSERVED_037 + PROBE_TRACE + STATIC_037: + * CScoreboard::Show calls CGame::SetCursorMode(3) before setting its + * visible flag (samp.dll+0x6F3D0..+0x6F415). The focused R5 probe + * recorded mode 3 immediately before scoreboard_show and mode 0 from + * Hide. This cursor mode does not patch GTA's gameplay-input call. + */ + InterlockedExchange(&g_runtime.scoreboard_mouse_mode, 1); + dialog_compat_set_mouse_mode(1); + scoreboard_compat_update_hud(); + runtime_tracef("scoreboard: keyup visible=0->1 " + "evidence=OBSERVED_037,PROBE_TRACE,STATIC_037:" + "samp.dll+0x61785,+0x617b6,+0x6f415 cursor_mode=3"); + } return consume; } if (!scoreboard_compat_available()) { @@ -12093,7 +14087,7 @@ static int scoreboard_compat_handle_key(UINT msg, WPARAM wparam) { if (wparam == VK_TAB) { return 1; } - if (game_window_key_down_compat(VK_TAB)) { + if (scoreboard_compat_active()) { if (wparam == VK_PRIOR) { LONG offset = InterlockedCompareExchange(&g_runtime.scoreboard_offset, 0, 0); offset -= SAMP_SCOREBOARD_MAX_VISIBLE; @@ -12376,24 +14370,20 @@ static int scoreboard_compat_handle_mouse(HWND hwnd, UINT msg, WPARAM wparam, LP int hit = 0; (void)wparam; - if (!scoreboard_compat_active()) { + if (!scoreboard_compat_visible_latched()) { return 0; } - /* ALT_02X_CODE + TODO_VERIFY: - * RMB while TAB is held enters the scoreboard interaction mode. The - * TAB-only path is OBSERVED_037 and deliberately remains non-modal. + /* + * OBSERVED_037 + PROBE_TRACE: + * The correctly key-up-latched R5 scoreboard already owns cursor mode 3. + * RMB is forwarded to its GUI handler but does not replace GTA's gameplay + * input CALL or stop W movement. Keep the cursor active and consume the + * GUI edge without applying chat_input_game_controls_apply_compat(). */ if (msg == WM_RBUTTONDOWN || msg == WM_RBUTTONDBLCLK) { - if (InterlockedExchange(&g_runtime.scoreboard_mouse_mode, 1) == 0) { - dialog_compat_set_mouse_mode(1); - if (InterlockedCompareExchange(&g_runtime.scoreboard_game_input_owner, 1, 0) == 0 && - !chat_input_game_controls_apply_compat("scoreboard_right_button")) { - InterlockedExchange(&g_runtime.scoreboard_game_input_owner, 0); - } - runtime_tracef("scoreboard: mouse mode enabled trigger=right_button " - "evidence=ALT_02X_CODE,TODO_VERIFY"); - } + runtime_tracef("scoreboard: right_button cursor_mode=3 game_input=unchanged " + "evidence=OBSERVED_037,PROBE_TRACE"); InterlockedExchange(&g_runtime.scoreboard_mouse_down, 0); return 1; } @@ -12642,10 +14632,10 @@ static void scoreboard_compat_update_from_snapshot(const samp_raknet_rpc_probe_s } static void scoreboard_compat_draw_border(void *device, int x, int y, int w, int h, DWORD color) { - dialog_compat_d3d_fill_rect(device, x, y, w, 1, color); - dialog_compat_d3d_fill_rect(device, x, y + h - 1, w, 1, color); - dialog_compat_d3d_fill_rect(device, x, y, 1, h, color); - dialog_compat_d3d_fill_rect(device, x + w - 1, y, 1, h, color); + (void)ui_compat_draw_blended_rect(device, x, y, w, 1, color); + (void)ui_compat_draw_blended_rect(device, x, y + h - 1, w, 1, color); + (void)ui_compat_draw_blended_rect(device, x, y, 1, h, color); + (void)ui_compat_draw_blended_rect(device, x + w - 1, y, 1, h, color); } static void scoreboard_compat_draw_text_shadowed(samp_id3dx_font_compat *font, RECT rect, const char *text, @@ -12858,7 +14848,20 @@ static int scoreboard_compat_draw_d3dx_overlay(void *device) { char value[64]; const char *host = NULL; - if (!scoreboard_compat_active() || !scoreboard_compat_ensure_font(device)) { + if (!scoreboard_compat_visible_latched()) { + return 0; + } + /* + * OBSERVED_037 + PROBE_TRACE: + * Original R5 keeps HUD=0/radar_blank=1 for the entire latched interval. + * GTA can rewrite these bytes during gameplay, so enforce them on the + * render cadence rather than only from the lower-frequency runtime tick. + */ + scoreboard_compat_update_hud(); + if (!scoreboard_compat_visible_latched()) { + return 0; + } + if (!scoreboard_compat_ensure_font(device)) { return 0; } @@ -12886,14 +14889,10 @@ static int scoreboard_compat_draw_d3dx_overlay(void *device) { hover_valid = InterlockedCompareExchange(&g_runtime.scoreboard_hover_valid, 0, 0) != 0; hover_player_id = (int)InterlockedCompareExchange(&g_runtime.scoreboard_hover_player_id, 0, 0); - if (!dialog_compat_d3d_alpha_rect(device, panel_x, panel_y, panel_w, panel_h, SAMP_SCOREBOARD_COLOR_PANEL)) { - dialog_compat_d3d_fill_rect(device, panel_x, panel_y, panel_w, panel_h, SAMP_MODERN_UI_PANEL_FALLBACK); - } - if (!dialog_compat_d3d_alpha_rect(device, panel_x, panel_y, panel_w, 42, SAMP_SCOREBOARD_COLOR_HEADER)) { - dialog_compat_d3d_fill_rect(device, panel_x, panel_y, panel_w, 42, SAMP_MODERN_UI_PANEL_FALLBACK); - } + ui_compat_draw_glass_panel(device, panel_x, panel_y, panel_w, panel_h, 42); scoreboard_compat_draw_border(device, panel_x, panel_y, panel_w, panel_h, SAMP_MODERN_UI_BORDER_COLOR); - dialog_compat_d3d_fill_rect(device, panel_x, panel_y + 41, panel_w, 1, SAMP_SCOREBOARD_COLOR_GRID); + (void)ui_compat_draw_blended_rect(device, panel_x, panel_y + 41, panel_w, + 1, SAMP_SCOREBOARD_COLOR_GRID); host = g_runtime.raknet_init_hostname[0] != '\0' ? g_runtime.raknet_init_hostname : "SA-MP 0.3.7-R5"; if (connected && player_count > 0) { @@ -12939,16 +14938,13 @@ static int scoreboard_compat_draw_d3dx_overlay(void *device) { DT_SINGLELINE | DT_CENTER | DT_NOCLIP); row_y = layout.row_y; - dialog_compat_d3d_fill_rect(device, panel_x + 10, row_y - 5, panel_w - 20, 1, SAMP_SCOREBOARD_COLOR_GRID); + (void)ui_compat_draw_blended_rect(device, panel_x + 10, row_y - 5, + panel_w - 20, 1, + SAMP_SCOREBOARD_COLOR_GRID); if (!connected) { if (InterlockedCompareExchange(&g_runtime.scoreboard_logged, 1, 0) == 0) { runtime_tracef("scoreboard: drawing enabled preconnect evidence=STATIC_037 TODO_VERIFY remote_pool=decoded"); } - if (mouse_mode) { - ui_compat_draw_cursor(device, - (int)InterlockedCompareExchange(&g_runtime.dialog_mouse_x, 0, 0), - (int)InterlockedCompareExchange(&g_runtime.dialog_mouse_y, 0, 0)); - } return 1; } @@ -12968,14 +14964,17 @@ static int scoreboard_compat_draw_d3dx_overlay(void *device) { row_color = slot->color != 0u ? chat_compat_samp_color_to_argb(slot->color) : 0xFFFFFFFFu; if (mouse_mode && hover_valid && (int)slot->player_id == hover_player_id) { - (void)dialog_compat_d3d_alpha_rect(device, panel_x + 8, row_y - 1, panel_w - 16, - SAMP_SCOREBOARD_LINE_HEIGHT, SAMP_SCOREBOARD_COLOR_HOVER); + (void)ui_compat_draw_blended_rect( + device, panel_x + 8, row_y - 1, panel_w - 16, + SAMP_SCOREBOARD_LINE_HEIGHT, SAMP_SCOREBOARD_COLOR_HOVER); } else if ((int)slot->player_id == local_id) { - (void)dialog_compat_d3d_alpha_rect(device, panel_x + 8, row_y - 1, panel_w - 16, - SAMP_SCOREBOARD_LINE_HEIGHT, 0x38FFFFFFu); + (void)ui_compat_draw_blended_rect( + device, panel_x + 8, row_y - 1, panel_w - 16, + SAMP_SCOREBOARD_LINE_HEIGHT, SAMP_SCOREBOARD_COLOR_LOCAL); } else if ((drawn_rows & 1) != 0) { - (void)dialog_compat_d3d_alpha_rect(device, panel_x + 8, row_y - 1, panel_w - 16, - SAMP_SCOREBOARD_LINE_HEIGHT, 0x28000000u); + (void)ui_compat_draw_blended_rect( + device, panel_x + 8, row_y - 1, panel_w - 16, + SAMP_SCOREBOARD_LINE_HEIGHT, SAMP_SCOREBOARD_COLOR_ALTERNATE); } rect.left = col_id; @@ -13011,11 +15010,6 @@ static int scoreboard_compat_draw_d3dx_overlay(void *device) { runtime_tracef("scoreboard: drawing enabled local_id=%d players=%d host='%s' evidence=STATIC_037,OPENMP_REF,PROBE_TRACE remote_pool=decoded", scoreboard_compat_local_id(), player_count, host); } - if (mouse_mode) { - ui_compat_draw_cursor(device, - (int)InterlockedCompareExchange(&g_runtime.dialog_mouse_x, 0, 0), - (int)InterlockedCompareExchange(&g_runtime.dialog_mouse_y, 0, 0)); - } return 1; } @@ -14022,6 +16016,7 @@ static int remote_player_compat_bootstrap_from_sync(const samp_raknet_remote_onf slot->up_down_keys = sync->up_down_keys; slot->keys = sync->keys; slot->vehicle_id = 0xFFFFu; + slot->trailer_id = 0xFFFFu; slot->skin = SAMP_REMOTE_PLAYER_COMPAT_FALLBACK_SKIN; slot->color = scoreboard_slot->color; memcpy(slot->pos, sync->position, sizeof(slot->pos)); @@ -14621,6 +16616,7 @@ static void remote_player_compat_set_digital_key( static void remote_player_compat_update_control_set( samp_remote_player_slot_compat *slot) { samp_gta_control_set_compat *controls = NULL; + uintptr_t ped = 0u; uint16_t keys = 0u; uint8_t gta_player_index = 0u; uint16_t analog2 = 0u; @@ -14651,10 +16647,16 @@ static void remote_player_compat_update_control_set( remote_player_compat_set_digital_key(controls, 18u, (keys & (1u << 1u)) != 0u); remote_player_compat_set_digital_key(controls, 17u, (keys & (1u << 2u)) != 0u); remote_player_compat_set_digital_key(controls, 16u, (keys & (1u << 3u)) != 0u); + ped = remote_player_compat_resolve_ped(slot); + /* + * STATIC_037: + * samp.dll+0xAF44F..+0xAF493 suppresses bit 4 only while the live ped is + * on foot with an actual CTaskSimpleJetPack at CPed+0x47C/+0x10. The + * packet SpecialAction byte alone is not authoritative for this decision. + */ remote_player_compat_set_digital_key( - controls, 15u, - (keys & (1u << 4u)) != 0u && - slot->special_action != SAMP_SPECIAL_ACTION_USEJETPACK); + controls, 15u, (keys & (1u << 4u)) != 0u && + !gta_ped_jetpack_task_compat(ped, NULL, NULL)); remote_player_compat_set_digital_key(controls, 14u, (keys & (1u << 5u)) != 0u); remote_player_compat_set_digital_key(controls, 7u, (keys & (1u << 6u)) != 0u); remote_player_compat_set_digital_key(controls, 6u, (keys & (1u << 7u)) != 0u); @@ -14729,7 +16731,10 @@ static void SAMP_THISCALL remote_player_compat_process_control_hook( uint16_t remote_camera_mode2 = 0u; float saved_zoom = 0.0f; float remote_zoom = 70.0f; + float saved_aspect = 1.0f; + float remote_aspect = 1.0f; float remote_aim_z = 0.0f; + int remote_aim_built = 0; LONG call = 0; LONG remote_call = 0; @@ -14783,6 +16788,11 @@ static void SAMP_THISCALL remote_player_compat_process_control_hook( (const void *)SAMP_ADDR_CAMERA_EXT_ZOOM, sizeof(saved_zoom)) || !memory_is_writable_compat( (void *)SAMP_ADDR_CAMERA_EXT_ZOOM, sizeof(saved_zoom)) || + !memory_is_readable_compat( + (const void *)SAMP_ADDR_CAMERA_EXT_ASPECT, + sizeof(saved_aspect)) || + !memory_is_writable_compat( + (void *)SAMP_ADDR_CAMERA_EXT_ASPECT, sizeof(saved_aspect)) || !memory_is_writable_compat( (void *)SAMP_ADDR_CAMERA_MODE, sizeof(saved_camera_mode)) || !memory_is_writable_compat( @@ -14812,6 +16822,9 @@ static void SAMP_THISCALL remote_player_compat_process_control_hook( memcpy( &saved_zoom, (const void *)SAMP_ADDR_CAMERA_EXT_ZOOM, sizeof(saved_zoom)); + memcpy( + &saved_aspect, (const void *)SAMP_ADDR_CAMERA_EXT_ASPECT, + sizeof(saved_aspect)); memcpy( &saved_camera_mode, (const void *)SAMP_ADDR_CAMERA_MODE, sizeof(saved_camera_mode)); @@ -14827,31 +16840,37 @@ static void SAMP_THISCALL remote_player_compat_process_control_hook( (const void *)SAMP_ADDR_CAMERA_DRIVE_BY_RIGHT, sizeof(saved_drive_by_right)); - memset(&remote_aim, 0, sizeof(remote_aim)); + remote_aim = saved_aim; + remote_camera_mode = saved_camera_mode; + remote_camera_mode2 = saved_camera_mode2; + remote_zoom = saved_zoom; + remote_aspect = saved_aspect; if (slot->aim_valid != 0u) { - remote_camera_mode = slot->aim_sync.camera_mode; - remote_zoom = - ((float)(slot->aim_sync.zoom_weapon_state & 0x3Fu) / 63.0f) * - 35.0f + - 35.0f; - memcpy( - remote_aim.f1, slot->aim_sync.camera_front, - sizeof(remote_aim.f1)); - memcpy( - remote_aim.f2, slot->aim_sync.camera_front, - sizeof(remote_aim.f2)); - memcpy( - remote_aim.pos1, slot->aim_sync.camera_position, - sizeof(remote_aim.pos1)); - memcpy( - remote_aim.pos2, slot->aim_sync.camera_position, - sizeof(remote_aim.pos2)); - remote_aim_z = slot->aim_sync.aim_z; + remote_aim_built = samp_gta_camera_aim_build_r5( + slot->aim_sync.camera_front, slot->aim_sync.camera_position, + remote_aim.f1, remote_aim.pos1, remote_aim.pos2, remote_aim.f2); + if (remote_aim_built) { + remote_camera_mode = slot->aim_sync.camera_mode; + remote_zoom = + ((float)(slot->aim_sync.zoom_weapon_state & 0x3Fu) / 63.0f) * + 35.0f + + 35.0f; + /* + * STATIC_037: + * R5 samp.dll+0x15760 installs 1.0f + aspect_ratio / 255.0f at + * GTA USA 1.0 0xC3EFA4 and restores it at samp.dll+0x1582B. + * Binary SHA256: + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2 + */ + remote_aspect = + 1.0f + (float)slot->aim_sync.aspect_ratio / 255.0f; + remote_aim_z = slot->aim_sync.aim_z; + remote_camera_mode2 = + remote_camera_mode == SAMP_GTA_CAMERA_MODE_GAMEPLAY + ? 0u + : (uint16_t)remote_camera_mode; + } } - remote_camera_mode2 = - remote_camera_mode == SAMP_GTA_CAMERA_MODE_GAMEPLAY - ? 0u - : (uint16_t)remote_camera_mode; InterlockedIncrement( &g_runtime.remote_player_process_control_hook_active); @@ -14878,12 +16897,17 @@ static void SAMP_THISCALL remote_player_compat_process_control_hook( memcpy( (void *)SAMP_ADDR_CAMERA_EXT_ZOOM, &remote_zoom, sizeof(remote_zoom)); + memcpy( + (void *)SAMP_ADDR_CAMERA_EXT_ASPECT, &remote_aspect, + sizeof(remote_aspect)); memcpy( (void *)SAMP_ADDR_CAMERA_INTERNAL_AIM, &remote_aim, sizeof(remote_aim)); - memcpy( - (void *)(player_info + SAMP_PLAYER_INFO_OFFSET_AIM_Z), - &remote_aim_z, sizeof(remote_aim_z)); + if (remote_aim_built) { + memcpy( + (void *)(player_info + SAMP_PLAYER_INFO_OFFSET_AIM_Z), + &remote_aim_z, sizeof(remote_aim_z)); + } write_game_u8(SAMP_ADDR_CURRENT_PLAYER, gta_player_index); original(ped_ptr); @@ -14897,6 +16921,9 @@ static void SAMP_THISCALL remote_player_compat_process_control_hook( memcpy( (void *)SAMP_ADDR_CAMERA_EXT_ZOOM, &saved_zoom, sizeof(saved_zoom)); + memcpy( + (void *)SAMP_ADDR_CAMERA_EXT_ASPECT, &saved_aspect, + sizeof(saved_aspect)); memcpy( (void *)SAMP_ADDR_CAMERA_INTERNAL_AIM, &saved_aim, sizeof(saved_aim)); @@ -14915,11 +16942,15 @@ static void SAMP_THISCALL remote_player_compat_process_control_hook( runtime_tracef( "remote_process_control: call=%ld remote_call=%ld player=%u " "gta_slot=%u ped=0x%08lx keys=0x%04x mode=%u zoom=%.3f " - "aim_valid=%u evidence=STATIC_037,ALT_02X_CODE,PROBE_TRACE,TODO_VERIFY", + "aspect=%.6f " + "aim_valid=%u aim_built=%u " + "evidence=OBSERVED_037,STATIC_037,ALT_02X_CODE,PROBE_TRACE,TODO_VERIFY", (long)call, (long)remote_call, (unsigned)slot->player_id, (unsigned)gta_player_index, (unsigned long)ped, (unsigned)slot->keys, (unsigned)remote_camera_mode, - (double)remote_zoom, (unsigned)slot->aim_valid); + (double)remote_zoom, (double)remote_aspect, + (unsigned)slot->aim_valid, + (unsigned)remote_aim_built); } #else (void)ped_ptr; @@ -15148,6 +17179,7 @@ static void SAMP_THISCALL remote_vehicle_compat_process_control_hook( uint16_t saved_camera_mode2 = 0u; uint16_t remote_camera_mode2 = 0u; uint16_t vehicle_id = 0xFFFFu; + int remote_aim_built = 0; LONG call = 0; LONG remote_call = 0; @@ -15270,26 +17302,22 @@ static void SAMP_THISCALL remote_vehicle_compat_process_control_hook( return; } - memset(&remote_aim, 0, sizeof(remote_aim)); + remote_aim = saved_aim; + remote_camera_mode = saved_camera_mode; + remote_camera_mode2 = saved_camera_mode2; if (player_slot->aim_valid != 0u) { - remote_camera_mode = player_slot->aim_sync.camera_mode; - memcpy( - remote_aim.f1, player_slot->aim_sync.camera_front, - sizeof(remote_aim.f1)); - memcpy( - remote_aim.f2, player_slot->aim_sync.camera_front, - sizeof(remote_aim.f2)); - memcpy( - remote_aim.pos1, player_slot->aim_sync.camera_position, - sizeof(remote_aim.pos1)); - memcpy( - remote_aim.pos2, player_slot->aim_sync.camera_position, - sizeof(remote_aim.pos2)); + remote_aim_built = samp_gta_camera_aim_build_r5( + player_slot->aim_sync.camera_front, + player_slot->aim_sync.camera_position, remote_aim.f1, + remote_aim.pos1, remote_aim.pos2, remote_aim.f2); + if (remote_aim_built) { + remote_camera_mode = player_slot->aim_sync.camera_mode; + remote_camera_mode2 = + remote_camera_mode == SAMP_GTA_CAMERA_MODE_GAMEPLAY + ? 0u + : (uint16_t)remote_camera_mode; + } } - remote_camera_mode2 = - remote_camera_mode == SAMP_GTA_CAMERA_MODE_GAMEPLAY - ? 0u - : (uint16_t)remote_camera_mode; /* * STATIC_037 (samp.dll+0xA3100) + PROBE_TRACE: @@ -15407,8 +17435,9 @@ static void SAMP_THISCALL remote_vehicle_compat_process_control_hook( "remote_vehicle_process_control: call=%ld remote_call=%ld " "class=%s vehicle=%u ptr=0x%08lx player=%u gta_slot=%u " "driver=0x%08lx keys=0x%04x lr=%d ud=%d mode=%u " - "aim_valid=%u vehicle_flags=0x%02x control_flags=0x%02x " - "evidence=STATIC_037,PROBE_TRACE,TODO_VERIFY", + "aim_valid=%u aim_built=%u vehicle_flags=0x%02x " + "control_flags=0x%02x " + "evidence=OBSERVED_037,STATIC_037,PROBE_TRACE,TODO_VERIFY", (long)call, (long)remote_call, target->name, (unsigned)vehicle_id, (unsigned long)vehicle, @@ -15419,6 +17448,7 @@ static void SAMP_THISCALL remote_vehicle_compat_process_control_hook( (int)(int16_t)player_slot->up_down_keys, (unsigned)remote_camera_mode, (unsigned)player_slot->aim_valid, + (unsigned)remote_aim_built, (unsigned)vehicle_process_flags, (unsigned)process_control_flags); } @@ -15816,8 +17846,8 @@ static int remote_player_compat_create_gta_player( return 0; } - if (!gta_script_command_compat( - 0x09C7u, "ii", (int)gta_player_index, (int)slot->skin)) { + if (!gta_player_ped_set_loaded_model_direct_compat( + ped, slot->skin, "remote_gta_player_skin")) { cleanup_ok = remote_player_compat_destroy_gta_player( player_id, gta_player_index, actor_id, ped, "skin_apply"); if (cleanup_ok) { @@ -15827,13 +17857,11 @@ static int remote_player_compat_create_gta_player( runtime_tracef( "remote_player_gta_slot: create_fallback id=%u gta_slot=%u gta=%lu " "reason=set_player_skin_failed cleanup=%d " - "evidence=STATIC_037,TODO_VERIFY", + "evidence=STATIC_037:samp.dll+0x9EF50,+0xAFF50,TODO_VERIFY", (unsigned)player_id, (unsigned)gta_player_index, (unsigned long)actor_id, cleanup_ok); return 0; } - (void)gta_reset_ped_audio_attributes_compat( - ped, "remote_gta_player_skin"); slot->gta_player_index = gta_player_index; slot->gta_player_backed = 1u; @@ -16001,7 +18029,16 @@ static int remote_player_compat_create_slot(uint16_t player_id, samp_remote_play (void)remote_player_compat_apply_special_action_state(slot); InterlockedExchange(&slot->active, 1); InterlockedExchange(&slot->spawned, 1); - remote_player_compat_update_control_set(slot); + /* + * STATIC_037: + * Driver controls advance in the normal-driver Process branch, not while + * the GTA player wrapper is being constructed. Passenger playback performs + * its own single update after construction. Keep the bootstrap update only + * for an on-foot player so a fresh Packet 207 press is not lost. + */ + if (slot->vehicle_target_valid == 0u && slot->vehicle_seat == 0u) { + remote_player_compat_update_control_set(slot); + } if (slot->aim_valid != 0u) { (void)remote_player_compat_apply_aim_weapon_state(slot, &slot->aim_sync); if (slot->gta_player_backed != 0u && @@ -16061,6 +18098,7 @@ static void remote_player_compat_queue_add(const samp_raknet_remote_player_event slot->health = 100u; slot->armour = 0u; slot->vehicle_id = 0xFFFFu; + slot->trailer_id = 0xFFFFu; slot->skin = event->skin; slot->color = event->color; memcpy(slot->pos, event->pos, sizeof(slot->pos)); @@ -16260,6 +18298,7 @@ static int gta_ped_jetpack_task_compat(uintptr_t ped, uintptr_t *out_task_manage uintptr_t task_manager = 0u; uintptr_t task = 0u; uintptr_t vtable = 0u; + uint32_t ped_flags = 0u; if (out_task_manager != NULL) { *out_task_manager = 0u; @@ -16268,10 +18307,24 @@ static int gta_ped_jetpack_task_compat(uintptr_t ped, uintptr_t *out_task_manage *out_task = 0u; } if (ped < 0x10000u || ped >= 0x80000000u || + !memory_is_readable_compat((const void *)(ped + SAMP_PED_OFFSET_STATE_FLAGS), + sizeof(ped_flags)) || !memory_is_readable_compat((const void *)(ped + SAMP_PED_OFFSET_INTELLIGENCE), sizeof(intelligence))) { return 0; } + memcpy(&ped_flags, (const void *)(ped + SAMP_PED_OFFSET_STATE_FLAGS), + sizeof(ped_flags)); + /* + * STATIC_037: + * R5 samp.dll SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2, + * CPlayerPed::IsInJetpackMode at samp.dll+0xACDC0 rejects the task while + * CPed+0x46C bit 0x100 marks the ped as in a vehicle. StopJetpack at + * samp.dll+0xACD60 uses the same guard before touching the task slot. + */ + if ((ped_flags & SAMP_PED_STATE_IN_VEHICLE) != 0u) { + return 0; + } memcpy(&intelligence, (const void *)(ped + SAMP_PED_OFFSET_INTELLIGENCE), sizeof(intelligence)); if (intelligence < 0x10000u || intelligence >= 0x80000000u) { @@ -16334,40 +18387,51 @@ static int gta_ped_start_jetpack_compat(uintptr_t ped, uint8_t gta_player_index) typedef void(__cdecl *gta_jetpack_cheat_fn)(void); gta_jetpack_cheat_fn jetpack_cheat = (gta_jetpack_cheat_fn)(uintptr_t)SAMP_GTA_FUNC_CCHEAT_JETPACK; - uint8_t saved_current_player = 0u; float x = 0.0f; float y = 0.0f; float z = 0.0f; +#if defined(__i386__) || defined(_M_IX86) + uintptr_t vtable = 0u; + uintptr_t set_position = 0u; +#endif - if (gta_ped_jetpack_task_compat(ped, NULL, NULL)) { - return 1; - } if (ped < 0x10000u || ped >= 0x80000000u || !gta_code_ptr_compat((uintptr_t)jetpack_cheat) || !memory_is_readable_compat((const void *)SAMP_ADDR_CURRENT_PLAYER, - sizeof(saved_current_player)) || + sizeof(uint8_t)) || !memory_is_writable_compat((void *)SAMP_ADDR_CURRENT_PLAYER, - sizeof(saved_current_player)) || + sizeof(uint8_t)) || !gta_entity_read_position_compat(ped, &x, &y, &z)) { return 0; } /* * STATIC_037: + * R5 samp.dll SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2, * R5 CPlayerPed::StartJetpack at samp.dll+0xACD10 temporarily selects the - * CPlayerPed GTA slot, teleports to its current matrix position, calls - * CCheat::JetpackCheat at GTA 0x439600 and restores player slot zero. - * Preserve the actual prior selector because our remote ProcessControl hook - * may be active while a sync state is applied. + * CPlayerPed GTA slot before dispatching SetPosition through the ped's + * vtable+0x38 with its current matrix position. It then calls + * CCheat::JetpackCheat at GTA 0x439600 exactly once and resets the selector + * to slot zero. Task creation is asynchronous from this wrapper's point of + * view, so dispatch success must not depend on an immediate task readback. */ - memcpy(&saved_current_player, (const void *)SAMP_ADDR_CURRENT_PLAYER, - sizeof(saved_current_player)); - if (!gta_entity_teleport_compat(ped, x, y, z)) { - return 0; - } write_game_u8(SAMP_ADDR_CURRENT_PLAYER, gta_player_index); +#if defined(__i386__) || defined(_M_IX86) + if (memory_is_readable_compat((const void *)ped, sizeof(vtable))) { + memcpy(&vtable, (const void *)ped, sizeof(vtable)); + if (gta_vtable_ptr_compat(vtable) && + memory_is_readable_compat((const void *)(vtable + 0x38u), + sizeof(set_position))) { + memcpy(&set_position, (const void *)(vtable + 0x38u), + sizeof(set_position)); + if (gta_code_ptr_compat(set_position)) { + ((gta_entity_teleport_method_fn)set_position)((void *)ped, x, y, z, 0); + } + } + } +#endif jetpack_cheat(); - write_game_u8(SAMP_ADDR_CURRENT_PLAYER, saved_current_player); - return gta_ped_jetpack_task_compat(ped, NULL, NULL); + write_game_u8(SAMP_ADDR_CURRENT_PLAYER, 0u); + return 1; } static int remote_player_compat_apply_special_action_state(samp_remote_player_slot_compat *slot) { @@ -16779,7 +18843,6 @@ static uintptr_t remote_bullet_resolve_target_compat( const samp_raknet_remote_bullet_sync *sync) { samp_remote_player_slot_compat *player_slot = NULL; samp_vehicle_slot_compat *vehicle_slot = NULL; - samp_object_slot_compat *object_slot = NULL; if (sync == NULL) { return 0u; @@ -16808,16 +18871,14 @@ static uintptr_t remote_bullet_resolve_target_compat( return 0u; } return vehicle_compat_game_pool_get_at(vehicle_slot->gta_id); - case 3u: - if (!object_compat_id_valid(sync->hit_id)) { - return 0u; - } - object_slot = &g_runtime.object_slots[sync->hit_id]; - if (InterlockedCompareExchange(&object_slot->active, 0, 0) == 0) { - return 0u; - } - return object_slot->entity; default: + /* + * OBSERVED_037 + PROBE_TRACE + STATIC_037: + * samp.dll+0x1646C..+0x16513 resolves an entity only for HitType 1 + * (player) and 2 (vehicle). All 24 captured HitType-3 object shots kept + * a null target even though ObjectID 11 existed. Player objects follow + * the same null-target path. + */ return 0u; } } @@ -17037,6 +19098,9 @@ static int remote_bullet_apply_effect_compat( uintptr_t target = 0u; samp_gta_vector origin; samp_gta_vector muzzle; + samp_gta_camera_aim_compat saved_aim; + samp_gta_camera_aim_compat remote_aim; + int aim_installed = 0; int invoked = 0; if (!remote_bullet_sync_plausible_compat(sync) || @@ -17087,6 +19151,34 @@ static int remote_bullet_apply_effect_compat( return 0; } + /* + * OBSERVED_037 + PROBE_TRACE + STATIC_037: + * Every one of the 29 captured R5 shots installed the shooter's complete + * 0x30 aim context at samp.dll+0xAFAFA before entering GTA fire, then + * restored the previous bytes at +0xAFC52. R5's +0x9C940/+0x9C9C0/ + * +0x9C960 helpers operate on exactly these 0x30 bytes. + * + * Other R5 fire-scope helpers also exchange player/camera/weapon-skill + * state. Their layouts remain TODO_VERIFY; keep this proven scope narrow. + */ + if (slot->aim_valid != 0u && + memory_is_readable_compat( + (const void *)SAMP_ADDR_CAMERA_INTERNAL_AIM, + sizeof(saved_aim)) && + memory_is_writable_compat( + (void *)SAMP_ADDR_CAMERA_INTERNAL_AIM, sizeof(saved_aim)) && + samp_gta_camera_aim_build_r5( + slot->aim_sync.camera_front, slot->aim_sync.camera_position, + remote_aim.f1, remote_aim.pos1, remote_aim.pos2, remote_aim.f2)) { + memcpy( + &saved_aim, (const void *)SAMP_ADDR_CAMERA_INTERNAL_AIM, + sizeof(saved_aim)); + memcpy( + (void *)SAMP_ADDR_CAMERA_INTERNAL_AIM, &remote_aim, + sizeof(remote_aim)); + aim_installed = 1; + } + /* * STATIC_037 + GTA_REVERSED_REF: * R5 samp.dll+0xAFA70 uses FireSniper for weapon 34. All other instant-hit @@ -17106,12 +19198,24 @@ static int remote_bullet_apply_effect_compat( invoked = 1; } + if (aim_installed) { + memcpy( + (void *)SAMP_ADDR_CAMERA_INTERNAL_AIM, &saved_aim, + sizeof(saved_aim)); + } InterlockedExchange(&g_runtime.remote_bullet_effect_active, 0); g_runtime.remote_bullet_effect_thread_id = 0u; g_runtime.remote_bullet_effect_shooter = 0u; g_runtime.remote_bullet_effect_target = 0u; memset(g_runtime.remote_bullet_effect_offset, 0, sizeof(g_runtime.remote_bullet_effect_offset)); + if (sync->seq <= 3u || (sync->seq % 32u) == 0u) { + runtime_tracef( + "remote_bullet_aim_scope: seq=%lu player=%u installed=%u " + "restored=%u evidence=OBSERVED_037,PROBE_TRACE,STATIC_037", + (unsigned long)sync->seq, (unsigned)sync->player_id, + (unsigned)aim_installed, (unsigned)aim_installed); + } /* * STATIC_037: * R5 ignores the GTA weapon method's bool return. "Applied" therefore @@ -17160,21 +19264,193 @@ static void remote_player_compat_observe_bullet_sync( } } -static void remote_player_compat_apply_vehicle_sync(const samp_raknet_remote_vehicle_sync *sync) { +typedef enum samp_remote_vehicle_position_mode_compat { + SAMP_REMOTE_VEHICLE_POSITION_FAILED = 0, + SAMP_REMOTE_VEHICLE_POSITION_NOT_ADDED, + SAMP_REMOTE_VEHICLE_POSITION_NOOP, + SAMP_REMOTE_VEHICLE_POSITION_CORRECT, + SAMP_REMOTE_VEHICLE_POSITION_SNAP, + SAMP_REMOTE_VEHICLE_POSITION_TRAIN +} samp_remote_vehicle_position_mode_compat; + +static const char *remote_vehicle_position_mode_name_compat( + samp_remote_vehicle_position_mode_compat mode) { + switch (mode) { + case SAMP_REMOTE_VEHICLE_POSITION_NOT_ADDED: + return "not_added"; + case SAMP_REMOTE_VEHICLE_POSITION_NOOP: + return "noop"; + case SAMP_REMOTE_VEHICLE_POSITION_CORRECT: + return "correct"; + case SAMP_REMOTE_VEHICLE_POSITION_SNAP: + return "snap"; + case SAMP_REMOTE_VEHICLE_POSITION_TRAIN: + return "train"; + default: + return "failed"; + } +} + +static int remote_vehicle_normalize_quaternion_compat( + const float input[4], float output[4]) { + return samp_gta_quaternion_normalize_wxyz(input, output); +} + +static int remote_vehicle_slerp_quaternion_compat( + const float current[4], const float target[4], float amount, + float output[4]) { + return samp_gta_quaternion_slerp_wxyz( + current, target, amount, output); +} + +static int remote_vehicle_live_model_compat( + uintptr_t vehicle, const samp_vehicle_slot_compat *slot, + uint16_t *model_out) { + uint16_t model = 0u; + + if (model_out == NULL) { + return 0; + } + if (vehicle >= 0x10000u && vehicle < 0x80000000u && + memory_is_readable_compat( + (const void *)(vehicle + SAMP_ENTITY_OFFSET_MODEL_INDEX), + sizeof(model))) { + memcpy(&model, + (const void *)(vehicle + SAMP_ENTITY_OFFSET_MODEL_INDEX), + sizeof(model)); + } else if (slot != NULL && slot->model >= 0 && slot->model <= 65535) { + model = (uint16_t)slot->model; + } else { + return 0; + } + *model_out = model; + return 1; +} + +static int remote_vehicle_is_train_compat( + uintptr_t vehicle, const samp_vehicle_slot_compat *slot) { + uint16_t model = 0u; + + if (!remote_vehicle_live_model_compat(vehicle, slot, &model)) { + return 0; + } + return model == 449u || model == 537u || model == 538u; +} + +static int remote_vehicle_entity_is_added_compat(uintptr_t vehicle) { + uintptr_t vtable = 0u; + uint32_t world_link = 0u; + + if (!vehicle_compat_read_vtable(vehicle, &vtable) || + vtable == (uintptr_t)SAMP_GTA_VTBL_CPLACEABLE || + !memory_is_readable_compat( + (const void *)(vehicle + SAMP_ENTITY_OFFSET_WORLD_LINK), + sizeof(world_link))) { + return 0; + } + memcpy(&world_link, + (const void *)(vehicle + SAMP_ENTITY_OFFSET_WORLD_LINK), + sizeof(world_link)); + return world_link != 0u; +} + +static float remote_vehicle_position_snap_z_compat(uintptr_t vehicle) { + uintptr_t vtable = 0u; + + if (vehicle_compat_read_vtable(vehicle, &vtable) && + (vtable == (uintptr_t)SAMP_GTA_VTBL_CHELI || + vtable == (uintptr_t)SAMP_GTA_VTBL_CBOAT || + vtable == (uintptr_t)SAMP_GTA_VTBL_CPLANE)) { + return SAMP_REMOTE_VEHICLE_POSITION_SNAP_Z_AIR_WATER; + } + return SAMP_REMOTE_VEHICLE_POSITION_SNAP_Z; +} + +static samp_remote_vehicle_position_mode_compat +remote_vehicle_apply_position_r5_compat( + uintptr_t vehicle, const float target_position[3], + const float target_speed[3], float delta_out[3]) { + float current_position[3] = {0.0f, 0.0f, 0.0f}; + float corrected_speed[3] = {0.0f, 0.0f, 0.0f}; + float snap_z = 0.0f; + int should_write_correction = 0; + samp_remote_vehicle_position_decision_compat decision = + SAMP_REMOTE_VEHICLE_DECISION_INVALID; + + if (target_position == NULL || target_speed == NULL || + delta_out == NULL || + !gta_entity_write_move_speed_compat(vehicle, target_speed) || + !gta_entity_read_position_compat( + vehicle, ¤t_position[0], ¤t_position[1], + ¤t_position[2])) { + return SAMP_REMOTE_VEHICLE_POSITION_FAILED; + } + snap_z = remote_vehicle_position_snap_z_compat(vehicle); + decision = samp_remote_vehicle_position_decision_r5( + current_position, target_position, target_speed, snap_z, delta_out, + corrected_speed, &should_write_correction); + if (decision == SAMP_REMOTE_VEHICLE_DECISION_INVALID) { + return SAMP_REMOTE_VEHICLE_POSITION_FAILED; + } + + /* + * STATIC_037: + * This is samp.dll+0x15140 after +0x155E0 cached the target quaternion, + * position and speed and called the exact move-speed setter. It deliberately + * does not call ApplyMoveSpeed or CEntity::UpdateRwFrame. + */ + if (!remote_vehicle_entity_is_added_compat(vehicle)) { + return gta_entity_write_position_preserve_speed_compat( + vehicle, target_position) + ? SAMP_REMOTE_VEHICLE_POSITION_NOT_ADDED + : SAMP_REMOTE_VEHICLE_POSITION_FAILED; + } + if (decision == SAMP_REMOTE_VEHICLE_DECISION_NOOP) { + return SAMP_REMOTE_VEHICLE_POSITION_NOOP; + } + + if (decision == SAMP_REMOTE_VEHICLE_DECISION_SNAP) { + if (!gta_entity_write_position_preserve_speed_compat( + vehicle, target_position) || + !gta_entity_write_move_speed_compat(vehicle, target_speed)) { + return SAMP_REMOTE_VEHICLE_POSITION_FAILED; + } + return SAMP_REMOTE_VEHICLE_POSITION_SNAP; + } + + if (should_write_correction && + !gta_entity_write_move_speed_compat(vehicle, corrected_speed)) { + return SAMP_REMOTE_VEHICLE_POSITION_FAILED; + } + return SAMP_REMOTE_VEHICLE_POSITION_CORRECT; +} + +static samp_remote_edge_apply_result_compat +remote_player_compat_apply_vehicle_sync( + const samp_raknet_remote_vehicle_sync *sync) { samp_remote_player_slot_compat *player_slot = NULL; samp_vehicle_slot_compat *vehicle_slot = NULL; uintptr_t vehicle = 0u; uintptr_t ped = 0u; uint32_t driver32 = 0u; + uint32_t live_trailer32 = 0u; + uint16_t previous_trailer_id = 0xFFFFu; + float normalized_quaternion[4] = {1.0f, 0.0f, 0.0f, 0.0f}; + float position_delta[3] = {0.0f, 0.0f, 0.0f}; float yaw = 0.0f; + samp_remote_vehicle_position_mode_compat position_mode = + SAMP_REMOTE_VEHICLE_POSITION_FAILED; if (sync == NULL || !remote_player_compat_id_valid(sync->player_id) || remote_player_compat_is_local(sync->player_id) || !vehicle_compat_id_valid(sync->vehicle_id) || - !remote_player_compat_vec_plausible(sync->position)) { - return; + !remote_player_compat_vec_plausible(sync->position) || + !remote_edge_speed_plausible_compat(sync->move_speed, 100.0f) || + !remote_vehicle_normalize_quaternion_compat( + sync->rotation, normalized_quaternion)) { + return SAMP_REMOTE_EDGE_DROP; } - yaw = remote_player_compat_vehicle_yaw(sync->rotation); + yaw = remote_player_compat_vehicle_yaw(normalized_quaternion); player_slot = &g_runtime.remote_player_slots[sync->player_id]; if (InterlockedCompareExchange(&player_slot->active, 0, 0) == 0 && InterlockedCompareExchange(&player_slot->pending, 0, 0) == 0) { @@ -17184,10 +19460,11 @@ static void remote_player_compat_apply_vehicle_sync(const samp_raknet_remote_veh "evidence=OPENMP_REF,PROBE_TRACE,TODO_VERIFY", (unsigned)sync->player_id, (unsigned)sync->vehicle_id); } - return; + return SAMP_REMOTE_EDGE_DROP; } } + previous_trailer_id = player_slot->trailer_id; player_slot->sync_seq = sync->seq; player_slot->health = sync->health; player_slot->armour = sync->armour; @@ -17196,25 +19473,80 @@ static void remote_player_compat_apply_vehicle_sync(const samp_raknet_remote_veh player_slot->up_down_keys = sync->up_down_keys; player_slot->keys = sync->keys; player_slot->vehicle_id = sync->vehicle_id; + player_slot->trailer_id = sync->trailer_id; player_slot->vehicle_seat = 0u; + player_slot->in_vehicle = 0u; + player_slot->drive_by = 0u; + player_slot->cuffed = 0u; + player_slot->additional_key = sync->additional_key; memcpy(player_slot->pos, sync->position, sizeof(player_slot->pos)); player_slot->rotation = yaw; memcpy(player_slot->move_speed, sync->move_speed, sizeof(player_slot->move_speed)); + memcpy(player_slot->vehicle_target_quaternion, normalized_quaternion, + sizeof(player_slot->vehicle_target_quaternion)); + memcpy(player_slot->vehicle_target_position, sync->position, + sizeof(player_slot->vehicle_target_position)); + memcpy(player_slot->vehicle_target_speed, sync->move_speed, + sizeof(player_slot->vehicle_target_speed)); + player_slot->vehicle_target_valid = 1u; player_slot->last_sync_tick = GetTickCount(); if (InterlockedCompareExchange(&player_slot->active, 0, 0) == 0 && !remote_player_compat_create_slot(sync->player_id, player_slot)) { - return; + return SAMP_REMOTE_EDGE_DEFER; } - remote_player_compat_update_control_set(player_slot); remote_player_compat_stop_aim_task(player_slot, "vehicle_sync"); if (!vehicle_compat_ensure_active(sync->vehicle_id)) { runtime_tracef("remote_vehicle: sync_deferred seq=%lu player=%u vehicle=%u reason=vehicle_inactive", (unsigned long)sync->seq, (unsigned)sync->player_id, (unsigned)sync->vehicle_id); - return; + return InterlockedCompareExchange( + &g_runtime.vehicle_slots[sync->vehicle_id].pending, 0, 0) != 0 + ? SAMP_REMOTE_EDGE_DEFER + : SAMP_REMOTE_EDGE_DROP; + } + if (vehicle_compat_id_valid(sync->trailer_id) && + sync->trailer_id != 0u && sync->trailer_id != 0xFFFFu && + !vehicle_compat_ensure_active(sync->trailer_id)) { + runtime_tracef( + "remote_vehicle: sync_deferred seq=%lu player=%u vehicle=%u " + "trailer=%u reason=trailer_inactive", + (unsigned long)sync->seq, (unsigned)sync->player_id, + (unsigned)sync->vehicle_id, (unsigned)sync->trailer_id); + return InterlockedCompareExchange( + &g_runtime.vehicle_slots[sync->trailer_id].pending, 0, 0) != 0 + ? SAMP_REMOTE_EDGE_DEFER + : SAMP_REMOTE_EDGE_DROP; } vehicle_slot = &g_runtime.vehicle_slots[sync->vehicle_id]; + if (samp_vehicle_attach_blocks_driver_sync( + vehicle_slot->trailer_attach_timing_seq, + vehicle_slot->trailer_attach_timing_vehicle_id, + sync->trailer_id)) { + /* + * OBSERVED_037 + PROBE_TRACE: + * The R5 run reaches RPC 148 / SetTowLink while both GTA vehicles are + * still ABANDONED (raw packed status 0x22). Paced replacement creation + * can leave the older RPC queued while Packet 200 is already present in + * the aggregate snapshot. Applying that packet here would run opcode + * 036A first and change the tractor to PLAYER (0x02), which changes + * SetTowLink's input state and placement. Preserve the observed receive + * order by retaining the movement cursor until the queued association is + * applied; vehicle_compat_update_from_snapshot() runs before this unified + * movement drain and clears the timing marker on success. + */ + if (vehicle_slot->trailer_attach_timing_logged < 2u) { + vehicle_slot->trailer_attach_timing_logged = 2u; + runtime_tracef( + "remote_vehicle: sync_deferred seq=%lu player=%u vehicle=%u " + "trailer=%u reason=preceding_rpc148_pending rpc_seq=%lu " + "evidence=OBSERVED_037,PROBE_TRACE:receive_order", + (unsigned long)sync->seq, (unsigned)sync->player_id, + (unsigned)sync->vehicle_id, (unsigned)sync->trailer_id, + (unsigned long)vehicle_slot->trailer_attach_timing_seq); + } + return SAMP_REMOTE_EDGE_DEFER; + } vehicle = vehicle_compat_game_pool_get_at(vehicle_slot->gta_id); ped = remote_player_compat_resolve_ped(player_slot); if (vehicle == 0u || ped == 0u) { @@ -17222,30 +19554,60 @@ static void remote_player_compat_apply_vehicle_sync(const samp_raknet_remote_veh "ped=0x%08lx ptr=0x%08lx reason=pool_lookup", (unsigned long)sync->seq, (unsigned)sync->player_id, (unsigned)sync->vehicle_id, (unsigned long)vehicle_slot->gta_id, (unsigned long)ped, (unsigned long)vehicle); - return; + return SAMP_REMOTE_EDGE_DEFER; } /* - * OPENMP_REF + PROBE_TRACE + TODO_VERIFY: - * Packet 200 is now decoded from the observed 0.3.7 server layout. Until the - * original remote-driver ProcessControl context swap is reproduced, apply - * the authoritative transform and velocity directly and keep the streamed - * remote ped in the driver seat. This restores visible remote driving while - * preserving a clear boundary around the still-missing control hook. + * STATIC_037: + * Packet 200 reaches samp.dll+0x17340, then the fresh driver branch at + * +0x168E7 normalizes the complete quaternion and calls +0x155E0 followed + * by +0x15140. Keep this playback independent from the optional vehicle + * ProcessControl context hook: that hook models samp.dll+0xA3100 and does + * not own packet transform correction. */ memcpy(vehicle_slot->pos, sync->position, sizeof(vehicle_slot->pos)); vehicle_slot->rotation = yaw; vehicle_slot->health = (float)sync->vehicle_health; - (void)gta_entity_direct_position_compat(vehicle, sync->position[0], sync->position[1], sync->position[2]); - (void)gta_entity_write_move_speed_compat(vehicle, sync->move_speed); - (void)gta_entity_apply_move_speed_compat(vehicle); - (void)gta_script_command_compat(0x0175u, "if", (int)vehicle_slot->gta_id, yaw); + vehicle_slot->remote_unoccupied = 0u; + vehicle_slot->unoccupied_owner_id = 0xFFFFu; + if (remote_vehicle_is_train_compat(vehicle, vehicle_slot)) { + position_mode = + gta_entity_write_position_preserve_speed_compat( + vehicle, sync->position) && + gta_entity_write_move_speed_compat( + vehicle, sync->move_speed) + ? SAMP_REMOTE_VEHICLE_POSITION_TRAIN + : SAMP_REMOTE_VEHICLE_POSITION_FAILED; + } else { + position_mode = remote_vehicle_apply_position_r5_compat( + vehicle, sync->position, sync->move_speed, position_delta); + } (void)vehicle_compat_write_float(vehicle, SAMP_VEHICLE_OFFSET_HEALTH, (float)sync->vehicle_health); + if (InterlockedCompareExchange(&g_runtime.gta_version, 0, 0) == + SAMP_GTA_VERSION_USA10) { + uint8_t siren_flags = 0u; + if (vehicle_compat_read_u8( + vehicle, SAMP_VEHICLE_OFFSET_SIREN_FLAGS, &siren_flags)) { + /* + * STATIC_037: + * The fresh Packet-200 driver branch loads byte +0x50 and calls + * samp.dll+0xB7540 at +0x16A13. The setter preserves bits 0..6 of + * GTA CVehicle+0x42D and replaces bit 7 with the wire byte's low bit. + */ + siren_flags = + (uint8_t)((siren_flags & 0x7Fu) | + ((sync->siren & 1u) << 7u)); + (void)vehicle_compat_write_u8( + vehicle, SAMP_VEHICLE_OFFSET_SIREN_FLAGS, siren_flags); + } + } (void)vehicle_compat_read_u32(vehicle, SAMP_VEHICLE_OFFSET_DRIVER, &driver32); if ((uintptr_t)driver32 == ped) { player_slot->in_vehicle = 1u; } else if (driver32 == 0u) { + vehicle_compat_trace_lifecycle_state( + "packet200_pre_seat", sync->seq, sync->vehicle_id, vehicle); if (gta_script_command_compat(0x036Au, "ii", (int)player_slot->gta_id, (int)vehicle_slot->gta_id)) { player_slot->in_vehicle = 1u; } else { @@ -17254,6 +19616,8 @@ static void remote_player_compat_apply_vehicle_sync(const samp_raknet_remote_veh (unsigned long)player_slot->gta_id, (unsigned)sync->vehicle_id, (unsigned long)vehicle_slot->gta_id); } + vehicle_compat_trace_lifecycle_state( + "packet200_post_seat", sync->seq, sync->vehicle_id, vehicle); } else if (sync->seq <= 3u || (sync->seq % 64u) == 0u) { runtime_tracef("remote_vehicle: driver_occupied seq=%lu player=%u vehicle=%u driver=0x%08lx ped=0x%08lx " "evidence=PROBE_TRACE,TODO_VERIFY", @@ -17261,17 +19625,991 @@ static void remote_player_compat_apply_vehicle_sync(const samp_raknet_remote_veh (unsigned long)driver32, (unsigned long)ped); } + /* + * OBSERVED_037 + PROBE_TRACE: + * In the golden run Packet 200 continues to advertise the trailer after GTA + * loses the live association, but does not call SetTowLink. The following + * Packet 210 observes association=0 and owns the single transition attach. + */ + if (!vehicle_compat_read_u32(vehicle, SAMP_VEHICLE_OFFSET_TRAILER, + &live_trailer32)) { + return SAMP_REMOTE_EDGE_DEFER; + } + if (vehicle_compat_id_valid(sync->trailer_id) && + sync->trailer_id != 0u && sync->trailer_id != 0xFFFFu && + vehicle_compat_ensure_active(sync->trailer_id)) { + samp_vehicle_slot_compat *trailer_slot = + &g_runtime.vehicle_slots[sync->trailer_id]; + uintptr_t trailer = + vehicle_compat_game_pool_get_at(trailer_slot->gta_id); + if (trailer == 0u) { + return SAMP_REMOTE_EDGE_DEFER; + } + vehicle_slot->trailer_vehicle_id = sync->trailer_id; + vehicle_slot->trailer_attached = + (uintptr_t)live_trailer32 == trailer ? 1u : 0u; + if ((uintptr_t)live_trailer32 != trailer && + (sync->seq <= 3u || (sync->seq % 64u) == 0u)) { + runtime_tracef( + "remote_vehicle: trailer_transition_pending seq=%lu player=%u " + "vehicle=%u trailer=%u live=0x%08lx expected=0x%08lx " + "action=wait_packet210 evidence=OBSERVED_037,PROBE_TRACE", + (unsigned long)sync->seq, (unsigned)sync->player_id, + (unsigned)sync->vehicle_id, (unsigned)sync->trailer_id, + (unsigned long)(uintptr_t)live_trailer32, + (unsigned long)trailer); + } + } else { + uint16_t attached_id = vehicle_slot->trailer_attached != 0u + ? vehicle_slot->trailer_vehicle_id + : previous_trailer_id; + if (live_trailer32 != 0u && vehicle_compat_id_valid(attached_id) && + attached_id != 0u && attached_id != 0xFFFFu) { + samp_vehicle_slot_compat *trailer_slot = + &g_runtime.vehicle_slots[attached_id]; + if (InterlockedCompareExchange(&trailer_slot->active, 0, 0) != 0 && + trailer_slot->gta_id != 0u) { + (void)gta_script_command_compat(0x07ACu, "ii", + (int)trailer_slot->gta_id, + (int)vehicle_slot->gta_id); + } + } + vehicle_slot->trailer_vehicle_id = 0u; + vehicle_slot->trailer_attached = 0u; + vehicle_compat_clear_attach_timing(vehicle_slot); + vehicle_compat_clear_trailer_transition(vehicle_slot); + } + remote_player_compat_apply_health_state(player_slot); (void)remote_player_compat_apply_weapon_state(player_slot); - if (sync->seq <= 3u || (sync->seq % 64u) == 0u) { - runtime_tracef("remote_vehicle: apply seq=%lu player=%u vehicle=%u gta=%lu pos=(%.3f,%.3f,%.3f) " - "yaw=%.3f speed=(%.3f,%.3f,%.3f) seated=%u evidence=OPENMP_REF,PROBE_TRACE", + if (sync->seq <= 3u || (sync->seq % 64u) == 0u || + position_mode == SAMP_REMOTE_VEHICLE_POSITION_FAILED) { + runtime_tracef("remote_vehicle: apply seq=%lu player=%u vehicle=%u gta=%lu " + "target_pos=(%.3f,%.3f,%.3f) delta=(%.3f,%.3f,%.3f) " + "target_quat=(%.5f,%.5f,%.5f,%.5f) " + "speed=(%.3f,%.3f,%.3f) position_mode=%s seated=%u " + "evidence=STATIC_037:samp.dll+0x15140,+0x155E0,+0x168E7", (unsigned long)sync->seq, (unsigned)sync->player_id, (unsigned)sync->vehicle_id, (unsigned long)vehicle_slot->gta_id, (double)sync->position[0], - (double)sync->position[1], (double)sync->position[2], (double)yaw, + (double)sync->position[1], (double)sync->position[2], + (double)position_delta[0], (double)position_delta[1], + (double)position_delta[2], + (double)normalized_quaternion[0], + (double)normalized_quaternion[1], + (double)normalized_quaternion[2], + (double)normalized_quaternion[3], (double)sync->move_speed[0], (double)sync->move_speed[1], - (double)sync->move_speed[2], (unsigned)player_slot->in_vehicle); + (double)sync->move_speed[2], + remote_vehicle_position_mode_name_compat(position_mode), + (unsigned)player_slot->in_vehicle); + } + return position_mode == SAMP_REMOTE_VEHICLE_POSITION_FAILED + ? SAMP_REMOTE_EDGE_DEFER + : SAMP_REMOTE_EDGE_APPLIED; +} + +static int remote_edge_speed_plausible_compat(const float speed[3], + float limit) { + unsigned int i = 0u; + if (speed == NULL || !isfinite(limit) || limit <= 0.0f) { + return 0; + } + for (i = 0u; i < 3u; ++i) { + if (!isfinite(speed[i]) || speed[i] <= -limit || speed[i] >= limit) { + return 0; + } + } + return 1; +} + +static void remote_player_compat_trace_passenger_apply( + const samp_raknet_remote_passenger_sync *event, + const samp_remote_player_slot_compat *player_slot, + uint8_t seat, + const char *mode, + int seat_read_ok) { + const samp_raknet_passenger_sync *sync = NULL; + if (event == NULL || player_slot == NULL || + !(event->seq <= 3u || (event->seq % 64u) == 0u)) { + return; + } + sync = &event->sync; + runtime_tracef( + "remote_passenger: apply seq=%lu player=%u vehicle=%u seat=%u " + "drive_by=%u cuffed=%u weapon=%u mode=%s seated=%u seat_read=%u " + "evidence=STATIC_037:samp.dll+0x17440,TODO_VERIFY:live_seat", + (unsigned long)event->seq, (unsigned)event->player_id, + (unsigned)sync->vehicle_id, (unsigned)seat, + (unsigned)player_slot->drive_by, (unsigned)player_slot->cuffed, + (unsigned)player_slot->current_weapon, + mode != NULL ? mode : "unknown", + (unsigned)player_slot->in_vehicle, (unsigned)seat_read_ok); +} + +static void remote_player_compat_trace_passenger_lifecycle_state( + const samp_raknet_remote_passenger_sync *event, uintptr_t ped, + uintptr_t vehicle, uint8_t seat, const char *phase) { + uint32_t ped_vehicle = 0u; + uint32_t seat_offset = 0u; + uint32_t seat_occupant = 0u; + uint32_t entity_flags = 0u; + uint32_t vehicle_flags = 0u; + uint32_t driver = 0u; + uint32_t valid = 0u; + uint8_t status = 0u; + uint8_t fake_physics = 0u; + + if (event == NULL || + !(event->seq <= 3u || (event->seq % 64u) == 0u)) { + return; + } + + /* + * OBSERVED_037 + PROBE_TRACE: + * Original run 20260728-122135-windows-sync-edge-passenger-1340453 + * captured the remote-sync PutDirectlyInVehicle transition with the ped + * vehicle link at +0x58C, passenger slot at +0x464+(seat-1)*4 and the GTA + * vehicle fields below. Keep this replacement-only probe read-only and + * tightly rate-limited so its pre/post lines can be normalized directly + * against vehicle_lifecycle_state_r5. + */ + if (ped >= 0x10000u && ped < 0x80000000u && + memory_is_readable_compat( + (const void *)(ped + SAMP_PED_OFFSET_VEHICLE), + sizeof(ped_vehicle))) { + memcpy(&ped_vehicle, (const void *)(ped + SAMP_PED_OFFSET_VEHICLE), + sizeof(ped_vehicle)); + valid |= 0x001u; + } + if (seat > 0u && seat <= SAMP_VEHICLE_PASSENGER_COUNT) { + seat_offset = + SAMP_VEHICLE_OFFSET_PASSENGERS + + ((uint32_t)(seat - 1u) * sizeof(uint32_t)); + } + if (seat_offset != 0u && + vehicle_compat_read_u32( + vehicle, seat_offset, &seat_occupant)) { + valid |= 0x002u; + } + valid |= vehicle_compat_read_u32( + vehicle, SAMP_ENTITY_OFFSET_RENDER_FLAGS, &entity_flags) + ? 0x004u + : 0u; + valid |= vehicle_compat_read_u8( + vehicle, SAMP_ENTITY_OFFSET_CONTROL_FLAGS, &status) + ? 0x008u + : 0u; + valid |= vehicle_compat_read_u8( + vehicle, SAMP_PHYSICAL_OFFSET_FAKE_PHYSICS, &fake_physics) + ? 0x010u + : 0u; + valid |= vehicle_compat_read_u32( + vehicle, SAMP_VEHICLE_OFFSET_PROCESS_FLAGS, &vehicle_flags) + ? 0x020u + : 0u; + valid |= vehicle_compat_read_u32( + vehicle, SAMP_VEHICLE_OFFSET_DRIVER, &driver) + ? 0x040u + : 0u; + + runtime_tracef( + "vehicle_lifecycle_state_replacement: seq=%lu " + "event=remote_passenger phase=%s player=%u vehicle_id=%u seat=%u " + "gta_ped=0x%08lx ped_vehicle_58c=0x%08lx " + "seat_offset=0x%03lx seat_occupant=0x%08lx occupant_matches_ped=%u " + "gta_vehicle=0x%08lx valid=0x%03lx " + "entity_flags_1c=0x%08lx status_36=0x%02x fake_b8=0x%02x " + "vehicle_flags_428=0x%08lx driver_460=0x%08lx " + "evidence=OBSERVED_037,PROBE_TRACE,STATIC_037", + (unsigned long)event->seq, phase != NULL ? phase : "unknown", + (unsigned)event->player_id, (unsigned)event->sync.vehicle_id, + (unsigned)seat, (unsigned long)ped, (unsigned long)ped_vehicle, + (unsigned long)seat_offset, + (unsigned long)seat_occupant, + ped != 0u && (uintptr_t)seat_occupant == ped ? 1u : 0u, + (unsigned long)vehicle, (unsigned long)valid, + (unsigned long)entity_flags, (unsigned)status, + (unsigned)fake_physics, (unsigned long)vehicle_flags, + (unsigned long)driver); +} + +static samp_remote_edge_apply_result_compat +remote_player_compat_apply_passenger_sync( + const samp_raknet_remote_passenger_sync *event) { + const samp_raknet_passenger_sync *sync = NULL; + samp_remote_player_slot_compat *player_slot = NULL; + samp_vehicle_slot_compat *vehicle_slot = NULL; + uintptr_t ped = 0u; + uintptr_t current_vehicle = 0u; + uintptr_t vehicle = 0u; + uint32_t occupant32 = 0u; + uint8_t seat = 0u; + + if (event == NULL || + !remote_player_compat_id_valid(event->player_id) || + remote_player_compat_is_local(event->player_id)) { + return SAMP_REMOTE_EDGE_DROP; + } + sync = &event->sync; + seat = (uint8_t)(sync->seat_flags & 0x3Fu); + if (!vehicle_compat_id_valid(sync->vehicle_id) || seat == 0u || + seat > SAMP_VEHICLE_PASSENGER_COUNT || + !remote_player_compat_vec_plausible(sync->position)) { + runtime_tracef( + "remote_passenger: drop seq=%lu player=%u vehicle=%u seat=%u " + "reason=invalid_payload evidence=STATIC_037:samp.dll+0x17440," + "TODO_VERIFY:safety", + (unsigned long)event->seq, (unsigned)event->player_id, + (unsigned)sync->vehicle_id, (unsigned)seat); + return SAMP_REMOTE_EDGE_DROP; + } + + player_slot = &g_runtime.remote_player_slots[event->player_id]; + if (InterlockedCompareExchange(&player_slot->active, 0, 0) == 0 && + InterlockedCompareExchange(&player_slot->pending, 0, 0) == 0) { + return SAMP_REMOTE_EDGE_DROP; + } + + /* + * STATIC_037: + * R5 samp.dll+0x17440 commits the complete passenger state before resolving + * the vehicle wrapper. A missing vehicle leaves that pointer null but does + * not postpone or discard the new player state. + */ + player_slot->sync_seq = event->seq; + player_slot->health = sync->health; + player_slot->armour = sync->armour; + player_slot->current_weapon = + (uint8_t)(sync->additional_key_weapon & 0x3Fu); + player_slot->additional_key = + (uint8_t)(sync->additional_key_weapon >> 6u); + player_slot->drive_by = (uint8_t)((sync->seat_flags >> 6u) & 1u); + player_slot->cuffed = (uint8_t)((sync->seat_flags >> 7u) & 1u); + player_slot->left_right_keys = sync->left_right_keys; + player_slot->up_down_keys = sync->up_down_keys; + player_slot->keys = sync->keys; + player_slot->vehicle_id = sync->vehicle_id; + player_slot->trailer_id = 0xFFFFu; + player_slot->vehicle_seat = seat; + player_slot->vehicle_target_valid = 0u; + memcpy(player_slot->pos, sync->position, sizeof(player_slot->pos)); + player_slot->last_sync_tick = GetTickCount(); + player_slot->in_vehicle = 0u; + if (InterlockedCompareExchange(&player_slot->active, 0, 0) == 0 && + !remote_player_compat_create_slot(event->player_id, player_slot)) { + return SAMP_REMOTE_EDGE_APPLIED; + } + remote_player_compat_update_control_set(player_slot); + remote_player_compat_stop_aim_task(player_slot, "passenger_sync"); + remote_player_compat_apply_health_state(player_slot); + (void)remote_player_compat_apply_weapon_state(player_slot); + + if (!vehicle_compat_ensure_active(sync->vehicle_id)) { + ped = remote_player_compat_resolve_ped(player_slot); + remote_player_compat_trace_passenger_lifecycle_state( + event, ped, 0u, seat, "pre"); + remote_player_compat_trace_passenger_apply( + event, player_slot, seat, "state_only_vehicle_unavailable", 0); + remote_player_compat_trace_passenger_lifecycle_state( + event, ped, 0u, seat, "post"); + return SAMP_REMOTE_EDGE_APPLIED; + } + vehicle_slot = &g_runtime.vehicle_slots[sync->vehicle_id]; + vehicle = vehicle_compat_game_pool_get_at(vehicle_slot->gta_id); + ped = remote_player_compat_resolve_ped(player_slot); + remote_player_compat_trace_passenger_lifecycle_state( + event, ped, vehicle, seat, "pre"); + if (vehicle == 0u || ped == 0u) { + remote_player_compat_trace_passenger_apply( + event, player_slot, seat, "state_only_dependency_missing", 0); + remote_player_compat_trace_passenger_lifecycle_state( + event, ped, vehicle, seat, "post"); + return SAMP_REMOTE_EDGE_APPLIED; + } + + if (gta_ped_read_vehicle_compat(ped, ¤t_vehicle)) { + if (current_vehicle != vehicle) { + (void)gta_script_command_compat(0x05CDu, "i", + (int)player_slot->gta_id); + player_slot->in_vehicle = 0u; + runtime_tracef( + "remote_passenger: transition seq=%lu player=%u vehicle=%u " + "seat=%u action=leave_previous evidence=STATIC_037,TODO_VERIFY", + (unsigned long)event->seq, (unsigned)event->player_id, + (unsigned)sync->vehicle_id, (unsigned)seat); + remote_player_compat_trace_passenger_lifecycle_state( + event, ped, vehicle, seat, "post"); + return SAMP_REMOTE_EDGE_APPLIED; + } + if (!vehicle_compat_read_u32( + vehicle, + SAMP_VEHICLE_OFFSET_PASSENGERS + + ((uint32_t)(seat - 1u) * sizeof(uint32_t)), + &occupant32)) { + remote_player_compat_trace_passenger_apply( + event, player_slot, seat, "state_only_seat_read_failed", 0); + remote_player_compat_trace_passenger_lifecycle_state( + event, ped, vehicle, seat, "post"); + return SAMP_REMOTE_EDGE_APPLIED; + } + if ((uintptr_t)occupant32 == ped) { + player_slot->in_vehicle = 1u; + remote_player_compat_trace_passenger_apply( + event, player_slot, seat, "already_seated", 1); + remote_player_compat_trace_passenger_lifecycle_state( + event, ped, vehicle, seat, "post"); + return SAMP_REMOTE_EDGE_APPLIED; + } + (void)gta_script_command_compat(0x05CDu, "i", + (int)player_slot->gta_id); + player_slot->in_vehicle = 0u; + remote_player_compat_trace_passenger_lifecycle_state( + event, ped, vehicle, seat, "post"); + return SAMP_REMOTE_EDGE_APPLIED; + } + + if (!vehicle_compat_read_u32( + vehicle, + SAMP_VEHICLE_OFFSET_PASSENGERS + + ((uint32_t)(seat - 1u) * sizeof(uint32_t)), + &occupant32)) { + remote_player_compat_trace_passenger_apply( + event, player_slot, seat, "state_only_seat_read_failed", 0); + remote_player_compat_trace_passenger_lifecycle_state( + event, ped, vehicle, seat, "post"); + return SAMP_REMOTE_EDGE_APPLIED; + } + if (occupant32 != 0u && (uintptr_t)occupant32 != ped) { + runtime_tracef( + "remote_passenger: drop seq=%lu player=%u vehicle=%u seat=%u " + "reason=occupied occupant=0x%08lx evidence=TODO_VERIFY:safety", + (unsigned long)event->seq, (unsigned)event->player_id, + (unsigned)sync->vehicle_id, (unsigned)seat, + (unsigned long)occupant32); + remote_player_compat_trace_passenger_lifecycle_state( + event, ped, vehicle, seat, "post"); + return SAMP_REMOTE_EDGE_DROP; + } + if (!gta_script_command_compat(0x0430u, "iii", + (int)player_slot->gta_id, + (int)vehicle_slot->gta_id, + (int)(seat - 1u))) { + remote_player_compat_trace_passenger_apply( + event, player_slot, seat, "seat_command_failed", 0); + remote_player_compat_trace_passenger_lifecycle_state( + event, ped, vehicle, seat, "post"); + return SAMP_REMOTE_EDGE_APPLIED; + } + occupant32 = 0u; + { + int seat_read_ok = vehicle_compat_read_u32( + vehicle, + SAMP_VEHICLE_OFFSET_PASSENGERS + + ((uint32_t)(seat - 1u) * sizeof(uint32_t)), + &occupant32); + player_slot->in_vehicle = + seat_read_ok && (uintptr_t)occupant32 == ped ? 1u : 0u; + remote_player_compat_trace_passenger_apply( + event, player_slot, seat, "seat_command", seat_read_ok); + } + remote_player_compat_trace_passenger_lifecycle_state( + event, ped, vehicle, seat, "post"); + return SAMP_REMOTE_EDGE_APPLIED; +} + +static samp_remote_edge_apply_result_compat +vehicle_compat_apply_unoccupied_sync( + const samp_raknet_remote_unoccupied_sync *event) { + const samp_raknet_unoccupied_sync *sync = NULL; + samp_vehicle_slot_compat *slot = NULL; + samp_remote_player_slot_compat *owner = NULL; + uintptr_t vehicle = 0u; + uintptr_t vehicle_vtable = 0u; + uint32_t driver32 = 0u; + uint32_t driver_flags = 0u; + uint32_t driver_link32 = 0u; + uint32_t tractor32 = 0u; + uint16_t tractor_vehicle_id = 0xFFFFu; + float current[3] = {0.0f, 0.0f, 0.0f}; + float corrected_speed[3]; + float delta[3]; + float post_position[3] = {0.0f, 0.0f, 0.0f}; + float post_move_speed[3] = {0.0f, 0.0f, 0.0f}; + float post_turn_speed[3] = {0.0f, 0.0f, 0.0f}; + float post_roll[3] = {0.0f, 0.0f, 0.0f}; + float post_rotation[3] = {0.0f, 0.0f, 0.0f}; + float yaw = 0.0f; + int position_write_required = 0; + int position_write_ok = 1; + int move_write_ok = 1; + int turn_write_ok = 1; + int readback_ok = 0; + unsigned int i = 0u; + + if (event == NULL || + !remote_player_compat_id_valid(event->player_id) || + remote_player_compat_is_local(event->player_id)) { + return SAMP_REMOTE_EDGE_DROP; + } + sync = &event->sync; + owner = &g_runtime.remote_player_slots[event->player_id]; + if (InterlockedCompareExchange(&owner->active, 0, 0) == 0 && + InterlockedCompareExchange(&owner->pending, 0, 0) == 0) { + return SAMP_REMOTE_EDGE_DROP; + } + if (!vehicle_compat_id_valid(sync->vehicle_id) || + sync->vehicle_id == 0u || sync->vehicle_id == 0xFFFFu) { + return SAMP_REMOTE_EDGE_DROP; + } + slot = &g_runtime.vehicle_slots[sync->vehicle_id]; + /* + * STATIC_037: + * R5 samp.dll+0x15929..+0x1593A records the UnoccupiedSync sender in the + * vehicle-pool authority array before resolving the vehicle wrapper or + * checking its live state. + */ + slot->unoccupied_owner_id = event->player_id; + for (i = 0u; i < 3u; ++i) { + if (!isfinite(sync->roll[i]) || sync->roll[i] < -1.0f || + sync->roll[i] > 1.0f || !isfinite(sync->rotation[i]) || + sync->rotation[i] < -1.0f || sync->rotation[i] > 1.0f) { + return SAMP_REMOTE_EDGE_DROP; + } + } + if (!isfinite(sync->position[0]) || + sync->position[0] <= -20000.0f || + sync->position[0] >= 20000.0f || + !isfinite(sync->position[1]) || + sync->position[1] <= -20000.0f || + sync->position[1] >= 20000.0f || + !isfinite(sync->position[2]) || + sync->position[2] <= -10000.0f || + sync->position[2] >= 100000.0f || + !remote_edge_speed_plausible_compat(sync->move_speed, 100.0f) || + !remote_edge_speed_plausible_compat(sync->turn_speed, 100.0f)) { + return SAMP_REMOTE_EDGE_DROP; + } + if (InterlockedCompareExchange(&slot->active, 0, 0) == 0 || + slot->gta_id == 0u) { + return InterlockedCompareExchange(&slot->pending, 0, 0) != 0 + ? SAMP_REMOTE_EDGE_DEFER + : SAMP_REMOTE_EDGE_DROP; + } + vehicle = vehicle_compat_game_pool_get_at(slot->gta_id); + if (vehicle == 0u || + !vehicle_compat_read_u32(vehicle, SAMP_VEHICLE_OFFSET_DRIVER, + &driver32)) { + return SAMP_REMOTE_EDGE_DEFER; + } + /* + * STATIC_037: + * R5 does not reject every non-null GTA driver here. samp.dll+0xB70E0 + * returns true only for the narrow transition state where driver+0x46C has + * bit 8 set while driver+0x598 is null. This distinction preserves + * UnoccupiedSync received during ordinary enter/exit seat races. + */ + if (driver32 != 0u) { + if (!memory_is_readable_compat( + (const void *)((uintptr_t)driver32 + + SAMP_PED_OFFSET_UNOCCUPIED_BLOCK_FLAGS), + sizeof(driver_flags))) { + return SAMP_REMOTE_EDGE_DEFER; + } + memcpy(&driver_flags, + (const void *)((uintptr_t)driver32 + + SAMP_PED_OFFSET_UNOCCUPIED_BLOCK_FLAGS), + sizeof(driver_flags)); + if ((driver_flags & 0x100u) != 0u) { + if (!memory_is_readable_compat( + (const void *)((uintptr_t)driver32 + + SAMP_PED_OFFSET_UNOCCUPIED_BLOCK_LINK), + sizeof(driver_link32))) { + return SAMP_REMOTE_EDGE_DEFER; + } + memcpy(&driver_link32, + (const void *)((uintptr_t)driver32 + + SAMP_PED_OFFSET_UNOCCUPIED_BLOCK_LINK), + sizeof(driver_link32)); + if (driver_link32 == 0u) { + return SAMP_REMOTE_EDGE_DROP; + } + } + } + /* + * STATIC_037: + * R5 samp.dll+0x15950 calls samp.dll+0xB7CF0 and skips correction when the + * network vehicle is currently attached to a tractor. The helper excludes + * the exact Bike/Boat/Train/BMX vtables before reading GTA vehicle +0x4C4, + * then resolves that pointer through the SA-MP vehicle pool. + */ + if (!vehicle_compat_read_vtable(vehicle, &vehicle_vtable)) { + return SAMP_REMOTE_EDGE_DEFER; + } + if (vehicle_vtable != (uintptr_t)SAMP_GTA_VTBL_CBIKE && + vehicle_vtable != (uintptr_t)SAMP_GTA_VTBL_CBOAT && + vehicle_vtable != (uintptr_t)SAMP_GTA_VTBL_CTRAIN && + vehicle_vtable != (uintptr_t)SAMP_GTA_VTBL_CBMX) { + if (!vehicle_compat_read_u32(vehicle, SAMP_VEHICLE_OFFSET_TRACTOR, + &tractor32)) { + return SAMP_REMOTE_EDGE_DEFER; + } + if (tractor32 != 0u && + vehicle_compat_find_id_from_gta_ptr((uintptr_t)tractor32, + &tractor_vehicle_id)) { + return SAMP_REMOTE_EDGE_DROP; + } + } + if (!gta_entity_read_position_compat( + vehicle, ¤t[0], ¤t[1], ¤t[2])) { + return SAMP_REMOTE_EDGE_DEFER; + } + for (i = 0u; i < 3u; ++i) { + delta[i] = sync->position[i] - current[i]; + } + if (fabsf(delta[0]) <= 0.1f && fabsf(delta[1]) <= 0.1f && + fabsf(delta[2]) <= 0.1f) { + if (event->seq <= 3u || (event->seq % 64u) == 0u) { + memcpy(post_position, current, sizeof(post_position)); + readback_ok = + gta_entity_read_move_speed_compat(vehicle, post_move_speed) && + gta_entity_read_turn_speed_compat(vehicle, post_turn_speed) && + gta_entity_read_unoccupied_axes_compat( + vehicle, post_roll, post_rotation); + runtime_tracef( + "remote_unoccupied: apply seq=%lu owner=%u vehicle=%u " + "delta=(%.3f,%.3f,%.3f) mode=noop writes=none " + "readback=%u post_pos=(%.3f,%.3f,%.3f) " + "post_move=(%.3f,%.3f,%.3f) post_turn=(%.3f,%.3f,%.3f) " + "evidence=STATIC_037:samp.dll+0x15A57,TODO_VERIFY:live_readback", + (unsigned long)event->seq, (unsigned)event->player_id, + (unsigned)sync->vehicle_id, (double)delta[0], (double)delta[1], + (double)delta[2], (unsigned)readback_ok, + (double)post_position[0], (double)post_position[1], + (double)post_position[2], (double)post_move_speed[0], + (double)post_move_speed[1], (double)post_move_speed[2], + (double)post_turn_speed[0], (double)post_turn_speed[1], + (double)post_turn_speed[2]); + } + return SAMP_REMOTE_EDGE_APPLIED; + } + if (!gta_entity_write_unoccupied_axes_compat( + vehicle, sync->roll, sync->rotation)) { + return SAMP_REMOTE_EDGE_DROP; + } + if (fabsf(delta[0]) > 8.0f || fabsf(delta[1]) > 8.0f || + fabsf(delta[2]) > 3.0f) { + position_write_required = 1; + position_write_ok = gta_entity_direct_position_compat( + vehicle, sync->position[0], sync->position[1], sync->position[2]); + move_write_ok = + gta_entity_write_move_speed_compat(vehicle, sync->move_speed); + } else { + memcpy(corrected_speed, sync->move_speed, sizeof(corrected_speed)); + for (i = 0u; i < 3u; ++i) { + if (fabsf(delta[i]) > 0.05f) { + corrected_speed[i] += delta[i] * 0.06f; + } + } + move_write_ok = + gta_entity_write_move_speed_compat(vehicle, corrected_speed); + /* + * STATIC_037: + * R5 correction path sets the vehicle-wrapper remote-unoccupied byte at + * +0x83 (samp.dll+0x15BF6). Keep the equivalent internal state explicit + * for ProcessControl/cleanup parity work. + */ + slot->remote_unoccupied = 1u; + } + turn_write_ok = + gta_entity_write_turn_speed_compat(vehicle, sync->turn_speed); + vehicle_compat_call_thiscall0(vehicle, + SAMP_ADDR_CENTITY_UPDATE_RW_FRAME); + memcpy(slot->pos, sync->position, sizeof(slot->pos)); + yaw = atan2f(sync->roll[1], sync->roll[0]) * + (180.0f / 3.14159265358979323846f); + if (yaw < 0.0f) { + yaw += 360.0f; + } + slot->rotation = yaw; + if (event->seq <= 3u || (event->seq % 64u) == 0u || + !position_write_ok || !move_write_ok || !turn_write_ok) { + readback_ok = + gta_entity_read_position_compat( + vehicle, &post_position[0], &post_position[1], + &post_position[2]) && + gta_entity_read_move_speed_compat(vehicle, post_move_speed) && + gta_entity_read_turn_speed_compat(vehicle, post_turn_speed) && + gta_entity_read_unoccupied_axes_compat( + vehicle, post_roll, post_rotation); + runtime_tracef( + "remote_unoccupied: apply seq=%lu owner=%u vehicle=%u " + "delta=(%.3f,%.3f,%.3f) mode=%s " + "position_write=%s move_write=%u turn_write=%u readback=%u " + "post_pos=(%.3f,%.3f,%.3f) post_move=(%.3f,%.3f,%.3f) " + "post_turn=(%.3f,%.3f,%.3f) evidence=STATIC_037:" + "samp.dll+0x158D0,TODO_VERIFY:live_readback", + (unsigned long)event->seq, (unsigned)event->player_id, + (unsigned)sync->vehicle_id, (double)delta[0], (double)delta[1], + (double)delta[2], + fabsf(delta[0]) > 8.0f || fabsf(delta[1]) > 8.0f || + fabsf(delta[2]) > 3.0f + ? "snap" + : "correct", + position_write_required + ? (position_write_ok ? "ok" : "failed") + : "none", + (unsigned)move_write_ok, (unsigned)turn_write_ok, + (unsigned)readback_ok, (double)post_position[0], + (double)post_position[1], (double)post_position[2], + (double)post_move_speed[0], (double)post_move_speed[1], + (double)post_move_speed[2], (double)post_turn_speed[0], + (double)post_turn_speed[1], (double)post_turn_speed[2]); + } + return SAMP_REMOTE_EDGE_APPLIED; +} + +static int vehicle_compat_is_trailer_model_compat(int32_t model) { + switch (model) { + case 435: + case 450: + case 584: + case 591: + case 606: + case 607: + case 608: + case 610: + case 611: + return 1; + default: + return 0; + } +} + +static samp_remote_edge_apply_result_compat +vehicle_compat_apply_trailer_sync( + const samp_raknet_remote_trailer_sync *event) { + const samp_raknet_trailer_sync *sync = NULL; + samp_remote_player_slot_compat *owner = NULL; + samp_vehicle_slot_compat *towing_slot = NULL; + samp_vehicle_slot_compat *trailer_slot = NULL; + uintptr_t towing_vehicle = 0u; + uintptr_t trailer_vehicle = 0u; + uint32_t live_trailer32 = 0u; + uint32_t live_tractor32 = 0u; + float current[3] = {0.0f, 0.0f, 0.0f}; + float corrected_speed[3] = {0.0f, 0.0f, 0.0f}; + float delta[3] = {0.0f, 0.0f, 0.0f}; + float post_position[3] = {0.0f, 0.0f, 0.0f}; + float post_move_speed[3] = {0.0f, 0.0f, 0.0f}; + float post_turn_speed[3] = {0.0f, 0.0f, 0.0f}; + float post_quaternion[4] = {1.0f, 0.0f, 0.0f, 0.0f}; + float quaternion_basis_dot = 0.0f; + float quaternion_target_length_sq = 0.0f; + float yaw = 0.0f; + samp_remote_trailer_position_decision_compat position_decision = + SAMP_REMOTE_TRAILER_DECISION_INVALID; + int attach_commanded = 0; + int attach_command_ok = 1; + int attach_read_ok = 0; + int attached = 0; + int association_transition = 0; + int position_write_required = 0; + int position_write_ok = 1; + int move_write_ok = 1; + int turn_write_ok = 1; + int readback_ok = 0; + int quaternion_read_ok = 0; + DWORD now = 0u; + DWORD towing_age_ms = 0u; + DWORD trailer_age_ms = 0u; + unsigned int i = 0u; + + if (event == NULL || + !remote_player_compat_id_valid(event->player_id) || + remote_player_compat_is_local(event->player_id)) { + return SAMP_REMOTE_EDGE_DROP; + } + sync = &event->sync; + if (!vehicle_compat_id_valid(sync->vehicle_id) || + sync->vehicle_id == 0u || sync->vehicle_id == 0xFFFFu || + !remote_player_compat_vec_plausible(sync->position) || + !remote_edge_speed_plausible_compat(sync->move_speed, 100.0f) || + !remote_edge_speed_plausible_compat(sync->turn_speed, 100.0f)) { + return SAMP_REMOTE_EDGE_DROP; + } + owner = &g_runtime.remote_player_slots[event->player_id]; + if (InterlockedCompareExchange(&owner->active, 0, 0) == 0 || + !vehicle_compat_id_valid(owner->vehicle_id)) { + return SAMP_REMOTE_EDGE_DROP; + } + towing_slot = &g_runtime.vehicle_slots[owner->vehicle_id]; + trailer_slot = &g_runtime.vehicle_slots[sync->vehicle_id]; + if (InterlockedCompareExchange(&towing_slot->active, 0, 0) == 0 || + InterlockedCompareExchange(&trailer_slot->active, 0, 0) == 0 || + towing_slot->gta_id == 0u || trailer_slot->gta_id == 0u) { + return (InterlockedCompareExchange(&towing_slot->pending, 0, 0) != 0 || + InterlockedCompareExchange(&trailer_slot->pending, 0, 0) != 0) + ? SAMP_REMOTE_EDGE_DEFER + : SAMP_REMOTE_EDGE_DROP; + } + if (!vehicle_compat_is_trailer_model_compat(trailer_slot->model) && + towing_slot->model != 525) { + return SAMP_REMOTE_EDGE_DROP; + } + now = GetTickCount(); + towing_age_ms = towing_slot->activated_tick != 0u + ? now - towing_slot->activated_tick + : 0u; + trailer_age_ms = trailer_slot->activated_tick != 0u + ? now - trailer_slot->activated_tick + : 0u; + towing_vehicle = vehicle_compat_game_pool_get_at(towing_slot->gta_id); + trailer_vehicle = vehicle_compat_game_pool_get_at(trailer_slot->gta_id); + if (towing_vehicle == 0u || trailer_vehicle == 0u || + !gta_entity_read_position_compat( + trailer_vehicle, ¤t[0], ¤t[1], ¤t[2])) { + return SAMP_REMOTE_EDGE_DEFER; + } + if (!vehicle_compat_read_u32(towing_vehicle, + SAMP_VEHICLE_OFFSET_TRAILER, + &live_trailer32) || + !vehicle_compat_read_u32(trailer_vehicle, + SAMP_VEHICLE_OFFSET_TRACTOR, + &live_tractor32)) { + return SAMP_REMOTE_EDGE_DEFER; + } + association_transition = + vehicle_compat_trailer_transition_pending( + towing_slot, sync->vehicle_id); + if ((uintptr_t)live_trailer32 != trailer_vehicle || + (uintptr_t)live_tractor32 != towing_vehicle) { + if (vehicle_compat_attach_timing_pending( + towing_slot, trailer_slot, sync->vehicle_id, now, + &towing_age_ms, &trailer_age_ms)) { + runtime_tracef( + "remote_trailer: defer seq=%lu owner=%u towing=%u trailer=%u " + "reason=rpc148_source_timing active_age_ms=(%lu,%lu) " + "required_age_ms=(%u,%u) " + "evidence=PROBE_TRACE,INFERRED:preserve_rpc164_to_rpc148", + (unsigned long)event->seq, (unsigned)event->player_id, + (unsigned)owner->vehicle_id, (unsigned)sync->vehicle_id, + (unsigned long)towing_age_ms, (unsigned long)trailer_age_ms, + (unsigned)towing_slot->trailer_attach_towing_age_ms, + (unsigned)towing_slot->trailer_attach_trailer_age_ms); + return SAMP_REMOTE_EDGE_DEFER; + } + vehicle_compat_stage_trailer_transition( + towing_slot, sync->vehicle_id, event->seq); + association_transition = 1; + attach_commanded = 1; + attach_command_ok = + gta_script_command_compat(0x0893u, "ii", + (int)trailer_slot->gta_id, + (int)towing_slot->gta_id); + if (!attach_command_ok) { + runtime_tracef( + "remote_trailer: defer seq=%lu owner=%u towing=%u trailer=%u " + "reason=attach_command_failed evidence=STATIC_037:" + "samp.dll+0x15C90,TODO_VERIFY:live_reconcile", + (unsigned long)event->seq, (unsigned)event->player_id, + (unsigned)owner->vehicle_id, (unsigned)sync->vehicle_id); + return SAMP_REMOTE_EDGE_DEFER; + } + } + live_trailer32 = 0u; + live_tractor32 = 0u; + attach_read_ok = + vehicle_compat_read_u32(towing_vehicle, + SAMP_VEHICLE_OFFSET_TRAILER, + &live_trailer32) && + vehicle_compat_read_u32(trailer_vehicle, + SAMP_VEHICLE_OFFSET_TRACTOR, + &live_tractor32); + attached = + attach_read_ok && (uintptr_t)live_trailer32 == trailer_vehicle && + (uintptr_t)live_tractor32 == towing_vehicle; + if (!attached) { + /* + * STATIC_037: + * R5 resolves Packet 210 through the towing vehicle's live GTA trailer + * association (samp.dll+0x15C90). Do not publish an attachment in the + * compatibility slots until CVehicle+1224 confirms the expected pointer; + * a later TrailerSync can then reconcile a delayed script-command attach. + */ + runtime_tracef( + "remote_trailer: defer seq=%lu owner=%u towing=%u trailer=%u " + "reason=attach_readback_mismatch attach_commanded=%u " + "attach_cmd_ok=%u attach_read=%u live=(0x%08lx,0x%08lx) " + "expected=(0x%08lx,0x%08lx) " + "evidence=STATIC_037:samp.dll+0x15C90,TODO_VERIFY:live_reconcile", + (unsigned long)event->seq, (unsigned)event->player_id, + (unsigned)owner->vehicle_id, (unsigned)sync->vehicle_id, + (unsigned)attach_commanded, (unsigned)attach_command_ok, + (unsigned)attach_read_ok, (unsigned long)(uintptr_t)live_trailer32, + (unsigned long)(uintptr_t)live_tractor32, + (unsigned long)trailer_vehicle, (unsigned long)towing_vehicle); + return SAMP_REMOTE_EDGE_DEFER; + } + owner->trailer_id = sync->vehicle_id; + towing_slot->trailer_vehicle_id = sync->vehicle_id; + towing_slot->trailer_attached = 1u; + if (attach_commanded) { + vehicle_compat_clear_attach_timing(towing_slot); + } + position_decision = samp_remote_trailer_position_decision_r5( + current, sync->position, association_transition, delta); + if (position_decision == SAMP_REMOTE_TRAILER_DECISION_INVALID) { + return SAMP_REMOTE_EDGE_DROP; + } + if (position_decision == SAMP_REMOTE_TRAILER_DECISION_NOOP) { + if (event->seq <= 3u || (event->seq % 64u) == 0u || + !attach_read_ok || !attached) { + memcpy(post_position, current, sizeof(post_position)); + readback_ok = + gta_entity_read_move_speed_compat( + trailer_vehicle, post_move_speed) && + gta_entity_read_turn_speed_compat( + trailer_vehicle, post_turn_speed); + runtime_tracef( + "remote_trailer: apply seq=%lu owner=%u towing=%u trailer=%u " + "active_age_ms=(%lu,%lu) " + "delta=(%.3f,%.3f,%.3f) mode=noop writes=none " + "attach_commanded=%u transition=%u attach_cmd_ok=%u attach_read=%u " + "attached=%u readback=%u post_pos=(%.3f,%.3f,%.3f) " + "post_move=(%.3f,%.3f,%.3f) post_turn=(%.3f,%.3f,%.3f) " + "evidence=STATIC_037:samp.dll+0x15D9A,TODO_VERIFY:live_readback", + (unsigned long)event->seq, (unsigned)event->player_id, + (unsigned)owner->vehicle_id, (unsigned)sync->vehicle_id, + (unsigned long)towing_age_ms, (unsigned long)trailer_age_ms, + (double)delta[0], (double)delta[1], (double)delta[2], + (unsigned)attach_commanded, (unsigned)association_transition, + (unsigned)attach_command_ok, + (unsigned)attach_read_ok, (unsigned)attached, + (unsigned)readback_ok, (double)post_position[0], + (double)post_position[1], (double)post_position[2], + (double)post_move_speed[0], (double)post_move_speed[1], + (double)post_move_speed[2], (double)post_turn_speed[0], + (double)post_turn_speed[1], (double)post_turn_speed[2]); + } + return SAMP_REMOTE_EDGE_APPLIED; + } + if (!gta_entity_write_quaternion_compat( + trailer_vehicle, sync->quaternion)) { + return SAMP_REMOTE_EDGE_DEFER; } + /* + * OBSERVED_037 + PROBE_TRACE: + * On the Packet-210 association transition, R5 publishes the packet + * transform exactly after SetTowLink even when the pre-link position delta + * is below the ordinary correction threshold. + */ + if (position_decision == SAMP_REMOTE_TRAILER_DECISION_TRANSITION || + position_decision == SAMP_REMOTE_TRAILER_DECISION_SNAP) { + position_write_required = 1; + position_write_ok = gta_entity_direct_position_compat( + trailer_vehicle, sync->position[0], sync->position[1], + sync->position[2]); + move_write_ok = gta_entity_write_move_speed_compat( + trailer_vehicle, sync->move_speed); + } else { + if (!gta_entity_read_move_speed_compat( + trailer_vehicle, corrected_speed)) { + return SAMP_REMOTE_EDGE_DEFER; + } + for (i = 0u; i < 3u; ++i) { + if (fabsf(delta[i]) > 0.05f) { + corrected_speed[i] += delta[i] * 0.025f; + } + } + move_write_ok = gta_entity_write_move_speed_compat( + trailer_vehicle, corrected_speed); + } + turn_write_ok = gta_entity_write_turn_speed_compat( + trailer_vehicle, sync->turn_speed); + /* + * STATIC_037: + * The Packet-210 transform path calls the SA-MP vehicle-wrapper matrix + * setter at samp.dll+0x9EBC0. In R5 SHA256 + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2 + * that setter only copies the 0x40-byte matrix and returns; it does not call + * GTA CEntity::UpdateRwFrame. Keep this path free of an extra frame update. + */ + memcpy(trailer_slot->pos, sync->position, sizeof(trailer_slot->pos)); + yaw = samp_gta_yaw_degrees_from_quaternion( + sync->quaternion[0], sync->quaternion[1], sync->quaternion[2], + sync->quaternion[3]); + trailer_slot->rotation = yaw; + if (event->seq <= 3u || (event->seq % 64u) == 0u || + !attach_read_ok || !attached || !position_write_ok || + !move_write_ok || !turn_write_ok || position_write_required) { + readback_ok = + gta_entity_read_position_compat( + trailer_vehicle, &post_position[0], &post_position[1], + &post_position[2]) && + gta_entity_read_move_speed_compat( + trailer_vehicle, post_move_speed) && + gta_entity_read_turn_speed_compat( + trailer_vehicle, post_turn_speed); + quaternion_read_ok = gta_entity_read_full_quaternion_compat( + trailer_vehicle, post_quaternion, 0.0f); + if (quaternion_read_ok) { + for (i = 0u; i < 4u; ++i) { + quaternion_target_length_sq += + sync->quaternion[i] * sync->quaternion[i]; + } + if (quaternion_target_length_sq > 0.000001f) { + /* + * STATIC_037: + * gta_entity_read_full_quaternion_compat applies R5's inverse + * matrix conversion at samp.dll+0xB52B0/+0xB4D70, so both operands + * are normalized SA-MP w,x,y,z values in the same basis. + */ + for (i = 0u; i < 4u; ++i) { + quaternion_basis_dot += + post_quaternion[i] * sync->quaternion[i]; + } + quaternion_basis_dot = + fabsf(quaternion_basis_dot) / + sqrtf(quaternion_target_length_sq); + } + } + runtime_tracef( + "remote_trailer: apply seq=%lu owner=%u towing=%u trailer=%u " + "active_age_ms=(%lu,%lu) " + "delta=(%.3f,%.3f,%.3f) mode=%s " + "attach_commanded=%u transition=%u attach_cmd_ok=%u " + "attach_read=%u attached=%u " + "position_write=%s move_write=%u turn_write=%u readback=%u " + "post_pos=(%.3f,%.3f,%.3f) post_move=(%.3f,%.3f,%.3f) " + "post_turn=(%.3f,%.3f,%.3f) quat_read=%u basis_dot=%.6f " + "evidence=STATIC_037:" + "samp.dll+0x15C90,TODO_VERIFY:live_readback", + (unsigned long)event->seq, (unsigned)event->player_id, + (unsigned)owner->vehicle_id, (unsigned)sync->vehicle_id, + (unsigned long)towing_age_ms, (unsigned long)trailer_age_ms, + (double)delta[0], (double)delta[1], (double)delta[2], + samp_remote_trailer_position_decision_name(position_decision), + (unsigned)attach_commanded, (unsigned)association_transition, + (unsigned)attach_command_ok, + (unsigned)attach_read_ok, (unsigned)attached, + position_write_required + ? (position_write_ok ? "ok" : "failed") + : "none", + (unsigned)move_write_ok, (unsigned)turn_write_ok, + (unsigned)readback_ok, (double)post_position[0], + (double)post_position[1], (double)post_position[2], + (double)post_move_speed[0], (double)post_move_speed[1], + (double)post_move_speed[2], (double)post_turn_speed[0], + (double)post_turn_speed[1], (double)post_turn_speed[2], + (unsigned)quaternion_read_ok, (double)quaternion_basis_dot); + } + if (position_write_ok && move_write_ok && turn_write_ok) { + if (association_transition) { + vehicle_compat_clear_trailer_transition(towing_slot); + } + return SAMP_REMOTE_EDGE_APPLIED; + } + return SAMP_REMOTE_EDGE_DEFER; } static void remote_player_compat_apply_sync(const samp_raknet_remote_onfoot_sync *sync) { @@ -17312,7 +20650,16 @@ static void remote_player_compat_apply_sync(const samp_raknet_remote_onfoot_sync } slot->in_vehicle = 0u; slot->vehicle_id = 0xFFFFu; + slot->trailer_id = 0xFFFFu; } + slot->in_vehicle = 0u; + slot->vehicle_id = 0xFFFFu; + slot->trailer_id = 0xFFFFu; + slot->vehicle_seat = 0u; + slot->drive_by = 0u; + slot->cuffed = 0u; + slot->additional_key = 0u; + slot->vehicle_target_valid = 0u; slot->sync_seq = sync->seq; previous_health = slot->health; @@ -17371,6 +20718,126 @@ static void remote_player_compat_apply_sync(const samp_raknet_remote_onfoot_sync } } +static void remote_player_compat_process_vehicle_targets(void) { + unsigned int i = 0u; + + for (i = 0u; i < SAMP_RAKNET_MAX_PLAYERS; ++i) { + samp_remote_player_slot_compat *slot = + &g_runtime.remote_player_slots[i]; + samp_vehicle_slot_compat *vehicle_slot = NULL; + uintptr_t vehicle = 0u; + uintptr_t ped = 0u; + uintptr_t ped_vehicle = 0u; + uint32_t driver32 = 0u; + float turn_speed[3] = {0.0f, 0.0f, 0.0f}; + float current_quaternion[4] = {1.0f, 0.0f, 0.0f, 0.0f}; + float next_quaternion[4] = {1.0f, 0.0f, 0.0f, 0.0f}; + float target_dot = 0.0f; + unsigned int component = 0u; + uint32_t frame = 0u; + + if (InterlockedCompareExchange(&slot->active, 0, 0) == 0 || + InterlockedCompareExchange(&slot->spawned, 0, 0) == 0 || + slot->vehicle_target_valid == 0u || slot->in_vehicle == 0u || + slot->vehicle_seat != 0u || + !vehicle_compat_id_valid(slot->vehicle_id)) { + continue; + } + vehicle_slot = &g_runtime.vehicle_slots[slot->vehicle_id]; + if (InterlockedCompareExchange(&vehicle_slot->active, 0, 0) == 0 || + vehicle_slot->gta_id == 0u) { + continue; + } + vehicle = vehicle_compat_game_pool_get_at(vehicle_slot->gta_id); + ped = remote_player_compat_resolve_ped(slot); + if (vehicle == 0u || ped == 0u || + !vehicle_compat_read_u32( + vehicle, SAMP_VEHICLE_OFFSET_DRIVER, &driver32) || + (uintptr_t)driver32 != ped || + !gta_ped_read_vehicle_compat(ped, &ped_vehicle) || + ped_vehicle != vehicle) { + continue; + } + + /* + * STATIC_037: + * The normal-driver frame branch at samp.dll+0x16E8E calls +0x15460 + * independently from fresh packet handling. +0x15460 clamps all three + * turn-speed axes to +/-0.02, then Slerps the current complete matrix + * quaternion toward the cached target at t=0.75 and writes the complete + * orientation basis back. It does not invoke ApplyMoveSpeed, a yaw opcode, + * or CEntity::UpdateRwFrame. + */ + if (!remote_vehicle_is_train_compat(vehicle, vehicle_slot) && + gta_entity_read_turn_speed_compat(vehicle, turn_speed)) { + for (component = 0u; component < 3u; ++component) { + if (turn_speed[component] > + SAMP_REMOTE_VEHICLE_TURN_SPEED_LIMIT) { + turn_speed[component] = SAMP_REMOTE_VEHICLE_TURN_SPEED_LIMIT; + } else if (turn_speed[component] < + -SAMP_REMOTE_VEHICLE_TURN_SPEED_LIMIT) { + turn_speed[component] = -SAMP_REMOTE_VEHICLE_TURN_SPEED_LIMIT; + } + } + if (gta_entity_write_turn_speed_compat(vehicle, turn_speed) && + gta_entity_read_full_quaternion_compat( + vehicle, current_quaternion, 0.0f) && + remote_vehicle_slerp_quaternion_compat( + current_quaternion, slot->vehicle_target_quaternion, + SAMP_REMOTE_VEHICLE_QUATERNION_SLERP, next_quaternion) && + gta_entity_write_quaternion_compat(vehicle, next_quaternion)) { + frame = ++slot->vehicle_target_frames; + if (frame <= 3u || (frame % 512u) == 0u) { + for (component = 0u; component < 4u; ++component) { + target_dot += + next_quaternion[component] * + slot->vehicle_target_quaternion[component]; + } + if (target_dot < 0.0f) { + target_dot = -target_dot; + } + if (target_dot > 1.0f) { + target_dot = 1.0f; + } + runtime_tracef( + "remote_vehicle: attitude_frame player=%u vehicle=%u frame=%lu " + "turn=(%.4f,%.4f,%.4f) target_dot=%.6f " + "evidence=STATIC_037:samp.dll+0x15460,+0x16E8E", + i, (unsigned)slot->vehicle_id, (unsigned long)frame, + (double)turn_speed[0], (double)turn_speed[1], + (double)turn_speed[2], (double)target_dot); + } + } + } + + /* + * STATIC_037: + * After the optional +0x15460 attitude call, R5 loads the Packet-200 + * UD/LR/keys at +0x16F83..+0x16F8F and calls SetKeys(+0xAF340). + * This ordering also applies to the train models that skip +0x15460. + */ + remote_player_compat_update_control_set(slot); + if (InterlockedCompareExchange(&g_runtime.gta_version, 0, 0) == + SAMP_GTA_VERSION_USA10 && + slot->gta_player_backed != 0u && + slot->gta_player_index >= SAMP_REMOTE_PLAYER_GTA_SLOT_FIRST && + slot->gta_player_index < SAMP_REMOTE_PLAYER_GTA_SLOT_COUNT) { + /* + * STATIC_037: + * Immediately after SetKeys, samp.dll+0x16FA7 calls +0xAE270 and + * mirrors CPad key index 18 into the single byte GTA CVehicle+0x514. + */ + (void)vehicle_compat_write_u8( + vehicle, SAMP_VEHICLE_OFFSET_HORN_STATE, + g_runtime + .remote_player_control_sets[slot->gta_player_index] + .keys1[18] != 0u + ? 1u + : 0u); + } + } +} + static void remote_player_compat_update_marker(uint16_t player_id, samp_remote_player_slot_compat *slot) { int should_show = 0; const samp_scoreboard_player_compat *scoreboard_slot = NULL; @@ -18423,7 +21890,8 @@ static void remote_player_compat_update_from_snapshot(const samp_raknet_rpc_prob previous_sync_seq = (uint32_t)InterlockedCompareExchange(&g_runtime.remote_player_sync_seq, 0, 0); latest_sync_seq = previous_sync_seq; - if ((snapshot->flags & SAMP_RAKNET_RPC_FLAG_REMOTE_PLAYER_SYNC) != 0u) { + if ((snapshot->flags & SAMP_RAKNET_RPC_FLAG_REMOTE_PLAYER_SYNC) != 0u && + snapshot->remote_movement_sync_count == 0u) { count = snapshot->remote_player_sync_count; if (count > SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING) { count = SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; @@ -18443,7 +21911,8 @@ static void remote_player_compat_update_from_snapshot(const samp_raknet_rpc_prob previous_vehicle_sync_seq = (uint32_t)InterlockedCompareExchange(&g_runtime.remote_vehicle_sync_seq, 0, 0); latest_vehicle_sync_seq = previous_vehicle_sync_seq; - if ((snapshot->flags & SAMP_RAKNET_RPC_FLAG_REMOTE_PLAYER_SYNC) != 0u) { + if ((snapshot->flags & SAMP_RAKNET_RPC_FLAG_REMOTE_PLAYER_SYNC) != 0u && + snapshot->remote_movement_sync_count == 0u) { count = snapshot->remote_vehicle_sync_count; if (count > SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING) { count = SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; @@ -18451,7 +21920,11 @@ static void remote_player_compat_update_from_snapshot(const samp_raknet_rpc_prob for (i = 0u; i < count; ++i) { const samp_raknet_remote_vehicle_sync *sync = &snapshot->remote_vehicle_syncs[i]; if (sync->seq != 0u && sync->seq > previous_vehicle_sync_seq) { - remote_player_compat_apply_vehicle_sync(sync); + samp_remote_edge_apply_result_compat result = + remote_player_compat_apply_vehicle_sync(sync); + if (result == SAMP_REMOTE_EDGE_DEFER) { + break; + } latest_vehicle_sync_seq = sync->seq; } } @@ -18515,6 +21988,291 @@ static void remote_player_compat_update_from_snapshot(const samp_raknet_rpc_prob remote_player_compat_flush_pending(SAMP_REMOTE_PLAYER_COMPAT_CREATE_BUDGET); } +static void remote_edge_sync_update_from_snapshot( + const samp_raknet_rpc_probe_snapshot *snapshot) { + uint32_t previous_movement_seq = 0u; + uint32_t latest_movement_seq = 0u; + uint32_t previous_unoccupied_seq = 0u; + uint32_t latest_unoccupied_seq = 0u; + uint32_t previous_trailer_seq = 0u; + uint32_t latest_trailer_seq = 0u; + uint32_t count = 0u; + uint32_t i = 0u; + + if (snapshot == NULL) { + return; + } + + /* + * STATIC_037 + PROBE_TRACE: + * Vehicle StreamIn dependencies have already been applied by the caller. + * The adapter's shared movement ring preserves Receive() arrival order + * across packet 207/200/209/210/211. Packets 207/200 use channel 0 while + * 209/210/211 use channel 1; preserving the order delivered across both + * channels avoids applying a trailer/unoccupied state against a later + * driver or on-foot state. + */ + previous_movement_seq = (uint32_t)InterlockedCompareExchange( + &g_runtime.remote_movement_sync_seq, 0, 0); + latest_movement_seq = previous_movement_seq; + count = snapshot->remote_movement_sync_count; + if (count > SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING) { + count = SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + } + for (i = 0u; i < count; ++i) { + const samp_raknet_remote_movement_sync *movement = + &snapshot->remote_movement_syncs[i]; + samp_remote_edge_apply_result_compat result = + SAMP_REMOTE_EDGE_APPLIED; + samp_remote_edge_cursor_decision_compat cursor_decision; + DWORD defer_age_ms = 0u; + LONG defer_attempts = 0; + uint32_t packet_seq = 0u; + uint32_t movement_distance = samp_remote_edge_seq_distance( + latest_movement_seq, movement->seq); + uint16_t packet_id = 0u; + if (!samp_remote_edge_seq_is_newer( + latest_movement_seq, movement->seq)) { + continue; + } + if (movement_distance > 1u) { + uint32_t deferred_seq = (uint32_t)InterlockedCompareExchange( + &g_runtime.remote_movement_defer_logged_seq, 0, 0); + runtime_tracef( + "remote_edge: movement_ring_gap cursor=%lu next=%lu lost=%lu " + "deferred_seq=%lu action=resume_from_oldest_available " + "evidence=PROBE_TRACE,TODO_VERIFY:ring_overflow", + (unsigned long)latest_movement_seq, + (unsigned long)movement->seq, + (unsigned long)(movement_distance - 1u), + (unsigned long)deferred_seq); + if (samp_remote_edge_seq_is_newer( + latest_movement_seq, deferred_seq) && + samp_remote_edge_seq_is_newer( + deferred_seq, movement->seq)) { + InterlockedExchange( + &g_runtime.remote_movement_defer_logged_seq, 0); + InterlockedExchange( + &g_runtime.remote_movement_defer_first_tick, 0); + InterlockedExchange( + &g_runtime.remote_movement_defer_attempts, 0); + } + } + if (movement->type == SAMP_RAKNET_REMOTE_MOVEMENT_ONFOOT) { + remote_player_compat_apply_sync(&movement->state.onfoot); + packet_seq = movement->state.onfoot.seq; + packet_id = 207u; + } else if (movement->type == + SAMP_RAKNET_REMOTE_MOVEMENT_DRIVER) { + const uint16_t vehicle_id = + movement->state.driver.vehicle_id; + const uint16_t trailer_id = + movement->state.driver.trailer_id; + packet_seq = movement->state.driver.seq; + packet_id = 200u; + if (vehicle_compat_id_valid(vehicle_id) && + InterlockedCompareExchange( + &g_runtime.vehicle_slots[vehicle_id].pending, 0, 0) != 0 && + !vehicle_compat_ensure_active(vehicle_id)) { + result = SAMP_REMOTE_EDGE_DEFER; + } + if (result != SAMP_REMOTE_EDGE_DEFER && + vehicle_compat_id_valid(trailer_id) && + trailer_id != 0u && trailer_id != 0xFFFFu && + InterlockedCompareExchange( + &g_runtime.vehicle_slots[trailer_id].pending, 0, 0) != 0 && + !vehicle_compat_ensure_active(trailer_id)) { + result = SAMP_REMOTE_EDGE_DEFER; + } + if (result != SAMP_REMOTE_EDGE_DEFER) { + result = remote_player_compat_apply_vehicle_sync( + &movement->state.driver); + } + } else if (movement->type == + SAMP_RAKNET_REMOTE_MOVEMENT_PASSENGER) { + result = remote_player_compat_apply_passenger_sync( + &movement->state.passenger); + packet_seq = movement->state.passenger.seq; + packet_id = 211u; + } else if (movement->type == + SAMP_RAKNET_REMOTE_MOVEMENT_UNOCCUPIED) { + result = vehicle_compat_apply_unoccupied_sync( + &movement->state.unoccupied); + packet_seq = movement->state.unoccupied.seq; + packet_id = 209u; + } else if (movement->type == + SAMP_RAKNET_REMOTE_MOVEMENT_TRAILER) { + result = vehicle_compat_apply_trailer_sync( + &movement->state.trailer); + packet_seq = movement->state.trailer.seq; + packet_id = 210u; + } else { + result = SAMP_REMOTE_EDGE_DROP; + } + if (result == SAMP_REMOTE_EDGE_DEFER) { + DWORD now = GetTickCount(); + DWORD first_tick = 0u; + uint32_t deferred_seq = (uint32_t)InterlockedCompareExchange( + &g_runtime.remote_movement_defer_logged_seq, 0, 0); + /* + * PROBE_TRACE + INFERRED: + * Replacement-side vehicle creation can be delayed across several GTA + * frames. Retain the shared arrival-order cursor so the same movement is + * retried after its runtime vehicle dependency becomes active; consuming + * it here permanently lost the first Packet 200/209/210 state. Bound the + * retry so a permanently invalid live pointer cannot head-of-line block + * every remote player behind this global arrival-order stream. + */ + if (deferred_seq != movement->seq) { + InterlockedExchange( + &g_runtime.remote_movement_defer_logged_seq, + (LONG)movement->seq); + InterlockedExchange( + &g_runtime.remote_movement_defer_first_tick, (LONG)now); + InterlockedExchange( + &g_runtime.remote_movement_defer_attempts, 1); + first_tick = now; + defer_attempts = 1; + runtime_tracef( + "remote_edge: retain_deferred movement_seq=%lu packet=%u " + "packet_seq=%lu type=%u reason=runtime_dependency_retry " + "evidence=PROBE_TRACE,INFERRED,TODO_VERIFY:original_timing", + (unsigned long)movement->seq, (unsigned)packet_id, + (unsigned long)packet_seq, (unsigned)movement->type); + } else { + first_tick = (DWORD)InterlockedCompareExchange( + &g_runtime.remote_movement_defer_first_tick, 0, 0); + defer_attempts = InterlockedIncrement( + &g_runtime.remote_movement_defer_attempts); + } + defer_age_ms = (DWORD)(now - first_tick); + } + cursor_decision = samp_remote_edge_cursor_decide( + latest_movement_seq, movement->seq, result, defer_age_ms); + if (cursor_decision.stop) { + break; + } + if (result == SAMP_REMOTE_EDGE_DEFER && + cursor_decision.effective_result == SAMP_REMOTE_EDGE_DROP) { + runtime_tracef( + "remote_edge: deferred_retry_exhausted movement_seq=%lu " + "packet=%u packet_seq=%lu type=%u age_ms=%lu attempts=%ld " + "action=drop_and_unblock evidence=INFERRED,TODO_VERIFY", + (unsigned long)movement->seq, (unsigned)packet_id, + (unsigned long)packet_seq, (unsigned)movement->type, + (unsigned long)defer_age_ms, (long)defer_attempts); + } + result = cursor_decision.effective_result; + if (!cursor_decision.consume) { + continue; + } + if (packet_id != 0u && + (packet_seq <= 3u || (packet_seq % 64u) == 0u || + result != SAMP_REMOTE_EDGE_APPLIED)) { + runtime_tracef( + "remote_edge: consume movement_seq=%lu packet=%u packet_seq=%lu " + "type=%u result=%s evidence=STATIC_037,TODO_VERIFY:live_apply", + (unsigned long)movement->seq, (unsigned)packet_id, + (unsigned long)packet_seq, (unsigned)movement->type, + result == SAMP_REMOTE_EDGE_APPLIED ? "applied" : "drop"); + } + if (movement->type == SAMP_RAKNET_REMOTE_MOVEMENT_ONFOOT) { + InterlockedExchange( + &g_runtime.remote_player_sync_seq, + (LONG)movement->state.onfoot.seq); + } else if (movement->type == + SAMP_RAKNET_REMOTE_MOVEMENT_DRIVER) { + InterlockedExchange( + &g_runtime.remote_vehicle_sync_seq, + (LONG)movement->state.driver.seq); + } else if (movement->type == + SAMP_RAKNET_REMOTE_MOVEMENT_PASSENGER) { + InterlockedExchange( + &g_runtime.remote_passenger_sync_seq, + (LONG)movement->state.passenger.seq); + } else if (movement->type == + SAMP_RAKNET_REMOTE_MOVEMENT_UNOCCUPIED) { + InterlockedExchange( + &g_runtime.remote_unoccupied_sync_seq, + (LONG)movement->state.unoccupied.seq); + } else if (movement->type == + SAMP_RAKNET_REMOTE_MOVEMENT_TRAILER) { + InterlockedExchange( + &g_runtime.remote_trailer_sync_seq, + (LONG)movement->state.trailer.seq); + } + if ((uint32_t)InterlockedCompareExchange( + &g_runtime.remote_movement_defer_logged_seq, 0, 0) == + movement->seq) { + InterlockedExchange( + &g_runtime.remote_movement_defer_logged_seq, 0); + InterlockedExchange( + &g_runtime.remote_movement_defer_first_tick, 0); + InterlockedExchange( + &g_runtime.remote_movement_defer_attempts, 0); + } + latest_movement_seq = cursor_decision.next_cursor; + } + if (latest_movement_seq != previous_movement_seq) { + InterlockedExchange(&g_runtime.remote_movement_sync_seq, + (LONG)latest_movement_seq); + } + + previous_unoccupied_seq = (uint32_t)InterlockedCompareExchange( + &g_runtime.remote_unoccupied_sync_seq, 0, 0); + latest_unoccupied_seq = previous_unoccupied_seq; + count = snapshot->remote_movement_sync_count == 0u + ? snapshot->remote_unoccupied_sync_count + : 0u; + if (count > SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING) { + count = SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + } + for (i = 0u; i < count; ++i) { + const samp_raknet_remote_unoccupied_sync *event = + &snapshot->remote_unoccupied_syncs[i]; + samp_remote_edge_apply_result_compat result; + if (event->seq == 0u || event->seq <= previous_unoccupied_seq) { + continue; + } + result = vehicle_compat_apply_unoccupied_sync(event); + if (result == SAMP_REMOTE_EDGE_DEFER) { + break; + } + latest_unoccupied_seq = event->seq; + } + if (latest_unoccupied_seq != previous_unoccupied_seq) { + InterlockedExchange(&g_runtime.remote_unoccupied_sync_seq, + (LONG)latest_unoccupied_seq); + } + + previous_trailer_seq = (uint32_t)InterlockedCompareExchange( + &g_runtime.remote_trailer_sync_seq, 0, 0); + latest_trailer_seq = previous_trailer_seq; + count = snapshot->remote_movement_sync_count == 0u + ? snapshot->remote_trailer_sync_count + : 0u; + if (count > SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING) { + count = SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING; + } + for (i = 0u; i < count; ++i) { + const samp_raknet_remote_trailer_sync *event = + &snapshot->remote_trailer_syncs[i]; + samp_remote_edge_apply_result_compat result; + if (event->seq == 0u || event->seq <= previous_trailer_seq) { + continue; + } + result = vehicle_compat_apply_trailer_sync(event); + if (result == SAMP_REMOTE_EDGE_DEFER) { + break; + } + latest_trailer_seq = event->seq; + } + if (latest_trailer_seq != previous_trailer_seq) { + InterlockedExchange(&g_runtime.remote_trailer_sync_seq, + (LONG)latest_trailer_seq); + } +} + static void remote_player_compat_reset_pool(const char *reason) { unsigned int i = 0u; @@ -18529,6 +22287,13 @@ static void remote_player_compat_reset_pool(const char *reason) { InterlockedExchange(&g_runtime.remote_vehicle_sync_seq, 0); InterlockedExchange(&g_runtime.remote_aim_sync_seq, 0); InterlockedExchange(&g_runtime.remote_bullet_sync_seq, 0); + InterlockedExchange(&g_runtime.remote_unoccupied_sync_seq, 0); + InterlockedExchange(&g_runtime.remote_trailer_sync_seq, 0); + InterlockedExchange(&g_runtime.remote_passenger_sync_seq, 0); + InterlockedExchange(&g_runtime.remote_movement_sync_seq, 0); + InterlockedExchange(&g_runtime.remote_movement_defer_logged_seq, 0); + InterlockedExchange(&g_runtime.remote_movement_defer_first_tick, 0); + InterlockedExchange(&g_runtime.remote_movement_defer_attempts, 0); InterlockedExchange(&g_runtime.remote_player_name_tag_event_seq, 0); InterlockedExchange(&g_runtime.remote_player_active_count, 0); InterlockedExchange(&g_runtime.remote_player_pending_count, 0); @@ -20664,15 +24429,18 @@ static void textdraw_compat_clear_select_mode(const char *reason) { LONG mouse_mode = InterlockedCompareExchange(&g_runtime.dialog_mouse_mode, 0, 0); int dialog_active = dialog_compat_active(); int class_selection_mouse_mode = InterlockedCompareExchange(&g_runtime.class_selection_mouse_mode, 0, 0) != 0; + int scoreboard_cursor_owned = scoreboard_compat_cursor_owned(); - if (!dialog_active && !class_selection_mouse_mode && (was_select != 0 || was_down != 0 || mouse_mode != 0)) { + if (!dialog_active && !class_selection_mouse_mode && !scoreboard_cursor_owned && + (was_select != 0 || was_down != 0 || mouse_mode != 0)) { dialog_compat_set_mouse_mode(0); } - if (was_select != 0 || was_down != 0 || (!dialog_active && !class_selection_mouse_mode && mouse_mode != 0)) { + if (was_select != 0 || was_down != 0 || + (!dialog_active && !class_selection_mouse_mode && !scoreboard_cursor_owned && mouse_mode != 0)) { runtime_tracef("textdraw: select mode cleared reason=%s active=%ld mouse_down=%ld dialog=%d class_mouse=%d " - "mouse_mode=%ld", + "scoreboard_cursor_owned=%d mouse_mode=%ld", reason != NULL ? reason : "unknown", (long)was_select, (long)was_down, dialog_active, - class_selection_mouse_mode, (long)mouse_mode); + class_selection_mouse_mode, scoreboard_cursor_owned, (long)mouse_mode); } } @@ -20694,8 +24462,17 @@ static int textdraw_compat_handle_mouse(HWND hwnd, UINT msg, LPARAM lparam) { InterlockedExchange(&g_runtime.textdraw_mouse_down, 1); return 1; } - if (msg == WM_LBUTTONUP && InterlockedExchange(&g_runtime.textdraw_mouse_down, 0) != 0) { + if (msg == WM_LBUTTONUP) { uint16_t textdraw_id = 0xFFFFu; + + /* + * STATIC_037:samp.dll+0x71570 + * SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2 + * R5 consumes every primary-button release while selection is active and + * submits the current valid hover ID without consulting a button-down + * latch. Keep the latch only as local pressed-state bookkeeping. + */ + (void)InterlockedExchange(&g_runtime.textdraw_mouse_down, 0); if (textdraw_compat_hit_test(cursor.x, cursor.y, &textdraw_id)) { (void)textdraw_compat_submit_click(textdraw_id); } @@ -20714,26 +24491,6 @@ static void textdraw_compat_draw_rect_outline(void *device, int x, int y, int w, textdraw_compat_d3d_fill_rect(device, x + w - 1, y, 1, h, color); } -static void ui_compat_draw_cursor(void *device, int x, int y) { - static const uint8_t outline_widths[17] = {1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u, 11u, 7u, 7u, 4u, 4u, 2u, 2u}; - static const uint8_t fill_offsets[17] = {0u, 0u, 1u, 1u, 1u, 1u, 1u, 1u, 1u, 3u, 4u, 3u, 4u, 3u, 4u, 0u, 0u}; - static const uint8_t fill_widths[17] = {0u, 0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 4u, 3u, 3u, 2u, 2u, 1u, 0u, 0u}; - int row = 0; - - if (device == NULL) { - return; - } - for (row = 0; row < (int)(sizeof(outline_widths) / sizeof(outline_widths[0])); ++row) { - if (outline_widths[row] != 0u) { - dialog_compat_d3d_fill_rect(device, x, y + row, outline_widths[row], 1, 0xFF000000u); - } - if (fill_widths[row] != 0u) { - dialog_compat_d3d_fill_rect(device, x + fill_offsets[row], y + row, fill_widths[row], 1, - 0xFFFFFFFFu); - } - } -} - static int class_selection_compat_active(void) { uint32_t rpc_flags = 0u; LONG class_outcome = 0; @@ -20791,6 +24548,7 @@ static int class_selection_compat_request_f4(void) { static int local_death_compat_process_report(uintptr_t ped, int spawn_ready) { LONG reported = InterlockedCompareExchange(&g_runtime.local_death_reported, 0, 0); + LONG respawn_pending = InterlockedCompareExchange(&g_runtime.local_respawn_pending, 0, 0); float health = 100.0f; uint32_t action = 0u; int health_read = 0; @@ -20815,21 +24573,23 @@ static int local_death_compat_process_report(uintptr_t ped, int spawn_ready) { action_is_death = action_read && (action == SAMP_PED_ACTION_DEATH || action == SAMP_PED_ACTION_WASTED); dead_now = (health_read && isfinite(health) && health <= 0.0f) || action_is_death; if (!dead_now) { - if (reported != 0 && health_read && isfinite(health) && health > 0.0f) { + if (reported != 0 && respawn_pending == 0 && health_read && isfinite(health) && health > 0.0f) { /* * OBSERVED_037 + PROBE_TRACE: - * R5 keeps the server-side player in WASTED state until GTA finishes - * the roughly four-second death camera, then sends RPC_Spawn before - * resuming on-foot sync. Sending sync while the death latch is active - * makes open.mp jump directly from WASTED back to ONFOOT. + * R5 artifact=20260728-141552-distributed-sync-death-1526481 keeps the + * same SpawnInfo and Ped pointer, then calls CLocalPlayer::Spawn before + * sending RPC_Spawn. The observed state edge is active 0->1, + * wasted 1->0, camera 29->4. Reopen the existing complete + * spawn-finalize path by invalidating only its consumed sequence; do + * not manufacture a new SpawnInfo. */ - result = samp_raknet_client_send_respawn_notification(g_runtime.net_mgr.raknet_client); - if (result == 0) { - InterlockedExchange(&g_runtime.local_death_reported, 0); - } - runtime_tracef("local_death: respawn_notify result=%d health=%.3f action=%lu " - "evidence=OBSERVED_037,PROBE_TRACE,TODO_VERIFY", - result, (double)health, (unsigned long)action); + InterlockedExchange(&g_runtime.mp_session_finalized_spawn_seq, 0); + InterlockedExchange(&g_runtime.local_respawn_pending, SAMP_LOCAL_RESPAWN_PENDING_APPLY); + runtime_tracef("local_death: respawn_apply_scheduled health=%.3f action=%lu " + "spawn_seq=%ld ped=0x%08lx evidence=OBSERVED_037,PROBE_TRACE", + (double)health, (unsigned long)action, + (long)InterlockedCompareExchange(&g_runtime.raknet_spawn_info_seq, 0, 0), + (unsigned long)ped); } return 0; } @@ -20837,17 +24597,22 @@ static int local_death_compat_process_report(uintptr_t ped, int spawn_ready) { return 0; } + /* + * OBSERVED_037 + PROBE_TRACE: + * m_bIsWasted is a local state transition, not the return value of the + * RakNet send. Latch it before the one-shot Death RPC so local sync remains + * suppressed even when queuing that notification fails. + */ + InterlockedExchange(&g_runtime.local_respawn_pending, 0); + InterlockedExchange(&g_runtime.local_death_reported, 1); result = samp_raknet_client_send_death_notification(g_runtime.net_mgr.raknet_client, (uint8_t)SAMP_DEATH_REPORT_REASON_UNKNOWN, (uint16_t)SAMP_DEATH_REPORT_RESPONSIBLE_NONE); - if (result == 0) { - InterlockedExchange(&g_runtime.local_death_reported, 1); - } runtime_tracef("local_death: report reason=%u responsible=%u result=%d health=%.3f action=%lu health_read=%d " "action_read=%d spawn_ready=%d evidence=OBSERVED_037,ALT_02X_CODE,INFERRED,TODO_VERIFY", (unsigned)SAMP_DEATH_REPORT_REASON_UNKNOWN, (unsigned)SAMP_DEATH_REPORT_RESPONSIBLE_NONE, result, (double)health, (unsigned long)action, health_read, action_read, spawn_ready); - return result == 0; + return 1; } static int class_selection_compat_process_after_death_latch(uintptr_t ped, int spawn_ready) { @@ -20931,6 +24696,7 @@ static int class_selection_compat_process_after_death_latch(uintptr_t ped, int s * RPC_Spawn (52) or suppress the later, explicitly selected spawn. */ InterlockedExchange(&g_runtime.local_death_reported, 0); + InterlockedExchange(&g_runtime.local_respawn_pending, 0); runtime_tracef("class_selection: f4_after_death_consumed health=%.3f action=%lu " "spawn_ready=%d normal_respawn_suppressed=1 " "evidence=STATIC_037:samp.dll+0x7D24..+0x7DE1", @@ -20993,10 +24759,12 @@ static void class_selection_compat_update_mouse_mode(void) { const int active = class_selection_compat_active(); const int dialog_active = dialog_compat_active(); const int textdraw_select_active = InterlockedCompareExchange(&g_runtime.textdraw_select_active, 0, 0) != 0; + const int scoreboard_cursor_owned = scoreboard_compat_cursor_owned(); const int cursor_available = game_window_foreground_compat() && !gta_frontend_menu_active_compat(); - if (active && cursor_available && !dialog_active && !textdraw_select_active) { + if (active && cursor_available && !dialog_active && !textdraw_select_active && + !scoreboard_cursor_owned) { if (InterlockedExchange(&g_runtime.class_selection_mouse_mode, 1) == 0) { dialog_compat_set_mouse_mode(1); runtime_tracef("class_selection: mouse mode enabled evidence=STATIC_037,INFERRED,TODO_VERIFY"); @@ -21005,12 +24773,13 @@ static void class_selection_compat_update_mouse_mode(void) { } if (InterlockedExchange(&g_runtime.class_selection_mouse_mode, 0) != 0 && !dialog_active && - !textdraw_select_active) { + !textdraw_select_active && !scoreboard_cursor_owned) { InterlockedExchange(&g_runtime.class_selection_mouse_down, 0); dialog_compat_set_mouse_mode(0); runtime_tracef("class_selection: mouse mode disabled active=%d cursor_available=%d dialog=%d textdraw=%d " + "scoreboard_cursor_owned=%d " "evidence=STATIC_037:samp.dll+0x61B75,INFERRED,TODO_VERIFY", - active, cursor_available, dialog_active, textdraw_select_active); + active, cursor_available, dialog_active, textdraw_select_active, scoreboard_cursor_owned); } } @@ -21080,6 +24849,9 @@ static int class_selection_compat_ensure_resources(void *device) { char path[MAX_PATH]; int written = 0; HRESULT hr = E_FAIL; + static LONG texture_style_initialized = 0; + static int texture_style_enabled = 0; + const char *texture_style_value = NULL; if (device == NULL) { return 0; @@ -21111,6 +24883,25 @@ static int class_selection_compat_ensure_resources(void *device) { } } + /* + * USER_REQUESTED: + * Keep the replacement's square/outlined controls as the default. The + * external R5 sampgui.png skin remains available as an explicit opt-in for + * original-client pixel-pair work. + */ + if (InterlockedCompareExchange(&texture_style_initialized, 0, 0) == 0) { + texture_style_value = getenv(SAMP_CLASS_SELECTION_R5_TEXTURE_ENV); + texture_style_enabled = + texture_style_value != NULL && texture_style_value[0] != '\0' && + texture_style_value[0] != '0' && texture_style_value[0] != 'n' && + texture_style_value[0] != 'N' && texture_style_value[0] != 'f' && + texture_style_value[0] != 'F'; + InterlockedExchange(&texture_style_initialized, 1); + } + if (!texture_style_enabled) { + return 0; + } + if (g_runtime.class_selection_texture != NULL) { return 1; } @@ -21383,6 +25174,8 @@ static int class_selection_compat_draw_d3dx_overlay(void *device, samp_id3dx_fon int spawn_w = 0; LONG mouse_x = 0; LONG mouse_y = 0; + LONG class_mouse_down = 0; + int class_mouse_mode = 0; int skin_drawn = 0; int dialog_x = 0; int dialog_y = 0; @@ -21404,11 +25197,9 @@ static int class_selection_compat_draw_d3dx_overlay(void *device, samp_id3dx_fon &button_h, &spawn_w); dialog_x = left_x - SAMP_CLASS_SELECTION_BUTTON_X0; dialog_y = left_y - SAMP_CLASS_SELECTION_BUTTON_Y; - if (!dialog_compat_d3d_alpha_rect(device, dialog_x, dialog_y, SAMP_CLASS_SELECTION_DIALOG_WIDTH, - SAMP_CLASS_SELECTION_DIALOG_HEIGHT, SAMP_CLASS_SELECTION_DIALOG_COLOR)) { - dialog_compat_d3d_fill_rect(device, dialog_x, dialog_y, SAMP_CLASS_SELECTION_DIALOG_WIDTH, - SAMP_CLASS_SELECTION_DIALOG_HEIGHT, SAMP_CLASS_SELECTION_DIALOG_COLOR); - } + ui_compat_draw_glass_panel(device, dialog_x, dialog_y, + SAMP_CLASS_SELECTION_DIALOG_WIDTH, + SAMP_CLASS_SELECTION_DIALOG_HEIGHT, 0); (void)class_selection_compat_ensure_resources(device); if (g_runtime.class_selection_d3dx_font != NULL) { @@ -21419,21 +25210,36 @@ static int class_selection_compat_draw_d3dx_overlay(void *device, samp_id3dx_fon } mouse_x = InterlockedCompareExchange(&g_runtime.dialog_mouse_x, 0, 0); mouse_y = InterlockedCompareExchange(&g_runtime.dialog_mouse_y, 0, 0); + class_mouse_down = InterlockedCompareExchange( + &g_runtime.class_selection_mouse_down, 0, 0); + class_mouse_mode = + InterlockedCompareExchange(&g_runtime.class_selection_mouse_mode, 0, + 0) != 0; skin_drawn = class_selection_compat_draw_button_skin(device, left_x, left_y, right_x, right_y, spawn_x, spawn_y, button_w, button_h, mouse_x, mouse_y); if (!skin_drawn) { - if (!dialog_compat_d3d_alpha_rect(device, left_x, left_y, button_w, button_h, SAMP_MODERN_UI_PANEL_COLOR)) { - dialog_compat_d3d_fill_rect(device, left_x, left_y, button_w, button_h, SAMP_MODERN_UI_PANEL_FALLBACK); - } - if (!dialog_compat_d3d_alpha_rect(device, right_x, right_y, button_w, button_h, SAMP_MODERN_UI_PANEL_COLOR)) { - dialog_compat_d3d_fill_rect(device, right_x, right_y, button_w, button_h, SAMP_MODERN_UI_PANEL_FALLBACK); - } - if (!dialog_compat_d3d_alpha_rect(device, spawn_x, spawn_y, spawn_w, button_h, SAMP_MODERN_UI_PANEL_COLOR)) { - dialog_compat_d3d_fill_rect(device, spawn_x, spawn_y, spawn_w, button_h, SAMP_MODERN_UI_PANEL_FALLBACK); - } - textdraw_compat_draw_rect_outline(device, left_x, left_y, button_w, button_h, SAMP_MODERN_UI_BORDER_COLOR); - textdraw_compat_draw_rect_outline(device, right_x, right_y, button_w, button_h, SAMP_MODERN_UI_BORDER_COLOR); - textdraw_compat_draw_rect_outline(device, spawn_x, spawn_y, spawn_w, button_h, SAMP_MODERN_UI_BORDER_COLOR); + const int left_hover = + class_mouse_mode && + dialog_compat_point_in_rect((int)mouse_x, (int)mouse_y, left_x, + left_y, button_w, button_h); + const int right_hover = + class_mouse_mode && + dialog_compat_point_in_rect((int)mouse_x, (int)mouse_y, right_x, + right_y, button_w, button_h); + const int spawn_hover = + class_mouse_mode && + dialog_compat_point_in_rect((int)mouse_x, (int)mouse_y, spawn_x, + spawn_y, spawn_w, button_h); + + ui_compat_draw_glass_control(device, left_x, left_y, button_w, button_h, + 0, left_hover, + left_hover && class_mouse_down != 0); + ui_compat_draw_glass_control(device, right_x, right_y, button_w, + button_h, 0, right_hover, + right_hover && class_mouse_down != 0); + ui_compat_draw_glass_control(device, spawn_x, spawn_y, spawn_w, button_h, + 1, spawn_hover, + spawn_hover && class_mouse_down != 0); } rect.left = left_x; @@ -21452,9 +25258,6 @@ static int class_selection_compat_draw_d3dx_overlay(void *device, samp_id3dx_fon rect.bottom = spawn_y + button_h; class_selection_compat_draw_centered_text(ui_font, rect, "Spawn", 0xFFFFFFFFu); - if (InterlockedCompareExchange(&g_runtime.class_selection_mouse_mode, 0, 0) != 0) { - ui_compat_draw_cursor(device, (int)mouse_x, (int)mouse_y); - } if (InterlockedCompareExchange(&g_runtime.class_selection_overlay_logged, 1, 0) == 0) { runtime_tracef("class_selection: overlay enabled skin=%ld texture=%d " "evidence=STATIC_037:samp.dll+0x6150..+0x619E,+0xC5620..+0xC5972", @@ -23751,11 +27554,42 @@ static void game_text_compat_clear_slot(int style, uint32_t seq, const char *rea (unsigned long)seq, style, reason != NULL ? reason : "unknown"); } +static void game_text_compat_clear_all(uint32_t seq, const char *reason) { + int style = 0; + int cleared = 0; + + /* + * STATIC_037: + * CGame::DisplayGameText at samp.dll+0xA0CE0 first executes the script + * descriptor at samp.dll+0xEC724. Its bytes are BE 00, GTA opcode 0x00BE + * text_clear_all. Only afterwards does R5 copy/sanitize the new text and + * call GTA's display routine at samp.dll+0xA0D3E. GameText styles therefore share + * one global lifetime; a newly accepted RPC 73 cannot overlap an older one. + */ + for (style = 0; style < (int)SAMP_RAKNET_GAMETEXT_MAX_STYLES; ++style) { + samp_game_text_slot_compat *slot = &g_runtime.game_text_slots[style]; + + if (InterlockedExchange(&slot->active, 0) != 0) { + ++cleared; + } + memset(slot, 0, sizeof(*slot)); + } + InterlockedExchange(&g_runtime.game_text_active_count, 0); + InterlockedExchange(&g_runtime.game_text_active, 0); + InterlockedExchange(&g_runtime.game_text_logged, 0); + InterlockedExchange(&g_runtime.game_text_seq, (LONG)seq); + InterlockedExchange(&g_runtime.game_text_style, -1); + g_runtime.game_text_expire_tick = 0u; + g_runtime.game_text[0] = '\0'; + runtime_tracef("game_text: clear_all seq=%lu cleared=%d reason=%s " + "evidence=STATIC_037:samp.dll+0xA0CE0,+0xA0CEC,+0xEC724,+0xA0D3E", + (unsigned long)seq, cleared, reason != NULL ? reason : "unknown"); +} + static void game_text_compat_show_slot(int style, uint32_t seq, int32_t time_ms, const char *text) { samp_game_text_slot_compat *slot = NULL; DWORD display_ms = 0u; DWORD now = 0u; - LONG was_active = 0; if (!game_text_compat_style_valid(style)) { runtime_tracef("game_text: ignore_show seq=%lu invalid_style=%d evidence=OPENMP_REF,INFERRED,TODO_VERIFY", @@ -23763,17 +27597,17 @@ static void game_text_compat_show_slot(int style, uint32_t seq, int32_t time_ms, return; } if (text == NULL || text[0] == '\0') { - game_text_compat_clear_slot(style, seq, "empty_text"); + game_text_compat_clear_all(seq, "empty_text"); return; } display_ms = game_text_compat_display_time_ms(time_ms); if (display_ms == 0u) { - game_text_compat_clear_slot(style, seq, "zero_time"); + game_text_compat_clear_all(seq, "zero_time"); return; } + game_text_compat_clear_all(seq, "replace_before_show"); slot = &g_runtime.game_text_slots[style]; - was_active = InterlockedCompareExchange(&slot->active, 0, 0); memset(slot, 0, sizeof(*slot)); slot->seq = seq; slot->style = style; @@ -23783,9 +27617,7 @@ static void game_text_compat_show_slot(int style, uint32_t seq, int32_t time_ms, now = GetTickCount(); slot->expire_tick = now + display_ms; InterlockedExchange(&slot->active, 1); - if (was_active == 0) { - InterlockedIncrement(&g_runtime.game_text_active_count); - } + InterlockedExchange(&g_runtime.game_text_active_count, 1); InterlockedExchange(&g_runtime.game_text_seq, (LONG)seq); InterlockedExchange(&g_runtime.game_text_style, (LONG)style); g_runtime.game_text_expire_tick = slot->expire_tick; @@ -23794,7 +27626,7 @@ static void game_text_compat_show_slot(int style, uint32_t seq, int32_t time_ms, InterlockedExchange(&g_runtime.game_text_active, 1); InterlockedExchange(&g_runtime.game_text_logged, 0); runtime_tracef("game_text: show seq=%lu style=%d time=%lu text='%s' " - "evidence=STATIC_037,OPENMP_REF,MTA_REF,INFERRED,TODO_VERIFY", + "evidence=STATIC_037:samp.dll+0xA0CE0,+0xA0CEC,+0xEC724,+0xA0D3E", (unsigned long)seq, style, (unsigned long)display_ms, slot->text); } @@ -23803,7 +27635,7 @@ static void game_text_compat_apply_event(const samp_raknet_game_text_event *even return; } if (event->action == SAMP_RAKNET_GAMETEXT_ACTION_HIDE) { - game_text_compat_clear_slot(event->style, event->seq, "event"); + game_text_compat_clear_all(event->seq, "event_hide"); return; } if (event->action == SAMP_RAKNET_GAMETEXT_ACTION_SHOW) { @@ -23904,8 +27736,7 @@ static void game_text_compat_update_from_snapshot(const samp_raknet_rpc_probe_sn return; } if (snapshot->game_text[0] == '\0' || snapshot->game_text_time_ms <= 0) { - game_text_compat_clear_slot((int)snapshot->game_text_style, snapshot->game_text_seq, "legacy_snapshot"); - InterlockedExchange(&g_runtime.game_text_seq, (LONG)snapshot->game_text_seq); + game_text_compat_clear_all(snapshot->game_text_seq, "legacy_snapshot_hide"); return; } game_text_compat_show_slot((int)snapshot->game_text_style, snapshot->game_text_seq, snapshot->game_text_time_ms, @@ -24012,9 +27843,6 @@ static int textdraw_compat_draw_d3dx_overlay(void *device) { ++drawn; } } - if (InterlockedCompareExchange(&g_runtime.textdraw_select_active, 0, 0) != 0 && !dialog_compat_active()) { - ui_compat_draw_cursor(device, (int)mouse_x, (int)mouse_y); - } if (drawn > 0 && InterlockedCompareExchange(&g_runtime.textdraw_logged, 1, 0) == 0) { runtime_tracef("textdraw_d3dx: drawing enabled active=%ld drawn=%d", (long)active_count, drawn); } @@ -24022,13 +27850,23 @@ static int textdraw_compat_draw_d3dx_overlay(void *device) { } static void dialog_compat_draw_button(samp_id3dx_font_compat *font, void *device, int x, int y, int w, int h, - const char *text, int primary) { + const char *text, int primary, + LONG target) { + const LONG mouse_x = + InterlockedCompareExchange(&g_runtime.dialog_mouse_x, 0, 0); + const LONG mouse_y = + InterlockedCompareExchange(&g_runtime.dialog_mouse_y, 0, 0); + const LONG dialog_mouse_down = + InterlockedCompareExchange(&g_runtime.dialog_mouse_down, 0, 0); + const int hovered = dialog_compat_point_in_rect( + (int)mouse_x, (int)mouse_y, x, y, w, h); + const int pressed = hovered && dialog_mouse_down == target; RECT rect; - textdraw_compat_d3d_fill_rect(device, x, y, w, h, primary ? SAMP_DIALOG_COMPAT_COLOR_PRIMARY_BUTTON - : SAMP_DIALOG_COMPAT_COLOR_BUTTON); + ui_compat_draw_glass_control(device, x, y, w, h, primary, hovered, + pressed); rect.left = x; - rect.top = y + 5; + rect.top = y + 5 + (pressed ? 1 : 0); rect.right = x + w; rect.bottom = y + h; chat_compat_d3dx_draw_text(font, rect, text != NULL && text[0] != '\0' ? text : "OK", SAMP_DIALOG_COMPAT_COLOR_TEXT, @@ -24130,6 +27968,7 @@ static int dialog_compat_draw_d3dx_overlay(void *device, samp_id3dx_font_compat LONG style = InterlockedCompareExchange(&g_runtime.dialog_overlay_style, 0, 0); LONG selected = InterlockedCompareExchange(&g_runtime.dialog_overlay_selected, 0, 0); LONG scroll = InterlockedCompareExchange(&g_runtime.dialog_overlay_scroll, 0, 0); + LONG local_kind = InterlockedCompareExchange(&g_runtime.dialog_overlay_local_kind, 0, 0); samp_dialog_layout_compat layout; int panel_x = 0; int panel_y = 0; @@ -24182,25 +28021,25 @@ static int dialog_compat_draw_d3dx_overlay(void *device, samp_id3dx_font_compat if (dialog_compat_uses_input(style)) { /* - * OBSERVED_037: - * The 800x600 R5 password golden has a 230x130 centered body plus a - * two-pixel outer shadow. Its body is uniformly dark; there is no - * separately visible flat header strip. + * OBSERVED_037 + USER_REQUESTED: + * Preserve the measured R5 geometry and control positions, but present + * the replacement's requested dark-glass surface. The external R5 atlas + * remains an explicit opt-in and can still skin the controls below. */ - if (!dialog_compat_d3d_alpha_rect(device, panel_x - 2, panel_y - 2, panel_w + 4, panel_h + 4, - SAMP_DIALOG_INPUT_PANEL_COLOR)) { - textdraw_compat_d3d_fill_rect(device, panel_x - 2, panel_y - 2, panel_w + 4, panel_h + 4, - SAMP_DIALOG_INPUT_PANEL_COLOR); - } + ui_compat_draw_glass_panel(device, panel_x - 2, panel_y - 2, + panel_w + 4, panel_h + 4, + layout.caption_h > 0 ? layout.caption_h + 2 + : 0); rect.left = panel_x + 5; rect.top = panel_y; rect.right = panel_x + panel_w; rect.bottom = panel_y + layout.caption_h; } else { - dialog_compat_d3d_fill_rect(device, panel_x - 2, panel_y - 2, panel_w + 4, panel_h + 4, 0xFF000000u); - textdraw_compat_d3d_fill_rect(device, panel_x, panel_y, panel_w, panel_h, SAMP_DIALOG_COMPAT_COLOR_PANEL); - textdraw_compat_d3d_fill_rect(device, panel_x, panel_y, panel_w, 30, SAMP_DIALOG_COMPAT_COLOR_HEADER); - textdraw_compat_d3d_fill_rect(device, body_x, body_y, body_w, body_h, SAMP_DIALOG_COMPAT_COLOR_BODY); + ui_compat_draw_glass_panel(device, panel_x, panel_y, panel_w, panel_h, + 30); + (void)ui_compat_draw_blended_rect( + device, body_x, body_y, body_w, body_h, + SAMP_DIALOG_COMPAT_COLOR_BODY); rect.left = panel_x + 12; rect.top = panel_y + 7; rect.right = panel_x + panel_w - 12; @@ -24237,8 +28076,9 @@ static int dialog_compat_draw_d3dx_overlay(void *device, samp_id3dx_font_compat continue; } if (item_index == selected) { - dialog_compat_d3d_fill_rect(device, body_x + 4, item_y - 1, body_w - 8, 21, - SAMP_DIALOG_COMPAT_COLOR_SELECTED); + (void)ui_compat_draw_blended_rect( + device, body_x + 4, item_y - 1, body_w - 8, 21, + SAMP_DIALOG_COMPAT_COLOR_SELECTED); } rect.left = body_x + 10; rect.top = item_y + 1; @@ -24273,12 +28113,37 @@ static int dialog_compat_draw_d3dx_overlay(void *device, samp_id3dx_font_compat -1, -1); } if (!input_skin_drawn) { - dialog_compat_d3d_fill_rect(device, layout.input_x, layout.input_y, layout.input_w, layout.input_h, - 0xFF050505u); - textdraw_compat_draw_rect_outline(device, layout.input_x, layout.input_y, layout.input_w, layout.input_h, - SAMP_DIALOG_INPUT_BORDER_COLOR); + (void)ui_compat_draw_blended_rect( + device, layout.input_x, layout.input_y, layout.input_w, + layout.input_h, SAMP_MODERN_UI_BODY_COLOR); + scoreboard_compat_draw_border( + device, layout.input_x, layout.input_y, layout.input_w, + layout.input_h, SAMP_DIALOG_INPUT_BORDER_COLOR); } dialog_compat_draw_input_value(input_font, device, &layout, style); + } else if (local_kind == SAMP_DIALOG_COMPAT_LOCAL_HELP) { + int line_y = body_y + 8; + int line_count = dialog_compat_line_count(g_runtime.dialog_overlay_info); + + /* + * STATIC_037: + * CHelpDialog::Show at samp.dll+0x6BB30 passes inline {RRGGBB} tags to the + * ordinary dialog renderer. Preserve those tags for the segmented draw; + * server dialog extraction intentionally strips them for list responses. + */ + for (i = 0; i < line_count; ++i) { + char line[SAMP_DIALOG_COMPAT_MAX_ITEM_BYTES]; + if (!dialog_compat_get_raw_line(g_runtime.dialog_overlay_info, i, line, + sizeof(line))) { + continue; + } + rect.left = body_x + 8; + rect.top = line_y + (i * 20); + rect.right = body_x + body_w - 8; + rect.bottom = rect.top + 20; + chat_compat_d3dx_draw_text_outline_segments( + body_font, rect, line, SAMP_DIALOG_COMPAT_COLOR_TEXT); + } } else { rect.left = body_x + 8; rect.top = body_y + 8; @@ -24320,17 +28185,16 @@ static int dialog_compat_draw_d3dx_overlay(void *device, samp_id3dx_font_compat SAMP_DIALOG_COMPAT_COLOR_TEXT); } } else if (g_runtime.dialog_overlay_button2[0] != '\0') { - dialog_compat_draw_button(font, device, layout.button2_x, layout.button2_y, layout.button2_w, - layout.button2_h, g_runtime.dialog_overlay_button2, 0); + dialog_compat_draw_button(font, device, layout.button2_x, layout.button2_y, + layout.button2_w, layout.button2_h, + g_runtime.dialog_overlay_button2, 0, 2); dialog_compat_draw_button(font, device, layout.button1_x, layout.button1_y, layout.button1_w, - layout.button1_h, g_runtime.dialog_overlay_button1, 1); + layout.button1_h, g_runtime.dialog_overlay_button1, 1, 1); } else { dialog_compat_draw_button(font, device, layout.button1_x, layout.button1_y, layout.button1_w, layout.button1_h, - g_runtime.dialog_overlay_button1, 1); + g_runtime.dialog_overlay_button1, 1, 1); } - ui_compat_draw_cursor(device, (int)mouse_x, (int)mouse_y); - return 1; } @@ -24392,9 +28256,6 @@ static int edit_state_compat_draw_d3dx_overlay(void *device, samp_id3dx_font_com } rect.left = x + 12; rect.top = y + 82; rect.right = x + w - 154; rect.bottom = y + 104; chat_compat_d3dx_draw_text(font, rect, value, 0xFFDDDDDDu, DT_SINGLELINE | DT_LEFT | DT_NOCLIP); - ui_compat_draw_cursor(device, - (int)InterlockedCompareExchange(&g_runtime.dialog_mouse_x, 0, 0), - (int)InterlockedCompareExchange(&g_runtime.dialog_mouse_y, 0, 0)); return 1; } @@ -24463,11 +28324,87 @@ static void chat_compat_end_d3dx_overlay_state(void *state_block, screenshot_compat_release_unknown(state_block); } +static int netstats_compat_draw_d3dx_overlay(samp_id3dx_font_compat *font) { + static LONG logged = 0; + char stats_text[SAMP_NETSTATS_COMPAT_TEXT_BYTES]; + char rate_line[128]; + double download_rate = 0.0; + double upload_rate = 0.0; + RECT rect; + int result = -1; + int line_count = 0; + int line_index = 0; + int y = 10; + const int line_height = 13; + + if (font == NULL || g_runtime.net_mgr.raknet_client == NULL) { + return 0; + } + result = samp_raknet_client_format_transport_statistics( + g_runtime.net_mgr.raknet_client, stats_text, sizeof(stats_text), + &download_rate, &upload_rate); + if (result != 0) { + return 0; + } + + /* + * STATIC_037: + * samp.dll+0x60D70 draws this title at (10,10), samples one-second byte + * deltas, and appends the verbosity-4 RakNet report from +0x36610. + * F5 is a held-key overlay, not a modal dialog. + */ + rect.left = 10; + rect.top = y; + rect.right = 2000; + rect.bottom = y + line_height + 4; + chat_compat_d3dx_draw_text_outline(font, rect, "Client Network Stats", + SAMP_NETSTATS_COMPAT_TITLE_COLOR); + y += line_height + 3; + + (void)snprintf(rate_line, sizeof(rate_line), "Download Rate: %.2f KB/s", + download_rate); + rate_line[sizeof(rate_line) - 1u] = '\0'; + rect.top = y; + rect.bottom = y + line_height + 4; + chat_compat_d3dx_draw_text_outline(font, rect, rate_line, + SAMP_CHAT_COMPAT_COLOR_INFO); + y += line_height; + (void)snprintf(rate_line, sizeof(rate_line), "Upload Rate: %.2f KB/s", + upload_rate); + rate_line[sizeof(rate_line) - 1u] = '\0'; + rect.top = y; + rect.bottom = y + line_height + 4; + chat_compat_d3dx_draw_text_outline(font, rect, rate_line, + SAMP_CHAT_COMPAT_COLOR_INFO); + y += line_height; + + line_count = dialog_compat_line_count(stats_text); + for (line_index = 0; line_index < line_count; ++line_index) { + char line[SAMP_DIALOG_COMPAT_MAX_ITEM_BYTES]; + if (!dialog_compat_get_raw_line(stats_text, line_index, line, + sizeof(line))) { + continue; + } + rect.top = y; + rect.bottom = y + line_height + 4; + chat_compat_d3dx_draw_text_outline(font, rect, line, + SAMP_CHAT_COMPAT_COLOR_INFO); + y += line_height; + } + if (InterlockedCompareExchange(&logged, 1, 0) == 0) { + runtime_tracef("netstats_overlay: F5 held lines=%d rates=%.2f/%.2f " + "evidence=STATIC_037:samp.dll+0x60D70,+0x36610,+0x75967", + line_count + 2, download_rate, upload_rate); + } + return 1; +} + static int chat_compat_draw_d3dx_overlay(void *device) { LONG count = 0; LONG display_start = 0; LONG display_count = 0; LONG page_size = 0; + LONG chat_display_mode = 0; LONG input_active = 0; LONG dialog_active = 0; int menu_active = 0; @@ -24483,6 +28420,7 @@ static int chat_compat_draw_d3dx_overlay(void *device) { int class_selection_active = 0; int loading_active = 0; int scoreboard_active = 0; + int netstats_active = 0; int x = 0; int y = 0; int history_y = 0; @@ -24510,7 +28448,15 @@ static int chat_compat_draw_d3dx_overlay(void *device) { return 0; } + netstats_active = + InterlockedCompareExchange(&g_runtime.netgame_state, 0, 0) == + SAMP_NETGAME_CONNECTED && + g_runtime.net_mgr.raknet_client != NULL && + samp_raknet_client_is_connected(g_runtime.net_mgr.raknet_client) && + GetAsyncKeyState(VK_F5) != 0; count = InterlockedCompareExchange(&g_runtime.chat_overlay_line_count, 0, 0); + chat_display_mode = + InterlockedCompareExchange(&g_runtime.chat_display_mode, 0, 0); input_active = InterlockedCompareExchange(&g_runtime.chat_input_active, 0, 0); dialog_active = InterlockedCompareExchange(&g_runtime.dialog_overlay_active, 0, 0); menu_active = menu_compat_active(); @@ -24525,8 +28471,10 @@ static int chat_compat_draw_d3dx_overlay(void *device) { death_window_active = death_window_compat_active(); class_selection_active = class_selection_compat_active(); loading_active = loading_screen_compat_active(); - scoreboard_active = scoreboard_compat_active(); - if (count <= 0 && input_active == 0 && dialog_active == 0 && !menu_active && !edit_active && textdraw_active <= 0 && !game_text_active && + scoreboard_active = scoreboard_compat_visible_latched(); + if ((count <= 0 || chat_display_mode == SAMP_CHAT_COMPAT_DISPLAY_HIDDEN) && + input_active == 0 && dialog_active == 0 && !menu_active && !edit_active && + textdraw_active <= 0 && !game_text_active && !netstats_active && !loading_active && !scoreboard_active && vehicle_debug_active == 0 && object_visual_active == 0 && !text_labels_active && !name_tags_active && !chat_bubbles_active && @@ -24542,6 +28490,9 @@ static int chat_compat_draw_d3dx_overlay(void *device) { if (count > SAMP_CHAT_COMPAT_MAX_LINES) { count = SAMP_CHAT_COMPAT_MAX_LINES; } + if (chat_display_mode == SAMP_CHAT_COMPAT_DISPLAY_HIDDEN) { + count = 0; + } page_size = InterlockedCompareExchange(&g_runtime.chat_page_size, 0, 0); if (page_size < SAMP_CHAT_COMPAT_MIN_PAGE_SIZE || page_size > SAMP_CHAT_COMPAT_MAX_PAGE_SIZE) { page_size = SAMP_CHAT_COMPAT_DEFAULT_PAGE_SIZE; @@ -24551,30 +28502,66 @@ static int chat_compat_draw_d3dx_overlay(void *device) { display_count = count; } display_start = count - display_count; + + /* + * STATIC_037: + * CNetGame's R5 render branch reads CScoreboard+0x0 at + * samp.dll+0x7593A..+0x7593F. A visible scoreboard calls + * CGame::DisplayHud(0) and CScoreboard::Draw at +0x75944..+0x75957, then + * jumps to +0x75C65. This skips Chat::Draw (+0x75A81), + * ChatInput::Draw (+0x75A90), F5 netstats, GameText, TextDraws, labels and + * every other normal SA-MP overlay while leaving their buffered state + * untouched. + */ + if (scoreboard_active) { + (void)chat_compat_begin_d3dx_overlay_state(device, &state_block, &apply_state_block); + (void)scoreboard_compat_draw_d3dx_overlay(device); + (void)ui_compat_draw_cursor(device); + if (InterlockedCompareExchange(&g_runtime.scoreboard_exclusive_overlay_logged, 1, 0) == 0) { + runtime_tracef("scoreboard: exclusive_overlay normal_overlay_draws=0 chat_lines=%ld chat_input=%ld " + "gametext=%d textdraws=%ld dialog=%ld " + "evidence=STATIC_037:samp.dll+0x7593A,+0x7595C,+0x75A81,+0x75A90", + (long)display_count, (long)input_active, game_text_active, (long)textdraw_active, + (long)dialog_active); + } + chat_compat_end_d3dx_overlay_state(state_block, apply_state_block); + return 1; + } + if (!chat_compat_ensure_d3dx_font(device)) { return 0; } (void)chat_compat_begin_d3dx_overlay_state(device, &state_block, &apply_state_block); + /* + * STATIC_037: CNetGame::Process at samp.dll+0x75967 returns from the SA-MP + * overlay path after drawing held-F5 statistics in connected state 5. + */ + if (netstats_active) { + (void)netstats_compat_draw_d3dx_overlay(g_runtime.chat_d3dx_font); + chat_compat_end_d3dx_overlay_state(state_block, apply_state_block); + return 1; + } + if (loading_active) { (void)loading_screen_compat_draw_d3dx_overlay(device); } - if (textdraw_active > 0 && !scoreboard_active) { + if (textdraw_active > 0) { (void)textdraw_compat_draw_d3dx_overlay(device); } - if (game_text_active && dialog_active == 0 && !scoreboard_active) { + if (game_text_active && dialog_active == 0) { (void)game_text_compat_draw_d3dx_overlay(device); } - if (class_selection_active && dialog_active == 0 && !scoreboard_active) { + if (class_selection_active && dialog_active == 0) { (void)class_selection_compat_draw_d3dx_overlay(device, g_runtime.chat_d3dx_font); } else { class_selection_compat_update_mouse_mode(); } - if (menu_active && dialog_active == 0 && !scoreboard_active) { + if (menu_active && dialog_active == 0) { (void)menu_compat_draw_d3dx_overlay(device, g_runtime.chat_d3dx_font); } - if (edit_active && dialog_active == 0 && !scoreboard_active) { + if (edit_active && dialog_active == 0) { (void)edit_state_compat_draw_d3dx_overlay(device, g_runtime.chat_d3dx_font); } chat_compat_viewport_origin(&x, &y); @@ -24617,6 +28604,9 @@ static int chat_compat_draw_d3dx_overlay(void *device) { rect.right = x + 900; rect.bottom = rect.top + line_height + 4; chat_compat_format_stored_line(line_index, display_line, sizeof(display_line)); + if (chat_display_mode == SAMP_CHAT_COMPAT_DISPLAY_PLAIN) { + chat_compat_strip_samp_color_tags(display_line); + } chat_compat_d3dx_draw_text_outline_segments( g_runtime.chat_d3dx_font, rect, display_line, g_runtime.chat_overlay_colors[line_index] != 0u ? g_runtime.chat_overlay_colors[line_index] @@ -24625,19 +28615,19 @@ static int chat_compat_draw_d3dx_overlay(void *device) { if (vehicle_debug_active != 0) { (void)vehicle_debug_labels_draw_d3dx_overlay(device, g_runtime.chat_d3dx_font); } - if (object_visual_active != 0 && !class_selection_active && !scoreboard_active) { + if (object_visual_active != 0 && !class_selection_active) { (void)object_compat_draw_visual_fallback_d3dx_overlay(device, g_runtime.chat_d3dx_font); } - if (text_labels_active && !class_selection_active && !scoreboard_active) { + if (text_labels_active && !class_selection_active) { (void)text_label_compat_draw_d3dx_overlay(device, g_runtime.chat_d3dx_font); } - if (name_tags_active && !class_selection_active && !scoreboard_active) { + if (name_tags_active && !class_selection_active) { (void)remote_player_compat_draw_name_tags_d3dx_overlay(device, g_runtime.chat_d3dx_font); } - if (chat_bubbles_active && !class_selection_active && !scoreboard_active) { + if (chat_bubbles_active && !class_selection_active) { (void)chat_bubble_compat_draw_d3dx_overlay(device, g_runtime.chat_d3dx_font); } - if (death_window_active && !class_selection_active && !scoreboard_active) { + if (death_window_active && !class_selection_active) { (void)death_window_compat_draw_d3dx_overlay(device, g_runtime.chat_d3dx_font); } if (input_active != 0) { @@ -24654,9 +28644,8 @@ static int chat_compat_draw_d3dx_overlay(void *device) { } if (dialog_active != 0) { (void)dialog_compat_draw_d3dx_overlay(device, g_runtime.chat_d3dx_font); - } else if (scoreboard_active) { - (void)scoreboard_compat_draw_d3dx_overlay(device); } + (void)ui_compat_draw_cursor(device); if (InterlockedCompareExchange(&g_runtime.chat_d3d_draw_logged, 1, 0) == 0) { runtime_tracef("chat_d3dx: drawing enabled device=0x%08lx lines=%ld dialog=%ld textdraws=%ld gametext=%d class_selection=%d scoreboard=%d labels=%d nametags=%d deathwindow=%d x=%d y=%d", @@ -25083,6 +29072,7 @@ static void chat_compat_draw_overlay(void) { LONG display_start = 0; LONG display_count = 0; LONG page_size = 0; + LONG chat_display_mode = 0; LONG input_active = 0; HWND hwnd = NULL; HDC dc = NULL; @@ -25123,8 +29113,11 @@ static void chat_compat_draw_overlay(void) { } count = InterlockedCompareExchange(&g_runtime.chat_overlay_line_count, 0, 0); + chat_display_mode = + InterlockedCompareExchange(&g_runtime.chat_display_mode, 0, 0); input_active = InterlockedCompareExchange(&g_runtime.chat_input_active, 0, 0); - if (count <= 0 && input_active == 0) { + if ((count <= 0 || chat_display_mode == SAMP_CHAT_COMPAT_DISPLAY_HIDDEN) && + input_active == 0) { return; } if (count < 0) { @@ -25133,6 +29126,9 @@ static void chat_compat_draw_overlay(void) { if (count > SAMP_CHAT_COMPAT_MAX_LINES) { count = SAMP_CHAT_COMPAT_MAX_LINES; } + if (chat_display_mode == SAMP_CHAT_COMPAT_DISPLAY_HIDDEN) { + count = 0; + } page_size = InterlockedCompareExchange(&g_runtime.chat_page_size, 0, 0); if (page_size < SAMP_CHAT_COMPAT_MIN_PAGE_SIZE || page_size > SAMP_CHAT_COMPAT_MAX_PAGE_SIZE) { page_size = SAMP_CHAT_COMPAT_DEFAULT_PAGE_SIZE; @@ -25169,6 +29165,9 @@ static void chat_compat_draw_overlay(void) { char display_line[SAMP_CHAT_COMPAT_LINE_BYTES + 16]; LONG line_index = display_start + i; chat_compat_format_stored_line(line_index, display_line, sizeof(display_line)); + if (chat_display_mode == SAMP_CHAT_COMPAT_DISPLAY_PLAIN) { + chat_compat_strip_samp_color_tags(display_line); + } chat_compat_draw_text_outline_segments( dc, x, history_y + (i * line_height), display_line, g_runtime.chat_overlay_colors[line_index] != 0u ? g_runtime.chat_overlay_colors[line_index] @@ -27517,9 +31516,12 @@ static int samp_asset_register_custom_model_infos_compat(const char *source) { (void)samp_asset_read_model_info_store_count_compat(SAMP_ADDR_MODEL_INFO_ADD_ATOMIC, &store_before); /* OBSERVED_037 + PROBE_TRACE: - * Preserve SAMP.ide parse order. Stock R5 registers the high `objs` rows - * first and the low 11682..11753 block last; model 11753 is the allocation - * that advances the original AtomicModelInfo count from 15416 to 15417. + * Preserve SAMP.ide parse order for the pre-archive Atomic/Time pass. Stock + * R5 registers the high `objs` rows first and the low 11682..11753 block + * last; model 11753 is the allocation that advances the original + * AtomicModelInfo count from 15416 to 15417. Indexed `anim` rows belong to + * the separate clump/IFP lifecycle and neither consume this pass's limit nor + * get registered early here (TODO_VERIFY). */ for (order_index = 0; order_index < order_count; ++order_index) { int32_t model = (int32_t)g_runtime.samp_asset_model_order[order_index]; @@ -27528,6 +31530,10 @@ static int samp_asset_register_custom_model_infos_compat(const char *source) { if (model_entry == NULL) { continue; } + if (!samp_asset_ide_section_is_prearchive_model_info_compat( + model_entry->section)) { + continue; + } if (considered >= (LONG)bulk_limit) { break; } @@ -27559,15 +31565,52 @@ static int samp_asset_register_custom_model_infos_compat(const char *source) { return (registered + already_registered) > 0; } +static samp_asset_prearchive_bulk_plan_compat +samp_asset_build_prearchive_bulk_plan_compat(void) { + samp_asset_prearchive_bulk_plan_compat plan; + LONG order_count = g_runtime.samp_asset_model_order_count; + LONG order_index = 0; + + samp_asset_prearchive_bulk_plan_reset_compat(&plan); + if (order_count < 0) { + order_count = 0; + } else if (order_count > (LONG)SAMP_GTA_MODEL_INFO_COUNT) { + order_count = (LONG)SAMP_GTA_MODEL_INFO_COUNT; + } + + for (order_index = 0; order_index < order_count; ++order_index) { + const int32_t model = + (int32_t)g_runtime.samp_asset_model_order[order_index]; + const samp_asset_model_entry_compat *model_entry = + samp_asset_model_lookup_compat(model); + + if (model_entry != NULL) { + samp_asset_prearchive_bulk_plan_add_section_compat(&plan, + model_entry->section); + } + } + return plan; +} + static int samp_asset_full_bulk_registration_configured_compat(void) { - LONG indexed = g_runtime.samp_asset_model_order_count; + const samp_asset_prearchive_bulk_plan_compat plan = + samp_asset_build_prearchive_bulk_plan_compat(); + const int bulk_enabled = samp_asset_custom_asset_bulk_enabled_compat(); + const uint32_t bulk_limit = samp_asset_custom_asset_bulk_limit_compat(); int model_info_path_ready = samp_asset_custom_render_path_compat() == SAMP_OBJECT_COMPAT_CUSTOM_RENDER_PATH_HEAP || (samp_asset_atomic_model_store_expanded_compat() && samp_asset_atomic_model_store_capacity_compat() >= SAMP_GTA_ATOMIC_MODEL_INFO_COUNT); - return samp_asset_custom_asset_bulk_enabled_compat() && model_info_path_ready && indexed > 0 && - samp_asset_custom_asset_bulk_limit_compat() >= (uint32_t)indexed; + /* OBSERVED_037 + PROBE_TRACE: + * Stock SAMP.ide contributes 1,433 `objs` rows to R5's pre-archive + * ModelInfo pass. The two indexed `anim` rows use the clump/IFP lifecycle + * and must not make the default 1,433-entry Atomic/Time pass look partial. + * GTA_REVERSED_REF + TODO_VERIFY: animated-clump conversion remains a + * separate on-demand phase; this decision does not register it early. + */ + return samp_asset_prearchive_full_bulk_ready_compat( + bulk_enabled, model_info_path_ready, bulk_limit, &plan); } static int samp_asset_register_custom_model_compat(int32_t model, const char *source) { @@ -27830,7 +31873,7 @@ static int samp_asset_custom_proxy_enabled_compat(void) { static int local_spawn_skin_apply_enabled_compat(void) { static LONG initialized = 0; - static int enabled = 0; + static int enabled = 1; const char *value = NULL; if (InterlockedCompareExchange(&initialized, 0, 0)) { @@ -27838,8 +31881,8 @@ static int local_spawn_skin_apply_enabled_compat(void) { } value = getenv(SAMP_LOCAL_SPAWN_SKIN_APPLY_ENV); - if (value != NULL && value[0] == '1' && value[1] == '\0') { - enabled = 1; + if (value != NULL && value[0] != '\0') { + enabled = value[0] != '0' && value[0] != 'n' && value[0] != 'N' && value[0] != 'f' && value[0] != 'F'; } InterlockedExchange(&initialized, 1); return enabled; @@ -28701,25 +32744,499 @@ static void chat_input_game_controls_restore_compat(const char *reason) { reason != NULL ? reason : "unknown", (unsigned long)SAMP_ADDR_GAME_INPUT_UPDATE_CALL, (unsigned)current[0], (unsigned)current[1], (unsigned)current[2], (unsigned)current[3], (unsigned)current[4]); + g_runtime.chat_game_input_patch_owned = 0u; + InterlockedExchange(&g_runtime.chat_game_input_patch_applied, 0); } else if (patch_copy(SAMP_ADDR_GAME_INPUT_UPDATE_CALL, g_runtime.chat_game_input_patch_saved, sizeof(g_runtime.chat_game_input_patch_saved))) { runtime_tracef("chat_input_controls: restore reason=%s module=gta_sa.exe rva=0x00141df5 " "patch=9090909090 restored=e846f3feff length=5 evidence=ALT_02X_CODE,TODO_VERIFY", reason != NULL ? reason : "unknown"); + g_runtime.chat_game_input_patch_owned = 0u; + InterlockedExchange(&g_runtime.chat_game_input_patch_applied, 0); + } else { + runtime_tracef("chat_input_controls: restore_failed reason=%s addr=0x%08lx " + "gle=%lu retry=1 evidence=ALT_02X_CODE,TODO_VERIFY", + reason != NULL ? reason : "unknown", + (unsigned long)SAMP_ADDR_GAME_INPUT_UPDATE_CALL, + (unsigned long)GetLastError()); + } +} + +static int dialog_game_mouse_on_game_thread_compat(const char *reason) { + DWORD game_thread_id = g_runtime.actor_game_thread_id; + DWORD current_thread_id = GetCurrentThreadId(); + + if (game_thread_id != 0u && current_thread_id == game_thread_id) { + InterlockedExchange(&g_runtime.dialog_game_mouse_thread_skip_logged, 0); + return 1; + } + if (InterlockedCompareExchange( + &g_runtime.dialog_game_mouse_thread_skip_logged, 1, 0) == 0) { + runtime_tracef("dialog_mouse_controls: game_thread_defer reason=%s " + "game_thread=%lu current_thread=%lu " + "evidence=PROBE_TRACE,GTA_REVERSED_REF,INFERRED", + reason != NULL ? reason : "unknown", + (unsigned long)game_thread_id, + (unsigned long)current_thread_id); + } + return 0; +} + +static int dialog_game_mouse_controls_apply_compat(const char *reason) { + static const uint8_t poll_original[SAMP_GAME_MOUSE_POLL_CALL_SIZE] = { + 0xE8u, 0xB4u, 0x7Au, 0x20u, 0x00u}; + static const uint8_t poll_patch[SAMP_GAME_MOUSE_POLL_CALL_SIZE] = { + 0x90u, 0x90u, 0x90u, 0x90u, 0x90u}; + static const uint8_t result_original[SAMP_GAME_MOUSE_RESULT_BRANCH_SIZE] = { + 0x85u, 0xC0u, 0x0Fu, 0x8Cu}; + static const uint8_t result_patch[SAMP_GAME_MOUSE_RESULT_BRANCH_SIZE] = { + 0x33u, 0xC0u, 0x0Fu, 0x84u}; + uint8_t poll_current[SAMP_GAME_MOUSE_POLL_CALL_SIZE]; + uint8_t result_current[SAMP_GAME_MOUSE_RESULT_BRANCH_SIZE]; + + if (!dialog_game_mouse_on_game_thread_compat(reason)) { + return 0; + } + InterlockedExchange(&g_runtime.dialog_game_mouse_release_frames, 0); + if (!memory_is_readable_compat( + (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_POLL_CALL, + sizeof(poll_current)) || + !memory_is_readable_compat( + (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH, + sizeof(result_current))) { + runtime_tracef("dialog_mouse_controls: apply_skip reason=%s address_unreadable=1 " + "poll=0x%08lx result=0x%08lx evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown", + (unsigned long)SAMP_ADDR_GAME_MOUSE_POLL_CALL, + (unsigned long)SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH); + return 0; + } + memcpy(poll_current, (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_POLL_CALL, + sizeof(poll_current)); + memcpy(result_current, + (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH, + sizeof(result_current)); + if (memcmp(poll_current, poll_patch, sizeof(poll_current)) == 0 && + memcmp(result_current, result_patch, sizeof(result_current)) == 0) { + return InterlockedCompareExchange( + &g_runtime.dialog_game_mouse_patch_applied, 0, 0) != 0 && + g_runtime.dialog_game_mouse_patch_owned != 0u; + } + if (memcmp(poll_current, poll_original, sizeof(poll_current)) != 0 || + memcmp(result_current, result_original, sizeof(result_current)) != 0) { + runtime_tracef("dialog_mouse_controls: apply_skip reason=%s target_bytes_mismatch=1 " + "poll=%02x%02x%02x%02x%02x result=%02x%02x%02x%02x " + "expected=e8b47a2000/85c00f8c patch=9090909090/33c00f84 " + "evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown", + (unsigned)poll_current[0], (unsigned)poll_current[1], + (unsigned)poll_current[2], (unsigned)poll_current[3], + (unsigned)poll_current[4], (unsigned)result_current[0], + (unsigned)result_current[1], (unsigned)result_current[2], + (unsigned)result_current[3]); + return 0; + } + + memcpy(g_runtime.dialog_game_mouse_poll_saved, poll_current, + sizeof(poll_current)); + memcpy(g_runtime.dialog_game_mouse_result_saved, result_current, + sizeof(result_current)); + if (!patch_copy(SAMP_ADDR_GAME_MOUSE_POLL_CALL, poll_patch, + sizeof(poll_patch))) { + runtime_tracef("dialog_mouse_controls: apply_failed reason=%s target=poll " + "addr=0x%08lx gle=%lu evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown", + (unsigned long)SAMP_ADDR_GAME_MOUSE_POLL_CALL, + (unsigned long)GetLastError()); + return 0; + } + if (!patch_copy(SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH, result_patch, + sizeof(result_patch))) { + int rolled_back = + patch_copy(SAMP_ADDR_GAME_MOUSE_POLL_CALL, + g_runtime.dialog_game_mouse_poll_saved, + sizeof(g_runtime.dialog_game_mouse_poll_saved)); + if (!rolled_back) { + g_runtime.dialog_game_mouse_patch_owned = 1u; + InterlockedExchange(&g_runtime.dialog_game_mouse_patch_applied, 1); + } + runtime_tracef("dialog_mouse_controls: apply_failed reason=%s target=result " + "addr=0x%08lx gle=%lu poll_rollback=%d " + "evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown", + (unsigned long)SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH, + (unsigned long)GetLastError(), rolled_back); + return 0; + } + + g_runtime.dialog_game_mouse_patch_owned = 1u; + InterlockedExchange(&g_runtime.dialog_game_mouse_patch_applied, 1); + runtime_tracef( + "dialog_mouse_controls: apply reason=%s module=gta_sa.exe " + "rva=0x0013f417 original=e8b47a2000 patch=9090909090 length=5 " + "rva2=0x0013f41f original2=85c00f8c patch2=33c00f84 length2=4 " + "restore=dialog_close_plus_10_ticks " + "evidence=STATIC_037:samp.dll+0xA0580,+0xA06F3", + reason != NULL ? reason : "unknown"); + return 1; +} + +static int dialog_game_mouse_state_flush_compat(const char *reason, + int update_after_clear) { + samp_gta_void_fn clear_mouse_history = + (samp_gta_void_fn)(uintptr_t)SAMP_GTA_FUNC_CLEAR_MOUSE_HISTORY; + samp_gta_void_fn update_pads = + (samp_gta_void_fn)(uintptr_t)SAMP_GTA_FUNC_UPDATE_PADS; + + if (!dialog_game_mouse_on_game_thread_compat(reason)) { + return 0; + } + if (!memory_is_writable_compat( + (void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_MOVE_X, sizeof(float)) || + !memory_is_writable_compat( + (void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_MOVE_Y, sizeof(float)) || + !memory_is_gta_executable_compat(SAMP_GTA_FUNC_CLEAR_MOUSE_HISTORY) || + (update_after_clear && + !memory_is_gta_executable_compat(SAMP_GTA_FUNC_UPDATE_PADS))) { + if (InterlockedCompareExchange( + &g_runtime.dialog_game_mouse_flush_fail_logged, 1, 0) == 0) { + runtime_tracef("dialog_mouse_controls: flush_skip reason=%s " + "state_writable=%d clear_exec=%d update_required=%d " + "update_exec=%d " + "evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown", + memory_is_writable_compat( + (void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_MOVE_X, + sizeof(float)) && + memory_is_writable_compat( + (void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_MOVE_Y, + sizeof(float)), + memory_is_gta_executable_compat( + SAMP_GTA_FUNC_CLEAR_MOUSE_HISTORY), + update_after_clear, + memory_is_gta_executable_compat( + SAMP_GTA_FUNC_UPDATE_PADS)); + } + return 0; + } + + /* + * STATIC_037 + GTA_REVERSED_REF: + * R5 cursor mode 2 (samp.dll+0xA06F3..+0xA0761) clears + * CPad::NewMouseControllerState.m_AmountMoved at 0xB73424/28, then invokes + * CPad::ClearMouseHistory and CPad::UpdatePads. The poll/branch patches + * above make the latter rebuild an all-zero mouse state rather than reading + * DirectInput behind the modal dialog. + */ + write_game_float_compat(SAMP_ADDR_GAME_MOUSE_MOVE_X, 0.0f); + write_game_float_compat(SAMP_ADDR_GAME_MOUSE_MOVE_Y, 0.0f); + clear_mouse_history(); + if (update_after_clear) { + update_pads(); + } + InterlockedExchange(&g_runtime.dialog_game_mouse_flush_fail_logged, 0); + return 1; +} + +static int dialog_game_mouse_controls_flush_compat(const char *reason) { + static const uint8_t poll_patch[SAMP_GAME_MOUSE_POLL_CALL_SIZE] = { + 0x90u, 0x90u, 0x90u, 0x90u, 0x90u}; + static const uint8_t result_patch[SAMP_GAME_MOUSE_RESULT_BRANCH_SIZE] = { + 0x33u, 0xC0u, 0x0Fu, 0x84u}; + uint8_t poll_current[SAMP_GAME_MOUSE_POLL_CALL_SIZE]; + uint8_t result_current[SAMP_GAME_MOUSE_RESULT_BRANCH_SIZE]; + + if (!dialog_game_mouse_on_game_thread_compat(reason) || + InterlockedCompareExchange( + &g_runtime.dialog_game_mouse_patch_applied, 0, 0) == 0 || + g_runtime.dialog_game_mouse_patch_owned == 0u || + !memory_is_readable_compat( + (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_POLL_CALL, + sizeof(poll_current)) || + !memory_is_readable_compat( + (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH, + sizeof(result_current))) { + return 0; + } + memcpy(poll_current, (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_POLL_CALL, + sizeof(poll_current)); + memcpy(result_current, + (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH, + sizeof(result_current)); + if (memcmp(poll_current, poll_patch, sizeof(poll_current)) != 0 || + memcmp(result_current, result_patch, sizeof(result_current)) != 0) { + runtime_tracef("dialog_mouse_controls: flush_skip reason=%s " + "validated_patch_pair=0 evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown"); + return 0; + } + return dialog_game_mouse_state_flush_compat(reason, 1); +} + +static int dialog_game_mouse_controls_activate_compat(const char *reason) { + LONG flush_pending = 0; + + if (!dialog_game_mouse_controls_apply_compat(reason)) { + return 0; + } + flush_pending = + InterlockedExchange(&g_runtime.dialog_game_mouse_flush_pending, 0); + if (flush_pending != 0 && + !dialog_game_mouse_controls_flush_compat(reason)) { + InterlockedExchange(&g_runtime.dialog_game_mouse_flush_pending, 1); + return 0; } - g_runtime.chat_game_input_patch_owned = 0u; - InterlockedExchange(&g_runtime.chat_game_input_patch_applied, 0); + return 1; +} + +static int dialog_game_mouse_controls_restore_code_compat( + const char *reason, int flush_gta_state) { + static const uint8_t poll_patch[SAMP_GAME_MOUSE_POLL_CALL_SIZE] = { + 0x90u, 0x90u, 0x90u, 0x90u, 0x90u}; + static const uint8_t result_patch[SAMP_GAME_MOUSE_RESULT_BRANCH_SIZE] = { + 0x33u, 0xC0u, 0x0Fu, 0x84u}; + uint8_t poll_current[SAMP_GAME_MOUSE_POLL_CALL_SIZE]; + uint8_t result_current[SAMP_GAME_MOUSE_RESULT_BRANCH_SIZE]; + int poll_was_patch = 0; + int result_was_patch = 0; + int poll_restored = 0; + int result_restored = 0; + int poll_foreign = 0; + int result_foreign = 0; + int poll_rollback = 0; + int exact_original_pair = 0; + int flushed = 0; + int second_clear = 0; + + if (InterlockedCompareExchange( + &g_runtime.dialog_game_mouse_patch_applied, 0, 0) == 0 || + g_runtime.dialog_game_mouse_patch_owned == 0u) { + return 1; + } + if (!memory_is_readable_compat( + (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_POLL_CALL, + sizeof(poll_current)) || + !memory_is_readable_compat( + (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH, + sizeof(result_current))) { + runtime_tracef("dialog_mouse_controls: restore_defer reason=%s " + "address_unreadable=1 retry=1 evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown"); + return 0; + } + + memcpy(poll_current, (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_POLL_CALL, + sizeof(poll_current)); + memcpy(result_current, + (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH, + sizeof(result_current)); + poll_was_patch = + memcmp(poll_current, poll_patch, sizeof(poll_current)) == 0; + result_was_patch = + memcmp(result_current, result_patch, sizeof(result_current)) == 0; + + if (poll_was_patch) { + poll_restored = + patch_copy(SAMP_ADDR_GAME_MOUSE_POLL_CALL, + g_runtime.dialog_game_mouse_poll_saved, + sizeof(g_runtime.dialog_game_mouse_poll_saved)); + if (!poll_restored) { + runtime_tracef("dialog_mouse_controls: restore_failed reason=%s " + "target=poll gle=%lu retry=1 " + "evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown", + (unsigned long)GetLastError()); + return 0; + } + } else if (memcmp(poll_current, g_runtime.dialog_game_mouse_poll_saved, + sizeof(poll_current)) == 0) { + poll_restored = 1; + } else { + poll_foreign = 1; + runtime_tracef("dialog_mouse_controls: restore_release reason=%s " + "target=poll foreign_change=1 " + "current=%02x%02x%02x%02x%02x " + "evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown", + (unsigned)poll_current[0], (unsigned)poll_current[1], + (unsigned)poll_current[2], (unsigned)poll_current[3], + (unsigned)poll_current[4]); + } + + if (result_was_patch) { + result_restored = + patch_copy(SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH, + g_runtime.dialog_game_mouse_result_saved, + sizeof(g_runtime.dialog_game_mouse_result_saved)); + if (!result_restored) { + if (poll_was_patch && poll_restored) { + poll_rollback = patch_copy(SAMP_ADDR_GAME_MOUSE_POLL_CALL, poll_patch, + sizeof(poll_patch)); + } + runtime_tracef("dialog_mouse_controls: restore_failed reason=%s " + "target=result gle=%lu poll_rollback=%d retry=1 " + "evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown", + (unsigned long)GetLastError(), poll_rollback); + return 0; + } + } else if (memcmp(result_current, g_runtime.dialog_game_mouse_result_saved, + sizeof(result_current)) == 0) { + result_restored = 1; + } else { + result_foreign = 1; + runtime_tracef("dialog_mouse_controls: restore_release reason=%s " + "target=result foreign_change=1 " + "current=%02x%02x%02x%02x " + "evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown", + (unsigned)result_current[0], (unsigned)result_current[1], + (unsigned)result_current[2], (unsigned)result_current[3]); + } + + if (!memory_is_readable_compat( + (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_POLL_CALL, + sizeof(poll_current)) || + !memory_is_readable_compat( + (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH, + sizeof(result_current))) { + runtime_tracef("dialog_mouse_controls: restore_defer reason=%s " + "verify_unreadable=1 retry=1 evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown"); + return 0; + } + memcpy(poll_current, (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_POLL_CALL, + sizeof(poll_current)); + memcpy(result_current, + (const void *)(uintptr_t)SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH, + sizeof(result_current)); + if (memcmp(poll_current, poll_patch, sizeof(poll_current)) == 0 || + memcmp(result_current, result_patch, sizeof(result_current)) == 0) { + runtime_tracef("dialog_mouse_controls: restore_defer reason=%s " + "owned_patch_remaining=1 retry=1 " + "evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown"); + return 0; + } + exact_original_pair = + memcmp(poll_current, g_runtime.dialog_game_mouse_poll_saved, + sizeof(poll_current)) == 0 && + memcmp(result_current, g_runtime.dialog_game_mouse_result_saved, + sizeof(result_current)) == 0; + + /* + * STATIC_037: + * samp.dll+0xA0686..+0xA06B4 restores the DirectInput bytes first, then + * performs zero, ClearMouseHistory, UpdatePads, zero, ClearMouseHistory. + * The second clear removes a held close-edge button that UpdatePads may + * observe immediately after the original GetMouseState CALL is restored. + */ + if (flush_gta_state && exact_original_pair) { + flushed = dialog_game_mouse_state_flush_compat( + "dialog_restore_after_patch", 1); + if (flushed) { + second_clear = dialog_game_mouse_state_flush_compat( + "dialog_restore_second_clear", 0); + } + } + + g_runtime.dialog_game_mouse_patch_owned = 0u; + InterlockedExchange(&g_runtime.dialog_game_mouse_patch_applied, 0); + InterlockedExchange(&g_runtime.dialog_game_mouse_release_frames, 0); + InterlockedExchange(&g_runtime.dialog_game_mouse_flush_pending, 0); + if (flush_gta_state && + InterlockedCompareExchange(&g_runtime.dialog_mouse_mode, 0, 0) == 0) { + visible_cursor_recenter_patch_restore_compat( + reason != NULL ? reason : "dialog_mouse_restore"); + d3d9_cursor_show_compat( + 0, reason != NULL ? reason : "dialog_mouse_restore"); + } + runtime_tracef("dialog_mouse_controls: restore reason=%s poll=%d result=%d " + "foreign=%d/%d exact_pair=%d flush=%d second_clear=%d " + "module=gta_sa.exe rvas=0x0013f417,0x0013f41f " + "evidence=STATIC_037:samp.dll+0xA0530,+0xA05D0", + reason != NULL ? reason : "unknown", poll_restored, + result_restored, poll_foreign, result_foreign, + exact_original_pair, flushed, second_clear); + return 1; +} + +static int dialog_game_mouse_controls_restore_compat(const char *reason) { + if (!dialog_game_mouse_on_game_thread_compat(reason)) { + return 0; + } + return dialog_game_mouse_controls_restore_code_compat(reason, 1); +} + +static void dialog_game_mouse_controls_restore_shutdown_compat( + const char *reason) { + /* + * GTA_REVERSED_REF + INFERRED: + * Explicit FreeLibrary teardown may run under loader lock and is not + * guaranteed to be GTA's game thread. Restore only bytes owned by this DLL; + * never enter CPad or D3D from that path. + */ + (void)dialog_game_mouse_controls_restore_code_compat(reason, 0); + visible_cursor_recenter_patch_restore_compat( + reason != NULL ? reason : "dialog_mouse_shutdown"); } static void chat_input_game_controls_update_compat(void) { LONG frames = 0; + LONG dialog_frames = 0; + int dialog_active = dialog_compat_active(); + int chat_active = + InterlockedCompareExchange(&g_runtime.chat_input_active, 0, 0) != 0; + int dialog_restore_due = 0; + + /* + * USER_REQUESTED + STATIC_037 + PROBE_TRACE: + * Chat and dialogs are exclusive gameplay-input owners. The R5 scoreboard + * deliberately is not: OBSERVED_037 runs retained the original CALL bytes + * and allowed normal movement while it was latched. + */ + if (dialog_active) { + (void)chat_input_game_controls_apply_compat("dialog_active_tick"); + (void)dialog_game_mouse_controls_activate_compat("dialog_active_tick"); + } else { + dialog_frames = InterlockedCompareExchange( + &g_runtime.dialog_game_mouse_release_frames, 0, 0); + if (dialog_frames > 0) { + dialog_frames = InterlockedDecrement( + &g_runtime.dialog_game_mouse_release_frames); + } + dialog_restore_due = + dialog_frames <= 0 && + InterlockedCompareExchange( + &g_runtime.dialog_game_mouse_patch_applied, 0, 0) != 0; + if (dialog_restore_due) { + /* + * STATIC_037: + * R5 +0xA05EC restores the keyboard CALL before the mouse poll/branch. + * If chat opened during the ten-tick dialog edge it still owns the + * keyboard patch, but the dialog-only mouse gate must be released now. + */ + if (!chat_active) { + chat_input_game_controls_restore_compat("dialog_close_release"); + } + (void)dialog_game_mouse_controls_restore_compat( + "dialog_close_release"); + } + } - if (InterlockedCompareExchange(&g_runtime.chat_input_active, 0, 0) != 0 || scoreboard_compat_active()) { + if (chat_active || dialog_active) { InterlockedExchange(&g_runtime.chat_game_input_release_frames, 0); return; } - frames = InterlockedCompareExchange(&g_runtime.chat_game_input_release_frames, 0, 0); + frames = InterlockedCompareExchange( + &g_runtime.chat_game_input_release_frames, 0, 0); if (frames <= 0) { + if (InterlockedCompareExchange( + &g_runtime.chat_game_input_patch_applied, 0, 0) != 0 && + InterlockedCompareExchange( + &g_runtime.dialog_game_mouse_patch_applied, 0, 0) == 0) { + chat_input_game_controls_restore_compat("input_release_retry"); + } return; } frames = InterlockedDecrement(&g_runtime.chat_game_input_release_frames); @@ -29989,6 +34506,343 @@ static int restore_call_disp_hook_compat(uintptr_t call_disp_addr, const uint8_t return 1; } +typedef struct samp_remote_vehicle_collision_hook_target { + uintptr_t vtable; + uintptr_t slot; + uintptr_t original; + const char *name; +} samp_remote_vehicle_collision_hook_target; + +/* + * STATIC_037: + * R5 installs its vehicle/vehicle collision wrappers from + * samp.dll+0xA6DD7..+0xA6EFC into vtable index 22 (+0x58). The wrappers at + * +0xA5B10/+0xA5BB0/+0xA5C50/+0xA5CF0 return zero only when RPC 167's flag is + * enabled, the other entity is a GTA vehicle model (400..611), and both + * physicals have a non-null +0x460 collision-list pointer. + */ +static const samp_remote_vehicle_collision_hook_target + kRemoteVehicleCollisionHookTargets[] = { + {SAMP_GTA_VTBL_CAUTOMOBILE, 0x871178u, 0x6ACE70u, "automobile"}, + {SAMP_GTA_VTBL_CHELI, 0x8716D8u, 0x6ACE70u, "heli"}, + {SAMP_GTA_VTBL_CPLANE, 0x8719A0u, 0x6ACE70u, "plane"}, + {SAMP_GTA_VTBL_CQUAD_BIKE, 0x871B40u, 0x6ACE70u, "quad"}, + {SAMP_GTA_VTBL_CBIKE, 0x8713B8u, 0x6BDEA0u, "bike"}, + {SAMP_GTA_VTBL_CBMX, 0x871580u, 0x6BDEA0u, "bmx"}, + {SAMP_GTA_VTBL_CMONSTER_TRUCK, 0x871830u, 0x6C8AE0u, "monster"}, + {SAMP_GTA_VTBL_CBOAT, 0x8721F8u, 0x546D00u, "boat"}, +}; + +static const samp_remote_vehicle_collision_hook_target * +remote_vehicle_collision_target_compat(uintptr_t vehicle) { + uintptr_t vtable = 0u; + size_t index = 0u; + + if (!vehicle_compat_read_vtable(vehicle, &vtable)) { + return NULL; + } + for (index = 0u; + index < sizeof(kRemoteVehicleCollisionHookTargets) / + sizeof(kRemoteVehicleCollisionHookTargets[0]); + ++index) { + if (kRemoteVehicleCollisionHookTargets[index].vtable == vtable) { + return &kRemoteVehicleCollisionHookTargets[index]; + } + } + return NULL; +} + +static int remote_vehicle_collision_should_skip_compat(uintptr_t vehicle, + uintptr_t entity) { + uint16_t model = 0u; + uint32_t vehicle_collision_list = 0u; + uint32_t entity_collision_list = 0u; + + if (InterlockedCompareExchange( + &g_runtime.remote_vehicle_collisions_disabled, 0, 0) == 0 || + vehicle < 0x10000u || entity < 0x10000u || + !memory_is_readable_compat( + (const void *)(entity + SAMP_ENTITY_OFFSET_MODEL_INDEX), + sizeof(model)) || + !memory_is_readable_compat( + (const void *)(vehicle + SAMP_PHYSICAL_OFFSET_COLLISION_LIST), + sizeof(vehicle_collision_list)) || + !memory_is_readable_compat( + (const void *)(entity + SAMP_PHYSICAL_OFFSET_COLLISION_LIST), + sizeof(entity_collision_list))) { + return 0; + } + memcpy(&model, (const void *)(entity + SAMP_ENTITY_OFFSET_MODEL_INDEX), + sizeof(model)); + if (model < 400u || model > 611u) { + return 0; + } + memcpy(&vehicle_collision_list, + (const void *)(vehicle + SAMP_PHYSICAL_OFFSET_COLLISION_LIST), + sizeof(vehicle_collision_list)); + memcpy(&entity_collision_list, + (const void *)(entity + SAMP_PHYSICAL_OFFSET_COLLISION_LIST), + sizeof(entity_collision_list)); + return vehicle_collision_list != 0u && entity_collision_list != 0u; +} + +static int32_t SAMP_THISCALL remote_vehicle_process_entity_collision_hook_compat( + void *vehicle_ptr, void *entity_ptr, void *col_points) { +#if defined(__i386__) || defined(_M_IX86) + uintptr_t vehicle = (uintptr_t)vehicle_ptr; + const samp_remote_vehicle_collision_hook_target *target = + remote_vehicle_collision_target_compat(vehicle); + gta_vehicle_process_entity_collision_fn original = NULL; + + if (target != NULL) { + original = (gta_vehicle_process_entity_collision_fn)target->original; + } + if (remote_vehicle_collision_should_skip_compat( + vehicle, (uintptr_t)entity_ptr)) { + InterlockedIncrement(&g_runtime.remote_vehicle_collision_skip_count); + return 0; + } + if (original != NULL && gta_code_ptr_compat((uintptr_t)original)) { + return original(vehicle_ptr, entity_ptr, col_points); + } +#else + (void)vehicle_ptr; + (void)entity_ptr; + (void)col_points; +#endif + return 0; +} + +static int __cdecl remote_vehicle_camera_collision_hook_compat( + int32_t sector_x, int32_t sector_y, const void *bounds, + const void *scan_sphere, const void *camera_sphere, + const void *next_camera_sphere, const void *player_vehicle_velocity) { + gta_camera_collision_vehicles_fn original = + (gta_camera_collision_vehicles_fn) + g_runtime.remote_vehicle_collision_camera_original; + + /* + * STATIC_037: + * samp.dll+0xA5AC0 returns FALSE before GTA + * CCollision::CheckCameraCollisionVehicles whenever the RPC 167 flag is + * enabled. This keeps remote vehicles from pushing the local camera while + * their physical collision is disabled. + */ + if (InterlockedCompareExchange( + &g_runtime.remote_vehicle_collisions_disabled, 0, 0) != 0) { + return 0; + } + if (original == NULL || !gta_code_ptr_compat((uintptr_t)original)) { + return 0; + } + return original(sector_x, sector_y, bounds, scan_sphere, camera_sphere, + next_camera_sphere, player_vehicle_velocity); +} + +static int remote_vehicle_collision_read_call_target_compat( + uintptr_t call_addr, uintptr_t *out_target) { + int32_t rel32 = 0; + + if (out_target == NULL || + !memory_is_readable_compat((const void *)call_addr, 5u) || + read_game_u8(call_addr) != 0xE8u) { + return 0; + } + memcpy(&rel32, (const void *)(call_addr + 1u), sizeof(rel32)); + *out_target = + (uintptr_t)((intptr_t)(call_addr + 5u) + (intptr_t)rel32); + return 1; +} + +static void remote_vehicle_collision_install_hooks_compat(void) { +#if defined(__i386__) || defined(_M_IX86) + uint32_t installed_mask = 0u; + uintptr_t current_call_target = 0u; + size_t index = 0u; + + if (InterlockedCompareExchange( + &g_runtime.remote_vehicle_collision_hook_attempted, 1, 0) != 0) { + return; + } + if (InterlockedCompareExchange(&g_runtime.gta_version, 0, 0) != + SAMP_GTA_VERSION_USA10) { + runtime_tracef( + "remote_vehicle_collision_hook: install_skipped gta_version=%ld " + "evidence=STATIC_037,GTA_REVERSED_REF", + (long)InterlockedCompareExchange(&g_runtime.gta_version, 0, 0)); + return; + } + + if (remote_vehicle_collision_read_call_target_compat( + SAMP_ADDR_CAMERA_COLLISION_VEHICLES_CALL, + ¤t_call_target) && + current_call_target == SAMP_GTA_FUNC_CAMERA_COLLISION_VEHICLES && + install_call_disp_hook_compat( + SAMP_ADDR_CAMERA_COLLISION_VEHICLES_CALL_DISP, + (void *)&remote_vehicle_camera_collision_hook_compat, + g_runtime.remote_vehicle_collision_camera_saved_disp, + &g_runtime.remote_vehicle_collision_camera_original) && + g_runtime.remote_vehicle_collision_camera_original == + SAMP_GTA_FUNC_CAMERA_COLLISION_VEHICLES) { + InterlockedExchange( + &g_runtime.remote_vehicle_collision_camera_hook_installed, 1); + } else { + runtime_tracef( + "remote_vehicle_collision_hook: camera_skipped call=0x%08lx " + "expected=0x%08lx actual=0x%08lx evidence=STATIC_037", + (unsigned long)SAMP_ADDR_CAMERA_COLLISION_VEHICLES_CALL, + (unsigned long)SAMP_GTA_FUNC_CAMERA_COLLISION_VEHICLES, + (unsigned long)current_call_target); + } + + for (index = 0u; + index < sizeof(kRemoteVehicleCollisionHookTargets) / + sizeof(kRemoteVehicleCollisionHookTargets[0]); + ++index) { + const samp_remote_vehicle_collision_hook_target *target = + &kRemoteVehicleCollisionHookTargets[index]; + volatile uintptr_t *slot = (volatile uintptr_t *)target->slot; + uintptr_t current = 0u; + DWORD old_protect = 0u; + DWORD restore_protect = 0u; + + if (!memory_is_readable_compat((const void *)slot, sizeof(*slot))) { + runtime_tracef( + "remote_vehicle_collision_hook: target_unreadable class=%s " + "slot=0x%08lx", + target->name, (unsigned long)target->slot); + continue; + } + current = *slot; + if (current != target->original || !gta_code_ptr_compat(current)) { + runtime_tracef( + "remote_vehicle_collision_hook: target_mismatch class=%s " + "vtable=0x%08lx index=22 slot=0x%08lx expected=0x%08lx " + "actual=0x%08lx skipped=1 evidence=STATIC_037", + target->name, (unsigned long)target->vtable, + (unsigned long)target->slot, (unsigned long)target->original, + (unsigned long)current); + continue; + } + if (!VirtualProtect((LPVOID)slot, sizeof(*slot), + PAGE_EXECUTE_READWRITE, &old_protect)) { + runtime_tracef( + "remote_vehicle_collision_hook: VirtualProtect_failed class=%s " + "slot=0x%08lx gle=%lu", + target->name, (unsigned long)target->slot, + (unsigned long)GetLastError()); + continue; + } + *slot = + (uintptr_t)&remote_vehicle_process_entity_collision_hook_compat; + FlushInstructionCache(GetCurrentProcess(), (LPCVOID)slot, + sizeof(*slot)); + (void)VirtualProtect((LPVOID)slot, sizeof(*slot), old_protect, + &restore_protect); + installed_mask |= (uint32_t)(1u << index); + } + + g_runtime.remote_vehicle_collision_hook_mask = installed_mask; + if (installed_mask != 0u || + InterlockedCompareExchange( + &g_runtime.remote_vehicle_collision_camera_hook_installed, + 0, 0) != 0) { + InterlockedExchange( + &g_runtime.remote_vehicle_collision_hook_installed, 1); + } + runtime_tracef( + "remote_vehicle_collision_hook: install_done mask=0x%02lx " + "camera=%ld call=0x%08lx index=22 patch_len=4 " + "evidence=STATIC_037:samp.dll+0xA5AC0,+0xA5B10,+0xA5BB0," + "+0xA5C50,+0xA5CF0,+0xA6D71", + (unsigned long)installed_mask, + (long)InterlockedCompareExchange( + &g_runtime.remote_vehicle_collision_camera_hook_installed, 0, 0), + (unsigned long)SAMP_ADDR_CAMERA_COLLISION_VEHICLES_CALL); +#endif +} + +static void remote_vehicle_collision_uninstall_hooks_compat(void) { +#if defined(__i386__) || defined(_M_IX86) + uint32_t installed_mask = g_runtime.remote_vehicle_collision_hook_mask; + uintptr_t current_call_target = 0u; + size_t index = 0u; + + if (InterlockedCompareExchange( + &g_runtime.remote_vehicle_collision_hook_installed, 0, 0) == 0) { + return; + } + if (InterlockedCompareExchange( + &g_runtime.remote_vehicle_collision_camera_hook_installed, + 0, 0) != 0 && + remote_vehicle_collision_read_call_target_compat( + SAMP_ADDR_CAMERA_COLLISION_VEHICLES_CALL, + ¤t_call_target) && + current_call_target == + (uintptr_t)&remote_vehicle_camera_collision_hook_compat) { + (void)restore_call_disp_hook_compat( + SAMP_ADDR_CAMERA_COLLISION_VEHICLES_CALL_DISP, + g_runtime.remote_vehicle_collision_camera_saved_disp); + } else if (InterlockedCompareExchange( + &g_runtime.remote_vehicle_collision_camera_hook_installed, + 0, 0) != 0) { + runtime_tracef( + "remote_vehicle_collision_hook: camera_uninstall_skipped " + "current=0x%08lx reason=changed_by_other", + (unsigned long)current_call_target); + } + + for (index = 0u; + index < sizeof(kRemoteVehicleCollisionHookTargets) / + sizeof(kRemoteVehicleCollisionHookTargets[0]); + ++index) { + const samp_remote_vehicle_collision_hook_target *target = + &kRemoteVehicleCollisionHookTargets[index]; + volatile uintptr_t *slot = (volatile uintptr_t *)target->slot; + DWORD old_protect = 0u; + DWORD restore_protect = 0u; + + if ((installed_mask & (uint32_t)(1u << index)) == 0u) { + continue; + } + if (memory_is_readable_compat((const void *)slot, sizeof(*slot)) && + *slot == + (uintptr_t) + &remote_vehicle_process_entity_collision_hook_compat && + VirtualProtect((LPVOID)slot, sizeof(*slot), + PAGE_EXECUTE_READWRITE, &old_protect)) { + *slot = target->original; + FlushInstructionCache(GetCurrentProcess(), (LPCVOID)slot, + sizeof(*slot)); + (void)VirtualProtect((LPVOID)slot, sizeof(*slot), old_protect, + &restore_protect); + } else { + runtime_tracef( + "remote_vehicle_collision_hook: uninstall_skipped class=%s " + "slot=0x%08lx reason=changed_by_other", + target->name, (unsigned long)target->slot); + } + } + + runtime_tracef( + "remote_vehicle_collision_hook: uninstalled mask=0x%02lx " + "camera=%ld skipped=%ld restore_len=4", + (unsigned long)installed_mask, + (long)InterlockedCompareExchange( + &g_runtime.remote_vehicle_collision_camera_hook_installed, 0, 0), + (long)InterlockedCompareExchange( + &g_runtime.remote_vehicle_collision_skip_count, 0, 0)); + g_runtime.remote_vehicle_collision_hook_mask = 0u; + g_runtime.remote_vehicle_collision_camera_original = 0u; + memset(g_runtime.remote_vehicle_collision_camera_saved_disp, 0, + sizeof(g_runtime.remote_vehicle_collision_camera_saved_disp)); + InterlockedExchange( + &g_runtime.remote_vehicle_collision_camera_hook_installed, 0); + InterlockedExchange( + &g_runtime.remote_vehicle_collision_hook_installed, 0); +#endif +} + /* * STATIC_037 + TODO_VERIFY: * Original 0.3.7-R5 samp.dll SHA256= @@ -30588,6 +35442,15 @@ static void apply_ingame_compat_patches_once(void) { SAMP_APPLY_PATCH(patch_nop(SAMP_ADDR_INTERIOR_PEDS_PATCH, 8u)); SAMP_APPLY_PATCH(patch_nop(SAMP_ADDR_PED_SHADOWS_PATCH, 10u)); SAMP_APPLY_PATCH(patch_nop(SAMP_ADDR_ANTI_PAUSE_PATCH, 7u)); + /* + * STATIC_037 + PROBE_TRACE: + * Do not patch CClothes::RebuildPlayer (0x5A82C0) here. R5 arms that guard + * lazily inside CPlayerPed::SetModelIndex at samp.dll+0xAFF82, after GTA has + * constructed the initial MODEL_PLAYER clothes clump. Patching it during + * startup left the replacement with the 2-triangle/6-vertex placeholder and + * an invisible body. gta_player_ped_set_loaded_model_direct_compat mirrors + * the original lazy timing immediately before the first SA-MP model change. + */ /* * INFERRED + TODO_VERIFY: * The MP cursor path keeps GTA from hiding or recentering the visible multiplayer @@ -31099,6 +35962,397 @@ static int gta_local_set_weapon_ammo_compat(uint8_t weapon, uint16_t ammo) { return 0; } +typedef struct samp_player_ped_clump_trace_compat { + uint32_t atomic_count; + uint32_t render_atomic_count; + uint32_t geometry_count; + uintptr_t first_atomic; + uintptr_t first_geometry; + uintptr_t first_render_callback; + uint8_t first_atomic_flags; + uint32_t first_geometry_flags; + int32_t first_geometry_triangles; + int32_t first_geometry_vertices; +} samp_player_ped_clump_trace_compat; + +static void *__cdecl gta_player_ped_trace_atomic_cb_compat(void *atomic_ptr, + void *data) { + samp_player_ped_clump_trace_compat *trace = + (samp_player_ped_clump_trace_compat *)data; + samp_rp_atomic_compat *atomic = (samp_rp_atomic_compat *)atomic_ptr; + samp_rp_atomic_preview_compat *preview = + (samp_rp_atomic_preview_compat *)atomic_ptr; + + if (trace == NULL || atomic == NULL || + !memory_is_readable_compat(atomic, sizeof(*atomic)) || + !memory_is_readable_compat(preview, sizeof(*preview))) { + return atomic_ptr; + } + ++trace->atomic_count; + if ((atomic->object.object.flags & 0x04u) != 0u) { + ++trace->render_atomic_count; + } + if (game_pointer_plausible_compat((uintptr_t)atomic->geometry) && + memory_is_readable_compat(atomic->geometry, + sizeof(samp_rp_geometry_header_compat))) { + samp_rp_geometry_header_compat *geometry = + (samp_rp_geometry_header_compat *)atomic->geometry; + ++trace->geometry_count; + if (trace->first_geometry == 0u) { + trace->first_geometry = (uintptr_t)geometry; + trace->first_geometry_flags = geometry->flags; + trace->first_geometry_triangles = geometry->num_triangles; + trace->first_geometry_vertices = geometry->num_vertices; + } + } + if (trace->first_atomic == 0u) { + trace->first_atomic = (uintptr_t)atomic; + trace->first_atomic_flags = atomic->object.object.flags; + trace->first_render_callback = (uintptr_t)preview->render_callback; + } + return atomic_ptr; +} + +static void gta_player_ped_trace_render_clump_compat(uintptr_t ped, + const char *reason) { +#if defined(__i386__) || defined(_M_IX86) + gta_rp_clump_for_all_atomics_fn for_all_atomics = + (gta_rp_clump_for_all_atomics_fn)(uintptr_t) + SAMP_ADDR_RP_CLUMP_FOR_ALL_ATOMICS; + samp_player_ped_clump_trace_compat trace; + samp_rw_object_compat rw_object; + uintptr_t rw = 0u; + + memset(&trace, 0, sizeof(trace)); + memset(&rw_object, 0, sizeof(rw_object)); + if (!memory_is_readable_compat( + (const void *)(ped + SAMP_ENTITY_OFFSET_RW_OBJECT), sizeof(rw))) { + return; + } + memcpy(&rw, (const void *)(ped + SAMP_ENTITY_OFFSET_RW_OBJECT), sizeof(rw)); + if (!game_pointer_plausible_compat(rw) || + !memory_is_readable_compat((const void *)rw, sizeof(rw_object))) { + return; + } + memcpy(&rw_object, (const void *)rw, sizeof(rw_object)); + if (rw_object.type == SAMP_RW_TYPE_CLUMP && + gta_code_ptr_compat((uintptr_t)for_all_atomics)) { + (void)for_all_atomics((void *)rw, + gta_player_ped_trace_atomic_cb_compat, &trace); + } + runtime_tracef( + "player_ped_clump: reason=%s ped=0x%08lx rw=0x%08lx " + "rw_type=%u rw_flags=0x%02x rw_private=0x%02x " + "atomics=%lu render_atomics=%lu geometries=%lu " + "first_atomic=0x%08lx atomic_flags=0x%02x " + "render_callback=0x%08lx geometry=0x%08lx geometry_flags=0x%08lx " + "triangles=%ld vertices=%ld " + "evidence=PROBE_TRACE,GTA_REVERSED_REF,TODO_VERIFY", + reason != NULL ? reason : "unknown", (unsigned long)ped, + (unsigned long)rw, (unsigned)rw_object.type, + (unsigned)rw_object.flags, (unsigned)rw_object.private_flags, + (unsigned long)trace.atomic_count, + (unsigned long)trace.render_atomic_count, + (unsigned long)trace.geometry_count, + (unsigned long)trace.first_atomic, + (unsigned)trace.first_atomic_flags, + (unsigned long)trace.first_render_callback, + (unsigned long)trace.first_geometry, + (unsigned long)trace.first_geometry_flags, + (long)trace.first_geometry_triangles, + (long)trace.first_geometry_vertices); +#else + (void)ped; + (void)reason; +#endif +} + +static int gta_player_ped_set_initial_state_compat(uintptr_t ped, + const char *reason) { +#if defined(__i386__) || defined(_M_IX86) + gta_cplayerped_set_initial_state_fn set_initial_state = + (gta_cplayerped_set_initial_state_fn)(uintptr_t) + SAMP_GTA_FUNC_CPLAYERPED_SET_INITIAL_STATE; + uintptr_t vtable = 0u; + uint32_t before_visibility_flags = 0u; + uint32_t after_visibility_flags = 0u; + uint8_t before_entity_render_flags = 0u; + uint8_t after_entity_render_flags = 0u; + + if (ped < 0x10000u || ped >= 0x80000000u || + !memory_is_readable_compat((const void *)ped, sizeof(vtable)) || + !memory_is_readable_compat( + (const void *)(ped + SAMP_PED_OFFSET_VISIBILITY_FLAGS), + sizeof(before_visibility_flags)) || + !gta_code_ptr_compat(SAMP_GTA_FUNC_CPLAYERPED_SET_INITIAL_STATE)) { + return 0; + } + memcpy(&vtable, (const void *)ped, sizeof(vtable)); + if (vtable != SAMP_GTA_VTBL_CPLAYERPED) { + runtime_tracef( + "player_ped_initial_state: reject reason=%s ped=0x%08lx " + "vtable=0x%08lx evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown", (unsigned long)ped, + (unsigned long)vtable); + return 0; + } + + memcpy(&before_visibility_flags, + (const void *)(ped + SAMP_PED_OFFSET_VISIBILITY_FLAGS), + sizeof(before_visibility_flags)); + if (memory_is_readable_compat( + (const void *)(ped + SAMP_ENTITY_OFFSET_RENDER_FLAGS), + sizeof(before_entity_render_flags))) { + memcpy(&before_entity_render_flags, + (const void *)(ped + SAMP_ENTITY_OFFSET_RENDER_FLAGS), + sizeof(before_entity_render_flags)); + } + set_initial_state((void *)ped, 0u); + if (memory_is_readable_compat( + (const void *)(ped + SAMP_PED_OFFSET_VISIBILITY_FLAGS), + sizeof(after_visibility_flags))) { + memcpy(&after_visibility_flags, + (const void *)(ped + SAMP_PED_OFFSET_VISIBILITY_FLAGS), + sizeof(after_visibility_flags)); + } + if (memory_is_readable_compat( + (const void *)(ped + SAMP_ENTITY_OFFSET_RENDER_FLAGS), + sizeof(after_entity_render_flags))) { + memcpy(&after_entity_render_flags, + (const void *)(ped + SAMP_ENTITY_OFFSET_RENDER_FLAGS), + sizeof(after_entity_render_flags)); + } + runtime_tracef( + "player_ped_initial_state: applied reason=%s ped=0x%08lx " + "ped_flags=0x%08lx->0x%08lx dont_render=%u->%u " + "entity_visible=%u->%u function=0x%08lx " + "evidence=STATIC_037:samp.dll+0x4080,+0xABBD0,GTA:0x60CD20," + "PROBE_TRACE", + reason != NULL ? reason : "unknown", (unsigned long)ped, + (unsigned long)before_visibility_flags, + (unsigned long)after_visibility_flags, + (unsigned)((before_visibility_flags >> 1u) & 1u), + (unsigned)((after_visibility_flags >> 1u) & 1u), + (unsigned)((before_entity_render_flags >> 7u) & 1u), + (unsigned)((after_entity_render_flags >> 7u) & 1u), + (unsigned long)SAMP_GTA_FUNC_CPLAYERPED_SET_INITIAL_STATE); + return 1; +#else + (void)ped; + (void)reason; + return 0; +#endif +} + +static int gta_player_ped_set_loaded_model_direct_compat(uintptr_t ped, int32_t model, + const char *reason) { +#if defined(__i386__) || defined(_M_IX86) + typedef void(SAMP_THISCALL *gta_entity_destroy_rw_fn)(void *entity); + typedef void(SAMP_THISCALL *gta_entity_set_model_fn)(void *entity, uint32_t model); + samp_model_streaming_snapshot_compat stream; + gta_entity_destroy_rw_fn destroy_rw = NULL; + gta_entity_set_model_fn set_model = NULL; + gta_scalar_deleting_dtor_fn destroy_follow_task = + (gta_scalar_deleting_dtor_fn)(uintptr_t)SAMP_GTA_FUNC_CTASK_COMPLEX_FOLLOW_NODE_SCALAR_DTOR; + uintptr_t vtable = 0u; + uintptr_t destroy_rw_addr = 0u; + uintptr_t set_model_addr = 0u; + uintptr_t intelligence = 0u; + uintptr_t follow_task = 0u; + uintptr_t before_rw = 0u; + uintptr_t after_rw = 0u; + uint16_t before_model = 0u; + uint16_t after_model = 0u; + uint16_t requested_model = 0u; + uint32_t before_visibility_flags = 0u; + uint32_t after_visibility_flags = 0u; + uint8_t clothes_rebuild_opcode = 0u; + int follow_task_destroyed = 0; + int clothes_rebuild_guarded = 0; + int applied = 0; + + memset(&stream, 0, sizeof(stream)); + if (ped < 0x10000u || ped >= 0x80000000u || model < 0 || + model > SAMP_REMOTE_PLAYER_COMPAT_SKIN_MAX || model == 74 || + !object_compat_capture_model_streaming_snapshot(model, &stream) || + !object_compat_model_stream_ready(&stream)) { + runtime_tracef( + "player_ped_model: defer reason=%s ped=0x%08lx model=%ld " + "model_info=0x%08lx state=%u rw=0x%08lx " + "evidence=STATIC_037:samp.dll+0x9EF50,+0xAFF50,PROBE_TRACE,TODO_VERIFY", + reason != NULL ? reason : "unknown", (unsigned long)ped, (long)model, + (unsigned long)stream.model_info, (unsigned)stream.dff_stream.load_state, + (unsigned long)stream.rw_object); + return 0; + } + if (!memory_is_readable_compat((const void *)ped, + SAMP_ENTITY_OFFSET_MODEL_INDEX + sizeof(requested_model)) || + !memory_is_writable_compat((void *)(ped + SAMP_ENTITY_OFFSET_MODEL_INDEX), + sizeof(requested_model))) { + return 0; + } + + memcpy(&vtable, (const void *)ped, sizeof(vtable)); + if (vtable != SAMP_GTA_VTBL_CPLAYERPED || + !memory_is_readable_compat( + (const void *)(vtable + SAMP_CENTITY_DESTROY_RW_VTABLE_OFFSET), + sizeof(destroy_rw_addr)) || + !memory_is_readable_compat( + (const void *)(vtable + SAMP_CENTITY_SET_MODEL_VTABLE_OFFSET), + sizeof(set_model_addr))) { + runtime_tracef( + "player_ped_model: reject reason=%s ped=0x%08lx model=%ld " + "vtable=0x%08lx evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown", (unsigned long)ped, (long)model, + (unsigned long)vtable); + return 0; + } + memcpy(&destroy_rw_addr, + (const void *)(vtable + SAMP_CENTITY_DESTROY_RW_VTABLE_OFFSET), + sizeof(destroy_rw_addr)); + memcpy(&set_model_addr, + (const void *)(vtable + SAMP_CENTITY_SET_MODEL_VTABLE_OFFSET), + sizeof(set_model_addr)); + if (!gta_code_ptr_compat(destroy_rw_addr) || + !gta_code_ptr_compat(set_model_addr)) { + runtime_tracef( + "player_ped_model: reject reason=%s ped=0x%08lx model=%ld " + "destroy_rw=0x%08lx set_model=0x%08lx " + "evidence=STATIC_037,TODO_VERIFY", + reason != NULL ? reason : "unknown", (unsigned long)ped, (long)model, + (unsigned long)destroy_rw_addr, (unsigned long)set_model_addr); + return 0; + } + + /* + * STATIC_037: + * R5 CPlayerPed::SetModelIndex first patches GTA CClothes::RebuildPlayer at + * 0x5A82C0 from 0x56 (`push esi`) to 0xC3, then destroys the follow-node task + * at CPed+0x47C -> +0x2C with GTA 0x639330(delete=1), and calls the raw + * CEntity virtuals at +0x20 (DestroyRwObject) and +0x14 (SetModelIndex), + * writing the uint16 model at CEntity+0x22 between them. The guard must not + * be armed during startup because GTA still needs the function to construct + * the initial CJ clothes clump. + * samp.dll SHA256= + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2, + * RVAs +0x9EF50 and +0xAFF50. + */ + clothes_rebuild_opcode = read_game_u8(SAMP_ADDR_CLOTHES_REBUILD_PLAYER); + if (clothes_rebuild_opcode == 0x56u) { + const uint8_t ret_opcode = 0xC3u; + clothes_rebuild_guarded = patch_copy_expected_first_byte( + SAMP_ADDR_CLOTHES_REBUILD_PLAYER, &ret_opcode, sizeof(ret_opcode), + 0x56u, "clothes_rebuild_player_lazy"); + } else if (clothes_rebuild_opcode == 0xC3u) { + clothes_rebuild_guarded = 1; + } + if (!clothes_rebuild_guarded) { + runtime_tracef( + "player_ped_model: defer reason=%s ped=0x%08lx model=%ld " + "clothes_rebuild_opcode=0x%02x " + "evidence=STATIC_037:samp.dll+0xAFF82,TODO_VERIFY", + reason != NULL ? reason : "unknown", (unsigned long)ped, (long)model, + (unsigned)clothes_rebuild_opcode); + return 0; + } + + if (memory_is_readable_compat( + (const void *)(ped + SAMP_PED_OFFSET_INTELLIGENCE), + sizeof(intelligence))) { + memcpy(&intelligence, + (const void *)(ped + SAMP_PED_OFFSET_INTELLIGENCE), + sizeof(intelligence)); + if (intelligence >= 0x10000u && intelligence < 0x80000000u && + memory_is_readable_compat( + (const void *)(intelligence + + SAMP_PED_INTELLIGENCE_FOLLOW_TASK_OFFSET), + sizeof(follow_task)) && + memory_is_writable_compat( + (void *)(intelligence + + SAMP_PED_INTELLIGENCE_FOLLOW_TASK_OFFSET), + sizeof(follow_task))) { + memcpy(&follow_task, + (const void *)(intelligence + + SAMP_PED_INTELLIGENCE_FOLLOW_TASK_OFFSET), + sizeof(follow_task)); + if (follow_task >= 0x10000u && follow_task < 0x80000000u && + memory_is_readable_compat((const void *)follow_task, + sizeof(uintptr_t)) && + gta_code_ptr_compat( + SAMP_GTA_FUNC_CTASK_COMPLEX_FOLLOW_NODE_SCALAR_DTOR)) { + (void)destroy_follow_task((void *)follow_task, 1u); + follow_task = 0u; + memcpy((void *)(intelligence + + SAMP_PED_INTELLIGENCE_FOLLOW_TASK_OFFSET), + &follow_task, sizeof(follow_task)); + follow_task_destroyed = 1; + } + } + } + + memcpy(&before_rw, (const void *)(ped + SAMP_ENTITY_OFFSET_RW_OBJECT), + sizeof(before_rw)); + memcpy(&before_model, + (const void *)(ped + SAMP_ENTITY_OFFSET_MODEL_INDEX), + sizeof(before_model)); + if (memory_is_readable_compat( + (const void *)(ped + SAMP_PED_OFFSET_VISIBILITY_FLAGS), + sizeof(before_visibility_flags))) { + memcpy(&before_visibility_flags, + (const void *)(ped + SAMP_PED_OFFSET_VISIBILITY_FLAGS), + sizeof(before_visibility_flags)); + } + destroy_rw = (gta_entity_destroy_rw_fn)destroy_rw_addr; + set_model = (gta_entity_set_model_fn)set_model_addr; + destroy_rw((void *)ped); + requested_model = (uint16_t)model; + memcpy((void *)(ped + SAMP_ENTITY_OFFSET_MODEL_INDEX), &requested_model, + sizeof(requested_model)); + set_model((void *)ped, (uint32_t)requested_model); + memcpy(&after_rw, (const void *)(ped + SAMP_ENTITY_OFFSET_RW_OBJECT), + sizeof(after_rw)); + memcpy(&after_model, + (const void *)(ped + SAMP_ENTITY_OFFSET_MODEL_INDEX), + sizeof(after_model)); + if (memory_is_readable_compat( + (const void *)(ped + SAMP_PED_OFFSET_VISIBILITY_FLAGS), + sizeof(after_visibility_flags))) { + memcpy(&after_visibility_flags, + (const void *)(ped + SAMP_PED_OFFSET_VISIBILITY_FLAGS), + sizeof(after_visibility_flags)); + } + applied = after_model == requested_model && after_rw >= 0x10000u && + after_rw < 0x80000000u && + memory_is_readable_compat((const void *)after_rw, + sizeof(uint32_t)); + if (applied) { + (void)gta_reset_ped_audio_attributes_compat(ped, reason); + gta_player_ped_trace_render_clump_compat(ped, reason); + } + runtime_tracef( + "player_ped_model: direct reason=%s ped=0x%08lx model=%ld " + "before_model=%u after_model=%u before_rw=0x%08lx after_rw=0x%08lx " + "legacy_visible_bit3=%u->%u " + "destroy_rw=0x%08lx set_model=0x%08lx follow_task_destroyed=%d " + "clothes_rebuild_guarded=%d " + "applied=%d evidence=STATIC_037:samp.dll+0x9EF50,+0xAFF50,PROBE_TRACE", + reason != NULL ? reason : "unknown", (unsigned long)ped, (long)model, + (unsigned)before_model, (unsigned)after_model, (unsigned long)before_rw, + (unsigned long)after_rw, + (unsigned)((before_visibility_flags >> 3u) & 1u), + (unsigned)((after_visibility_flags >> 3u) & 1u), + (unsigned long)destroy_rw_addr, + (unsigned long)set_model_addr, follow_task_destroyed, + clothes_rebuild_guarded, applied); + return applied; +#else + (void)ped; + (void)model; + (void)reason; + return 0; +#endif +} + static int gta_local_set_skin_compat(int32_t skin) { uintptr_t ped = 0u; @@ -31110,13 +36364,113 @@ static int gta_local_set_skin_compat(int32_t skin) { if (!object_compat_model_available(skin)) { return 0; } - if (!gta_script_command_compat(0x09C7u, "ii", SAMP_GTA_PLAYER_LOCAL_ID, (int)skin)) { + ped = gta_find_player_ped_compat(); + return gta_player_ped_set_loaded_model_direct_compat( + ped, skin, "rpc153_skin"); +} + +static int gta_remote_player_set_skin_compat(uint32_t player_id, int32_t skin) { + samp_remote_player_slot_compat *slot = NULL; + uintptr_t ped = 0u; + uintptr_t pool_ped = 0u; + uintptr_t vtable = 0u; + uintptr_t player_info = 0u; + uint8_t gta_player_index = 0u; + LONG gta_slot_owner = 0; + + if (player_id >= SAMP_RAKNET_MAX_PLAYERS || + skin < 0 || skin > SAMP_REMOTE_PLAYER_COMPAT_SKIN_MAX || + skin == 74 || !object_compat_model_available(skin)) { return 0; } - ped = gta_find_player_ped_compat(); - if (ped != 0u) { - (void)gta_reset_ped_audio_attributes_compat(ped, "rpc153_skin"); + + slot = &g_runtime.remote_player_slots[player_id]; + if (InterlockedCompareExchange(&slot->active, 0, 0) == 0 || + InterlockedCompareExchange(&slot->spawned, 0, 0) == 0 || + slot->player_id != (uint16_t)player_id) { + runtime_tracef( + "remote_player_skin: ignore target=%lu skin=%ld " + "reason=remote_not_streamed evidence=STATIC_037:samp.dll+0x19190", + (unsigned long)player_id, (long)skin); + return 0; + } + + /* + * STATIC_037: + * R5 RPC 153 at samp.dll+0x19190 resolves a streamed remote player's + * CPlayerPed pointer (remote+0x1DD) and calls CPlayerPed::SetModelIndex at + * samp.dll+0xAFF50. The replacement's GTA-player-backed route is the only + * remote path with the equivalent GTA player index required by opcode 09C7. + * The actor fallback is deliberately not mutated until its non-CPlayerPed + * model-change lifetime has direct R5/runtime evidence. + * SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + */ + if (slot->gta_player_backed == 0u) { + runtime_tracef( + "remote_player_skin: defer target=%lu gta=%lu skin=%ld " + "path=actor_fallback reason=no_verified_cplayerped_route " + "evidence=STATIC_037:samp.dll+0x19190,+0xAFF50,TODO_VERIFY", + (unsigned long)player_id, (unsigned long)slot->gta_id, (long)skin); + return 0; } + + gta_player_index = slot->gta_player_index; + if (gta_player_index < SAMP_REMOTE_PLAYER_GTA_SLOT_FIRST || + gta_player_index >= SAMP_REMOTE_PLAYER_GTA_SLOT_COUNT) { + runtime_tracef( + "remote_player_skin: reject target=%lu gta=%lu gta_slot=%u skin=%ld " + "reason=invalid_gta_slot evidence=STATIC_037,TODO_VERIFY", + (unsigned long)player_id, (unsigned long)slot->gta_id, + (unsigned)gta_player_index, (long)skin); + return 0; + } + + gta_slot_owner = InterlockedCompareExchange( + &g_runtime.remote_player_gta_slot_owners[gta_player_index], 0, 0); + ped = remote_player_compat_resolve_ped(slot); + pool_ped = remote_player_compat_game_pool_get_at(slot->gta_id); + remote_player_compat_ped_identity(ped, &vtable, &player_info); + if (gta_slot_owner != (LONG)player_id + 1 || + ped == 0u || pool_ped != ped || + vtable != SAMP_GTA_VTBL_CPLAYERPED || + player_info < 0x10000u || player_info >= 0x80000000u || + !memory_is_readable_compat( + (const void *)(player_info + SAMP_PLAYER_INFO_OFFSET_STATE), + sizeof(uint32_t))) { + runtime_tracef( + "remote_player_skin: reject target=%lu gta=%lu gta_slot=%u skin=%ld " + "owner=%ld ped=0x%08lx pool=0x%08lx vtable=0x%08lx " + "player_info=0x%08lx reason=identity_guard " + "evidence=STATIC_037,TODO_VERIFY", + (unsigned long)player_id, (unsigned long)slot->gta_id, + (unsigned)gta_player_index, (long)skin, (long)gta_slot_owner, + (unsigned long)ped, (unsigned long)pool_ped, + (unsigned long)vtable, (unsigned long)player_info); + return 0; + } + + gta_streaming_request_model_compat( + skin, SAMP_REMOTE_PLAYER_COMPAT_MODEL_LOAD_FLAGS); + gta_streaming_load_all_requested_compat(0); + if (!object_compat_model_available(skin) || + !gta_player_ped_set_loaded_model_direct_compat( + ped, skin, "rpc153_remote_skin")) { + runtime_tracef( + "remote_player_skin: apply_failed target=%lu gta=%lu gta_slot=%u " + "skin=%ld path=direct_model evidence=STATIC_037:samp.dll+0xAFF50," + "TODO_VERIFY", + (unsigned long)player_id, (unsigned long)slot->gta_id, + (unsigned)gta_player_index, (long)skin); + return 0; + } + + slot->skin = skin; + runtime_tracef( + "remote_player_skin: applied target=%lu gta=%lu gta_slot=%u skin=%ld " + "ped=0x%08lx path=gta_player direct_model " + "evidence=STATIC_037:samp.dll+0x19190,+0xAFF50,TODO_VERIFY", + (unsigned long)player_id, (unsigned long)slot->gta_id, + (unsigned)gta_player_index, (long)skin, (unsigned long)ped); return 1; } @@ -31461,12 +36815,16 @@ static void gta_disable_race_checkpoint_compat(void) { } static int gta_apply_pickup_event_compat(const samp_raknet_pickup_event *event) { + uint32_t index = 0u; uint32_t *handle = NULL; - if (event == NULL || event->pickup_id < 0 || (uint32_t)event->pickup_id >= SAMP_PICKUP_COMPAT_MAX) { + int created = 0; + if (event == NULL || !samp_pickup_pool_id_valid(event->pickup_id)) { return 0; } - handle = &g_runtime.pickup_handles[(uint32_t)event->pickup_id]; - g_runtime.pickup_notify_after_tick[(uint32_t)event->pickup_id] = 0u; + index = (uint32_t)event->pickup_id; + handle = &g_runtime.pickup_handles[index]; + g_runtime.pickup_types[index] = 0u; + g_runtime.pickup_notify_after_tick[index] = 0u; if (*handle != 0u) { (void)gta_script_command_compat(0x0215u, "i", (int)*handle); *handle = 0u; @@ -31481,17 +36839,24 @@ static int gta_apply_pickup_event_compat(const samp_raknet_pickup_event *event) (void)gta_script_command_compat(0x0247u, "i", (int)event->model); (void)gta_script_command_compat(0x038Bu, ""); } - return gta_script_command_compat(0x0213u, "iifffv", (int)event->model, (int)event->type, - event->pos[0], event->pos[1], event->pos[2], handle) && *handle != 0u; + created = gta_script_command_compat(0x0213u, "iifffv", (int)event->model, + (int)event->type, event->pos[0], event->pos[1], + event->pos[2], handle) && + *handle != 0u; + if (created) { + g_runtime.pickup_types[index] = (uint8_t)event->type; + } + return created; } static void gta_destroy_all_pickups_compat(void) { uint32_t i = 0u; - for (i = 0u; i < SAMP_PICKUP_COMPAT_MAX; ++i) { + for (i = 0u; i < SAMP_PICKUP_POOL_CAPACITY_037; ++i) { if (g_runtime.pickup_handles[i] != 0u) { (void)gta_script_command_compat(0x0215u, "i", (int)g_runtime.pickup_handles[i]); g_runtime.pickup_handles[i] = 0u; } + g_runtime.pickup_types[i] = 0u; g_runtime.pickup_notify_after_tick[i] = 0u; } } @@ -31512,26 +36877,33 @@ static void __cdecl gta_pickup_collected_callback_compat(uintptr_t pickup_entry) } gta_index = (uint32_t)((pickup_entry - SAMP_GTA_PICKUP_POOL_BASE) / SAMP_GTA_PICKUP_POOL_ENTRY_SIZE); - for (i = 0u; i < SAMP_PICKUP_COMPAT_MAX; ++i) { + for (i = 0u; i < SAMP_PICKUP_POOL_CAPACITY_037; ++i) { uint32_t handle = g_runtime.pickup_handles[i]; DWORD notify_after = g_runtime.pickup_notify_after_tick[i]; if (handle == 0u || (uint16_t)(handle & 0xFFFFu) != (uint16_t)gta_index || (notify_after != 0u && (int32_t)(now - notify_after) < 0)) { continue; } + if (g_runtime.pickup_types[i] == 14u) { + /* STATIC_037: type 14 is reported by CPickupPool::Process, not by the + * ordinary PickedUp source. Preserve that call-source/QoS distinction. */ + return; + } /* - * ALT_02X_CODE + GTA_REVERSED_REF: + * STATIC_037 + OBSERVED_037 + PROBE_TRACE + ALT_02X_CODE + GTA_REVERSED_REF: * GTA-SA 1.0 US reaches gta_sa.exe+0x579C6 with ESI pointing at the * collected 0x20-byte CPickup entry. Legacy SA-MP hooks this exact point, * converts the entry address to the GTA pool index and forwards the - * matching SA-MP pool slot through RPC 131. + * matching SA-MP pool slot through RPC 131. Original-R5 run + * 20260802-112802-distributed-sync-pickup-57189 confirms priority 1, + * RELIABLE_ORDERED (numeric 9), channel 0 and a signed 32-bit slot. */ if (samp_raknet_client_send_pickup_notification( g_runtime.net_mgr.raknet_client, (int32_t)i) == 0) { g_runtime.pickup_notify_after_tick[i] = now + 1500u; runtime_tracef("pickup: collected id=%lu handle=%lu gta_index=%lu " - "source=gta_collect_hook guard_ms=1500 " - "evidence=ALT_02X_CODE,GTA_REVERSED_REF,PROBE_TRACE", + "source=ordinary_picked_up reliability=9 guard_ms=1500 " + "evidence=STATIC_037,OBSERVED_037,PROBE_TRACE,ALT_02X_CODE,GTA_REVERSED_REF", (unsigned long)i, (unsigned long)handle, (unsigned long)gta_index); } @@ -31541,6 +36913,7 @@ static void __cdecl gta_pickup_collected_callback_compat(uintptr_t pickup_entry) static void gta_process_pickups_compat(void) { DWORD now = GetTickCount(); + int collect_hook_installed = 0; uint32_t i = 0u; if (g_runtime.net_mgr.raknet_client == NULL || @@ -31549,33 +36922,49 @@ static void gta_process_pickups_compat(void) { InterlockedCompareExchange(&g_runtime.mp_session_spawn_finalized, 0, 0) == 0) { return; } + collect_hook_installed = + InterlockedCompareExchange(&g_runtime.pickup_collect_hook_installed, 0, 0) != 0; - for (i = 0u; i < SAMP_PICKUP_COMPAT_MAX; ++i) { + for (i = 0u; i < SAMP_PICKUP_POOL_CAPACITY_037; ++i) { uint32_t handle = g_runtime.pickup_handles[i]; DWORD notify_after = g_runtime.pickup_notify_after_tick[i]; + uint8_t pickup_type = g_runtime.pickup_types[i]; + int process_source = pickup_type == 14u; int result = 0; if (handle == 0u || (notify_after != 0u && (int32_t)(now - notify_after) < 0)) { continue; } + if (collect_hook_installed && !process_source) { + continue; + } /* - * ALT_02X_CODE + TODO_VERIFY: - * Legacy CPickupPool::Process queries GTA opcode 0214 for each live - * handle, sends RPC 131 with the pool index and applies a 15-process-tick - * guard. Use a 250 ms wall-clock guard so frame rate cannot multiply - * duplicate notifications while the server's destroy RPC is in flight. + * STATIC_037 + TODO_VERIFY: + * R5 CPickupPool::Process at samp.dll+0x13520 keeps a 15-process-tick + * counter for normal pickups and sends type-14 RPC 131 from a distinct + * HIGH_PRIORITY/RELIABLE_SEQUENCED source. With the GTA collection hook + * installed, ordinary pickups are therefore left to PickedUp above. The + * wall-clock guard remains a replacement safety fallback until type-14 + * has a comparable live trace. */ if (!gta_script_command_condition_compat(0x0214u, "i", (int)handle)) { continue; } - result = samp_raknet_client_send_pickup_notification( - g_runtime.net_mgr.raknet_client, (int32_t)i); + result = process_source + ? samp_raknet_client_send_pickup_process_notification( + g_runtime.net_mgr.raknet_client, (int32_t)i) + : samp_raknet_client_send_pickup_notification( + g_runtime.net_mgr.raknet_client, (int32_t)i); if (result == 0) { g_runtime.pickup_notify_after_tick[i] = now + 250u; - runtime_tracef("pickup: collected id=%lu handle=%lu guard_ms=250 " - "evidence=ALT_02X_CODE,TODO_VERIFY", - (unsigned long)i, (unsigned long)handle); + runtime_tracef("pickup: collected id=%lu handle=%lu type=%u source=%s " + "reliability=%u guard_ms=250 " + "evidence=STATIC_037,TODO_VERIFY", + (unsigned long)i, (unsigned long)handle, + (unsigned)pickup_type, + process_source ? "process" : "ordinary_fallback", + process_source ? 10u : 9u); } } } @@ -31623,6 +37012,7 @@ static void game_session_reset_to_preconnect_compat(const char *reason, int tran * loss still falls through to the ordinary reconnect path. */ chat_input_close_compat(); + InterlockedExchange(&g_runtime.scoreboard_visible, 0); scoreboard_compat_restore_hud(reason); InterlockedExchange(&g_runtime.class_selection_mouse_mode, 0); InterlockedExchange(&g_runtime.class_selection_mouse_down, 0); @@ -31659,6 +37049,8 @@ static void game_session_reset_to_preconnect_compat(const char *reason, int tran InterlockedExchange(&g_runtime.dialog_overlay_scroll, 0); InterlockedExchange(&g_runtime.dialog_overlay_input_len, 0); InterlockedExchange(&g_runtime.dialog_overlay_logged, 0); + InterlockedExchange(&g_runtime.dialog_overlay_local_kind, + SAMP_DIALOG_COMPAT_LOCAL_NONE); g_runtime.dialog_overlay_title[0] = '\0'; g_runtime.dialog_overlay_info[0] = '\0'; g_runtime.dialog_overlay_button1[0] = '\0'; @@ -31666,7 +37058,9 @@ static void game_session_reset_to_preconnect_compat(const char *reason, int tran g_runtime.dialog_overlay_input[0] = '\0'; InterlockedExchange(&g_runtime.scoreboard_offset, 0); + InterlockedExchange(&g_runtime.scoreboard_visible, 0); InterlockedExchange(&g_runtime.scoreboard_logged, 0); + InterlockedExchange(&g_runtime.scoreboard_exclusive_overlay_logged, 0); InterlockedExchange(&g_runtime.scoreboard_player_pool_event_seq, 0); InterlockedExchange(&g_runtime.scoreboard_score_ping_seq, 0); InterlockedExchange(&g_runtime.scoreboard_player_count, 0); @@ -31689,7 +37083,11 @@ static void game_session_reset_to_preconnect_compat(const char *reason, int tran chat_bubble_compat_reset(reason); object_compat_reset_pool(reason); attached_object_compat_reset_pool(reason); - remove_building_compat_reset(); + remove_building_compat_reset(reason); + InterlockedExchange(&g_runtime.local_virtual_world, 0); + InterlockedExchange(&g_runtime.local_virtual_world_seq, 0); + InterlockedExchange(&g_runtime.remote_vehicle_collisions_disabled, 0); + InterlockedExchange(&g_runtime.remote_vehicle_collisions_disabled_seq, 0); InterlockedExchange(&g_runtime.object_material_persist_count, 0); InterlockedExchange(&g_runtime.object_material_resolved_count, 0); InterlockedExchange(&g_runtime.object_material_render_count, 0); @@ -31774,7 +37172,11 @@ static void game_session_reset_to_preconnect_compat(const char *reason, int tran InterlockedExchange(&g_runtime.mp_session_observed_world_visual_seq, 0); InterlockedExchange(&g_runtime.mp_session_spawn_finalized, 0); InterlockedExchange(&g_runtime.mp_session_finalized_spawn_seq, 0); + InterlockedExchange(&g_runtime.mp_session_class_selection_initial_state_seq, 0); InterlockedExchange(&g_runtime.mp_session_class_selection_skin_seq, 0); + InterlockedExchange(&g_runtime.mp_session_class_selection_skin_request_seq, 0); + InterlockedExchange(&g_runtime.mp_session_class_selection_skin_request_model, -1); + InterlockedExchange(&g_runtime.mp_session_class_selection_skin_request_tick, 0); InterlockedExchange(&g_runtime.mp_session_class_scene_player_pos_seq, 0); InterlockedExchange(&g_runtime.mp_session_scene_loaded, 0); InterlockedExchange(&g_runtime.mp_session_post_spawn_camera_restored, 0); @@ -31828,12 +37230,71 @@ static void game_session_reset_to_preconnect_compat(const char *reason, int tran InterlockedExchange(&g_runtime.client_spectate_id, 0); InterlockedExchange(&g_runtime.client_spectate_mode, 0); InterlockedExchange(&g_runtime.local_death_reported, 0); - g_runtime.spectator_sync_last_tick = 0u; + InterlockedExchange(&g_runtime.local_respawn_pending, 0); + /* + * STATIC_037 + INFERRED: + * samp.dll+0xA540 tears down the game-mode-owned local-player state while + * keeping RakNet connected. Sender cadence and payload caches are therefore + * session state too: retaining them can suppress the first post-GMX sync or + * emit an ExitVehicle/BulletSync derived from the previous game mode. + */ + InterlockedExchange(&g_runtime.onfoot_sync_send_count, 0); + InterlockedExchange(&g_runtime.onfoot_sync_failures, 0); + InterlockedExchange(&g_runtime.onfoot_sync_logged, 0); + g_runtime.onfoot_sync_last_tick = 0u; + g_runtime.local_damage_last_tick = 0u; + InterlockedExchange(&g_runtime.incar_sync_send_count, 0); + InterlockedExchange(&g_runtime.incar_sync_failures, 0); + InterlockedExchange(&g_runtime.incar_sync_logged, 0); + g_runtime.incar_sync_last_tick = 0u; + InterlockedExchange(&g_runtime.unoccupied_sync_send_count, 0); + InterlockedExchange(&g_runtime.unoccupied_sync_failures, 0); + InterlockedExchange(&g_runtime.unoccupied_sync_logged, 0); g_runtime.unoccupied_sync_last_tick = 0u; + InterlockedExchange(&g_runtime.trailer_sync_send_count, 0); + InterlockedExchange(&g_runtime.trailer_sync_failures, 0); + InterlockedExchange(&g_runtime.trailer_sync_logged, 0); g_runtime.trailer_sync_last_tick = 0u; g_runtime.trailer_sync_last_valid = 0u; memset(&g_runtime.trailer_sync_last, 0, sizeof(g_runtime.trailer_sync_last)); + InterlockedExchange(&g_runtime.passenger_sync_send_count, 0); + InterlockedExchange(&g_runtime.passenger_sync_failures, 0); + InterlockedExchange(&g_runtime.passenger_sync_logged, 0); + InterlockedExchange(&g_runtime.passenger_entry_send_count, 0); + InterlockedExchange(&g_runtime.passenger_exit_send_count, 0); + g_runtime.passenger_sync_poll_last_tick = 0u; + g_runtime.passenger_sync_last_tick = 0u; + g_runtime.passenger_entry_tick = 0u; + g_runtime.passenger_sync_last_valid = 0u; + g_runtime.passenger_entry_pending = 0u; + g_runtime.passenger_entry_seen_seated = 0u; + g_runtime.passenger_entry_vehicle_id = 0xFFFFu; + memset(&g_runtime.passenger_sync_last, 0, + sizeof(g_runtime.passenger_sync_last)); + InterlockedExchange(&g_runtime.aim_sync_send_count, 0); + InterlockedExchange(&g_runtime.aim_sync_failures, 0); + InterlockedExchange(&g_runtime.aim_sync_logged, 0); + g_runtime.aim_sync_last_tick = 0u; + InterlockedExchange(&g_runtime.bullet_sync_send_count, 0); + InterlockedExchange(&g_runtime.bullet_sync_failures, 0); + InterlockedExchange(&g_runtime.bullet_sync_logged, 0); + InterlockedExchange(&g_runtime.remote_bullet_effect_calls, 0); + InterlockedExchange(&g_runtime.remote_bullet_effect_applied, 0); + InterlockedExchange(&g_runtime.remote_bullet_effect_skipped, 0); + InterlockedExchange(&g_runtime.actor_damage_send_count, 0); + g_runtime.bullet_sync_last_tick = 0u; + g_runtime.bullet_sync_last_clip_ammo = 0u; + g_runtime.bullet_sync_last_total_ammo = 0u; + g_runtime.bullet_sync_ammo_initialized = 0u; + g_runtime.bullet_sync_last_weapon = 0u; + g_runtime.spectator_sync_last_tick = 0u; + InterlockedExchange(&g_runtime.mp_session_script_failures, 0); + /* + * This payload is also used as an ungated local-sync fallback. Its sequence + * was already cleared above, but the value itself must not cross a GMX. + */ + g_runtime.raknet_player_armed_weapon = 0u; g_runtime.local_stream_refresh_last_tick = 0u; InterlockedExchange(&g_runtime.preconnect_apply_count, 0); @@ -31950,6 +37411,64 @@ static void client_control_compat_update_from_snapshot(const samp_raknet_rpc_pro interpolate_seq = 0u; bounds_active = 0u; } + if (snapshot->virtual_world_seq != 0u && + snapshot->virtual_world_seq != + (uint32_t)InterlockedCompareExchange( + &g_runtime.local_virtual_world_seq, 0, 0)) { + LONG previous_world = InterlockedExchange( + &g_runtime.local_virtual_world, (LONG)snapshot->virtual_world); + int changed = previous_world != (LONG)snapshot->virtual_world; + InterlockedExchange(&g_runtime.local_virtual_world_seq, + (LONG)snapshot->virtual_world_seq); + if (changed) { + /* + * STATIC_037: + * CLocalPlayer::SetVirtualWorld at samp.dll+0x17D10 stores the signed + * world and clears the wasted (+0x220) and wants-another-class + * (+0x221) latches only when the value changes. It performs no GTA + * streaming call here. + */ + InterlockedExchange(&g_runtime.local_death_reported, 0); + InterlockedExchange(&g_runtime.local_respawn_pending, 0); + InterlockedExchange( + &g_runtime.class_selection_after_death_requested, 0); + InterlockedExchange( + &g_runtime.class_selection_after_death_consumed, 0); + InterlockedExchange( + &g_runtime.class_selection_after_death_seen_dead, 0); + InterlockedExchange(&g_runtime.class_selection_forced_local, 0); + } + runtime_tracef( + "client_control: virtual_world seq=%lu previous=%ld current=%ld " + "changed=%d latches_cleared=%d " + "evidence=STATIC_037:samp.dll+0x1DCC0,samp.dll+0x17D10", + (unsigned long)snapshot->virtual_world_seq, (long)previous_world, + (long)snapshot->virtual_world, changed, changed); + } + if (snapshot->remote_vehicle_collisions_disabled_seq != 0u && + snapshot->remote_vehicle_collisions_disabled_seq != + (uint32_t)InterlockedCompareExchange( + &g_runtime.remote_vehicle_collisions_disabled_seq, 0, 0)) { + LONG disabled = + snapshot->remote_vehicle_collisions_disabled != 0u ? 1 : 0; + InterlockedExchange(&g_runtime.remote_vehicle_collisions_disabled, + disabled); + InterlockedExchange( + &g_runtime.remote_vehicle_collisions_disabled_seq, + (LONG)snapshot->remote_vehicle_collisions_disabled_seq); + runtime_tracef( + "client_control: remote_vehicle_collisions_disabled seq=%lu " + "disabled=%ld hook_installed=%ld camera_hook_installed=%ld " + "evidence=STATIC_037:samp.dll+0x17DE0,samp.dll+0xA5AC0..+0xA5CF0", + (unsigned long) + snapshot->remote_vehicle_collisions_disabled_seq, + (long)disabled, + (long)InterlockedCompareExchange( + &g_runtime.remote_vehicle_collision_hook_installed, 0, 0), + (long)InterlockedCompareExchange( + &g_runtime.remote_vehicle_collision_camera_hook_installed, 0, + 0)); + } if (snapshot->world_bounds_seq != 0u && snapshot->world_bounds_seq != world_bounds_seq) { world_bounds_seq = snapshot->world_bounds_seq; memcpy(bounds, snapshot->world_bounds, sizeof(bounds)); @@ -32381,6 +37900,7 @@ static uint32_t refresh_raknet_rpc_snapshot_compat(void) { chat_bubble_compat_update_from_snapshot(&snapshot); object_compat_update_from_snapshot(&snapshot); vehicle_compat_update_from_snapshot(&snapshot); + remote_edge_sync_update_from_snapshot(&snapshot); attached_object_compat_update_from_snapshot(&snapshot); edit_state_compat_update_from_snapshot(&snapshot); client_control_compat_update_from_snapshot(&snapshot); @@ -32574,10 +38094,14 @@ static uint32_t refresh_raknet_rpc_snapshot_compat(void) { previous_player_skin_seq = InterlockedCompareExchange(&g_runtime.mp_session_applied_player_skin_seq, 0, 0); if (snapshot.player_skin_seq != 0u && snapshot.player_skin_seq != (uint32_t)previous_player_skin_seq) { int target_is_local = rpc_target_is_local_compat(&snapshot, snapshot.player_skin_player_id); - int applied = target_is_local ? gta_local_set_skin_compat(snapshot.player_skin) : 0; + int applied = target_is_local + ? gta_local_set_skin_compat(snapshot.player_skin) + : gta_remote_player_set_skin_compat( + snapshot.player_skin_player_id, + snapshot.player_skin); InterlockedExchange(&g_runtime.mp_session_applied_player_skin_seq, (LONG)snapshot.player_skin_seq); runtime_tracef("network_prepare: apply_player_skin seq=%lu previous=%ld target=%lu local=%d skin=%ld applied=%d " - "evidence=STATIC_037,PROBE_TRACE,TODO_VERIFY", + "evidence=STATIC_037:samp.dll+0x19190,+0xAFF50,PROBE_TRACE,TODO_VERIFY", (unsigned long)snapshot.player_skin_seq, (long)previous_player_skin_seq, (unsigned long)snapshot.player_skin_player_id, target_is_local, (long)snapshot.player_skin, applied); @@ -33132,8 +38656,13 @@ static void maintain_online_session_state(void) { return; } - write_game_u8(SAMP_ADDR_ENABLE_HUD, 1u); - write_game_u8(SAMP_ADDR_RADAR_BLANK, 0u); + /* + * OBSERVED_037 + PROBE_TRACE: + * The key-up-latched R5 scoreboard owns HUD=0/radar_blank=1 for its whole + * visible lifetime. The monitor thread must neither publish the normal + * online HUD nor run shared cursor teardown while that raw owner exists. + */ + (void)scoreboard_compat_write_hud_if_unowned(1u, 0u); if (startgame_before == 0u) { return; @@ -33385,35 +38914,7 @@ static void gta_streaming_load_all_requested_compat(int only_priority_requests) load_all(only_priority_requests ? 1 : 0); } -static void gta_streaming_load_scene_collision_compat(const samp_gta_vector *point) { - typedef void(__cdecl *gta_streaming_load_scene_collision_fn)(const samp_gta_vector *); - gta_streaming_load_scene_collision_fn load_collision = - (gta_streaming_load_scene_collision_fn)(uintptr_t)SAMP_ADDR_STREAMING_LOAD_SCENE_COLLISION; - - if (point == NULL) { - return; - } - load_collision(point); -} - -static void gta_streaming_load_scene_compat(const samp_gta_vector *point) { - typedef void(__cdecl *gta_streaming_load_scene_fn)(const samp_gta_vector *); - gta_streaming_load_scene_fn load_scene = - (gta_streaming_load_scene_fn)(uintptr_t)SAMP_ADDR_STREAMING_LOAD_SCENE; - - if (point == NULL) { - return; - } - load_scene(point); -} - static void gta_prepare_scene_at_compat(const char *reason, float x, float y, float z, int32_t skin_model) { - samp_gta_vector point; - - point.x = x; - point.y = y; - point.z = z; - if (skin_model >= 0 && object_compat_model_available(skin_model)) { gta_streaming_request_model_compat(skin_model, 0x06); } else if (skin_model >= 0) { @@ -33421,21 +38922,21 @@ static void gta_prepare_scene_at_compat(const char *reason, float x, float y, fl "evidence=PROBE_TRACE,GTA_REVERSED_REF,TODO_VERIFY", reason != NULL ? reason : "unknown", (long)skin_model); } - runtime_tracef("scene_prepare_step: reason=%s step=LoadSceneCollision phase=begin " - "pos=(%.3f,%.3f,%.3f) evidence=PROBE_TRACE,GTA_REVERSED_REF", + /* + * STATIC_037 + PROBE_TRACE: + * Original SA-MP 0.3.7-R5 SHA256= + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2 + * has no references to GTA CStreaming::LoadSceneCollision (0x40ED80) or + * CStreaming::LoadScene (0x40EB70). Replacement cold starts and spawn + * handoffs intermittently blocked inside those synchronous calls while the + * render thread was still alive. Let GTA's normal streamer own scene loads; + * retain the established RefreshStreamingAt/script-opcode path below. + */ + runtime_tracef("scene_prepare_step: reason=%s step=DirectSceneLoads action=skip " + "pos=(%.3f,%.3f,%.3f) " + "evidence=STATIC_037:samp.dll(no_refs_0x40ED80,0x40EB70),PROBE_TRACE", reason != NULL ? reason : "unknown", (double)x, (double)y, (double)z); - gta_streaming_load_scene_collision_compat(&point); - runtime_tracef("scene_prepare_step: reason=%s step=LoadSceneCollision phase=end " - "evidence=PROBE_TRACE,GTA_REVERSED_REF", - reason != NULL ? reason : "unknown"); - runtime_tracef("scene_prepare_step: reason=%s step=LoadScene phase=begin " - "evidence=PROBE_TRACE,GTA_REVERSED_REF", - reason != NULL ? reason : "unknown"); - gta_streaming_load_scene_compat(&point); - runtime_tracef("scene_prepare_step: reason=%s step=LoadScene phase=end " - "evidence=PROBE_TRACE,GTA_REVERSED_REF", - reason != NULL ? reason : "unknown"); if (skin_model >= 0 && object_compat_model_available(skin_model)) { runtime_tracef("scene_prepare_step: reason=%s step=LoadAllRequested phase=begin " "evidence=PROBE_TRACE,GTA_REVERSED_REF", @@ -34172,6 +39673,38 @@ static int gta_entity_direct_position_compat(uintptr_t entity, float x, float y, return 1; } +static int gta_entity_write_position_preserve_speed_compat( + uintptr_t entity, const float position[3]) { + uintptr_t matrix = 0u; + + if (position == NULL || entity < 0x10000u || entity >= 0x80000000u || + !isfinite(position[0]) || !isfinite(position[1]) || + !isfinite(position[2]) || + !memory_is_readable_compat( + (const void *)(entity + SAMP_PED_OFFSET_MATRIX), sizeof(matrix))) { + return 0; + } + memcpy(&matrix, (const void *)(entity + SAMP_PED_OFFSET_MATRIX), + sizeof(matrix)); + if (matrix < 0x10000u || matrix >= 0x80000000u || + !memory_is_writable_compat( + (void *)(matrix + SAMP_MATRIX_OFFSET_POS), + sizeof(float) * 3u)) { + return 0; + } + + /* + * STATIC_037: + * samp.dll+0x9EBC0 copies the complete matrix basis/position without + * advancing physics and without CEntity::UpdateRwFrame. Packet 200's + * +0x15140 path changes only the position in a previously fetched matrix, + * so preserve the existing basis and CPhysical move speed here. + */ + memcpy((void *)(matrix + SAMP_MATRIX_OFFSET_POS), position, + sizeof(float) * 3u); + return 1; +} + static uint32_t gta_remove_building_pool_compat(uintptr_t pool_address, uint32_t stride, uint32_t hard_cap, const samp_raknet_remove_building_event *event) { uintptr_t pool = 0u; @@ -34240,21 +39773,19 @@ static uint32_t gta_remove_building_event_compat(const samp_raknet_remove_buildi return removed; } -static int remove_building_event_equal_compat(const samp_raknet_remove_building_event *left, - const samp_raknet_remove_building_event *right) { - return left != NULL && right != NULL && left->model == right->model && left->radius == right->radius && - memcmp(left->position, right->position, sizeof(left->position)) == 0; -} - static void remove_building_store_event_compat(const samp_raknet_remove_building_event *event) { LONG count = InterlockedCompareExchange(&g_runtime.remove_building_record_count, 0, 0); - LONG i = 0; if (event == NULL) return; if (count < 0) count = 0; if (count > (LONG)SAMP_REMOVE_BUILDING_COMPAT_MAX) count = (LONG)SAMP_REMOVE_BUILDING_COMPAT_MAX; - for (i = 0; i < count; ++i) { - if (remove_building_event_equal_compat(&g_runtime.remove_building_records[i], event)) return; - } + /* + * OBSERVED_037 + PROBE_TRACE + STATIC_037: + * The populated GMX trace in + * artifacts/runs/20260728-144428-distributed-sync-gmx-1646159 retains all + * 15 entries, including repeated fixture rules. R5's append path at + * samp.dll+0x9D3D0 has no equality branch. Preserve duplicate RPCs in their + * arrival order; the conservative local capacity check below remains. + */ if (count >= (LONG)SAMP_REMOVE_BUILDING_COMPAT_MAX) { runtime_tracef("remove_building: record_overflow max=%u seq=%lu evidence=TODO_VERIFY", (unsigned)SAMP_REMOVE_BUILDING_COMPAT_MAX, (unsigned long)event->seq); @@ -34307,11 +39838,25 @@ static void remove_building_compat_update_from_snapshot(const samp_raknet_rpc_pr } } -static void remove_building_compat_reset(void) { +static void remove_building_compat_reset(const char *reason) { + LONG persisted = + InterlockedCompareExchange(&g_runtime.remove_building_record_count, 0, 0); + + /* + * STATIC_037: + * R5's append-only rule store at samp.dll+0x14AF68 (count +0x14FD88) + * has no GMX/disconnect clear path. The IPL stream hook at +0xA4A70 keeps + * matching later stream-ins invisible by substituting model 19300. Preserve + * the process-lifetime rules and reset only the adapter consumer sequence; + * trying to restore entity Z/flags here would not match R5 and is unsafe + * after pool-slot reuse. + */ InterlockedExchange(&g_runtime.remove_building_event_seq, 0); - InterlockedExchange(&g_runtime.remove_building_record_count, 0); g_runtime.remove_building_last_scan_tick = 0u; - memset(g_runtime.remove_building_records, 0, sizeof(g_runtime.remove_building_records)); + runtime_tracef( + "remove_building: session_latches_reset records_persisted=%ld " + "reason=%s evidence=STATIC_037:samp.dll+0x9D3D0,+0x9D050,+0xA4A70", + (long)persisted, reason != NULL ? reason : "unknown"); } static int gta_entity_read_position_compat(uintptr_t entity, float *x, float *y, float *z) { @@ -34344,7 +39889,8 @@ static int gta_entity_read_position_compat(uintptr_t entity, float *x, float *y, static int gta_entity_write_move_speed_compat(uintptr_t entity, const float speed[3]) { if (speed == NULL || entity < 0x10000u || entity >= 0x80000000u || !isfinite(speed[0]) || !isfinite(speed[1]) || !isfinite(speed[2]) || - !memory_is_readable_compat((const void *)(entity + SAMP_ENTITY_OFFSET_MOVE_SPEED), sizeof(float) * 3u)) { + !memory_is_writable_compat((void *)(entity + SAMP_ENTITY_OFFSET_MOVE_SPEED), + sizeof(float) * 3u)) { return 0; } @@ -34352,6 +39898,135 @@ static int gta_entity_write_move_speed_compat(uintptr_t entity, const float spee return 1; } +static int gta_entity_write_turn_speed_compat(uintptr_t entity, + const float speed[3]) { + if (speed == NULL || entity < 0x10000u || entity >= 0x80000000u || + !isfinite(speed[0]) || !isfinite(speed[1]) || !isfinite(speed[2]) || + !memory_is_writable_compat( + (void *)(entity + SAMP_ENTITY_OFFSET_TURN_SPEED), + sizeof(float) * 3u)) { + return 0; + } + memcpy((void *)(entity + SAMP_ENTITY_OFFSET_TURN_SPEED), speed, + sizeof(float) * 3u); + return 1; +} + +static int gta_entity_write_unoccupied_axes_compat( + uintptr_t entity, const float right[3], const float forward[3]) { + uintptr_t matrix = 0u; + unsigned int i = 0u; + + if (right == NULL || forward == NULL || entity < 0x10000u || + entity >= 0x80000000u || + !memory_is_readable_compat( + (const void *)(entity + SAMP_PED_OFFSET_MATRIX), sizeof(matrix))) { + return 0; + } + for (i = 0u; i < 3u; ++i) { + if (!isfinite(right[i]) || !isfinite(forward[i])) { + return 0; + } + } + + memcpy(&matrix, (const void *)(entity + SAMP_PED_OFFSET_MATRIX), + sizeof(matrix)); + if (matrix < 0x10000u || matrix >= 0x80000000u || + !memory_is_writable_compat( + (void *)(matrix + SAMP_MATRIX_OFFSET_RIGHT), + sizeof(float) * 3u) || + !memory_is_writable_compat( + (void *)(matrix + SAMP_MATRIX_OFFSET_FORWARD_AXIS), + sizeof(float) * 3u)) { + return 0; + } + /* + * STATIC_037: + * R5 samp.dll+0x159D7..+0x15A08 starts from the vehicle's complete + * matrix, overwrites only +0x00 with UnoccupiedSync::roll and +0x10 with + * ::rotation, and preserves the +0x20 axis and matrix flags verbatim. + */ + memcpy((void *)(matrix + SAMP_MATRIX_OFFSET_RIGHT), right, + sizeof(float) * 3u); + memcpy((void *)(matrix + SAMP_MATRIX_OFFSET_FORWARD_AXIS), forward, + sizeof(float) * 3u); + return 1; +} + +static int gta_entity_write_quaternion_compat( + uintptr_t entity, const float quaternion[4]) { + uintptr_t matrix = 0u; + float q[4]; + float right[3]; + float forward[3]; + float up[3]; + float length_sq = 0.0f; + float inverse_length = 0.0f; + float w = 0.0f; + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + unsigned int i = 0u; + + if (quaternion == NULL || entity < 0x10000u || entity >= 0x80000000u || + !memory_is_readable_compat( + (const void *)(entity + SAMP_PED_OFFSET_MATRIX), sizeof(matrix))) { + return 0; + } + for (i = 0u; i < 4u; ++i) { + if (!isfinite(quaternion[i])) { + return 0; + } + length_sq += quaternion[i] * quaternion[i]; + } + if (length_sq < 0.000001f) { + return 0; + } + inverse_length = 1.0f / sqrtf(length_sq); + for (i = 0u; i < 4u; ++i) { + q[i] = quaternion[i] * inverse_length; + } + w = q[0]; + x = q[1]; + y = q[2]; + z = q[3]; + /* + * STATIC_037: + * R5 ApplyTrailerSync calls samp.dll+0xB6A80, which forwards the wire + * w,x,y,z components to +0xB4F10. That helper writes this exact + * D3DX/RenderWare basis. It is intentionally the transpose of GTA SA + * CQuaternion::Get at 0x59C080; replacing it with the GTA helper mirrors + * remote orientation and destabilizes articulated trailers. + */ + right[0] = 1.0f - 2.0f * (z * z + y * y); + right[1] = 2.0f * (x * y - z * w); + right[2] = 2.0f * (x * z + y * w); + forward[0] = 2.0f * (x * y + z * w); + forward[1] = 1.0f - 2.0f * (z * z + x * x); + forward[2] = 2.0f * (y * z - x * w); + up[0] = 2.0f * (x * z - y * w); + up[1] = 2.0f * (y * z + x * w); + up[2] = 1.0f - 2.0f * (y * y + x * x); + + memcpy(&matrix, (const void *)(entity + SAMP_PED_OFFSET_MATRIX), + sizeof(matrix)); + if (matrix < 0x10000u || matrix >= 0x80000000u || + !memory_is_writable_compat( + (void *)(matrix + SAMP_MATRIX_OFFSET_RIGHT), sizeof(right)) || + !memory_is_writable_compat( + (void *)(matrix + SAMP_MATRIX_OFFSET_FORWARD_AXIS), + sizeof(forward)) || + !memory_is_writable_compat( + (void *)(matrix + SAMP_MATRIX_OFFSET_UP_AXIS), sizeof(up))) { + return 0; + } + memcpy((void *)(matrix + SAMP_MATRIX_OFFSET_RIGHT), right, sizeof(right)); + memcpy((void *)(matrix + SAMP_MATRIX_OFFSET_FORWARD_AXIS), forward, + sizeof(forward)); + memcpy((void *)(matrix + SAMP_MATRIX_OFFSET_UP_AXIS), up, sizeof(up)); + return 1; +} + static int gta_entity_apply_move_speed_compat(uintptr_t entity) { #if defined(__i386__) || defined(_M_IX86) if (entity < 0x10000u || entity >= 0x80000000u) { @@ -34424,8 +40099,6 @@ static int gta_entity_read_full_quaternion_compat(uintptr_t entity, SAMP_GTA_FUNC_CQUATERNION_SET_MATRIX; uintptr_t matrix = 0u; float gta_quaternion[4] = {0.0f, 0.0f, 0.0f, 1.0f}; - float length_sq = 0.0f; - float inverse_length = 0.0f; if (quaternion == NULL) { return 0; @@ -34444,21 +40117,13 @@ static int gta_entity_read_full_quaternion_compat(uintptr_t entity, * R5 SendTrailerSync (samp.dll+0x53D0) converts the complete vehicle * matrix instead of dropping pitch/roll. GTA SA 1.0 US * CQuaternion::Set(RwMatrix const&) is at 0x59C3E0 and stores x,y,z,w; - * the SA-MP wire quaternion is w,x,y,z. + * R5's own matrix converter at samp.dll+0xB52B0/+0xB4D70 emits the + * conjugated, normalized SA-MP wire quaternion w,-x,-y,-z and + * canonicalizes the global sign to W >= 0. */ set_from_matrix(gta_quaternion, (const void *)matrix); - length_sq = gta_quaternion[0] * gta_quaternion[0] + - gta_quaternion[1] * gta_quaternion[1] + - gta_quaternion[2] * gta_quaternion[2] + - gta_quaternion[3] * gta_quaternion[3]; - if (isfinite(length_sq) && length_sq > 0.000001f && - isfinite(gta_quaternion[0]) && isfinite(gta_quaternion[1]) && - isfinite(gta_quaternion[2]) && isfinite(gta_quaternion[3])) { - inverse_length = 1.0f / sqrtf(length_sq); - quaternion[0] = gta_quaternion[3] * inverse_length; - quaternion[1] = gta_quaternion[0] * inverse_length; - quaternion[2] = gta_quaternion[1] * inverse_length; - quaternion[3] = gta_quaternion[2] * inverse_length; + if (samp_gta_matrix_quaternion_xyzw_to_wire_wxyz( + gta_quaternion, quaternion)) { return 1; } } @@ -34509,10 +40174,6 @@ static int gta_entity_read_unoccupied_axes_compat(uintptr_t entity, float roll[3], float rotation[3]) { uintptr_t matrix = 0u; - float roll_length_sq = 0.0f; - float rotation_length_sq = 0.0f; - float dot = 0.0f; - float inverse_length = 0.0f; unsigned int i = 0u; if (roll == NULL || rotation == NULL || entity < 0x10000u || @@ -34528,35 +40189,25 @@ static int gta_entity_read_unoccupied_axes_compat(uintptr_t entity, (const void *)(matrix + SAMP_MATRIX_OFFSET_RIGHT), sizeof(float) * 3u) || !memory_is_readable_compat( - (const void *)(matrix + SAMP_MATRIX_OFFSET_UP_AXIS), + (const void *)(matrix + SAMP_MATRIX_OFFSET_FORWARD_AXIS), sizeof(float) * 3u)) { return 0; } memcpy(roll, (const void *)(matrix + SAMP_MATRIX_OFFSET_RIGHT), sizeof(float) * 3u); - memcpy(rotation, (const void *)(matrix + SAMP_MATRIX_OFFSET_UP_AXIS), + memcpy(rotation, (const void *)(matrix + SAMP_MATRIX_OFFSET_FORWARD_AXIS), sizeof(float) * 3u); for (i = 0u; i < 3u; ++i) { if (!isfinite(roll[i]) || !isfinite(rotation[i])) { return 0; } - roll_length_sq += roll[i] * roll[i]; - rotation_length_sq += rotation[i] * rotation[i]; - dot += roll[i] * rotation[i]; - } - if (roll_length_sq < 0.000001f || rotation_length_sq < 0.000001f) { - return 0; - } - inverse_length = 1.0f / sqrtf(roll_length_sq); - for (i = 0u; i < 3u; ++i) { - roll[i] *= inverse_length; - } - inverse_length = 1.0f / sqrtf(rotation_length_sq); - for (i = 0u; i < 3u; ++i) { - rotation[i] *= inverse_length; } - dot /= sqrtf(roll_length_sq * rotation_length_sq); - return isfinite(dot) && fabsf(dot) < 0.1f; + /* + * STATIC_037: + * R5 sender samp.dll+0x4DCA..+0x4E18 copies the live matrix vectors at + * +0x00 and +0x10 without normalization. + */ + return 1; } static int valid_world_position_compat(float x, float y, float z) { @@ -34954,25 +40605,21 @@ static void refresh_local_streaming_from_entity_compat(uintptr_t ped) { /* * STATIC_037 + GTA_REVERSED_REF + INFERRED: - * Original spawn/teleport paths call RefreshStreamingAt. While driving, no server SetPlayerPos RPC may arrive for - * every local movement, so keep GTA's streamer warm from the local vehicle/ped position without forcing movement. - * GTA's scene/collision loaders match the existing spawn/teleport prepare path and are only ticked on movement. + * Original spawn/teleport paths call RefreshStreamingAt. While driving, no + * server SetPlayerPos RPC may arrive for every local movement, so keep GTA's + * streamer warm from the local vehicle/ped position without forcing + * movement. Do not add direct LoadSceneCollision/LoadScene calls: R5 has no + * references to either GTA routine, and replacement traces observed both + * calls blocking the game thread. */ ok = gta_script_command_compat(0x04E4u, "ff", x, y); if (!ok) { mp_bridge_record_script_failure("local_refresh_streaming_at", 0x04E4u); return; } - { - samp_gta_vector point; - point.x = x; - point.y = y; - point.z = z; - gta_streaming_load_scene_collision_compat(&point); - gta_streaming_load_scene_compat(&point); - } if (InterlockedCompareExchange(&g_runtime.local_stream_refresh_logged, 1, 0) == 0) { - runtime_tracef("local_stream_refresh: entity=0x%08lx pos=(%.3f,%.3f,%.3f) interval_ms=%lu distance=%.1f", + runtime_tracef("local_stream_refresh: entity=0x%08lx pos=(%.3f,%.3f,%.3f) interval_ms=%lu distance=%.1f " + "direct_scene_loads=0 evidence=STATIC_037,PROBE_TRACE", (unsigned long)entity, (double)x, (double)y, (double)z, (unsigned long)SAMP_LOCAL_STREAM_REFRESH_INTERVAL_MS, (double)SAMP_LOCAL_STREAM_REFRESH_DISTANCE); @@ -38112,7 +43759,10 @@ static void __cdecl bullet_impact_hook_callback_compat(uintptr_t weapon_ptr, uin int result = 0; LONG send_count = 0; - if (InterlockedCompareExchange(&g_runtime.weapon_hooks_shutdown, 0, 0) != 0 || + if (dialog_compat_active() || + InterlockedCompareExchange( + &g_runtime.dialog_game_mouse_patch_applied, 0, 0) != 0 || + InterlockedCompareExchange(&g_runtime.weapon_hooks_shutdown, 0, 0) != 0 || InterlockedCompareExchange(&g_runtime.bullet_impact_hook_installed, 0, 0) == 0 || g_runtime.net_mgr.raknet_client == NULL || InterlockedCompareExchange(&g_runtime.mp_session_spawn_finalized, 0, 0) == 0 || @@ -40224,13 +45874,13 @@ static void apply_multiplayer_session_bridge_compat(void) { apply_session_frontend_hold_flags_compat("class_select"); } - if (spawn_ready) { - write_game_u8(SAMP_ADDR_ENABLE_HUD, 1u); - write_game_u8(SAMP_ADDR_RADAR_BLANK, 0u); - } else { - write_game_u8(SAMP_ADDR_ENABLE_HUD, 0u); - write_game_u8(SAMP_ADDR_RADAR_BLANK, 1u); - } + /* + * OBSERVED_037 + PROBE_TRACE: + * Keep the scoreboard's raw HUD ownership atomic with the session bridge. + * Render/UI gates are not equivalent to an R5 Hide transition. + */ + (void)scoreboard_compat_write_hud_if_unowned(spawn_ready ? 1u : 0u, + spawn_ready ? 0u : 1u); if (spawn_ready && has_spawn_info) { memcpy(target_pos, g_runtime.raknet_spawn_pos, sizeof(target_pos)); @@ -40560,6 +46210,27 @@ static void apply_multiplayer_session_bridge_compat(void) { if (ped != 0u && class_outcome == 1 && !spawn_ready) { float health = 100.0f; + LONG class_initial_state_seq = InterlockedCompareExchange( + &g_runtime.mp_session_class_selection_initial_state_seq, 0, 0); + + /* + * STATIC_037: + * CLocalPlayer::HandleClassSelection at samp.dll+0x4080 first calls the + * CPlayerPed wrapper at +0xABBD0, which invokes GTA + * CPlayerPed::SetInitialState (0x60CD20, player number 0), before setting + * health/control and before HandleClassSelectionOutcome applies the skin. + * The replacement formerly skipped this lifecycle transition. GTA + * CPed::Render at 0x5E7680 rejects CPed+0x474 bit 1 (bDontRender), which + * SetInitialState clears; CEntity+0x1C bit 7 is a separate visibility + * gate and is logged here for the remaining lifecycle comparison. + */ + if (has_spawn_info && spawn_info_seq != 0 && + class_initial_state_seq != spawn_info_seq && + gta_player_ped_set_initial_state_compat(ped, "class_selection")) { + InterlockedExchange( + &g_runtime.mp_session_class_selection_initial_state_seq, + spawn_info_seq); + } memcpy((void *)(ped + SAMP_PED_OFFSET_HEALTH), &health, sizeof(health)); if (!gta_script_command_compat(0x01B4u, "ii", SAMP_GTA_PLAYER_LOCAL_ID, 0)) { mp_bridge_record_script_failure("toggle_player_controllable", 0x01B4u); @@ -40573,32 +46244,95 @@ static void apply_multiplayer_session_bridge_compat(void) { uintptr_t class_skin_model_info = object_compat_model_info_ptr(class_skin); int class_skin_apply = class_skin >= 0 && class_skin <= SAMP_REMOTE_PLAYER_COMPAT_SKIN_MAX && class_skin_model_info != 0u && class_selection_skin_apply_enabled_compat(); + int class_skin_applied = 0; + int class_skin_done = !class_skin_apply; /* - * INFERRED + PROBE_TRACE + TODO_VERIFY: - * Compatibility HandleClassSelectionOutcome(TRUE) clears weapons and SetModelIndex(spawn skin) before RequestSpawn. - * The latest run showed RequestClass replies for skins 0/7/19 while the local ped stayed at the old model - * because this path was guarded off. Apply only valid GTA-SA ped models here; final spawn skin remains - * controlled by SAMPDLL_LOCAL_SPAWN_SKIN_APPLY. + * STATIC_037 + PROBE_TRACE: + * R5 CPlayerPed::SetModelIndex at samp.dll+0x9EF50 first accepts an + * already-loaded model, otherwise requests it and gives availability a + * bounded 200 x 1 ms wait. Do not use opcode 038B here: the 2026-07-27 + * UFW sequence 45 -> 44 -> 45 -> 298 proved that our partially rebuilt + * custom-asset queue can make GTA's global LoadAllRequestedModels spin + * at full CPU until the process dies. Request only the target and let + * GTA's normal streaming tick finish it; retain R5's 200 ms failure + * budget rather than blocking the graphics callback. */ if (class_skin_apply) { - if (!gta_script_command_compat(0x0247u, "i", (int)class_skin)) { - mp_bridge_record_script_failure("request_model_class_select", 0x0247u); - } - if (!gta_script_command_compat(0x038Bu, "")) { - mp_bridge_record_script_failure("load_requested_models_class_select", 0x038Bu); - } - if (!gta_script_command_compat(0x09C7u, "ii", SAMP_GTA_PLAYER_LOCAL_ID, (int)class_skin)) { - mp_bridge_record_script_failure("set_player_skin_class_select", 0x09C7u); - } else { - (void)gta_reset_ped_audio_attributes_compat(ped, "class_select_skin"); + samp_model_streaming_snapshot_compat class_skin_stream; + LONG request_seq = + InterlockedCompareExchange(&g_runtime.mp_session_class_selection_skin_request_seq, 0, 0); + LONG request_model = + InterlockedCompareExchange(&g_runtime.mp_session_class_selection_skin_request_model, 0, 0); + DWORD request_tick = (DWORD)InterlockedCompareExchange( + &g_runtime.mp_session_class_selection_skin_request_tick, 0, 0); + DWORD now = GetTickCount(); + int class_skin_ready = 0; + + memset(&class_skin_stream, 0, sizeof(class_skin_stream)); + (void)object_compat_capture_model_streaming_snapshot(class_skin, &class_skin_stream); + class_skin_ready = + class_skin_stream.model_info_readable && class_skin_stream.rw_object >= 0x10000u && + memory_is_readable_compat((const void *)class_skin_stream.rw_object, sizeof(uint32_t)); + + if (class_skin_ready) { + runtime_tracef( + "mp_session_bridge: class_selection_skin_apply phase=loaded " + "seq=%ld skin=%ld state=%u rw=0x%08lx evidence=PROBE_TRACE,STATIC_037", + (long)spawn_info_seq, (long)class_skin, + (unsigned)class_skin_stream.dff_stream.load_state, + (unsigned long)class_skin_stream.rw_object); + class_skin_applied = + gta_player_ped_set_loaded_model_direct_compat( + ped, class_skin, "class_select_skin"); + class_skin_done = 1; + runtime_tracef( + "mp_session_bridge: class_selection_skin_apply phase=complete " + "seq=%ld skin=%ld applied=%d evidence=PROBE_TRACE,STATIC_037", + (long)spawn_info_seq, (long)class_skin, class_skin_applied); + } else if (class_skin_stream.dff_stream.readable && + class_skin_stream.dff_stream.load_state != SAMP_STREAMING_LOAD_STATE_LOADED && + class_skin_stream.dff_stream.cd_size == 0u) { + class_skin_done = 1; + runtime_tracef( + "mp_session_bridge: class_selection_skin_apply phase=unmapped " + "seq=%ld skin=%ld state=%u cd_size=0 evidence=PROBE_TRACE,TODO_VERIFY", + (long)spawn_info_seq, (long)class_skin, + (unsigned)class_skin_stream.dff_stream.load_state); + } else if (request_seq != spawn_info_seq || request_model != class_skin) { + gta_streaming_request_model_compat(class_skin, 0x02); + InterlockedExchange(&g_runtime.mp_session_class_selection_skin_request_model, class_skin); + InterlockedExchange(&g_runtime.mp_session_class_selection_skin_request_tick, (LONG)now); + InterlockedExchange(&g_runtime.mp_session_class_selection_skin_request_seq, spawn_info_seq); + runtime_tracef( + "mp_session_bridge: class_selection_skin_apply phase=requested " + "seq=%ld skin=%ld state=%u rw=0x%08lx evidence=PROBE_TRACE,STATIC_037", + (long)spawn_info_seq, (long)class_skin, + (unsigned)class_skin_stream.dff_stream.load_state, + (unsigned long)class_skin_stream.rw_object); + } else if ((DWORD)(now - request_tick) >= 200u) { + class_skin_done = 1; + runtime_tracef( + "mp_session_bridge: class_selection_skin_apply phase=timeout " + "seq=%ld skin=%ld elapsed_ms=%lu state=%u rw=0x%08lx " + "evidence=STATIC_037,PROBE_TRACE,TODO_VERIFY", + (long)spawn_info_seq, (long)class_skin, + (unsigned long)(DWORD)(now - request_tick), + (unsigned)class_skin_stream.dff_stream.load_state, + (unsigned long)class_skin_stream.rw_object); } } - InterlockedExchange(&g_runtime.mp_session_class_selection_skin_seq, spawn_info_seq); - runtime_tracef("mp_session_bridge: class_selection_skin seq=%ld previous=%ld skin=%ld model_info=0x%08lx " - "applied=%d env=%s evidence=INFERRED,PROBE_TRACE,TODO_VERIFY", - (long)spawn_info_seq, (long)class_selection_skin_seq, (long)class_skin, - (unsigned long)class_skin_model_info, class_skin_apply, SAMP_CLASS_SELECTION_SKIN_APPLY_ENV); + if (class_skin_done) { + InterlockedExchange(&g_runtime.mp_session_class_selection_skin_request_seq, 0); + InterlockedExchange(&g_runtime.mp_session_class_selection_skin_request_model, -1); + InterlockedExchange(&g_runtime.mp_session_class_selection_skin_request_tick, 0); + InterlockedExchange(&g_runtime.mp_session_class_selection_skin_seq, spawn_info_seq); + runtime_tracef("mp_session_bridge: class_selection_skin seq=%ld previous=%ld skin=%ld model_info=0x%08lx " + "applied=%d env=%s evidence=INFERRED,PROBE_TRACE,TODO_VERIFY", + (long)spawn_info_seq, (long)class_selection_skin_seq, (long)class_skin, + (unsigned long)class_skin_model_info, class_skin_applied, + SAMP_CLASS_SELECTION_SKIN_APPLY_ENV); + } } } @@ -40607,7 +46341,9 @@ static void apply_multiplayer_session_bridge_compat(void) { float health = 100.0f; int32_t spawn_skin = g_runtime.raknet_spawn_skin; uintptr_t spawn_skin_model_info = object_compat_model_info_ptr(spawn_skin); - int spawn_skin_apply = spawn_skin >= 0 && spawn_skin_model_info != 0u && local_spawn_skin_apply_enabled_compat(); + int spawn_skin_apply = spawn_skin >= 0 && spawn_skin_model_info != 0u && + local_spawn_skin_apply_enabled_compat(); + int spawn_skin_applied = 0; /* PROBE_TRACE + GTA_REVERSED_REF + INFERRED + TODO_VERIFY: * Arm the post-spawn object hold before publishing spawn readiness. The @@ -40640,23 +46376,33 @@ static void apply_multiplayer_session_bridge_compat(void) { write_game_u8(SAMP_ADDR_MENU, 0u); write_game_u8(SAMP_ADDR_MENU2, 0u); write_game_u8(SAMP_ADDR_MENU3, 0u); - write_game_u8(SAMP_ADDR_ENABLE_HUD, 1u); - write_game_u8(SAMP_ADDR_RADAR_BLANK, 0u); + (void)scoreboard_compat_write_hud_if_unowned(1u, 0u); memcpy((void *)(ped + SAMP_PED_OFFSET_HEALTH), &health, sizeof(health)); textdraw_compat_clear_select_mode("spawn_finalize"); - ui_compat_release_game_mouse("spawn_finalize"); + if (!scoreboard_compat_cursor_owned()) { + ui_compat_release_game_mouse("spawn_finalize"); + } else { + runtime_tracef("ui_mouse: preserve scoreboard cursor reason=spawn_finalize " + "evidence=STATIC_037:samp.dll+0x6F3D0,+0x6E9E0,PROBE_TRACE"); + } if (has_spawn_info) { - gta_prepare_scene_at_compat("spawn", target_pos[0], target_pos[1], target_z, - spawn_skin_apply ? spawn_skin : -1); + /* + * STATIC_037 + PROBE_TRACE: + * Scene preparation does not own player-model loading. R5 applies the + * already streamed model after RestartIfWastedAt through CEntity's + * direct virtual path. Keeping the model out of this helper also avoids + * the previously observed graphics-thread LoadAllRequestedModels hang. + */ + gta_prepare_scene_at_compat("spawn", target_pos[0], target_pos[1], + target_z, -1); *(volatile LONG *)(uintptr_t)SAMP_ADDR_ENTRY = 9; write_game_u8(SAMP_ADDR_GAME_STARTED, 0u); write_game_u8(SAMP_ADDR_STARTGAME, 0u); write_game_u8(SAMP_ADDR_MENU, 0u); write_game_u8(SAMP_ADDR_MENU2, 0u); write_game_u8(SAMP_ADDR_MENU3, 0u); - write_game_u8(SAMP_ADDR_ENABLE_HUD, 1u); - write_game_u8(SAMP_ADDR_RADAR_BLANK, 0u); + (void)scoreboard_compat_write_hud_if_unowned(1u, 0u); /* Refresh the early guard so GTA receives the full settle window after * scene preparation, while still keeping the pre-prepare race closed. */ @@ -40681,6 +46427,11 @@ static void apply_multiplayer_session_bridge_compat(void) { (double)target_angle); (void)gta_refresh_streaming_at_compat("spawn", target_pos[0], target_pos[1]); (void)gta_restart_if_wasted_at_compat("spawn", target_pos[0], target_pos[1], target_pos[2], target_angle); + if (spawn_skin_apply) { + spawn_skin_applied = + gta_player_ped_set_loaded_model_direct_compat( + ped, spawn_skin, "spawn_skin"); + } runtime_tracef("mp_session_bridge: spawn_stream_end seq=%ld", (long)spawn_info_seq); } @@ -40702,25 +46453,15 @@ static void apply_multiplayer_session_bridge_compat(void) { if (!gta_script_command_compat(0x04D7u, "ii", SAMP_GTA_ACTOR_LOCAL_ID, 0)) { mp_bridge_record_script_failure("unlock_actor_spawn", 0x04D7u); } - if (has_spawn_info && spawn_skin >= 0) { - if (!spawn_skin_apply) { - runtime_tracef("mp_session_bridge: defer_spawn_skin seq=%ld skin=%ld model_info=0x%08lx " - "reason=spawn_request_model_crash_guard env=%s evidence=PROBE_TRACE,TODO_VERIFY", - (long)spawn_info_seq, (long)spawn_skin, (unsigned long)spawn_skin_model_info, - SAMP_LOCAL_SPAWN_SKIN_APPLY_ENV); - } else { - if (!gta_script_command_compat(0x0247u, "i", (int)spawn_skin)) { - mp_bridge_record_script_failure("request_model", 0x0247u); - } - if (!gta_script_command_compat(0x038Bu, "")) { - mp_bridge_record_script_failure("load_requested_models", 0x038Bu); - } - if (!gta_script_command_compat(0x09C7u, "ii", SAMP_GTA_PLAYER_LOCAL_ID, (int)spawn_skin)) { - mp_bridge_record_script_failure("set_player_skin", 0x09C7u); - } else { - (void)gta_reset_ped_audio_attributes_compat(ped, "spawn_skin"); - } - } + if (has_spawn_info && spawn_skin >= 0 && !spawn_skin_applied) { + runtime_tracef( + "mp_session_bridge: defer_spawn_skin seq=%ld skin=%ld " + "model_info=0x%08lx enabled=%d " + "reason=model_not_ready_or_direct_apply_rejected env=%s " + "evidence=STATIC_037:samp.dll+0x9EF50,+0xAFF50,PROBE_TRACE,TODO_VERIFY", + (long)spawn_info_seq, (long)spawn_skin, + (unsigned long)spawn_skin_model_info, spawn_skin_apply, + SAMP_LOCAL_SPAWN_SKIN_APPLY_ENV); } if (has_spawn_info) { int i = 0; @@ -40753,13 +46494,15 @@ static void apply_multiplayer_session_bridge_compat(void) { { int spawn_notify_result = -1; + LONG respawn_pending = InterlockedCompareExchange(&g_runtime.local_respawn_pending, 0, 0); - if (g_runtime.net_mgr.raknet_client != NULL) { + if (respawn_pending != SAMP_LOCAL_RESPAWN_PENDING_APPLY && + g_runtime.net_mgr.raknet_client != NULL) { spawn_notify_result = samp_raknet_client_send_spawn_notification_for_seq(g_runtime.net_mgr.raknet_client, (uint32_t)spawn_info_seq); } - runtime_tracef("mp_session_bridge: spawn_notify_after_finalize seq=%ld result=%d", (long)spawn_info_seq, - spawn_notify_result); + runtime_tracef("mp_session_bridge: spawn_notify_after_finalize seq=%ld result=%d respawn_pending=%ld", + (long)spawn_info_seq, spawn_notify_result, (long)respawn_pending); } /* @@ -40783,6 +46526,44 @@ static void apply_multiplayer_session_bridge_compat(void) { (unsigned long)SAMP_OBJECT_COMPAT_POST_SPAWN_HOLD_MS); } + if (spawn_info_seq != 0 && + InterlockedCompareExchange(&g_runtime.mp_session_spawn_finalized, 0, 0) != 0 && + InterlockedCompareExchange(&g_runtime.mp_session_finalized_spawn_seq, 0, 0) == spawn_info_seq && + InterlockedCompareExchange(&g_runtime.local_respawn_pending, + SAMP_LOCAL_RESPAWN_PENDING_RPC, + SAMP_LOCAL_RESPAWN_PENDING_APPLY) == + SAMP_LOCAL_RESPAWN_PENDING_APPLY) { + /* + * OBSERVED_037 + PROBE_TRACE: + * artifact=20260728-141552-distributed-sync-death-1526481 enters + * CLocalPlayer::Spawn with the existing SpawnInfo and only clears + * wasted/sends RPC_Spawn after the local spawn body has restored active + * state. This point is below the shared full spawn-finalize block, + * including RestartIfWastedAt, model, weapons, TeleportTo and the camera + * restoration commands; the captured raw camera changes 29->4 on the + * following frame. + */ + InterlockedExchange(&g_runtime.local_death_reported, 0); + runtime_tracef("local_death: respawn_apply_complete spawn_seq=%ld ped=0x%08lx " + "evidence=OBSERVED_037,PROBE_TRACE", + (long)spawn_info_seq, (unsigned long)ped); + } + if (InterlockedCompareExchange(&g_runtime.local_respawn_pending, 0, 0) == + SAMP_LOCAL_RESPAWN_PENDING_RPC) { + int respawn_notify_result = -1; + + if (g_runtime.net_mgr.raknet_client != NULL) { + respawn_notify_result = + samp_raknet_client_send_respawn_notification(g_runtime.net_mgr.raknet_client); + } + if (respawn_notify_result == 0) { + InterlockedExchange(&g_runtime.local_respawn_pending, 0); + } + runtime_tracef("local_death: respawn_notify_after_apply result=%d spawn_seq=%ld ped=0x%08lx " + "evidence=OBSERVED_037,PROBE_TRACE", + respawn_notify_result, (long)spawn_info_seq, (unsigned long)ped); + } + if (ped != 0u && spawn_ready && InterlockedCompareExchange(&g_runtime.mp_session_spawn_finalized, 0, 0) != 0) { DWORD spawn_finalize_tick = g_runtime.mp_session_spawn_finalize_tick; @@ -40794,7 +46575,12 @@ static void apply_multiplayer_session_bridge_compat(void) { int behind_ok = gta_script_command_compat(0x0373u, ""); int mode_restored = 0; int mouse_camera_restored = 0; - ui_compat_release_game_mouse("post_spawn_camera_restore"); + if (!scoreboard_compat_cursor_owned()) { + ui_compat_release_game_mouse("post_spawn_camera_restore"); + } else { + runtime_tracef("ui_mouse: preserve scoreboard cursor reason=post_spawn_camera_restore " + "evidence=STATIC_037:samp.dll+0x6F3D0,+0x6E9E0,PROBE_TRACE"); + } mode_restored = restore_post_spawn_camera_mode_compat("post_spawn_camera_restore"); mouse_camera_restored = restore_post_spawn_mouse_camera_compat("post_spawn_camera_restore"); @@ -40826,8 +46612,12 @@ static void apply_multiplayer_session_bridge_compat(void) { int teleported = 0; memcpy(server_pos, g_runtime.raknet_player_pos, sizeof(server_pos)); - gta_prepare_scene_at_compat("server_player_pos", server_pos[0], server_pos[1], server_pos[2], - g_runtime.raknet_spawn_skin >= 0 ? g_runtime.raknet_spawn_skin : -1); + /* + * STATIC_037: ScrSetPlayerPos changes the streaming anchor/position; it + * does not reload the already selected spawn skin. Keep model loading + * scoped to the actual class/spawn path. + */ + gta_prepare_scene_at_compat("server_player_pos", server_pos[0], server_pos[1], server_pos[2], -1); teleported = gta_entity_teleport_compat(ped, server_pos[0], server_pos[1], server_pos[2]); if (!gta_script_command_compat(0x0373u, "")) { mp_bridge_record_script_failure("set_camera_behind_player_after_pos", 0x0373u); @@ -40877,7 +46667,8 @@ static void apply_multiplayer_session_bridge_compat(void) { process_local_damage_compat(ped); if (InterlockedCompareExchange(&g_runtime.client_spectate_active, 0, 0) != 0) { client_spectator_sync_compat(); - } else if (InterlockedCompareExchange(&g_runtime.local_death_reported, 0, 0) == 0) { + } else if (InterlockedCompareExchange(&g_runtime.local_death_reported, 0, 0) == 0 && + InterlockedCompareExchange(&g_runtime.local_respawn_pending, 0, 0) == 0) { local_passenger_entry_compat(ped); local_passenger_transition_compat(ped); send_aim_sync_compat(ped); @@ -40888,6 +46679,7 @@ static void apply_multiplayer_session_bridge_compat(void) { send_unoccupied_sync_compat(ped); } vehicle_compat_process_markers(ped); + remote_player_compat_process_vehicle_targets(); remote_player_compat_process_markers(); } @@ -40980,6 +46772,12 @@ static int phase_runtime_guard_attach(HINSTANCE instance) { g_runtime.netgame_state = SAMP_NETGAME_WAIT_CONNECT; InterlockedExchange(&g_runtime.death_window_enabled, 1); InterlockedExchange(&g_runtime.chat_page_size, SAMP_CHAT_COMPAT_DEFAULT_PAGE_SIZE); + /* + * STATIC_037: CChat constructor samp.dll+0x68380 initializes display mode + * +0x08 to 2, the plain (inline colour tags stripped) mode. + */ + InterlockedExchange(&g_runtime.chat_display_mode, + SAMP_CHAT_COMPAT_DISPLAY_PLAIN); InterlockedExchange(&g_runtime.audio_messages_enabled, 1); InterlockedExchange(&g_runtime.remote_headmove_enabled, 1); InterlockedExchange(&g_runtime.name_tag_status_enabled, 1); @@ -41025,6 +46823,9 @@ static int phase_load_settings_and_paths(void) { attrs = GetFileAttributesA(g_runtime.archive_path); g_runtime.archive_present = (attrs != INVALID_FILE_ATTRIBUTES) ? 1 : 0; + if (!samp_user_paths_init_compat()) { + runtime_tracef("user_paths: unavailable fallback_screenshots='%s'", g_runtime.module_dir); + } runtime_tracef("settings: online=%d debug=%d host='%s' port='%s' nick='%s' archive_present=%d", g_runtime.settings.play_online ? 1 : 0, g_runtime.settings.debug ? 1 : 0, g_runtime.settings.connect_host, g_runtime.settings.connect_port, g_runtime.settings.nickname, g_runtime.archive_present); @@ -41170,6 +46971,7 @@ static void launch_start_game_compat(void) { install_menu_gxt_hook_compat(); remote_player_compat_install_process_control_hook(); remote_vehicle_compat_install_process_control_hooks(); + remote_vehicle_collision_install_hooks_compat(); gang_zone_compat_install_render_hooks(); samp_object_material_install_render_hook_compat(); rc = samp_hook_bridge_install_graphics_callback(&g_runtime.hook_bridge, launch_graphics_loop_hook_callback); @@ -41186,6 +46988,8 @@ static void launch_start_game_compat(void) { &g_runtime.remote_player_process_control_hook_installed, 0, 0) || InterlockedCompareExchange( &g_runtime.remote_vehicle_process_control_hook_installed, 0, 0) || + InterlockedCompareExchange( + &g_runtime.remote_vehicle_collision_hook_installed, 0, 0) || (rc == 0 && (g_runtime.hook_bridge.install_succeeded || g_runtime.hook_bridge.installed || g_runtime.hook_bridge.secondary_installed))) { InterlockedExchange(&g_runtime.hooks_installed, 1); @@ -41196,6 +47000,7 @@ static void launch_start_game_compat(void) { "material_render=%ld bullet_impact=%ld remote_bullet_los=%ld actor_damage=%ld " "menu_gxt=%ld remote_process_control=%ld " "remote_vehicle_process_control=%ld " + "remote_vehicle_collision=%ld " "installed=%d installed2=%d " "configured=%d configured2=%d enabled=%d disp=0x%08lx disp2=0x%08lx", rc, (long)InterlockedCompareExchange(&g_runtime.game_process_hook_installed, 0, 0), @@ -41211,6 +47016,8 @@ static void launch_start_game_compat(void) { &g_runtime.remote_player_process_control_hook_installed, 0, 0), (long)InterlockedCompareExchange( &g_runtime.remote_vehicle_process_control_hook_installed, 0, 0), + (long)InterlockedCompareExchange( + &g_runtime.remote_vehicle_collision_hook_installed, 0, 0), g_runtime.hook_bridge.installed, g_runtime.hook_bridge.secondary_installed, g_runtime.hook_bridge.configured, g_runtime.hook_bridge.secondary_configured, g_runtime.hook_bridge.enabled, (unsigned long)g_runtime.hook_bridge.graphics_call_disp_addr, @@ -41316,6 +47123,8 @@ static void launch_prepare_network_compat(void) { InterlockedExchange(&g_runtime.dialog_overlay_scroll, 0); InterlockedExchange(&g_runtime.dialog_overlay_input_len, 0); InterlockedExchange(&g_runtime.dialog_overlay_logged, 0); + InterlockedExchange(&g_runtime.dialog_overlay_local_kind, + SAMP_DIALOG_COMPAT_LOCAL_NONE); g_runtime.dialog_overlay_title[0] = '\0'; g_runtime.dialog_overlay_info[0] = '\0'; g_runtime.dialog_overlay_button1[0] = '\0'; @@ -41345,7 +47154,9 @@ static void launch_prepare_network_compat(void) { memset(g_runtime.game_text_slots, 0, sizeof(g_runtime.game_text_slots)); scoreboard_compat_restore_hud("connect_reset"); InterlockedExchange(&g_runtime.scoreboard_offset, 0); + InterlockedExchange(&g_runtime.scoreboard_visible, 0); InterlockedExchange(&g_runtime.scoreboard_logged, 0); + InterlockedExchange(&g_runtime.scoreboard_exclusive_overlay_logged, 0); InterlockedExchange(&g_runtime.scoreboard_player_pool_event_seq, 0); InterlockedExchange(&g_runtime.scoreboard_score_ping_seq, 0); InterlockedExchange(&g_runtime.scoreboard_player_count, 0); @@ -41367,7 +47178,13 @@ static void launch_prepare_network_compat(void) { chat_bubble_compat_reset("connect_reset"); object_compat_reset_pool("connect_reset"); attached_object_compat_reset_pool("connect_reset"); - remove_building_compat_reset(); + remove_building_compat_reset("connect_reset"); + InterlockedExchange(&g_runtime.local_virtual_world, 0); + InterlockedExchange(&g_runtime.local_virtual_world_seq, 0); + InterlockedExchange( + &g_runtime.remote_vehicle_collisions_disabled, 0); + InterlockedExchange( + &g_runtime.remote_vehicle_collisions_disabled_seq, 0); InterlockedExchange(&g_runtime.object_material_persist_count, 0); InterlockedExchange(&g_runtime.object_material_resolved_count, 0); InterlockedExchange(&g_runtime.object_material_render_count, 0); @@ -41477,7 +47294,11 @@ static void launch_prepare_network_compat(void) { InterlockedExchange(&g_runtime.mp_session_frontend_hold_logged, 0); InterlockedExchange(&g_runtime.mp_session_spawn_finalized, 0); InterlockedExchange(&g_runtime.mp_session_finalized_spawn_seq, 0); + InterlockedExchange(&g_runtime.mp_session_class_selection_initial_state_seq, 0); InterlockedExchange(&g_runtime.mp_session_class_selection_skin_seq, 0); + InterlockedExchange(&g_runtime.mp_session_class_selection_skin_request_seq, 0); + InterlockedExchange(&g_runtime.mp_session_class_selection_skin_request_model, -1); + InterlockedExchange(&g_runtime.mp_session_class_selection_skin_request_tick, 0); InterlockedExchange(&g_runtime.mp_session_class_scene_player_pos_seq, 0); InterlockedExchange(&g_runtime.class_selection_mouse_mode, 0); InterlockedExchange(&g_runtime.class_selection_mouse_down, 0); @@ -41498,6 +47319,7 @@ static void launch_prepare_network_compat(void) { g_camera_rpc_pending.event_count = 0u; g_runtime.spectator_sync_last_tick = 0u; InterlockedExchange(&g_runtime.local_death_reported, 0); + InterlockedExchange(&g_runtime.local_respawn_pending, 0); InterlockedExchange(&g_runtime.mp_session_post_spawn_camera_restored, 0); InterlockedExchange(&g_runtime.mp_session_scene_loaded, 0); InterlockedExchange(&g_runtime.raknet_time_apply_logged, 0); @@ -41756,7 +47578,7 @@ static void launch_prepare_network_compat(void) { remote_player_compat_reset_pool("network_disconnect"); actor_compat_reset_pool("network_disconnect"); attached_object_compat_reset_pool("network_disconnect"); - remove_building_compat_reset(); + remove_building_compat_reset("network_disconnect"); InterlockedExchange(&g_runtime.menu_overlay_active, 0); rpc_flags = 0u; connected_after_pump = 0; @@ -42025,6 +47847,7 @@ static void phase_network_shutdown(void) { static void phase_runtime_modules_shutdown(void) { client_fps_restore_original_gta_state_compat(); chat_input_game_controls_restore_compat("module_shutdown"); + dialog_game_mouse_controls_restore_shutdown_compat("module_shutdown"); scoreboard_compat_restore_hud("module_shutdown"); chat_input_uninstall_wndproc_compat(); chat_compat_uninstall_d3d_hook(); @@ -42099,6 +47922,7 @@ static void rollback_from_phase(samp_boot_phase phase) { samp_object_material_uninstall_render_hook_compat(); samp_object_material_release_all_compat("rollback"); gang_zone_compat_uninstall_render_hooks(); + remote_vehicle_collision_uninstall_hooks_compat(); remote_vehicle_compat_uninstall_process_control_hooks(); remote_player_compat_uninstall_process_control_hook(); uninstall_actor_damage_hook_compat(); @@ -42186,6 +48010,10 @@ static void process_detach(LPVOID reserved) { &g_runtime.remote_player_process_control_hook_installed, 0); InterlockedExchange( &g_runtime.remote_vehicle_process_control_hook_installed, 0); + InterlockedExchange( + &g_runtime.remote_vehicle_collision_hook_installed, 0); + InterlockedExchange( + &g_runtime.remote_vehicle_collision_camera_hook_installed, 0); return; } @@ -42193,6 +48021,7 @@ static void process_detach(LPVOID reserved) { samp_object_material_uninstall_render_hook_compat(); samp_object_material_release_all_compat("process_detach"); gang_zone_compat_uninstall_render_hooks(); + remote_vehicle_collision_uninstall_hooks_compat(); remote_vehicle_compat_uninstall_process_control_hooks(); remote_player_compat_uninstall_process_control_hook(); uninstall_actor_damage_hook_compat(); diff --git a/reimpl/src/vehicle_attach_timing_compat.h b/reimpl/src/vehicle_attach_timing_compat.h new file mode 100644 index 0000000..57f8452 --- /dev/null +++ b/reimpl/src/vehicle_attach_timing_compat.h @@ -0,0 +1,73 @@ +#ifndef SAMPDLL_VEHICLE_ATTACH_TIMING_COMPAT_H +#define SAMPDLL_VEHICLE_ATTACH_TIMING_COMPAT_H + +#include + +/* + * PROBE_TRACE + INFERRED: + * Deferred GTA vehicle construction must not collapse the observed interval + * between RPC 164 and RPC 148 to a zero-age SetTowLink call. Keep the carried + * interval below the shared movement cursor's 2000-ms retry bound. + */ +#define SAMP_VEHICLE_ATTACH_SOURCE_DELAY_MAX_MS 1000u + +/* + * Internal vehicle events are action-tagged. ATTACH_TRAILER does not use the + * component field, so carry both 16-bit receive ages there without changing + * the public event struct or its source/binary ABI. + */ +static inline uint32_t samp_vehicle_attach_source_ages_pack( + uint16_t towing_age_ms, uint16_t trailer_age_ms) { + return (uint32_t)towing_age_ms | ((uint32_t)trailer_age_ms << 16u); +} + +static inline uint16_t samp_vehicle_attach_source_towing_age_unpack( + uint32_t packed_ages) { + return (uint16_t)(packed_ages & 0xFFFFu); +} + +static inline uint16_t samp_vehicle_attach_source_trailer_age_unpack( + uint32_t packed_ages) { + return (uint16_t)(packed_ages >> 16u); +} + +static inline uint16_t samp_vehicle_attach_source_elapsed_ms( + uint32_t create_tick, uint32_t attach_tick, int create_tick_valid) { + int32_t elapsed = 0; + + if (!create_tick_valid) { + return 0u; + } + elapsed = (int32_t)(attach_tick - create_tick); + if (elapsed <= 0) { + return 0u; + } + if ((uint32_t)elapsed > SAMP_VEHICLE_ATTACH_SOURCE_DELAY_MAX_MS) { + return (uint16_t)SAMP_VEHICLE_ATTACH_SOURCE_DELAY_MAX_MS; + } + return (uint16_t)elapsed; +} + +static inline int samp_vehicle_attach_source_wait_pending( + uint16_t towing_required_age_ms, uint32_t towing_active_age_ms, + uint16_t trailer_required_age_ms, uint32_t trailer_active_age_ms) { + return towing_active_age_ms < (uint32_t)towing_required_age_ms || + trailer_active_age_ms < (uint32_t)trailer_required_age_ms; +} + +/* + * OBSERVED_037 + PROBE_TRACE: + * RPC 148 is dispatched before the first Packet-200 driver update in the + * trailer scenario. The replacement may still be replaying that older RPC + * after paced vehicle construction, so keep the driver packet behind the + * pending association instead of seating the remote ped first. + */ +static inline int samp_vehicle_attach_blocks_driver_sync( + uint32_t pending_attach_seq, uint16_t pending_trailer_id, + uint16_t sync_trailer_id) { + return pending_attach_seq != 0u && pending_trailer_id != 0u && + pending_trailer_id != UINT16_MAX && + pending_trailer_id == sync_trailer_id; +} + +#endif diff --git a/reimpl/tests/test_custom_asset_bulk_compat.c b/reimpl/tests/test_custom_asset_bulk_compat.c new file mode 100644 index 0000000..a77e85a --- /dev/null +++ b/reimpl/tests/test_custom_asset_bulk_compat.c @@ -0,0 +1,67 @@ +#include "../src/custom_asset_bulk_compat.h" + +#include +#include + +static void add_sections(samp_asset_prearchive_bulk_plan_compat *plan, + uint8_t section, uint32_t count) { + uint32_t i = 0u; + + for (i = 0u; i < count; ++i) { + samp_asset_prearchive_bulk_plan_add_section_compat(plan, section); + } +} + +int main(void) { + samp_asset_prearchive_bulk_plan_compat stock_plan; + samp_asset_prearchive_bulk_plan_compat mixed_plan; + samp_asset_prearchive_bulk_plan_compat anim_only_plan; + + samp_asset_prearchive_bulk_plan_reset_compat(&stock_plan); + add_sections(&stock_plan, SAMP_ASSET_IDE_SECTION_OBJS, 1433u); + add_sections(&stock_plan, SAMP_ASSET_IDE_SECTION_ANIM, 2u); + + assert(stock_plan.model_info_count == 1433u); + assert(stock_plan.deferred_anim_count == 2u); + assert(samp_asset_prearchive_full_bulk_ready_compat(1, 1, 1433u, + &stock_plan)); + assert(!samp_asset_prearchive_full_bulk_ready_compat(1, 1, 1432u, + &stock_plan)); + assert(!samp_asset_prearchive_full_bulk_ready_compat(0, 1, 1433u, + &stock_plan)); + assert(!samp_asset_prearchive_full_bulk_ready_compat(1, 0, 1433u, + &stock_plan)); + + assert(samp_asset_ide_section_is_prearchive_model_info_compat( + SAMP_ASSET_IDE_SECTION_OBJS)); + assert(samp_asset_ide_section_is_prearchive_model_info_compat( + SAMP_ASSET_IDE_SECTION_TOBJ)); + assert(!samp_asset_ide_section_is_prearchive_model_info_compat( + SAMP_ASSET_IDE_SECTION_ANIM)); + assert(!samp_asset_ide_section_is_prearchive_model_info_compat( + SAMP_ASSET_IDE_SECTION_UNKNOWN)); + + samp_asset_prearchive_bulk_plan_reset_compat(&mixed_plan); + samp_asset_prearchive_bulk_plan_add_section_compat( + &mixed_plan, SAMP_ASSET_IDE_SECTION_OBJS); + samp_asset_prearchive_bulk_plan_add_section_compat( + &mixed_plan, SAMP_ASSET_IDE_SECTION_ANIM); + samp_asset_prearchive_bulk_plan_add_section_compat( + &mixed_plan, SAMP_ASSET_IDE_SECTION_UNKNOWN); + samp_asset_prearchive_bulk_plan_add_section_compat( + &mixed_plan, SAMP_ASSET_IDE_SECTION_TOBJ); + assert(mixed_plan.model_info_count == 2u); + assert(mixed_plan.deferred_anim_count == 1u); + assert(samp_asset_prearchive_full_bulk_ready_compat(1, 1, 2u, + &mixed_plan)); + + samp_asset_prearchive_bulk_plan_reset_compat(&anim_only_plan); + add_sections(&anim_only_plan, SAMP_ASSET_IDE_SECTION_ANIM, 2u); + assert(anim_only_plan.model_info_count == 0u); + assert(anim_only_plan.deferred_anim_count == 2u); + assert(!samp_asset_prearchive_full_bulk_ready_compat(1, 1, 1433u, + &anim_only_plan)); + assert(!samp_asset_prearchive_full_bulk_ready_compat(1, 1, 1433u, NULL)); + + return 0; +} diff --git a/reimpl/tests/test_gta_camera_aim_compat.c b/reimpl/tests/test_gta_camera_aim_compat.c new file mode 100644 index 0000000..bd7c333 --- /dev/null +++ b/reimpl/tests/test_gta_camera_aim_compat.c @@ -0,0 +1,60 @@ +#include "gta_camera_aim_compat.h" + +#include +#include +#include + +static void expect_context( + const float front[3], const float position[3], + const float expected_up[3]) { + float actual_front[3] = {0.0f, 0.0f, 0.0f}; + float position1[3] = {0.0f, 0.0f, 0.0f}; + float position2[3] = {0.0f, 0.0f, 0.0f}; + float actual_up[3] = {0.0f, 0.0f, 0.0f}; + float front_length_sq = 0.0f; + float up_length_sq = 0.0f; + float dot = 0.0f; + unsigned int i = 0u; + + assert(samp_gta_camera_aim_build_r5( + front, position, actual_front, position1, position2, actual_up)); + assert(memcmp(actual_front, front, sizeof(actual_front)) == 0); + assert(memcmp(position1, position, sizeof(position1)) == 0); + assert(memcmp(position2, position, sizeof(position2)) == 0); + for (i = 0u; i < 3u; ++i) { + assert(fabsf(actual_up[i] - expected_up[i]) < 0.00002f); + front_length_sq += actual_front[i] * actual_front[i]; + up_length_sq += actual_up[i] * actual_up[i]; + dot += actual_front[i] * actual_up[i]; + } + assert(fabsf(front_length_sq - up_length_sq) < 0.00002f); + assert(fabsf(dot) < 0.00002f); +} + +int main(void) { + const float position[3] = {1958.3783f, 1343.1572f, 16.1514f}; + const float forward_a[3] = {0.0f, 0.998750f, -0.049979f}; + const float up_a[3] = {0.0f, 0.049979f, 0.998750f}; + const float forward_b[3] = {-0.455182f, -0.889818f, 0.032151f}; + const float up_b[3] = {0.014642f, 0.028624f, 0.999483f}; + const float zero[3] = {0.0f, 0.0f, 0.0f}; + const float vertical[3] = {0.0f, 0.0f, 1.0f}; + const float invalid_front[3] = {NAN, 0.0f, 0.0f}; + const float invalid_position[3] = {0.0f, INFINITY, 0.0f}; + float output[3] = {7.0f, 8.0f, 9.0f}; + + expect_context(forward_a, position, up_a); + expect_context(forward_b, position, up_b); + + assert(!samp_gta_camera_aim_build_r5( + NULL, position, output, output, output, output)); + assert(!samp_gta_camera_aim_build_r5( + zero, position, output, output, output, output)); + assert(!samp_gta_camera_aim_build_r5( + vertical, position, output, output, output, output)); + assert(!samp_gta_camera_aim_build_r5( + invalid_front, position, output, output, output, output)); + assert(!samp_gta_camera_aim_build_r5( + forward_a, invalid_position, output, output, output, output)); + return 0; +} diff --git a/reimpl/tests/test_gta_quaternion_compat.c b/reimpl/tests/test_gta_quaternion_compat.c index a94a879..8e417f2 100644 --- a/reimpl/tests/test_gta_quaternion_compat.c +++ b/reimpl/tests/test_gta_quaternion_compat.c @@ -1,4 +1,6 @@ #include "gta_quaternion_compat.h" +#include "remote_vehicle_playback_compat.h" +#include "vehicle_attach_timing_compat.h" #include #include @@ -17,7 +19,160 @@ static void expect_round_trip(float gta_angle) { assert(angle_distance(decoded, gta_angle) < 0.001f); } +static void expect_remote_vehicle_slerp(void) { + const float identity[4] = {1.0f, 0.0f, 0.0f, 0.0f}; + const float target[4] = { + 0.70710678118f, 0.0f, 0.0f, 0.70710678118f}; + const float negative_target[4] = { + -0.70710678118f, 0.0f, 0.0f, -0.70710678118f}; + float output[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + float negative_output[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + const float expected_w = + cosf(67.5f * (3.14159265358979323846f / 360.0f)); + const float expected_z = + sinf(67.5f * (3.14159265358979323846f / 360.0f)); + + assert(samp_gta_quaternion_slerp_wxyz( + identity, target, 0.75f, output)); + assert(fabsf(output[0] - expected_w) < 0.00001f); + assert(fabsf(output[1]) < 0.00001f); + assert(fabsf(output[2]) < 0.00001f); + assert(fabsf(output[3] - expected_z) < 0.00001f); + + /* q and -q are the same attitude; Slerp must take the short path. */ + assert(samp_gta_quaternion_slerp_wxyz( + identity, negative_target, 0.75f, negative_output)); + assert(fabsf(negative_output[0] - output[0]) < 0.00001f); + assert(fabsf(negative_output[3] - output[3]) < 0.00001f); +} + +static void expect_matrix_to_wire_quaternion(void) { + const float gta_xyzw[4] = {0.18257418f, -0.36514837f, + 0.54772258f, 0.73029673f}; + const float negative_gta_xyzw[4] = {-0.18257418f, 0.36514837f, + -0.54772258f, -0.73029673f}; + const float invalid_gta_xyzw[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + float wire[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + float negative_wire[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + + assert(samp_gta_matrix_quaternion_xyzw_to_wire_wxyz( + gta_xyzw, wire)); + assert(fabsf(wire[0] - 0.73029673f) < 0.00001f); + assert(fabsf(wire[1] - -0.18257418f) < 0.00001f); + assert(fabsf(wire[2] - 0.36514837f) < 0.00001f); + assert(fabsf(wire[3] - -0.54772258f) < 0.00001f); + + /* q and -q describe the same matrix; R5 canonicalizes the emitted W. */ + assert(samp_gta_matrix_quaternion_xyzw_to_wire_wxyz( + negative_gta_xyzw, negative_wire)); + assert(fabsf(negative_wire[0] - wire[0]) < 0.00001f); + assert(fabsf(negative_wire[1] - wire[1]) < 0.00001f); + assert(fabsf(negative_wire[2] - wire[2]) < 0.00001f); + assert(fabsf(negative_wire[3] - wire[3]) < 0.00001f); + assert(!samp_gta_matrix_quaternion_xyzw_to_wire_wxyz( + invalid_gta_xyzw, wire)); +} + +static void expect_remote_vehicle_position_decision(void) { + const float current[3] = {0.0f, 0.0f, 0.0f}; + const float target_speed[3] = {0.1f, -0.2f, 0.0f}; + float target[3] = {0.05f, -0.05f, 0.05f}; + float delta[3] = {0.0f, 0.0f, 0.0f}; + float corrected[3] = {0.0f, 0.0f, 0.0f}; + int write_corrected = 0; + + assert(samp_remote_vehicle_position_decision_r5( + current, target, target_speed, + SAMP_REMOTE_VEHICLE_POSITION_SNAP_Z, delta, corrected, + &write_corrected) == SAMP_REMOTE_VEHICLE_DECISION_NOOP); + + target[0] = 1.0f; + target[1] = -0.1f; + target[2] = 0.25f; + assert(samp_remote_vehicle_position_decision_r5( + current, target, target_speed, + SAMP_REMOTE_VEHICLE_POSITION_SNAP_Z, delta, corrected, + &write_corrected) == SAMP_REMOTE_VEHICLE_DECISION_CORRECT); + assert(write_corrected); + assert(fabsf(corrected[0] - 0.16f) < 0.00001f); + assert(fabsf(corrected[1] - -0.206f) < 0.00001f); + assert(fabsf(corrected[2] - 0.015f) < 0.00001f); + + target[0] = 8.0001f; + assert(samp_remote_vehicle_position_decision_r5( + current, target, target_speed, + SAMP_REMOTE_VEHICLE_POSITION_SNAP_Z, delta, corrected, + &write_corrected) == SAMP_REMOTE_VEHICLE_DECISION_SNAP); + target[0] = 0.0f; + target[2] = 0.6f; + assert(samp_remote_vehicle_position_decision_r5( + current, target, target_speed, + SAMP_REMOTE_VEHICLE_POSITION_SNAP_Z, delta, corrected, + &write_corrected) == SAMP_REMOTE_VEHICLE_DECISION_SNAP); + assert(samp_remote_vehicle_position_decision_r5( + current, target, target_speed, + SAMP_REMOTE_VEHICLE_POSITION_SNAP_Z_AIR_WATER, delta, + corrected, &write_corrected) == + SAMP_REMOTE_VEHICLE_DECISION_CORRECT); +} + +static void expect_remote_trailer_position_decision(void) { + const float current[3] = {10.0f, 20.0f, 30.0f}; + float target[3] = {10.25f, 19.75f, 30.1f}; + float delta[3] = {0.0f, 0.0f, 0.0f}; + + assert(samp_remote_trailer_position_decision_r5( + current, target, 0, delta) == + SAMP_REMOTE_TRAILER_DECISION_NOOP); + assert(samp_remote_trailer_position_decision_r5( + current, target, 1, delta) == + SAMP_REMOTE_TRAILER_DECISION_TRANSITION); + + target[0] = 10.75f; + assert(samp_remote_trailer_position_decision_r5( + current, target, 0, delta) == + SAMP_REMOTE_TRAILER_DECISION_CORRECT); + target[0] = 16.001f; + assert(samp_remote_trailer_position_decision_r5( + current, target, 0, delta) == + SAMP_REMOTE_TRAILER_DECISION_SNAP); + assert(samp_remote_trailer_position_decision_r5( + current, target, 1, delta) == + SAMP_REMOTE_TRAILER_DECISION_TRANSITION); +} + +static void expect_vehicle_attach_source_timing(void) { + uint32_t packed_ages = + samp_vehicle_attach_source_ages_pack(250u, 150u); + + assert(samp_vehicle_attach_source_towing_age_unpack(packed_ages) == 250u); + assert(samp_vehicle_attach_source_trailer_age_unpack(packed_ages) == 150u); + assert(samp_vehicle_attach_source_elapsed_ms(1000u, 1250u, 1) == 250u); + assert(samp_vehicle_attach_source_elapsed_ms(1000u, 1250u, 0) == 0u); + assert(samp_vehicle_attach_source_elapsed_ms(1250u, 1000u, 1) == 0u); + assert(samp_vehicle_attach_source_elapsed_ms( + UINT32_MAX - 99u, 150u, 1) == 250u); + assert(samp_vehicle_attach_source_elapsed_ms(1000u, 70000u, 1) == + SAMP_VEHICLE_ATTACH_SOURCE_DELAY_MAX_MS); + + assert(samp_vehicle_attach_source_wait_pending(250u, 249u, 250u, 250u)); + assert(samp_vehicle_attach_source_wait_pending(250u, 250u, 250u, 249u)); + assert(!samp_vehicle_attach_source_wait_pending(250u, 250u, 250u, + 250u)); + + assert(samp_vehicle_attach_blocks_driver_sync(7u, 26u, 26u)); + assert(!samp_vehicle_attach_blocks_driver_sync(0u, 26u, 26u)); + assert(!samp_vehicle_attach_blocks_driver_sync(7u, 26u, 27u)); + assert(!samp_vehicle_attach_blocks_driver_sync(7u, 0u, 0u)); + assert(!samp_vehicle_attach_blocks_driver_sync(7u, UINT16_MAX, + UINT16_MAX)); +} + int main(void) { + const float scaled_identity[4] = {2.0f, 0.0f, 0.0f, 0.0f}; + const float zero[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + float normalized[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + expect_round_trip(0.0f); expect_round_trip(1.6030f); expect_round_trip(45.0f); @@ -27,5 +182,14 @@ int main(void) { expect_round_trip(358.3970f); assert(samp_gta_yaw_degrees_from_quaternion(NAN, 0.0f, 0.0f, 0.0f) == 0.0f); assert(samp_gta_yaw_degrees_from_quaternion(INFINITY, 0.0f, 0.0f, 0.0f) == 0.0f); + assert(samp_gta_quaternion_normalize_wxyz( + scaled_identity, normalized)); + assert(fabsf(normalized[0] - 1.0f) < 0.00001f); + assert(!samp_gta_quaternion_normalize_wxyz(zero, normalized)); + expect_matrix_to_wire_quaternion(); + expect_remote_vehicle_slerp(); + expect_remote_vehicle_position_decision(); + expect_remote_trailer_position_decision(); + expect_vehicle_attach_source_timing(); return 0; } diff --git a/reimpl/tests/test_pickup_pool_compat.c b/reimpl/tests/test_pickup_pool_compat.c new file mode 100644 index 0000000..07fd28c --- /dev/null +++ b/reimpl/tests/test_pickup_pool_compat.c @@ -0,0 +1,22 @@ +#include "../src/pickup_pool_compat.h" + +#include +#include + +_Static_assert(SAMP_PICKUP_POOL_CAPACITY_037 == 4096u, + "R5 pickup pool capacity must remain 4096 slots"); + +int main(void) { + assert(!samp_pickup_pool_id_valid(-1)); + assert(samp_pickup_pool_id_valid(0)); + assert(samp_pickup_pool_id_valid(4095)); + assert(!samp_pickup_pool_id_valid(4096)); + assert(!samp_pickup_pool_id_valid(INT32_MAX)); + assert(samp_pickup_rpc_reliability_r5( + SAMP_PICKUP_RPC_SOURCE_ORDINARY_PICKED_UP) == 9u); + assert(samp_pickup_rpc_reliability_r5( + SAMP_PICKUP_RPC_SOURCE_PROCESS) == 10u); + assert(samp_pickup_rpc_reliability_r5( + SAMP_PICKUP_RPC_SOURCE_INVALID) == 0u); + return 0; +} diff --git a/reimpl/tests/test_raknet_remote_edge_sync.cpp b/reimpl/tests/test_raknet_remote_edge_sync.cpp new file mode 100644 index 0000000..6a36dbf --- /dev/null +++ b/reimpl/tests/test_raknet_remote_edge_sync.cpp @@ -0,0 +1,217 @@ +#include "sampdll/net/raknet_client_adapter.h" + +#include "raknet_client_adapter_test.h" + +#include "raknet/GetTime.h" +#include "raknet/PacketEnumerations.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +template +std::vector make_packet(unsigned char packet_id, + std::uint16_t player_id, + const Payload &payload) { + std::vector packet(3U + sizeof(payload), 0U); + packet[0] = packet_id; + std::memcpy(packet.data() + 1U, &player_id, sizeof(player_id)); + std::memcpy(packet.data() + 3U, &payload, sizeof(payload)); + return packet; +} + +std::vector +timestamp_wrap(const std::vector &packet) { + std::vector wrapped( + 1U + sizeof(RakNet::RakNetTime) + packet.size(), 0U); + const RakNet::RakNetTime timestamp = 0x12345678U; + wrapped[0] = RakNet::ID_TIMESTAMP; + std::memcpy(wrapped.data() + 1U, ×tamp, sizeof(timestamp)); + std::memcpy(wrapped.data() + 1U + sizeof(timestamp), packet.data(), + packet.size()); + return wrapped; +} + +void expect_float(float actual, float expected) { + assert(std::fabs(actual - expected) < 0.00001f); +} + +} // namespace + +int main() { + void *client = nullptr; + samp_raknet_rpc_probe_snapshot snapshot{}; + + static_assert(sizeof(samp_raknet_unoccupied_sync) == 67U); + static_assert(sizeof(samp_raknet_trailer_sync) == 54U); + static_assert(sizeof(samp_raknet_passenger_sync) == 24U); + + assert(samp_raknet_test_remote_movement_drain_should_yield(0U, 0U) == 0); + assert(samp_raknet_test_remote_movement_drain_should_yield( + 0U, SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING - 1U) == 0); + assert(samp_raknet_test_remote_movement_drain_should_yield( + 0U, SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING) == 1); + assert(samp_raknet_test_remote_movement_drain_should_yield( + 900U, 900U + SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING) == 1); + assert(samp_raknet_test_remote_movement_drain_should_yield( + 0xFFFFFFFEU, 1U) == 0); + assert(samp_raknet_test_remote_movement_drain_should_yield( + 0xFFFFFF80U, 1U) == 1); + + assert(samp_raknet_client_create(&client) == 0); + assert(client != nullptr); + + samp_raknet_unoccupied_sync unoccupied{}; + unoccupied.vehicle_id = 601U; + unoccupied.seat_id = 3U; + unoccupied.roll[0] = 0.25f; + unoccupied.roll[1] = -0.5f; + unoccupied.roll[2] = 0.75f; + unoccupied.rotation[0] = -0.125f; + unoccupied.rotation[1] = 0.625f; + unoccupied.rotation[2] = 1.0f; + unoccupied.position[0] = 123.5f; + unoccupied.position[1] = -456.25f; + unoccupied.position[2] = 78.75f; + unoccupied.move_speed[0] = 1.25f; + unoccupied.move_speed[1] = -2.5f; + unoccupied.move_speed[2] = 3.75f; + unoccupied.turn_speed[0] = -0.75f; + unoccupied.turn_speed[1] = 0.5f; + unoccupied.turn_speed[2] = 0.25f; + unoccupied.vehicle_health = 777.5f; + const auto packet209 = make_packet(209U, 321U, unoccupied); + assert(packet209.size() == 70U); + assert(samp_raknet_test_ingest_remote_edge_sync( + packet209.data(), static_cast(packet209.size())) == + 1); + + samp_raknet_trailer_sync trailer{}; + trailer.vehicle_id = 602U; + trailer.position[0] = -10.5f; + trailer.position[1] = 20.25f; + trailer.position[2] = 30.75f; + trailer.quaternion[0] = 0.5f; + trailer.quaternion[1] = -0.25f; + trailer.quaternion[2] = 0.75f; + trailer.quaternion[3] = -0.125f; + trailer.move_speed[0] = 4.0f; + trailer.move_speed[1] = -5.0f; + trailer.move_speed[2] = 6.0f; + trailer.turn_speed[0] = -0.4f; + trailer.turn_speed[1] = 0.5f; + trailer.turn_speed[2] = -0.6f; + const auto packet210 = make_packet(210U, 322U, trailer); + assert(packet210.size() == 57U); + assert(samp_raknet_test_ingest_remote_edge_sync( + packet210.data(), static_cast(packet210.size())) == + 1); + + samp_raknet_passenger_sync passenger{}; + passenger.vehicle_id = 603U; + passenger.seat_flags = static_cast(4U | 0x40U | 0x80U); + passenger.additional_key_weapon = static_cast((2U << 6U) | 31U); + passenger.health = 87U; + passenger.armour = 43U; + passenger.left_right_keys = 0x1122U; + passenger.up_down_keys = 0x3344U; + passenger.keys = 0x5566U; + passenger.position[0] = 101.25f; + passenger.position[1] = -202.5f; + passenger.position[2] = 303.75f; + const auto packet211 = make_packet(211U, 323U, passenger); + assert(packet211.size() == 27U); + const auto timestamped211 = timestamp_wrap(packet211); + assert(samp_raknet_test_ingest_remote_edge_sync( + timestamped211.data(), + static_cast(timestamped211.size())) == 1); + + assert(samp_raknet_client_get_rpc_probe_snapshot(client, &snapshot) == 0); + assert((snapshot.flags & SAMP_RAKNET_RPC_FLAG_REMOTE_PLAYER_SYNC) != 0U); + assert(snapshot.remote_unoccupied_sync_count == 1U); + assert(snapshot.remote_trailer_sync_count == 1U); + assert(snapshot.remote_passenger_sync_count == 1U); + assert(snapshot.remote_movement_sync_count == 3U); + assert(snapshot.remote_unoccupied_syncs[0].player_id == 321U); + assert(snapshot.remote_unoccupied_syncs[0].sync.vehicle_id == 601U); + expect_float(snapshot.remote_unoccupied_syncs[0].sync.position[1], -456.25f); + expect_float(snapshot.remote_unoccupied_syncs[0].sync.vehicle_health, 777.5f); + assert(snapshot.remote_trailer_syncs[0].player_id == 322U); + assert(snapshot.remote_trailer_syncs[0].sync.vehicle_id == 602U); + expect_float(snapshot.remote_trailer_syncs[0].sync.quaternion[2], 0.75f); + assert(snapshot.remote_passenger_syncs[0].player_id == 323U); + assert(snapshot.remote_passenger_syncs[0].sync.seat_flags == + passenger.seat_flags); + assert(snapshot.remote_passenger_syncs[0].sync.additional_key_weapon == + passenger.additional_key_weapon); + assert(snapshot.remote_movement_syncs[0].type == + SAMP_RAKNET_REMOTE_MOVEMENT_UNOCCUPIED); + assert(snapshot.remote_movement_syncs[0].state.unoccupied.player_id == 321U); + assert(snapshot.remote_movement_syncs[1].type == + SAMP_RAKNET_REMOTE_MOVEMENT_TRAILER); + assert(snapshot.remote_movement_syncs[1].state.trailer.player_id == 322U); + assert(snapshot.remote_movement_syncs[2].type == + SAMP_RAKNET_REMOTE_MOVEMENT_PASSENGER); + assert(snapshot.remote_movement_syncs[2].state.passenger.player_id == 323U); + + auto truncated = packet209; + truncated.pop_back(); + assert(samp_raknet_test_ingest_remote_edge_sync( + truncated.data(), static_cast(truncated.size())) == + 0); + auto wrong_id = packet209; + wrong_id[0] = 208U; + assert(samp_raknet_test_ingest_remote_edge_sync( + wrong_id.data(), static_cast(wrong_id.size())) == 0); + auto trailing = packet210; + trailing.push_back(0xA5U); + assert(samp_raknet_test_ingest_remote_edge_sync( + trailing.data(), static_cast(trailing.size())) == 1); + + for (std::uint16_t i = 0U; i < SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING + 1U; + ++i) { + passenger.vehicle_id = static_cast(700U + i); + const auto packet = + make_packet(211U, static_cast(400U + i), passenger); + assert(samp_raknet_test_ingest_remote_edge_sync( + packet.data(), static_cast(packet.size())) == 1); + } + assert(samp_raknet_client_get_rpc_probe_snapshot(client, &snapshot) == 0); + assert(snapshot.remote_passenger_sync_count == + SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING); + assert(snapshot.remote_movement_sync_count == + SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING); + for (std::uint32_t i = 1U; i < snapshot.remote_movement_sync_count; ++i) { + assert(snapshot.remote_movement_syncs[i].seq == + snapshot.remote_movement_syncs[i - 1U].seq + 1U); + } + assert(snapshot.remote_movement_syncs[0].state.passenger.player_id == 401U); + const auto &last_movement = + snapshot.remote_movement_syncs[snapshot.remote_movement_sync_count - 1U]; + const auto expected_last_player = + static_cast(400U + SAMP_RAKNET_REMOTE_PLAYER_SYNC_RING); + if (last_movement.state.passenger.player_id != expected_last_player) { + std::fprintf(stderr, "last movement: seq=%u type=%u player=%u count=%u\n", + last_movement.seq, last_movement.type, + last_movement.state.passenger.player_id, + snapshot.remote_movement_sync_count); + return 1; + } + + assert(samp_raknet_client_destroy(client) == 0); + client = nullptr; + assert(samp_raknet_client_create(&client) == 0); + assert(samp_raknet_client_get_rpc_probe_snapshot(client, &snapshot) == 0); + assert(snapshot.remote_unoccupied_sync_count == 0U); + assert(snapshot.remote_trailer_sync_count == 0U); + assert(snapshot.remote_passenger_sync_count == 0U); + assert(snapshot.remote_movement_sync_count == 0U); + assert((snapshot.flags & SAMP_RAKNET_RPC_FLAG_REMOTE_PLAYER_SYNC) == 0U); + assert(samp_raknet_client_destroy(client) == 0); + return 0; +} diff --git a/reimpl/tests/test_remote_edge_cursor_compat.c b/reimpl/tests/test_remote_edge_cursor_compat.c new file mode 100644 index 0000000..a6dc2f0 --- /dev/null +++ b/reimpl/tests/test_remote_edge_cursor_compat.c @@ -0,0 +1,113 @@ +#include "remote_edge_cursor_compat.h" + +#include + +static void expect_applied_then_defer_then_retry(void) { + samp_remote_edge_cursor_decision_compat decision; + uint32_t cursor = 0u; + + decision = samp_remote_edge_cursor_decide( + cursor, 10u, SAMP_REMOTE_EDGE_APPLIED, 0u); + assert(decision.consume); + assert(!decision.stop); + assert(decision.effective_result == SAMP_REMOTE_EDGE_APPLIED); + cursor = decision.next_cursor; + assert(cursor == 10u); + + decision = samp_remote_edge_cursor_decide( + cursor, 11u, SAMP_REMOTE_EDGE_DEFER, 0u); + assert(!decision.consume); + assert(decision.stop); + assert(decision.effective_result == SAMP_REMOTE_EDGE_DEFER); + assert(decision.next_cursor == 10u); + + decision = samp_remote_edge_cursor_decide( + cursor, 11u, SAMP_REMOTE_EDGE_APPLIED, 20u); + assert(decision.consume); + assert(!decision.stop); + assert(decision.effective_result == SAMP_REMOTE_EDGE_APPLIED); + assert(decision.next_cursor == 11u); +} + +static void expect_bounded_defer(void) { + samp_remote_edge_cursor_decision_compat decision; + + decision = samp_remote_edge_cursor_decide( + 40u, 41u, SAMP_REMOTE_EDGE_DEFER, + SAMP_REMOTE_EDGE_DEFER_MAX_MS - 1u); + assert(!decision.consume); + assert(decision.stop); + assert(decision.next_cursor == 40u); + + decision = samp_remote_edge_cursor_decide( + 40u, 41u, SAMP_REMOTE_EDGE_DEFER, + SAMP_REMOTE_EDGE_DEFER_MAX_MS); + assert(decision.consume); + assert(!decision.stop); + assert(decision.effective_result == SAMP_REMOTE_EDGE_DROP); + assert(decision.next_cursor == 41u); + + decision = samp_remote_edge_cursor_decide( + 40u, 41u, SAMP_REMOTE_EDGE_DEFER, + SAMP_REMOTE_EDGE_DEFER_MAX_MS + 250u); + assert(decision.consume); + assert(!decision.stop); + assert(decision.effective_result == SAMP_REMOTE_EDGE_DROP); + assert(decision.next_cursor == 41u); +} + +static void expect_drop_stale_and_gap_input(void) { + samp_remote_edge_cursor_decision_compat decision; + + decision = samp_remote_edge_cursor_decide( + 70u, 71u, SAMP_REMOTE_EDGE_DROP, 0u); + assert(decision.consume); + assert(!decision.stop); + assert(decision.effective_result == SAMP_REMOTE_EDGE_DROP); + assert(decision.next_cursor == 71u); + + decision = samp_remote_edge_cursor_decide( + 71u, 71u, SAMP_REMOTE_EDGE_APPLIED, 0u); + assert(!decision.consume); + assert(!decision.stop); + assert(decision.next_cursor == 71u); + + decision = samp_remote_edge_cursor_decide( + 71u, 0u, SAMP_REMOTE_EDGE_APPLIED, 0u); + assert(!decision.consume); + assert(!decision.stop); + assert(decision.next_cursor == 71u); + + decision = samp_remote_edge_cursor_decide( + 71u, 75u, SAMP_REMOTE_EDGE_APPLIED, 0u); + assert(decision.consume); + assert(!decision.stop); + assert(decision.next_cursor == 75u); + + decision = samp_remote_edge_cursor_decide( + 71u, 75u, SAMP_REMOTE_EDGE_DEFER, 0u); + assert(!decision.consume); + assert(decision.stop); + assert(decision.next_cursor == 71u); + + decision = samp_remote_edge_cursor_decide( + UINT32_MAX, 1u, SAMP_REMOTE_EDGE_APPLIED, 0u); + assert(decision.consume); + assert(!decision.stop); + assert(decision.next_cursor == 1u); + assert(samp_remote_edge_seq_distance(UINT32_MAX, 1u) == 1u); + assert(samp_remote_edge_seq_distance(UINT32_MAX - 1u, 1u) == 2u); + + decision = samp_remote_edge_cursor_decide( + 1u, UINT32_MAX, SAMP_REMOTE_EDGE_APPLIED, 0u); + assert(!decision.consume); + assert(!decision.stop); + assert(decision.next_cursor == 1u); +} + +int main(void) { + expect_applied_then_defer_then_retry(); + expect_bounded_defer(); + expect_drop_stale_and_gap_input(); + return 0; +} diff --git a/tools/asi_probe/CMakeLists.txt b/tools/asi_probe/CMakeLists.txt index 0849b1d..06326ca 100644 --- a/tools/asi_probe/CMakeLists.txt +++ b/tools/asi_probe/CMakeLists.txt @@ -2,8 +2,13 @@ cmake_minimum_required(VERSION 3.20) project(samp_probe_asi C) +include(CTest) + add_library(samp_probe MODULE src/samp_probe_asi.c + src/samp_probe_death_cleanup.c + src/samp_probe_pickup.c + src/samp_probe_ui_latches.c ) set_target_properties(samp_probe PROPERTIES @@ -38,3 +43,15 @@ if(MINGW) -static-libgcc ) endif() + +if(BUILD_TESTING) + find_package(Python3 COMPONENTS Interpreter QUIET) + if(Python3_Interpreter_FOUND) + add_test( + NAME samp_probe_death_cleanup_exit_source_contract + COMMAND + ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_death_cleanup_exit_source_contract.py + ) + endif() +endif() diff --git a/tools/asi_probe/README.md b/tools/asi_probe/README.md index e7a7c10..b5730cf 100644 --- a/tools/asi_probe/README.md +++ b/tools/asi_probe/README.md @@ -26,6 +26,25 @@ It is intended for local reverse-engineering and compatibility work: 14. on the validated GTA-SA 1.0 US IPL null-entity fault at `0x00405E15`, passively log the bounded `CFileLoader::LoadObjectInstance` input line from the still-live cdecl argument at `ESP[0]`. +15. optionally trace original-R5 Packet-210 application at + `samp.dll+0x15C90`, buffering the packed payload and raw trailer + matrix/move/turn state before and after the original call. +16. optionally correlate successful GTA-SA 1.0 US + `CTrailer::SetTowLink` calls with the first 64 direct + `CTrailer::ProcessControl` calls, including bounded pre/post tow-link, + matrix, suspension, speed, and frame/timing state. +17. optionally trace original-R5 `VehiclePool::New` and + `CPlayerPed::PutDirectlyInVehicle` calls with bounded pre/post pool, + GTA vehicle, driver, passenger-seat, status, and flag snapshots. +18. optionally trace the original-R5 remote aim-context, key mapping, + BulletSync shot/fire dispatch, and jetpack wrapper paths with bounded + pre/post ped, matrix, weapon, task-root, aim-buffer, and shot snapshots. +19. optionally trace the original-R5 local death/spawn/class-selection state + machine and pre/post GMX, connection-loss, and destructor cleanup state, + including bounded pool, entity, UI, camera, and task-root snapshots. +20. optionally trace original-R5 pickup collection and processing, correlating + pool handles, raw GTA pickup indices, notification timers, dropped-weapon + metadata, processing cadence, and outgoing pickup RPCs. The first pass rewrites selected import slots inside `samp.dll`, so observed calls are attributable to `samp.dll` rather than process-global Wine/WinDbg noise. @@ -160,6 +179,13 @@ SAMP_PROBE_FONT5_HOOKS=1 SAMP_PROBE_ACTOR_HOOKS=1 SAMP_PROBE_ACTOR_HEAVY=1 SAMP_PROBE_RPC_GAP_HOOKS=1 +SAMP_PROBE_TRAILER_SYNC_HOOKS=1 +SAMP_PROBE_TRAILER_PHYSICS_HOOKS=1 +SAMP_PROBE_VEHICLE_LIFECYCLE_HOOKS=1 +SAMP_PROBE_AIM_BULLET_JETPACK_HOOKS=1 +SAMP_PROBE_DEATH_CLEANUP_HOOKS=1 +SAMP_PROBE_PICKUP_HOOKS=1 +SAMP_PROBE_UI_LATCHES_HOOKS=1 ``` The asset trace can also be toggled through files next to the ASI: @@ -179,6 +205,242 @@ samp_probe_actor_hooks.flag samp_probe_actor_heavy.flag samp_probe_rpc_gap_hooks.flag samp_probe_dialog_menu_rpc_hooks.flag +samp_probe_trailer_sync_hooks.flag +samp_probe_trailer_physics_hooks.flag +samp_probe_vehicle_lifecycle_hooks.flag +samp_probe_aim_bullet_jetpack_hooks.flag +samp_probe_death_cleanup_hooks.flag +samp_probe_pickup_hooks.flag +samp_probe_ui_latches_hooks.flag +``` + +Use `samp_probe_trailer_sync_hooks.flag` only for short original-R5 trailer +playback runs. It requires the exact supported R5 PE identity and validates +the complete eight-byte entry patch plus the function epilogue before +installing the single `samp.dll+0x15C90` hook. The hook copies state into a +fixed in-memory ring; the worker flushes it every 250 ms. This focused mode +does not install the normal Winsock/IAT or unrelated code hooks, avoiding +synchronous file I/O on the receive/game thread. Each `trailer_sync_r5` line +records target, pre/post position residual, move/turn vectors, live attachment +pointers, and the original `noop`/`correct`/`snap` branch predicted from the +statically observed thresholds. A pre-attachment mismatch is labeled +`transition`, because the original routine can attach before its own matrix +read. + +Use `samp_probe_trailer_physics_hooks.flag` for a focused GTA-SA 1.0 US +attach/physics run. This profile implies the Packet-210 trace above when the +loaded `samp.dll` has the exact supported R5 identity; with the replacement +DLL, the R5 hook is rejected while the GTA hooks remain available. The probe +requires the exact supported GTA executable PE identity proxy and exact entry +and tail bytes for both functions before changing either target: + +- `gta_sa.exe+0x002CFDF0`: `CTrailer::SetTowLink`; +- `gta_sa.exe+0x002CED20`: `CTrailer::ProcessControl`. + +The supported GTA executable has +SHA256=`a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26`. +`ProcessControl` starts with a relative base-class call, so this probe uses a +dedicated gateway that recalculates that call rather than copying it through +the generic trampoline. A successful `SetTowLink` arms one of eight fixed +tracking slots for at most 64 direct `ProcessControl` samples. Hooks write +only to a fixed 512-record ring; the worker emits the file log. Records share +an `event` sequence with `trailer_sync_r5` lines so attach, early physics, and +Packet-210 order can be compared without relying only on millisecond +timestamps. + +Use `samp_probe_vehicle_lifecycle_hooks.flag` by itself for a focused +original-R5 vehicle creation and occupancy run. It atomically preflights and +installs exactly two `samp.dll` entry hooks: + +- `samp.dll+0x0001F080`: `VehiclePool::New`; +- `samp.dll+0x000AC290`: `CPlayerPed::PutDirectlyInVehicle`. + +The supported original DLL has +SHA256=`b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2`. +The supported GTA executable has +SHA256=`a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26`. +Before changing either entry, the probe requires both exact PE identity +proxies, complete-instruction entry bytes, both known `VehiclePool::New` +return tails, and the shared `PutDirectlyInVehicle` return tail. A partial +install is restored from the exact saved bytes; normal probe shutdown also +restores only an owned `E9`/NOP patch. + +The hooks perform no file I/O. They copy bounded snapshots to a fixed +256-record ring, which the worker flushes every 250 ms as +`vehicle_lifecycle_r5` and `vehicle_lifecycle_state_r5`. Records include the +SA-MP caller RVA/source label, input vehicle ID or GTA pool reference, seat, +player-ped/current-vehicle pointers, pool listed/wrapper/entity pointers, +GTA vehicle status and flags, driver, all eight passenger pointers, and +tow/trailer links before and after the original call. Caller labels are +`STATIC_037`; fields derived from GTA layouts remain +`GTA_REVERSED_REF`/`TODO_VERIFY` until an original runtime trace confirms +them. This focused profile skips normal Winsock/IAT, trailer, render, and +unrelated code-hook sets. Keep the run short and process-bound; do not hot- +unload the probe while either entry could be executing. + +Use `samp_probe_aim_bullet_jetpack_hooks.flag` by itself for a focused +original-R5 combat/task run. The Windows lab profile name is +`aim-bullet-jetpack`. It atomically preflights and installs these eight +`samp.dll` hooks: + +- `samp.dll+0x0009C9C0`: install one remote 0x30-byte aim context; +- `samp.dll+0x0009C960`: restore the saved local aim context; +- `samp.dll+0x000AF340`: map remote sync keys into the GTA pad; +- `samp.dll+0x000AF280`: store the bounded internal shot context; +- `samp.dll+0x000AFA70`: dispatch the remote GTA fire path; +- `samp.dll+0x000ACD10`: start jetpack; +- `samp.dll+0x000ACD60`: stop jetpack; +- `samp.dll+0x000ACDC0`: query jetpack mode. + +The supported original DLL has +SHA256=`b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2`; +the GTA executable has +SHA256=`a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26`. +The profile requires both exact PE identity proxies, complete-instruction +entry spans, and every known return/tail form before changing the first +entry. R5 may be loader-relocated. The guarded HIGHLOW operands at relocation +RVAs `+0x9C964` and `+0xACD4B` are normalized from their preferred-image VAs +to the actual R5 module base while all surrounding bytes remain exact. GTA +has relocations stripped and is still required at its fixed preferred base. +The ABI and stack-pop form of every wrapper are recorded next to the hook +table. A partial install is restored in reverse order, and shutdown restores +only an owned `E9`/NOP patch from its exact saved bytes. + +Hook threads perform no file I/O. They publish to a fixed 512-record ring; +the worker emits `aim_bullet_jetpack_r5`, +`aim_bullet_jetpack_state_r5`, and `aim_bullet_jetpack_aim_r5`. Records carry +tick, GTA frame, thread, caller RVA, raw key arguments, shot input, target +entity, active/per-player aim buffers, ped matrix/basis, weapon slot/state, +eleven task-root pointers, and the jetpack task/vtable before and after the +original call. Packet 206 and Packet 207 are call-chain context only; this +first profile does not hook their large packet handlers. Field semantics not +directly established by the static R5 instructions remain `TODO_VERIFY`. +Keep runs short and do not hot-unload the ASI while a hook may be executing. + +Use `samp_probe_death_cleanup_hooks.flag` by itself for a focused original-R5 +death, respawn, F4 class-selection, and cleanup run. The Windows lab profile +name is `death-cleanup`. It atomically preflights and installs six +`samp.dll` entry hooks plus one terminal import checkpoint: + +- `samp.dll+0x000074C0`: `CLocalPlayer::Process`; +- `samp.dll+0x00003C20`: `CLocalPlayer::Spawn`; +- `samp.dll+0x00004080`: `CLocalPlayer::HandleClassSelection`; +- `samp.dll+0x0000A540`: `CNetGame::ShutdownForGameModeRestart`; +- `samp.dll+0x0000ACF0`: `CNetGame::Packet_ConnectionLost`; +- `samp.dll+0x00009880`: `CNetGame::~CNetGame`; +- `samp.dll+0x000E5188`: the R5 `KERNEL32!ExitProcess` IAT slot. + +The supported original DLL has +SHA256=`b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2`; +the GTA executable has +SHA256=`a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26`. +The profile requires both exact PE identity proxies, complete-instruction +entry spans, and the known return tail of every hook before changing the first +byte. R5 may be loader-relocated: the guarded HIGHLOW operand at relocation +RVA `+0x3C29` in the `CLocalPlayer::Spawn` entry is normalized from its +preferred-image VA to the actual R5 module base while all surrounding bytes +remain exact. The terminal checkpoint additionally requires the relocated +callsite bytes at `samp.dll+0xC508A`, the IAT operand resolving to +`samp.dll+0xE5188`, and the unmodified slot resolving to +`KERNEL32!ExitProcess`. GTA has relocations stripped and is still required at +its fixed preferred base. Hook installation is all-or-nothing. Shutdown +restores only owned `E9`/NOP patches and the owned four-byte IAT slot from +their exact saved values. + +Hook threads do no file I/O. They publish to a fixed 256-record ring; the +worker emits `death_cleanup_r5`, `death_cleanup_local_r5`, +`death_cleanup_tasks_r5`, `death_cleanup_ui_r5`, +`death_cleanup_pools_r5`, and `death_cleanup_entities_r5`. The local-process +hook publishes only on a state transition or one-second heartbeat. Snapshots +cover local active/wasted/spawn/class flags, the spawn-info hash, ped/entity +and task pointers, camera/frontend/UI state, all pool pointers and occupancy +counts, one bounded representative vehicle/object/actor chain, and the raw +RemoveBuilding counter. Field semantics remain `STATIC_037`/`TODO_VERIFY` +until a controlled original runtime trace confirms them. + +The terminal wrapper remains transparent for every caller except the exact +R5 clean-quit return address `samp.dll+0xC5091`. At that callsite it captures +the already-nullable global NetGame pointer and current ring boundary, signals +the existing worker stop event, and waits at most 1500 ms. The worker drains +the ring, restores owned hooks, closes each log append, then emits +`death_cleanup_exit_r5` and releases the game thread to the saved real +`ExitProcess`. The wrapper itself performs no file I/O; a timeout still calls +the original function. This terminal path remains `STATIC_037` / +`TODO_VERIFY` until a controlled original `/q` run observes the marker. Keep +the run short and do not hot-unload the ASI while a hook may be executing. + +Use `samp_probe_pickup_hooks.flag` by itself for a focused original-R5 pickup +collection run. The Windows lab profile name is `pickup-r5`. It atomically +preflights and installs these two `samp.dll` hooks: + +- `samp.dll+0x00013440`: `CPickupPool::PickedUp`; +- `samp.dll+0x00013520`: `CPickupPool::Process`. + +The supported original DLL has +SHA256=`b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2`; +the supported GTA executable has +SHA256=`a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26`. +Before changing either entry, the probe requires the exact PE identity of both +modules, GTA at its fixed preferred base, both complete-instruction entry +spans, and both known method tails. Installation is all-or-nothing; shutdown +restores only owned entry patches from the exact saved bytes. + +The method hooks and the shared outgoing RakClient RPC hook perform no file +I/O. They publish to one fixed 256-record ring; the worker emits +`pickup_r5`, `pickup_pool_r5`, and `pickup_slot_r5`. The records share an event +sequence and contain the raw `PickedUp` argument, GTA frame, caller RVA, +process ordinal and cadence delta, the process-gate value at +`samp.dll+0x00118A10`, pool count, up to eight active/focused slot snapshots, +and RPC 131/97 payload/QoS/result. Slot snapshots include the full GTA pickup +handle, raw GTA pool index, notification timer, dropped flag/player, model, +type, and position bits before and after the original method. Layout meanings +remain `STATIC_037`/`TODO_VERIFY` until controlled original runtime traces +confirm them. Keep runs short and do not hot-unload the ASI while a hook may +be executing. + +Use `samp_probe_ui_latches_hooks.flag` by itself for a focused original-R5 +AFK, pause-menu, TAB, chat, and cursor-latch run. The Windows lab profile name +is `ui-latches-r5`. It atomically preflights and installs these nine +`samp.dll` hooks: + +- `samp.dll+0x000612C0`: cycle the F7 chat display mode; +- `samp.dll+0x00069480` / `+0x00069580`: open and close chat input; +- `samp.dll+0x0006F3D0` / `+0x0006E9E0`: show and hide the scoreboard; +- `samp.dll+0x000A06F0`: set the cursor/input mode; +- `samp.dll+0x000A05D0`: advance the delayed input restore; +- `samp.dll+0x000A0920`: query the GTA frontend/menu state; +- `samp.dll+0x000166B0`: process one remote player and its AFK state. + +The profile accepts only R5 +SHA256=`b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2` +with GTA 1.0 US +SHA256=`a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26` +at GTA's fixed preferred base. R5 itself may be loader-relocated; its guarded +absolute operands are normalized from preferred-image VAs to the actual +module base while every opcode, RVA, and non-relocated byte remains exact. +Complete-instruction entries, every cursor-mode return form, both +remote-process tails, and the statically identified on-foot/driver/passenger +AFK transition blocks are checked before the first patch. Installation is +all-or-nothing; shutdown restores only owned `E9`/NOP patches from the exact +saved bytes. + +Hook threads perform no file I/O or input injection. They publish bounded +records to a 256-entry ring; the existing worker emits `ui_latches_r5` and +`ui_latch_state_r5`. TAB/chat/cursor calls receive pre/post snapshots. +Menu-query records are edge-only. Remote AFK records are emitted on the first +observation, a state edge, or a one-second heartbeat using a bounded +128-object tracker. Logged values include caller RVA, frame/thread, scoreboard +visibility, chat active/display mode, raw cursor mode and delayed-restore +counter, GTA input-patch bytes, raw frontend/pause bytes, Win32 +foreground/focus/capture/cursor flags, remote sync state, last-sync tick, +elapsed time, AFK state, and ped wrapper. The `pause_raw_b7cb49` label is +deliberately raw and remains `TODO_VERIFY`; no R5 claim currently assigns it a +stronger meaning. + +Select the profile without deploying or launching it with: + +```bash +tools/windows/remote_lab/samp_lab.sh probe-profile ui-latches-r5 ``` Use `samp_probe_asset_paths.flag` for normal original-DLL golden traces. It logs interesting SA-MP asset opens, size queries, seeks, and closes. `samp_probe_file_hooks.flag` additionally hooks `ReadFile`; keep that for short, targeted runs only because original 0.3.7 performs large overlapped reads against the SAMP archives. diff --git a/tools/asi_probe/src/samp_probe_asi.c b/tools/asi_probe/src/samp_probe_asi.c index 5693695..e3d20d3 100644 --- a/tools/asi_probe/src/samp_probe_asi.c +++ b/tools/asi_probe/src/samp_probe_asi.c @@ -6,6 +6,10 @@ #include #include +#include "samp_probe_death_cleanup.h" +#include "samp_probe_pickup.h" +#include "samp_probe_ui_latches.h" + #define PROBE_LOG_NAME "samp_probe.log" #define PROBE_NO_HOOKS_FLAG "samp_probe_no_hooks.flag" #define PROBE_ASSET_PATHS_FLAG "samp_probe_asset_paths.flag" @@ -22,6 +26,13 @@ #define PROBE_ACTOR_HEAVY_FLAG "samp_probe_actor_heavy.flag" #define PROBE_RPC_GAP_HOOKS_FLAG "samp_probe_rpc_gap_hooks.flag" #define PROBE_DIALOG_MENU_RPC_HOOKS_FLAG "samp_probe_dialog_menu_rpc_hooks.flag" +#define PROBE_TRAILER_SYNC_HOOKS_FLAG "samp_probe_trailer_sync_hooks.flag" +#define PROBE_TRAILER_PHYSICS_HOOKS_FLAG "samp_probe_trailer_physics_hooks.flag" +#define PROBE_VEHICLE_LIFECYCLE_HOOKS_FLAG "samp_probe_vehicle_lifecycle_hooks.flag" +#define PROBE_AIM_BULLET_JETPACK_HOOKS_FLAG "samp_probe_aim_bullet_jetpack_hooks.flag" +#define PROBE_DEATH_CLEANUP_HOOKS_FLAG "samp_probe_death_cleanup_hooks.flag" +#define PROBE_PICKUP_HOOKS_FLAG "samp_probe_pickup_hooks.flag" +#define PROBE_UI_LATCHES_HOOKS_FLAG "samp_probe_ui_latches_hooks.flag" #define PROBE_MAX_IMPORT_LOGS 4096 #define PROBE_WATCH_INTERVAL_MS 250 #define PROBE_WAIT_FOR_SAMP_MS 30000 @@ -114,14 +125,126 @@ #define PROBE_SAMP_R5_REMOTE_ACTOR_SET_HEALTH_RVA 0x0009c5d0u #define PROBE_SAMP_R5_REMOTE_ACTOR_SET_INVULNERABLE_RVA 0x0009c700u #define PROBE_SAMP_R5_REMOTE_ACTOR_SET_POSITION_RVA 0x0009f040u +#define PROBE_SAMP_R5_TRAILER_SYNC_APPLY_RVA 0x00015c90u +#define PROBE_SAMP_R5_VEHICLE_POOL_NEW_RVA 0x0001f080u +#define PROBE_SAMP_R5_PLAYER_PED_PUT_DIRECTLY_IN_VEHICLE_RVA 0x000ac290u +#define PROBE_SAMP_R5_AIM_CONTEXT_INSTALL_RVA 0x0009c9c0u +#define PROBE_SAMP_R5_AIM_CONTEXT_RESTORE_RVA 0x0009c960u +#define PROBE_SAMP_R5_PLAYER_PED_SET_KEYS_RVA 0x000af340u +#define PROBE_SAMP_R5_PLAYER_PED_SET_SHOT_CONTEXT_RVA 0x000af280u +#define PROBE_SAMP_R5_PLAYER_PED_FIRE_REMOTE_RVA 0x000afa70u +#define PROBE_SAMP_R5_PLAYER_PED_START_JETPACK_RVA 0x000acd10u +#define PROBE_SAMP_R5_PLAYER_PED_STOP_JETPACK_RVA 0x000acd60u +#define PROBE_SAMP_R5_PLAYER_PED_IS_IN_JETPACK_RVA 0x000acdc0u #define PROBE_SAMP_R5_NETGAME_PTR_RVA 0x0026eb94u -#define PROBE_RAKCLIENT_RPC_BITSTREAM_VTBL_INDEX 26u +#define PROBE_RAKCLIENT_RPC_BITSTREAM_VTBL_INDEX 25u +#define PROBE_SAMP_R5_RAKCLIENT_RPC_BITSTREAM_RVA 0x00034620u #define PROBE_SERVER_COMMAND_RPC 50u #define PROBE_DIALOG_RESPONSE_RPC 62u +#define PROBE_PICKUP_RPC 131u +#define PROBE_PICKUP_WEAPON_RPC 97u #define PROBE_MENU_SELECT_RPC 132u #define PROBE_MENU_QUIT_RPC 140u #define PROBE_DIALOG_MENU_RPC_MAX_BITS 8192u #define PROBE_SAMP_R5_NETGAME_POOLS_OFFSET 0x000003deu +#define PROBE_SAMP_R5_POOLS_VEHICLE_POOL_OFFSET 0x00000000u +#define PROBE_SAMP_R5_VEHICLE_POOL_WRAPPER_OFFSET 0x00001134u +#define PROBE_SAMP_R5_VEHICLE_POOL_LISTED_OFFSET 0x00003074u +#define PROBE_SAMP_R5_VEHICLE_POOL_CAPACITY 2000u +#define PROBE_SAMP_R5_REMOTE_PLAYER_VEHICLE_WRAPPER_OFFSET 0x000001e1u +#define PROBE_SAMP_R5_VEHICLE_WRAPPER_ENTITY_OFFSET 0x00000040u +#define PROBE_SAMP_R5_VEHICLE_WRAPPER_GTA_VEHICLE_OFFSET 0x0000004cu +#define PROBE_GTA_ENTITY_MATRIX_PTR_OFFSET 0x00000014u +#define PROBE_GTA_VEHICLE_TRAILER_OFFSET 0x000004c8u +#define PROBE_TRAILER_SYNC_TRACE_RING 256u +#define PROBE_GTA_US10_IMAGE_BASE 0x00400000u +#define PROBE_GTA_US10_TIMESTAMP 0x427101cau +#define PROBE_GTA_US10_ENTRY_RVA 0x00424570u +#define PROBE_GTA_US10_IMAGE_SIZE 0x01177000u +#define PROBE_GTA_US10_CHECKSUM 0x00dc5beau +#define PROBE_GTA_US10_CTRAILER_VTABLE 0x00871c28u +#define PROBE_GTA_US10_CTRAILER_PROCESS_CONTROL_ADDR 0x006ced20u +#define PROBE_GTA_US10_CTRAILER_SET_TOW_LINK_ADDR 0x006cfdf0u +#define PROBE_GTA_US10_CAUTOMOBILE_PROCESS_CONTROL_ADDR 0x006b1880u +#define PROBE_GTA_US10_FRAME_COUNTER_ADDR 0x00b7cb4cu +#define PROBE_GTA_US10_GAME_TIME_ADDR 0x00b7cb84u +#define PROBE_GTA_US10_TIME_STEP_ADDR 0x00b7cb5cu +#define PROBE_GTA_US10_VEHICLE_POOL_PTR_ADDR 0x00b74494u +#define PROBE_GTA_US10_VEHICLE_POOL_OBJECT_SIZE 0x00000a18u +#define PROBE_GTA_ENTITY_FLAGS_OFFSET 0x0000001cu +#define PROBE_GTA_ENTITY_STATUS_OFFSET 0x00000036u +#define PROBE_GTA_PHYSICAL_FAKE_PHYSICS_OFFSET 0x000000b8u +#define PROBE_GTA_VEHICLE_FLAGS_OFFSET 0x00000428u +#define PROBE_GTA_VEHICLE_DRIVER_OFFSET 0x00000460u +#define PROBE_GTA_TRAILER_TOWING_VEHICLE_OFFSET 0x000004c4u +#define PROBE_GTA_TRAILER_SUPPORT_RATIO_X_OFFSET 0x000009e0u +#define PROBE_GTA_TRAILER_SUPPORT_RATIO_Y_OFFSET 0x000009e4u +#define PROBE_GTA_TRAILER_SUPPORT_HEIGHT_OFFSET 0x000009e8u +#define PROBE_GTA_TRAILER_TOW_RATIO_X_OFFSET 0x000009ecu +#define PROBE_GTA_TRAILER_TOW_RATIO_Y_OFFSET 0x000009f0u +#define PROBE_GTA_AUTOMOBILE_WHEEL_COMPRESSION_OFFSET 0x000007d4u +#define PROBE_GTA_AUTOMOBILE_WHEEL_COMPRESSION_PREV_OFFSET 0x000007e4u +#define PROBE_GTA_AUTOMOBILE_SPRING_LENGTH_OFFSET 0x00000878u +#define PROBE_GTA_AUTOMOBILE_LINE_LENGTH_OFFSET 0x00000888u +#define PROBE_GTA_AUTOMOBILE_FRONT_HEIGHT_OFFSET 0x00000898u +#define PROBE_GTA_AUTOMOBILE_REAR_HEIGHT_OFFSET 0x0000089cu +#define PROBE_TRAILER_PHYSICS_TRACK_SLOTS 8u +#define PROBE_TRAILER_PHYSICS_FRAME_LIMIT 64u +#define PROBE_TRAILER_PHYSICS_TRACE_RING 512u +#define PROBE_TRAILER_PHYSICS_EVENT_SET_TOW_LINK 1u +#define PROBE_TRAILER_PHYSICS_EVENT_PROCESS_CONTROL 2u +#define PROBE_TRAILER_PHYSICS_TIMING_FRAME_VALID 0x01u +#define PROBE_TRAILER_PHYSICS_TIMING_GAME_TIME_VALID 0x02u +#define PROBE_TRAILER_PHYSICS_TIMING_STEP_VALID 0x04u +#define PROBE_TRAILER_PHYSICS_VEHICLE_COMMON_VALID 0x01u +#define PROBE_TRAILER_PHYSICS_VEHICLE_MATRIX_VALID 0x02u +#define PROBE_TRAILER_PHYSICS_VEHICLE_TRAILER_VALID 0x04u +#define PROBE_VEHICLE_LIFECYCLE_TRACE_RING 256u +#define PROBE_VEHICLE_LIFECYCLE_EVENT_POOL_NEW 1u +#define PROBE_VEHICLE_LIFECYCLE_EVENT_PUT_DIRECT 2u +#define PROBE_VEHICLE_LIFECYCLE_SOURCE_NONE 0u +#define PROBE_VEHICLE_LIFECYCLE_SOURCE_RPC164 1u +#define PROBE_VEHICLE_LIFECYCLE_SOURCE_POOL_RETRY 2u +#define PROBE_VEHICLE_LIFECYCLE_SOURCE_REMOTE_SYNC 3u +#define PROBE_VEHICLE_LIFECYCLE_SOURCE_RPC70 4u +#define PROBE_VEHICLE_LIFECYCLE_SOURCE_INTERNAL 5u +#define PROBE_SAMP_R5_PLAYER_PED_GTA_PED_OFFSET 0x000002a4u +#define PROBE_GTA_PED_VEHICLE_OFFSET 0x0000058cu +#define PROBE_SAMP_R5_PLAYER_PED_TABLE_RVA 0x0026bf10u +#define PROBE_SAMP_R5_PLAYER_PED_TABLE_CAPACITY 210u +#define PROBE_SAMP_R5_AIM_CONTEXT_PTR_RVA 0x001039c8u +#define PROBE_SAMP_R5_AIM_CONTEXT_ARRAY_RVA 0x00144428u +#define PROBE_SAMP_R5_AIM_CONTEXT_BYTES 0x30u +#define PROBE_SAMP_R5_PLAYER_PED_INDEX_OFFSET 0x000002b0u +#define PROBE_SAMP_R5_PLAYER_PED_SHOT_CONTEXT_OFFSET 0x000002fdu +#define PROBE_SAMP_R5_PLAYER_PED_SHOT_ORIGIN_OFFSET 0x00000301u +#define PROBE_SAMP_R5_PLAYER_PED_SHOT_HIT_OFFSET 0x0000030du +#define PROBE_SAMP_R5_PLAYER_PED_SHOT_OFFSET_OFFSET 0x00000319u +#define PROBE_SAMP_R5_PLAYER_PED_SHOT_TARGET_OFFSET 0x00000325u +#define PROBE_SAMP_R5_PLAYER_PED_SHOT_ACTIVE_OFFSET 0x00000329u +#define PROBE_GTA_PED_WEAPON_SLOTS_OFFSET 0x000005a0u +#define PROBE_GTA_PED_CURRENT_WEAPON_SLOT_OFFSET 0x00000718u +#define PROBE_GTA_WEAPON_SLOT_SIZE 0x0000001cu +#define PROBE_GTA_PED_TASK_ROOTS_OFFSET 0x00000004u +#define PROBE_GTA_PED_TASK_ROOT_COUNT 11u +#define PROBE_GTA_PED_JETPACK_TASK_OFFSET 0x00000010u +#define PROBE_AIM_BULLET_JETPACK_TRACE_RING 512u +#define PROBE_AIM_BULLET_JETPACK_EVENT_AIM_INSTALL 1u +#define PROBE_AIM_BULLET_JETPACK_EVENT_AIM_RESTORE 2u +#define PROBE_AIM_BULLET_JETPACK_EVENT_SET_KEYS 3u +#define PROBE_AIM_BULLET_JETPACK_EVENT_SHOT_CONTEXT 4u +#define PROBE_AIM_BULLET_JETPACK_EVENT_FIRE_REMOTE 5u +#define PROBE_AIM_BULLET_JETPACK_EVENT_JETPACK_START 6u +#define PROBE_AIM_BULLET_JETPACK_EVENT_JETPACK_STOP 7u +#define PROBE_AIM_BULLET_JETPACK_EVENT_JETPACK_QUERY 8u +#define PROBE_COMBAT_STATE_PLAYER_PED_VALID 0x00000001u +#define PROBE_COMBAT_STATE_GTA_PED_VALID 0x00000002u +#define PROBE_COMBAT_STATE_MATRIX_VALID 0x00000004u +#define PROBE_COMBAT_STATE_AIM_VALID 0x00000008u +#define PROBE_COMBAT_STATE_AIM_SOURCE_VALID 0x00000010u +#define PROBE_COMBAT_STATE_TASKS_VALID 0x00000020u +#define PROBE_COMBAT_STATE_WEAPON_VALID 0x00000040u +#define PROBE_COMBAT_STATE_SHOT_VALID 0x00000080u #define PROBE_SAMP_R5_POOLS_ACTOR_POOL_OFFSET 0x00000010u #define PROBE_SAMP_R5_ACTOR_POOL_SIZE 0x00004e24u #define PROBE_SAMP_R5_ACTOR_POOL_CAPACITY 1000u @@ -220,15 +343,24 @@ typedef struct probe_raknet_network_id { probe_raknet_player_id player_id; unsigned short local_system_id; } probe_raknet_network_id; + +typedef struct probe_samp_trailer_sync_r5 { + WORD vehicle_id; + float position[3]; + float quaternion[4]; + float move_speed[3]; + float turn_speed[3]; +} probe_samp_trailer_sync_r5; #pragma pack(pop) typedef char probe_assert_bitstream_prefix_size[(sizeof(probe_raknet_bitstream_prefix) == 20) ? 1 : -1]; typedef char probe_assert_player_id_size[(sizeof(probe_raknet_player_id) == 6) ? 1 : -1]; typedef char probe_assert_network_id_size[(sizeof(probe_raknet_network_id) == 8) ? 1 : -1]; +typedef char probe_assert_trailer_sync_r5_size[(sizeof(probe_samp_trailer_sync_r5) == 54) ? 1 : -1]; typedef BYTE(PROBE_THISCALL *probe_rakclient_rpc_bitstream_fn)( - void *, const BYTE *, probe_raknet_bitstream_prefix *, int, int, char, BYTE, - probe_raknet_network_id, probe_raknet_bitstream_prefix *); + void *, const BYTE *, probe_raknet_bitstream_prefix *, int, int, char, + BYTE); typedef HANDLE(WINAPI *probe_CreateFileA_fn)(LPCSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE); typedef HANDLE(WINAPI *probe_CreateFileW_fn)(LPCWSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE); typedef BOOL(WINAPI *probe_ReadFile_fn)(HANDLE, LPVOID, DWORD, LPDWORD, LPOVERLAPPED); @@ -244,6 +376,24 @@ typedef int(__cdecl *probe_gta_col_add_slot_fn)(const char *); typedef int(__cdecl *probe_gta_col_load_buffer_fn)(int, void *, int); typedef void(PROBE_THISCALL *probe_gta_physical_add_fn)(void *); typedef void(PROBE_THISCALL *probe_samp_font5_method_fn)(void *); +typedef void(PROBE_THISCALL *probe_samp_trailer_sync_apply_fn)( + void *, const probe_samp_trailer_sync_r5 *); +typedef BYTE(PROBE_THISCALL *probe_gta_trailer_set_tow_link_fn)( + void *, void *, DWORD); +typedef void(PROBE_THISCALL *probe_gta_trailer_process_control_fn)(void *); +typedef int(PROBE_THISCALL *probe_samp_vehicle_pool_new_fn)( + void *, const void *); +typedef void(PROBE_THISCALL *probe_samp_player_ped_put_direct_fn)( + void *, DWORD, DWORD); +typedef void(WINAPI *probe_samp_aim_context_install_fn)(DWORD); +typedef void(__cdecl *probe_samp_aim_context_restore_fn)(void); +typedef void(PROBE_THISCALL *probe_samp_player_ped_set_keys_fn)( + void *, DWORD, DWORD, DWORD); +typedef void(PROBE_THISCALL *probe_samp_player_ped_set_shot_context_fn)( + void *, const void *); +typedef int(PROBE_THISCALL *probe_samp_player_ped_fire_remote_fn)(void *); +typedef void(PROBE_THISCALL *probe_samp_player_ped_jetpack_void_fn)(void *); +typedef int(PROBE_THISCALL *probe_samp_player_ped_jetpack_query_fn)(void *); typedef struct probe_samp_rpc_parameters_prefix { const BYTE *input; int number_of_bits_of_data; @@ -391,6 +541,199 @@ typedef struct probe_code_hook { LONG installed; } probe_code_hook; +typedef struct probe_trailer_live_snapshot { + DWORD vehicle_pool; + DWORD listed; + DWORD wrapper; + DWORD entity; + DWORD matrix; + DWORD gta_vehicle; + float position[3]; + float move_speed[3]; + float turn_speed[3]; + BYTE valid; +} probe_trailer_live_snapshot; + +typedef struct probe_trailer_sync_trace { + volatile LONG committed_seq; + LONG call_seq; + LONG event_seq; + DWORD tick; + DWORD remote_player; + DWORD towing_wrapper_pre; + DWORD towing_wrapper_post; + DWORD towing_gta_pre; + DWORD towing_gta_post; + DWORD live_trailer_gta_pre; + DWORD live_trailer_gta_post; + probe_samp_trailer_sync_r5 sync; + probe_trailer_live_snapshot before; + probe_trailer_live_snapshot after; + BYTE payload_valid; + BYTE association_pre; + BYTE association_post; + BYTE predicted_mode; +} probe_trailer_sync_trace; + +typedef struct probe_trailer_physics_timing { + DWORD tick; + DWORD thread_id; + DWORD gta_frame; + DWORD game_time; + float time_step; + BYTE valid_mask; +} probe_trailer_physics_timing; + +typedef struct probe_trailer_physics_vehicle_state { + DWORD object; + DWORD vtable; + DWORD matrix; + DWORD flags; + DWORD vehicle_flags; + DWORD driver; + DWORD passengers[8]; + DWORD towing_vehicle; + DWORD trailer_vehicle; + float right[3]; + float forward[3]; + float up[3]; + float position[3]; + float move_speed[3]; + float turn_speed[3]; + float support[5]; + float wheel_compression[4]; + float wheel_compression_previous[4]; + float spring_length[4]; + float line_length[4]; + float ride_height[2]; + BYTE status; + BYTE fake_physics; + BYTE valid_mask; +} probe_trailer_physics_vehicle_state; + +typedef struct probe_trailer_physics_pair_state { + probe_trailer_physics_timing timing; + probe_trailer_physics_vehicle_state trailer; + probe_trailer_physics_vehicle_state tractor; +} probe_trailer_physics_pair_state; + +typedef struct probe_trailer_physics_trace { + volatile LONG committed_seq; + LONG ring_seq; + LONG event_seq; + LONG generation; + DWORD frame_index; + DWORD trailer; + DWORD tractor; + DWORD set_my_pos_raw; + BYTE kind; + BYTE result; + probe_trailer_physics_pair_state before; + probe_trailer_physics_pair_state after; +} probe_trailer_physics_trace; + +typedef struct probe_trailer_physics_track { + volatile LONG generation; + volatile LONG next_frame; + DWORD trailer; + DWORD tractor; +} probe_trailer_physics_track; + +typedef struct probe_vehicle_lifecycle_pool_state { + DWORD pool; + DWORD listed; + DWORD wrapper; + DWORD wrapper_entity; + DWORD gta_vehicle; + probe_trailer_physics_vehicle_state vehicle; + BYTE id_valid; +} probe_vehicle_lifecycle_pool_state; + +typedef struct probe_vehicle_lifecycle_trace { + volatile LONG committed_seq; + LONG ring_seq; + LONG event_seq; + DWORD tick; + DWORD thread_id; + DWORD caller_rva; + DWORD hook_rva; + DWORD object; + DWORD input; + DWORD gta_vehicle_ref; + DWORD seat; + DWORD gta_ped; + DWORD ped_vehicle_pre; + DWORD ped_vehicle_post; + WORD vehicle_id; + BYTE kind; + BYTE source; + BYTE input_valid; + BYTE result; + probe_vehicle_lifecycle_pool_state before; + probe_vehicle_lifecycle_pool_state after; +} probe_vehicle_lifecycle_trace; + +typedef struct probe_combat_shot_state { + DWORD header; + DWORD target; + DWORD active; + float origin[3]; + float hit[3]; + float offset[3]; + BYTE valid; +} probe_combat_shot_state; + +typedef struct probe_combat_ped_state { + DWORD valid_mask; + DWORD player_ped; + DWORD gta_ped; + DWORD ped_vtable; + DWORD matrix; + DWORD ped_flags; + DWORD ped_state; + DWORD intelligence; + DWORD jetpack_task; + DWORD jetpack_task_vtable; + DWORD aim_context; + DWORD aim_source; + DWORD aim_words[PROBE_SAMP_R5_AIM_CONTEXT_BYTES / sizeof(DWORD)]; + DWORD aim_source_words[PROBE_SAMP_R5_AIM_CONTEXT_BYTES / sizeof(DWORD)]; + DWORD task_roots[PROBE_GTA_PED_TASK_ROOT_COUNT]; + DWORD weapon; + DWORD weapon_type; + DWORD weapon_state; + DWORD weapon_ammo_in_clip; + DWORD weapon_total_ammo; + float matrix_right[3]; + float matrix_forward[3]; + float matrix_up[3]; + float matrix_position[3]; + float aiming_rotation; + probe_combat_shot_state stored_shot; + BYTE player_index; + BYTE weapon_slot; +} probe_combat_ped_state; + +typedef struct probe_aim_bullet_jetpack_trace { + volatile LONG committed_seq; + LONG ring_seq; + LONG event_seq; + DWORD tick; + DWORD thread_id; + DWORD gta_frame; + DWORD caller_rva; + DWORD hook_rva; + DWORD object; + DWORD input; + DWORD args[3]; + DWORD result; + BYTE kind; + BYTE frame_valid; + probe_combat_shot_state input_shot; + probe_combat_ped_state before; + probe_combat_ped_state after; +} probe_aim_bullet_jetpack_trace; + typedef struct probe_asset_handle { HANDLE handle; char path[MAX_PATH]; @@ -491,6 +834,20 @@ static void *g_orig_gta_col_load_buffer; static void *g_orig_gta_physical_add; static void *g_orig_samp_font5_prepare; static void *g_orig_samp_font5_draw_dispatch; +static void *g_orig_samp_trailer_sync_apply; +static void *g_orig_samp_vehicle_pool_new; +static void *g_orig_samp_player_ped_put_direct; +static void *g_orig_samp_aim_context_install; +static void *g_orig_samp_aim_context_restore; +static void *g_orig_samp_player_ped_set_keys; +static void *g_orig_samp_player_ped_set_shot_context; +static void *g_orig_samp_player_ped_fire_remote; +static void *g_orig_samp_player_ped_start_jetpack; +static void *g_orig_samp_player_ped_stop_jetpack; +static void *g_orig_samp_player_ped_is_in_jetpack; +static void *g_orig_gta_trailer_set_tow_link; +static void *g_orig_gta_trailer_process_control; +static void *g_trailer_physics_gateway_allocation; static void *g_orig_gta_font_set_scale; static void *g_orig_gta_font_set_color; static void *g_orig_gta_font_set_style; @@ -558,6 +915,35 @@ static LONG g_actor_rpc_call_count; static LONG g_actor_heavy_call_count; static LONG g_rpc_gap_call_count; static LONG g_rpc_gap_downstream_call_count; +static volatile LONG g_trailer_trace_event_seq; +static probe_trailer_sync_trace g_trailer_sync_trace_ring[PROBE_TRAILER_SYNC_TRACE_RING]; +static volatile LONG g_trailer_sync_trace_write_seq; +static LONG g_trailer_sync_trace_flushed_seq; +static LONG g_trailer_sync_trace_overflow_count; +static probe_trailer_physics_trace + g_trailer_physics_trace_ring[PROBE_TRAILER_PHYSICS_TRACE_RING]; +static probe_trailer_physics_track + g_trailer_physics_tracks[PROBE_TRAILER_PHYSICS_TRACK_SLOTS]; +static volatile LONG g_trailer_physics_trace_write_seq; +static LONG g_trailer_physics_trace_flushed_seq; +static LONG g_trailer_physics_trace_overflow_count; +static volatile LONG g_trailer_physics_generation; +static volatile LONG g_trailer_physics_hook_attempted; +static volatile LONG g_trailer_physics_hooks_installed; +static BYTE g_trailer_physics_set_tow_link_saved[5]; +static BYTE g_trailer_physics_process_control_saved[8]; +static probe_vehicle_lifecycle_trace + g_vehicle_lifecycle_trace_ring[PROBE_VEHICLE_LIFECYCLE_TRACE_RING]; +static volatile LONG g_vehicle_lifecycle_trace_write_seq; +static LONG g_vehicle_lifecycle_trace_flushed_seq; +static LONG g_vehicle_lifecycle_trace_overflow_count; +static volatile LONG g_vehicle_lifecycle_event_seq; +static probe_aim_bullet_jetpack_trace + g_aim_bullet_jetpack_trace_ring[PROBE_AIM_BULLET_JETPACK_TRACE_RING]; +static volatile LONG g_aim_bullet_jetpack_trace_write_seq; +static LONG g_aim_bullet_jetpack_trace_flushed_seq; +static LONG g_aim_bullet_jetpack_trace_overflow_count; +static volatile LONG g_aim_bullet_jetpack_event_seq; static volatile LONG g_actor_heavy_global_scope_depth; static LONG g_textdraw_current_font_style = -1; static LONG g_d3d_device_hooks_installed; @@ -582,6 +968,13 @@ static int actor_hooks_enabled(void); static int actor_heavy_enabled(void); static int rpc_gap_hooks_enabled(void); static int dialog_menu_rpc_hooks_enabled(void); +static int trailer_sync_hooks_enabled(void); +static int trailer_physics_hooks_enabled(void); +static int vehicle_lifecycle_hooks_enabled(void); +static int aim_bullet_jetpack_hooks_enabled(void); +static int death_cleanup_hooks_enabled(void); +static int pickup_hooks_enabled(void); +static int ui_latches_hooks_enabled(void); static PIMAGE_NT_HEADERS get_samp_nt_headers(void); static LONG CALLBACK probe_exception_handler(PEXCEPTION_POINTERS info); static int WINAPI hook_WSAStartup(WORD version, LPWSADATA data); @@ -629,6 +1022,39 @@ static int __cdecl hook_gta_col_load_buffer(int slot, void *buffer, int size); static void PROBE_THISCALL hook_gta_physical_add(void *entity); static void PROBE_THISCALL hook_samp_font5_prepare(void *textdraw); static void PROBE_THISCALL hook_samp_font5_draw_dispatch(void *textdraw); +static void PROBE_THISCALL hook_samp_trailer_sync_apply( + void *remote_player, const probe_samp_trailer_sync_r5 *sync); +static int install_samp_trailer_sync_code_hook(int log_summary); +static void flush_trailer_sync_trace_ring(void); +static BYTE PROBE_THISCALL hook_gta_trailer_set_tow_link( + void *trailer, void *tractor, DWORD set_my_pos_raw); +static void PROBE_THISCALL hook_gta_trailer_process_control(void *trailer); +static int PROBE_THISCALL hook_samp_vehicle_pool_new( + void *vehicle_pool, const void *vehicle_info); +static void PROBE_THISCALL hook_samp_player_ped_put_direct( + void *player_ped, DWORD gta_vehicle_ref, DWORD seat); +static int install_samp_vehicle_lifecycle_hooks(int log_summary); +static void uninstall_samp_vehicle_lifecycle_hooks(void); +static void flush_vehicle_lifecycle_trace_ring(void); +static void WINAPI hook_samp_aim_context_install(DWORD player_index); +static void __cdecl hook_samp_aim_context_restore(void); +static void PROBE_THISCALL hook_samp_player_ped_set_keys( + void *player_ped, DWORD keys, DWORD left_right, DWORD up_down); +static void PROBE_THISCALL hook_samp_player_ped_set_shot_context( + void *player_ped, const void *shot_context); +static int PROBE_THISCALL hook_samp_player_ped_fire_remote(void *player_ped); +static void PROBE_THISCALL hook_samp_player_ped_start_jetpack( + void *player_ped); +static void PROBE_THISCALL hook_samp_player_ped_stop_jetpack( + void *player_ped); +static int PROBE_THISCALL hook_samp_player_ped_is_in_jetpack( + void *player_ped); +static int install_samp_aim_bullet_jetpack_hooks(int log_summary); +static void uninstall_samp_aim_bullet_jetpack_hooks(void); +static void flush_aim_bullet_jetpack_trace_ring(void); +static int install_gta_trailer_physics_hooks(int log_summary); +static void uninstall_gta_trailer_physics_hooks(void); +static void flush_trailer_physics_trace_ring(void); static void __cdecl hook_samp_actor_show(probe_samp_rpc_parameters_prefix *rpc); static void __cdecl hook_samp_actor_hide(probe_samp_rpc_parameters_prefix *rpc); static void __cdecl hook_samp_actor_apply_animation(probe_samp_rpc_parameters_prefix *rpc); @@ -643,8 +1069,8 @@ static void __cdecl hook_samp_rpc_edit_attached_object(probe_samp_rpc_parameters static void __cdecl hook_samp_rpc_edit_object(probe_samp_rpc_parameters_prefix *rpc); static BYTE PROBE_THISCALL hook_rakclient_rpc_bitstream( void *rakclient, const BYTE *rpc_id_ptr, probe_raknet_bitstream_prefix *bitstream, - int priority, int reliability, char ordering_channel, BYTE shift_timestamp, - probe_raknet_network_id network_id, probe_raknet_bitstream_prefix *reply_from_target); + int priority, int reliability, char ordering_channel, + BYTE shift_timestamp); static int install_dialog_menu_rpc_hook(int log_summary); static void __cdecl hook_samp_remove_object(void *entity); static void __cdecl hook_samp_remove_static(void *entity); @@ -862,6 +1288,113 @@ static probe_code_hook g_samp_code_hooks[] = { {0x6a, 0xff, 0x68}, 3, NULL, NULL, {0}, 0, 0}, }; +static probe_code_hook g_samp_trailer_sync_code_hook = { + /* STATIC_037: + * Original R5 samp.dll SHA256= + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + * samp.dll+0x15C90 is the sole Packet-210 apply routine. It is a thiscall + * with one packed 54-byte TrailerSync pointer argument and returns with + * `ret 4`. The eight expected bytes contain three complete instructions + * and are relocation-free, so the generic trampoline is safe here. */ + "samp.RemotePlayer.ApplyTrailerSync", + PROBE_SAMP_R5_TRAILER_SYNC_APPLY_RVA, + (void *)hook_samp_trailer_sync_apply, + &g_orig_samp_trailer_sync_apply, + {0x83, 0xec, 0x4c, 0x53, 0x8b, 0x5c, 0x24, 0x54}, + 8, + NULL, + NULL, + {0}, + 0, + 0}; + +static probe_code_hook g_samp_vehicle_lifecycle_code_hooks[] = { + /* + * STATIC_037: + * Original R5 samp.dll SHA256= + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + * These entry spans contain only complete relocation-free instructions. + * install_samp_vehicle_lifecycle_hooks() also validates every known + * return tail before changing either target. + */ + {"samp.VehiclePool.New", PROBE_SAMP_R5_VEHICLE_POOL_NEW_RVA, + (void *)hook_samp_vehicle_pool_new, &g_orig_samp_vehicle_pool_new, + {0x56, 0x8b, 0x74, 0x24, 0x08}, 5, NULL, NULL, {0}, 0, 0}, + {"samp.CPlayerPed.PutDirectlyInVehicle", + PROBE_SAMP_R5_PLAYER_PED_PUT_DIRECTLY_IN_VEHICLE_RVA, + (void *)hook_samp_player_ped_put_direct, + &g_orig_samp_player_ped_put_direct, + {0x53, 0x8b, 0xd9, 0x8b, 0x83, 0xa4, 0x02, 0x00, 0x00}, 9, + NULL, NULL, {0}, 0, 0}, +}; + +static probe_code_hook g_samp_aim_bullet_jetpack_code_hooks[] = { + /* + * STATIC_037 + TODO_VERIFY: + * Original R5 samp.dll SHA256= + * b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + * Every entry span below is exactly the complete-instruction patch span + * selected by calculate_patch_length(). None contains a relative branch + * or call. AimContext.Restore contains one PE HIGHLOW operand at relocation + * RVA +0x9C964; preflight_samp_aim_bullet_jetpack_hooks() normalizes it to + * the loaded R5 base and additionally checks every return/tail form before + * this all-or-nothing set is installed. + * + * ABI summary from the same image: + * +0x9C9C0 WINAPI/stdcall(player_index), `ret 4`; + * +0x9C960 cdecl(), `ret`; + * +0xAF340 thiscall(CPlayerPed*, keys, LR, UD), `ret 0x0C`; + * +0xAF280 thiscall(CPlayerPed*, shot*), `ret 4` or tail jump; + * +0xAFA70 thiscall(CPlayerPed*), int return; + * +0xACD10/+0xACD60 thiscall(CPlayerPed*), void return; + * +0xACDC0 thiscall(CPlayerPed*), int return. + */ + {"samp.AimContext.Install", PROBE_SAMP_R5_AIM_CONTEXT_INSTALL_RVA, + (void *)hook_samp_aim_context_install, + &g_orig_samp_aim_context_install, + {0x8b, 0x44, 0x24, 0x04, 0x56}, 5, + NULL, NULL, {0}, 0, 0}, + {"samp.AimContext.Restore", PROBE_SAMP_R5_AIM_CONTEXT_RESTORE_RVA, + (void *)hook_samp_aim_context_restore, + &g_orig_samp_aim_context_restore, + {0x56, 0x57, 0x8b, 0x3d, 0xc8, 0x39, 0x10, 0x10}, 8, + NULL, NULL, {0}, 0, 0}, + {"samp.CPlayerPed.SetKeys", PROBE_SAMP_R5_PLAYER_PED_SET_KEYS_RVA, + (void *)hook_samp_player_ped_set_keys, + &g_orig_samp_player_ped_set_keys, + {0x53, 0x55, 0x8b, 0xe9, 0x0f, 0xb6, 0x85, 0xb0, 0x02, 0x00, 0x00}, + 11, NULL, NULL, {0}, 0, 0}, + {"samp.CPlayerPed.SetShotContext", + PROBE_SAMP_R5_PLAYER_PED_SET_SHOT_CONTEXT_RVA, + (void *)hook_samp_player_ped_set_shot_context, + &g_orig_samp_player_ped_set_shot_context, + {0x8b, 0x44, 0x24, 0x04, 0x85, 0xc0}, 6, + NULL, NULL, {0}, 0, 0}, + {"samp.CPlayerPed.FireRemote", PROBE_SAMP_R5_PLAYER_PED_FIRE_REMOTE_RVA, + (void *)hook_samp_player_ped_fire_remote, + &g_orig_samp_player_ped_fire_remote, + {0x83, 0xec, 0x20, 0x56, 0x8b, 0xf1}, 6, + NULL, NULL, {0}, 0, 0}, + {"samp.CPlayerPed.StartJetpack", + PROBE_SAMP_R5_PLAYER_PED_START_JETPACK_RVA, + (void *)hook_samp_player_ped_start_jetpack, + &g_orig_samp_player_ped_start_jetpack, + {0x8b, 0x81, 0xa4, 0x02, 0x00, 0x00}, 6, + NULL, NULL, {0}, 0, 0}, + {"samp.CPlayerPed.StopJetpack", + PROBE_SAMP_R5_PLAYER_PED_STOP_JETPACK_RVA, + (void *)hook_samp_player_ped_stop_jetpack, + &g_orig_samp_player_ped_stop_jetpack, + {0x55, 0x8b, 0xec, 0x51, 0x56}, 5, + NULL, NULL, {0}, 0, 0}, + {"samp.CPlayerPed.IsInJetpackMode", + PROBE_SAMP_R5_PLAYER_PED_IS_IN_JETPACK_RVA, + (void *)hook_samp_player_ped_is_in_jetpack, + &g_orig_samp_player_ped_is_in_jetpack, + {0x8b, 0x81, 0xa4, 0x02, 0x00, 0x00}, 6, + NULL, NULL, {0}, 0, 0}, +}; + static probe_code_hook g_samp_font5_code_hooks[] = { /* STATIC_037: * Original samp.dll SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. @@ -1369,6 +1902,81 @@ static int dialog_menu_rpc_hooks_enabled(void) { return env_or_flag_enabled("SAMP_PROBE_DIALOG_MENU_RPC_HOOKS", PROBE_DIALOG_MENU_RPC_HOOKS_FLAG); } +static int trailer_sync_hooks_enabled(void) { + /* STATIC_037: + * This focused mode patches only the exact R5 Packet-210 apply entry and + * buffers raw pre/post state in memory. The worker flushes it later so the + * network/game thread never performs probe file I/O. The trailer-physics + * profile implies this trace where the loaded samp.dll has the exact R5 + * identity, giving SetTowLink/ProcessControl/Packet210 one event clock. */ + return env_or_flag_enabled("SAMP_PROBE_TRAILER_SYNC_HOOKS", + PROBE_TRAILER_SYNC_HOOKS_FLAG) || + trailer_physics_hooks_enabled(); +} + +static int trailer_physics_hooks_enabled(void) { + /* GTA_REVERSED_REF + STATIC_037 + TODO_VERIFY: + * This profile patches only exact GTA-SA 1.0 US CTrailer entry/tail bytes. + * Hooks write bounded snapshots to fixed storage and never perform I/O. */ + return env_or_flag_enabled("SAMP_PROBE_TRAILER_PHYSICS_HOOKS", + PROBE_TRAILER_PHYSICS_HOOKS_FLAG); +} + +static int vehicle_lifecycle_hooks_enabled(void) { + /* + * STATIC_037 + TODO_VERIFY: + * Focused original-R5 vehicle creation and seating snapshots use a + * dedicated profile so no trailer, Winsock, render or unrelated RPC hooks + * perturb the calls being measured. + */ + return env_or_flag_enabled("SAMP_PROBE_VEHICLE_LIFECYCLE_HOOKS", + PROBE_VEHICLE_LIFECYCLE_HOOKS_FLAG); +} + +static int aim_bullet_jetpack_hooks_enabled(void) { + /* + * STATIC_037 + TODO_VERIFY: + * Focused original-R5 aim, shot-dispatch, and jetpack lifecycle hooks use + * only exact byte/ABI-validated entries. Hook threads publish to a fixed + * ring and never write the log directly. + */ + return env_or_flag_enabled("SAMP_PROBE_AIM_BULLET_JETPACK_HOOKS", + PROBE_AIM_BULLET_JETPACK_HOOKS_FLAG); +} + +static int death_cleanup_hooks_enabled(void) { + /* + * STATIC_037 + TODO_VERIFY: + * Focused R5 death/respawn/F4 and GMX/reconnect/quit snapshots use exact + * byte- and ABI-guarded entries. Hook threads publish to a fixed ring; + * only the existing worker writes the log. + */ + return env_or_flag_enabled("SAMP_PROBE_DEATH_CLEANUP_HOOKS", + PROBE_DEATH_CLEANUP_HOOKS_FLAG); +} + +static int pickup_hooks_enabled(void) { + /* + * STATIC_037 + TODO_VERIFY: + * Focused original-R5 pickup collection and pool cadence snapshots use + * exact entry/tail guards. Hook threads and the shared outgoing-RPC hook + * publish to a bounded ring; only the worker writes the log. + */ + return env_or_flag_enabled("SAMP_PROBE_PICKUP_HOOKS", + PROBE_PICKUP_HOOKS_FLAG); +} + +static int ui_latches_hooks_enabled(void) { + /* + * STATIC_037 + TODO_VERIFY: + * Focused original-R5 AFK/menu/TAB/chat/cursor edge snapshots use only + * identity- and byte-guarded entries. Hook threads publish to a fixed ring; + * only the existing worker writes the log. + */ + return env_or_flag_enabled("SAMP_PROBE_UI_LATCHES_HOOKS", + PROBE_UI_LATCHES_HOOKS_FLAG); +} + PROBE_ALWAYS_INLINE void *probe_return_address(void) { return __builtin_return_address(0); } @@ -3173,238 +3781,3185 @@ static int samp_r5_identity_matches(void) { nt->OptionalHeader.SizeOfImage == PROBE_SAMP_R5_IMAGE_SIZE; } -static BYTE PROBE_THISCALL hook_rakclient_rpc_bitstream( - void *rakclient, const BYTE *rpc_id_ptr, probe_raknet_bitstream_prefix *bitstream, - int priority, int reliability, char ordering_channel, BYTE shift_timestamp, - probe_raknet_network_id network_id, probe_raknet_bitstream_prefix *reply_from_target) { - BYTE result = 0; - BYTE rpc_id = 0xffu; - int bits = -1; - int bytes = 0; - const BYTE *data = NULL; - char payload[PROBE_PAYLOAD_PREVIEW_BYTES * 3 + 8]; - char command[129]; - void *caller = probe_return_address(); - int focused; +static int samp_r5_relocation_identity_matches(void) { + PIMAGE_NT_HEADERS nt = get_samp_nt_headers(); - if (memory_is_readable((uintptr_t)rpc_id_ptr, sizeof(*rpc_id_ptr))) { - rpc_id = *rpc_id_ptr; + /* + * STATIC_037: + * Relocation-normalized hook profiles additionally require the analyzed + * R5 image's i386 preferred-base header and a non-empty base-relocation + * directory. This validates the loader contract without requiring the + * discardable .reloc contents to remain resident after process startup. + */ + return samp_r5_identity_matches() && nt != NULL && + nt->FileHeader.Machine == IMAGE_FILE_MACHINE_I386 && + (nt->FileHeader.Characteristics & IMAGE_FILE_RELOCS_STRIPPED) == 0 && + nt->OptionalHeader.ImageBase == + PROBE_SAMP_R5_PREFERRED_IMAGE_BASE && + nt->OptionalHeader.NumberOfRvaAndSizes > + IMAGE_DIRECTORY_ENTRY_BASERELOC && + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC] + .VirtualAddress != 0u && + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC] + .Size != 0u; +} + +static PIMAGE_NT_HEADERS get_gta_us10_nt_headers(void) { + HMODULE module = GetModuleHandleA(NULL); + PIMAGE_DOS_HEADER dos; + PIMAGE_NT_HEADERS nt; + uintptr_t base = (uintptr_t)module; + + if (base != PROBE_GTA_US10_IMAGE_BASE || + !memory_is_readable(base, sizeof(IMAGE_DOS_HEADER))) { + return NULL; + } + dos = (PIMAGE_DOS_HEADER)module; + if (dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew <= 0 || + (DWORD)dos->e_lfanew > PROBE_GTA_US10_IMAGE_SIZE - sizeof(IMAGE_NT_HEADERS) || + !memory_is_readable(base + (DWORD)dos->e_lfanew, + sizeof(IMAGE_NT_HEADERS))) { + return NULL; + } + nt = (PIMAGE_NT_HEADERS)(base + (DWORD)dos->e_lfanew); + if (nt->Signature != IMAGE_NT_SIGNATURE || + nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR32_MAGIC) { + return NULL; } - focused = rpc_id == PROBE_SERVER_COMMAND_RPC || rpc_id == PROBE_DIALOG_RESPONSE_RPC || - rpc_id == PROBE_MENU_SELECT_RPC || rpc_id == PROBE_MENU_QUIT_RPC; + return nt; +} - payload[0] = '\0'; - command[0] = '\0'; - if (focused) { - if (bitstream == NULL) { - bits = 0; - } else if (memory_is_readable((uintptr_t)bitstream, sizeof(*bitstream))) { - bits = bitstream->number_of_bits_used; - data = bitstream->data; - if (bits >= 0 && (unsigned)bits <= PROBE_DIALOG_MENU_RPC_MAX_BITS) { - bytes = (bits + 7) / 8; - payload_to_hex(data, bytes, payload, sizeof(payload)); - } else { - snprintf(payload, sizeof(payload), "invalid_bits"); - } - } else { - snprintf(payload, sizeof(payload), "unreadable_bitstream"); - } +static int gta_us10_identity_matches(void) { + PIMAGE_NT_HEADERS nt = get_gta_us10_nt_headers(); - if (rpc_id == PROBE_SERVER_COMMAND_RPC && bytes >= 4 && - memory_is_readable((uintptr_t)data, (size_t)bytes)) { - int command_length; - size_t preview_length; - size_t i; - memcpy(&command_length, data, sizeof(command_length)); - preview_length = command_length > 0 ? (size_t)command_length : 0u; - if (preview_length > (size_t)(bytes - 4)) { - preview_length = (size_t)(bytes - 4); - } - if (preview_length > sizeof(command) - 1) { - preview_length = sizeof(command) - 1; - } - memcpy(command, data + 4, preview_length); - command[preview_length] = '\0'; - for (i = 0; i < preview_length; ++i) { - if ((unsigned char)command[i] < 0x20u || (unsigned char)command[i] > 0x7eu) { - command[i] = '.'; - } - } - probe_log("dialog_menu_rpc: before rpc=%u name=ServerCommand caller=%p caller_samp_rva=0x%08lx " - "bits=%d bytes=%d command_length=%d command='%s' priority=%d reliability=%d " - "channel=%d shift_timestamp=%u payload='%s' rakclient=%p bitstream=%p " - "network_local=%u reply=%p evidence=OBSERVED_037,PROBE_TRACE,TODO_VERIFY", - (unsigned)rpc_id, caller, samp_rva_from_address(caller), bits, bytes, - command_length, command, priority, reliability, (int)ordering_channel, - (unsigned)shift_timestamp, payload, rakclient, bitstream, - (unsigned)network_id.local_system_id, reply_from_target); - } else if (rpc_id == PROBE_DIALOG_RESPONSE_RPC && bytes >= 6 && - memory_is_readable((uintptr_t)data, (size_t)bytes)) { - short dialog_id; - short list_item; - unsigned input_length = data[5]; - memcpy(&dialog_id, data, sizeof(dialog_id)); - memcpy(&list_item, data + 3, sizeof(list_item)); - probe_log("dialog_menu_rpc: before rpc=%u name=DialogResponse caller=%p caller_samp_rva=0x%08lx " - "bits=%d bytes=%d dialog=%d response=%u list_item=%d input_length=%u " - "priority=%d reliability=%d channel=%d shift_timestamp=%u payload='%s' " - "rakclient=%p bitstream=%p network_local=%u reply=%p " - "evidence=OBSERVED_037,PROBE_TRACE,TODO_VERIFY", - (unsigned)rpc_id, caller, samp_rva_from_address(caller), bits, bytes, - (int)dialog_id, (unsigned)data[2], (int)list_item, input_length, - priority, reliability, (int)ordering_channel, (unsigned)shift_timestamp, - payload, rakclient, bitstream, (unsigned)network_id.local_system_id, - reply_from_target); - } else if (rpc_id == PROBE_MENU_SELECT_RPC && bytes >= 1 && - memory_is_readable((uintptr_t)data, 1)) { - probe_log("dialog_menu_rpc: before rpc=%u name=MenuSelect caller=%p caller_samp_rva=0x%08lx " - "bits=%d bytes=%d row=%u priority=%d reliability=%d channel=%d " - "shift_timestamp=%u payload='%s' rakclient=%p bitstream=%p " - "network_local=%u reply=%p evidence=OBSERVED_037,PROBE_TRACE,TODO_VERIFY", - (unsigned)rpc_id, caller, samp_rva_from_address(caller), bits, bytes, - (unsigned)data[0], priority, reliability, (int)ordering_channel, - (unsigned)shift_timestamp, payload, rakclient, bitstream, - (unsigned)network_id.local_system_id, reply_from_target); - } else { - probe_log("dialog_menu_rpc: before rpc=%u name=%s caller=%p caller_samp_rva=0x%08lx " - "bits=%d bytes=%d priority=%d reliability=%d channel=%d shift_timestamp=%u " - "payload='%s' rakclient=%p bitstream=%p network_local=%u reply=%p " - "evidence=OBSERVED_037,PROBE_TRACE,TODO_VERIFY", - (unsigned)rpc_id, rpc_id == PROBE_MENU_QUIT_RPC ? "MenuQuit" : "focused_invalid", - caller, samp_rva_from_address(caller), bits, bytes, priority, reliability, - (int)ordering_channel, (unsigned)shift_timestamp, payload, rakclient, - bitstream, (unsigned)network_id.local_system_id, reply_from_target); - } + /* + * GTA_REVERSED_REF + STATIC_037: + * Supported executable SHA256= + * a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26. + * The image has relocations stripped, so every absolute address below also + * requires its preferred 0x00400000 mapping in addition to this PE proxy. + */ + return nt != NULL && + nt->FileHeader.Machine == IMAGE_FILE_MACHINE_I386 && + (nt->FileHeader.Characteristics & IMAGE_FILE_RELOCS_STRIPPED) != 0 && + nt->FileHeader.TimeDateStamp == PROBE_GTA_US10_TIMESTAMP && + nt->OptionalHeader.ImageBase == PROBE_GTA_US10_IMAGE_BASE && + nt->OptionalHeader.AddressOfEntryPoint == PROBE_GTA_US10_ENTRY_RVA && + nt->OptionalHeader.SizeOfImage == PROBE_GTA_US10_IMAGE_SIZE && + nt->OptionalHeader.CheckSum == PROBE_GTA_US10_CHECKSUM; +} + +static int probe_make_rel32(uintptr_t instruction, size_t instruction_size, + uintptr_t target, int32_t *out_rel) { + int64_t delta; + + if (out_rel == NULL || instruction > UINTPTR_MAX - instruction_size) { + return 0; + } + delta = (int64_t)(uint64_t)target - + (int64_t)(uint64_t)(instruction + instruction_size); + if (delta < INT32_MIN || delta > INT32_MAX) { + return 0; } + *out_rel = (int32_t)delta; + return 1; +} - if (g_orig_rakclient_rpc_bitstream != NULL) { - result = ((probe_rakclient_rpc_bitstream_fn)g_orig_rakclient_rpc_bitstream)( - rakclient, rpc_id_ptr, bitstream, priority, reliability, ordering_channel, - shift_timestamp, network_id, reply_from_target); +static int trailer_physics_patch_is_ours(uintptr_t target, size_t patch_len, + const void *replacement) { + int32_t rel = 0; + uintptr_t destination; + const BYTE *bytes = (const BYTE *)target; + size_t i; + + if (replacement == NULL || patch_len < 5u || + !memory_is_readable(target, patch_len) || bytes[0] != 0xe9) { + return 0; } - if (focused) { - probe_log("dialog_menu_rpc: after rpc=%u caller_samp_rva=0x%08lx result=%u " - "evidence=OBSERVED_037,PROBE_TRACE,TODO_VERIFY", - (unsigned)rpc_id, samp_rva_from_address(caller), (unsigned)result); + memcpy(&rel, bytes + 1, sizeof(rel)); + destination = target + 5u + (intptr_t)rel; + if (destination != (uintptr_t)replacement) { + return 0; } - return result; + for (i = 5u; i < patch_len; ++i) { + if (bytes[i] != 0x90u) { + return 0; + } + } + return 1; } -static int install_dialog_menu_rpc_hook(int log_summary) { - DWORD netgame_value; - DWORD rakclient_value; - void *rakclient; - void **vtable; - void **slot; - void *current; - DWORD old_protect; - DWORD restore_protect; +static int install_gta_trailer_physics_hooks(int log_summary) { +#if defined(_M_IX86) || defined(__i386__) + static const BYTE expected_set_entry[5] = { + 0x83, 0xec, 0x24, 0x56, 0x57}; + static const BYTE expected_set_success_tail[10] = { + 0x5f, 0xb0, 0x01, 0x5e, 0x83, 0xc4, 0x24, 0xc2, 0x08, 0x00}; + static const BYTE expected_set_failure_tail[10] = { + 0x5f, 0x32, 0xc0, 0x5e, 0x83, 0xc4, 0x24, 0xc2, 0x08, 0x00}; + static const BYTE expected_process_entry[8] = { + 0x56, 0x8b, 0xf1, 0xe8, 0x58, 0x2b, 0xfe, 0xff}; + static const BYTE expected_process_tail[14] = { + 0x89, 0x96, 0xe0, 0x09, 0x00, 0x00, 0x89, + 0x86, 0xe4, 0x09, 0x00, 0x00, 0x5e, 0xc3}; + const uintptr_t set_target = + PROBE_GTA_US10_CTRAILER_SET_TOW_LINK_ADDR; + const uintptr_t set_success_tail = 0x006cffc0u; + const uintptr_t set_failure_tail = 0x006cfe16u; + const uintptr_t process_target = + PROBE_GTA_US10_CTRAILER_PROCESS_CONTROL_ADDR; + const uintptr_t process_tail = 0x006cee41u; + BYTE *gateways = NULL; + BYTE set_patch[5]; + BYTE process_patch[8]; + int32_t rel_set_gateway_back; + int32_t rel_process_gateway_call; + int32_t rel_process_gateway_back; + int32_t rel_set_hook; + int32_t rel_process_hook; + DWORD set_old_protect = 0; + DWORD process_old_protect = 0; + DWORD ignored_protect = 0; + int set_unprotected = 0; + int process_unprotected = 0; + PIMAGE_NT_HEADERS nt; - if (!dialog_menu_rpc_hooks_enabled()) { + if (!trailer_physics_hooks_enabled()) { if (log_summary) { - probe_log("dialog_menu_rpc_hook: disabled; enable with SAMP_PROBE_DIALOG_MENU_RPC_HOOKS=1 or %s", - PROBE_DIALOG_MENU_RPC_HOOKS_FLAG); + probe_log("trailer_physics_hook: disabled by default; enable with " + "SAMP_PROBE_TRAILER_PHYSICS_HOOKS=1 or %s", + PROBE_TRAILER_PHYSICS_HOOKS_FLAG); } return 0; } - if (!samp_r5_identity_matches()) { - if (log_summary) { - probe_log("dialog_menu_rpc_hook: skip reason=unsupported_identity"); - } + if (InterlockedCompareExchange(&g_trailer_physics_hooks_installed, 0, 0) != + 0) { return 0; } - if (g_samp_base == 0 || PROBE_SAMP_R5_NETGAME_PTR_RVA > g_samp_size - sizeof(DWORD)) { + if (InterlockedCompareExchange(&g_trailer_physics_hook_attempted, 1, 0) != + 0) { return 0; } - netgame_value = read_u32_or(g_samp_base + PROBE_SAMP_R5_NETGAME_PTR_RVA, 0u); - if (netgame_value < 0x10000u || !memory_is_readable((uintptr_t)netgame_value, sizeof(DWORD))) { + + nt = get_gta_us10_nt_headers(); + if (!gta_us10_identity_matches()) { if (log_summary) { - probe_log("dialog_menu_rpc_hook: waiting reason=netgame_unavailable netgame=%p", - (void *)(uintptr_t)netgame_value); - } + probe_log( + "trailer_physics_hook: skip unsupported_identity base=0x%08lx " + "headers_valid=%d timestamp=0x%08lx entry=0x%08lx " + "image_size=0x%08lx checksum=0x%08lx relocs_stripped=%d " + "supported_sha256=" + "a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26 " + "evidence=GTA_REVERSED_REF,STATIC_037", + (unsigned long)(uintptr_t)GetModuleHandleA(NULL), nt != NULL, + (unsigned long)(nt != NULL ? nt->FileHeader.TimeDateStamp : 0u), + (unsigned long)(nt != NULL + ? nt->OptionalHeader.AddressOfEntryPoint + : 0u), + (unsigned long)(nt != NULL ? nt->OptionalHeader.SizeOfImage : 0u), + (unsigned long)(nt != NULL ? nt->OptionalHeader.CheckSum : 0u), + nt != NULL && + (nt->FileHeader.Characteristics & IMAGE_FILE_RELOCS_STRIPPED) != + 0); + } + InterlockedExchange(&g_trailer_physics_hook_attempted, -1); return 0; } - rakclient_value = read_u32_or((uintptr_t)netgame_value, 0u); - rakclient = (void *)(uintptr_t)rakclient_value; - if (rakclient_value < 0x10000u || !memory_is_readable((uintptr_t)rakclient, sizeof(void *))) { + + /* + * GTA_REVERSED_REF + STATIC_037: + * Preflight every entry and both SetTowLink/ProcessControl return paths + * before mutating either function. Exact-byte matching also refuses an + * existing third-party E9 rather than chasing or overwriting it. + */ + if (!memory_is_readable(set_target, sizeof(expected_set_entry)) || + !memory_is_readable(set_success_tail, + sizeof(expected_set_success_tail)) || + !memory_is_readable(set_failure_tail, + sizeof(expected_set_failure_tail)) || + !memory_is_readable(process_target, sizeof(expected_process_entry)) || + !memory_is_readable(process_tail, sizeof(expected_process_tail)) || + memcmp((const void *)set_target, expected_set_entry, + sizeof(expected_set_entry)) != 0 || + memcmp((const void *)set_success_tail, expected_set_success_tail, + sizeof(expected_set_success_tail)) != 0 || + memcmp((const void *)set_failure_tail, expected_set_failure_tail, + sizeof(expected_set_failure_tail)) != 0 || + memcmp((const void *)process_target, expected_process_entry, + sizeof(expected_process_entry)) != 0 || + memcmp((const void *)process_tail, expected_process_tail, + sizeof(expected_process_tail)) != 0) { if (log_summary) { - probe_log("dialog_menu_rpc_hook: waiting reason=rakclient_unavailable netgame=%p rakclient=%p", - (void *)(uintptr_t)netgame_value, rakclient); - } + probe_log( + "trailer_physics_hook: skip exact_bytes_mismatch " + "set=0x%08lx set_success=0x%08lx set_failure=0x%08lx " + "process=0x%08lx process_tail=0x%08lx installed=0 " + "evidence=GTA_REVERSED_REF,STATIC_037", + (unsigned long)set_target, (unsigned long)set_success_tail, + (unsigned long)set_failure_tail, (unsigned long)process_target, + (unsigned long)process_tail); + } + InterlockedExchange(&g_trailer_physics_hook_attempted, -1); return 0; } - vtable = *(void ***)rakclient; - if (vtable == NULL || !memory_is_readable((uintptr_t)&vtable[PROBE_RAKCLIENT_RPC_BITSTREAM_VTBL_INDEX], - sizeof(void *))) { + + gateways = (BYTE *)VirtualAlloc(NULL, 32u, MEM_COMMIT | MEM_RESERVE, + PAGE_READWRITE); + if (gateways == NULL) { + if (log_summary) { + probe_log("trailer_physics_hook: gateway_alloc_failed error=%lu", + (unsigned long)GetLastError()); + } + InterlockedExchange(&g_trailer_physics_hook_attempted, -1); return 0; } - slot = &vtable[PROBE_RAKCLIENT_RPC_BITSTREAM_VTBL_INDEX]; - current = *slot; - if (current == (void *)hook_rakclient_rpc_bitstream) { - return 1; - } - if (!address_in_samp(current)) { + + /* + * SetTowLink starts with three complete relocation-free instructions, so + * its ten-byte gateway can replay those five bytes then jump to +5. + */ + memcpy(gateways, expected_set_entry, sizeof(expected_set_entry)); + gateways[5] = 0xe9; + /* + * ProcessControl starts `push esi; mov esi,ecx; call rel32`. Copying that + * rel32 through the generic trampoline would retarget the call incorrectly. + * Build the exact 13-byte gateway explicitly: + * 56 8b f1 + * e8 + * e9 + */ + gateways[16] = 0x56; + gateways[17] = 0x8b; + gateways[18] = 0xf1; + gateways[19] = 0xe8; + gateways[24] = 0xe9; + + if (!probe_make_rel32((uintptr_t)gateways + 5u, 5u, set_target + 5u, + &rel_set_gateway_back) || + !probe_make_rel32((uintptr_t)gateways + 19u, 5u, + PROBE_GTA_US10_CAUTOMOBILE_PROCESS_CONTROL_ADDR, + &rel_process_gateway_call) || + !probe_make_rel32((uintptr_t)gateways + 24u, 5u, process_target + 8u, + &rel_process_gateway_back) || + !probe_make_rel32(set_target, 5u, + (uintptr_t)(void *)hook_gta_trailer_set_tow_link, + &rel_set_hook) || + !probe_make_rel32(process_target, 5u, + (uintptr_t)(void *)hook_gta_trailer_process_control, + &rel_process_hook)) { if (log_summary) { - probe_log("dialog_menu_rpc_hook: skip reason=slot_target_outside_samp rakclient=%p vtable=%p " - "index=%u target=%p", - rakclient, vtable, (unsigned)PROBE_RAKCLIENT_RPC_BITSTREAM_VTBL_INDEX, current); + probe_log("trailer_physics_hook: rel32_out_of_range installed=0"); } + (void)VirtualFree(gateways, 0, MEM_RELEASE); + InterlockedExchange(&g_trailer_physics_hook_attempted, -1); return 0; } - if (g_orig_rakclient_rpc_bitstream != NULL && current != g_orig_rakclient_rpc_bitstream) { + memcpy(gateways + 6, &rel_set_gateway_back, + sizeof(rel_set_gateway_back)); + memcpy(gateways + 20, &rel_process_gateway_call, + sizeof(rel_process_gateway_call)); + memcpy(gateways + 25, &rel_process_gateway_back, + sizeof(rel_process_gateway_back)); + FlushInstructionCache(GetCurrentProcess(), gateways, 32u); + if (!VirtualProtect(gateways, 32u, PAGE_EXECUTE_READ, &ignored_protect)) { if (log_summary) { - probe_log("dialog_menu_rpc_hook: skip reason=vtable_changed rakclient=%p current=%p original=%p", - rakclient, current, g_orig_rakclient_rpc_bitstream); + probe_log("trailer_physics_hook: gateway_protect_failed error=%lu", + (unsigned long)GetLastError()); } + (void)VirtualFree(gateways, 0, MEM_RELEASE); + InterlockedExchange(&g_trailer_physics_hook_attempted, -1); return 0; } - if (!VirtualProtect(slot, sizeof(void *), PAGE_EXECUTE_READWRITE, &old_protect)) { + + memset(set_patch, 0x90, sizeof(set_patch)); + set_patch[0] = 0xe9; + memcpy(set_patch + 1, &rel_set_hook, sizeof(rel_set_hook)); + memset(process_patch, 0x90, sizeof(process_patch)); + process_patch[0] = 0xe9; + memcpy(process_patch + 1, &rel_process_hook, sizeof(rel_process_hook)); + + if (!VirtualProtect((void *)set_target, sizeof(set_patch), + PAGE_EXECUTE_READWRITE, &set_old_protect)) { if (log_summary) { - probe_log("dialog_menu_rpc_hook: VirtualProtect failed slot=%p err=%lu", slot, + probe_log("trailer_physics_hook: set_VirtualProtect_failed error=%lu", (unsigned long)GetLastError()); } + (void)VirtualFree(gateways, 0, MEM_RELEASE); + InterlockedExchange(&g_trailer_physics_hook_attempted, -1); return 0; } - g_orig_rakclient_rpc_bitstream = current; - *slot = (void *)hook_rakclient_rpc_bitstream; - FlushInstructionCache(GetCurrentProcess(), slot, sizeof(void *)); - (void)VirtualProtect(slot, sizeof(void *), old_protect, &restore_protect); - probe_log("dialog_menu_rpc_hook: installed netgame=%p rakclient=%p vtable=%p index=%u " - "slot=%p original=%p original_samp_rva=0x%08lx replacement=%p " - "evidence=INFERRED,PROBE_TRACE,TODO_VERIFY", - (void *)(uintptr_t)netgame_value, rakclient, vtable, - (unsigned)PROBE_RAKCLIENT_RPC_BITSTREAM_VTBL_INDEX, slot, current, - samp_rva_from_address(current), (void *)hook_rakclient_rpc_bitstream); - return 1; -} + set_unprotected = 1; + if (!VirtualProtect((void *)process_target, sizeof(process_patch), + PAGE_EXECUTE_READWRITE, &process_old_protect)) { + if (log_summary) { + probe_log( + "trailer_physics_hook: process_VirtualProtect_failed error=%lu " + "installed=0", + (unsigned long)GetLastError()); + } + (void)VirtualProtect((void *)set_target, sizeof(set_patch), + set_old_protect, &ignored_protect); + (void)VirtualFree(gateways, 0, MEM_RELEASE); + InterlockedExchange(&g_trailer_physics_hook_attempted, -1); + return 0; + } + process_unprotected = 1; -static int preflight_samp_font5_code_hooks(void) { - size_t i; + memcpy(g_trailer_physics_set_tow_link_saved, (const void *)set_target, + sizeof(g_trailer_physics_set_tow_link_saved)); + memcpy(g_trailer_physics_process_control_saved, + (const void *)process_target, + sizeof(g_trailer_physics_process_control_saved)); + g_orig_gta_trailer_set_tow_link = gateways; + g_orig_gta_trailer_process_control = gateways + 16u; + g_trailer_physics_gateway_allocation = gateways; + MemoryBarrier(); - /* STATIC_037 + TODO_VERIFY: - * Treat the two Font 5 hooks as one guarded probe. Validate every target, - * exact overwritten prologue, and decoded patch length before the first - * target is mutated. This avoids leaving only the prepare hook installed - * when the dispatch site is already owned by another module. */ - for (i = 0; i < sizeof(g_samp_font5_code_hooks) / sizeof(g_samp_font5_code_hooks[0]); ++i) { - probe_code_hook *hook = &g_samp_font5_code_hooks[i]; - void *patch_target; - size_t readable_len; - size_t patch_len; + /* + * Patch ProcessControl first. Until SetTowLink is patched there are no + * armed probe slots, so its hook immediately executes the dedicated + * gateway. Both target pages were made writable before either mutation. + */ + memcpy((void *)process_target, process_patch, sizeof(process_patch)); + FlushInstructionCache(GetCurrentProcess(), (const void *)process_target, + sizeof(process_patch)); + memcpy((void *)set_target, set_patch, sizeof(set_patch)); + FlushInstructionCache(GetCurrentProcess(), (const void *)set_target, + sizeof(set_patch)); - if (hook->expected_len < 5 || hook->expected_len > sizeof(hook->expected) || - hook->expected_len > PROBE_INLINE_HOOK_MAX_COPY) { - probe_log("font5_code_hook: pair_preflight_failed name=%s rva=0x%08lx reason=invalid_expected_len len=%lu", - hook->name, (unsigned long)hook->rva, (unsigned long)hook->expected_len); - return 0; - } + if (process_unprotected) { + (void)VirtualProtect((void *)process_target, sizeof(process_patch), + process_old_protect, &ignored_protect); + } + if (set_unprotected) { + (void)VirtualProtect((void *)set_target, sizeof(set_patch), + set_old_protect, &ignored_protect); + } + InterlockedExchange(&g_trailer_physics_hooks_installed, 1); - readable_len = hook->expected_len > 8 ? hook->expected_len : 8; - if (g_samp_base == 0 || g_samp_size < readable_len || hook->rva >= g_samp_size || - hook->rva > g_samp_size - readable_len) { - probe_log("font5_code_hook: pair_preflight_failed name=%s rva=0x%08lx reason=target_bounds", - hook->name, (unsigned long)hook->rva); + if (log_summary) { + probe_log( + "trailer_physics_hook: summary installed=2 requested=2 " + "set_addr=0x%08lx set_patch=e9_rel32 restore=83ec245657 " + "process_addr=0x%08lx process_patch=e9_rel32_909090 " + "process_gateway=568bf1_e8_base_e9_plus8 frame_limit=%u " + "track_slots=%u ring=%u supported_sha256=" + "a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26 " + "evidence=GTA_REVERSED_REF,STATIC_037,TODO_VERIFY", + (unsigned long)set_target, (unsigned long)process_target, + (unsigned)PROBE_TRAILER_PHYSICS_FRAME_LIMIT, + (unsigned)PROBE_TRAILER_PHYSICS_TRACK_SLOTS, + (unsigned)PROBE_TRAILER_PHYSICS_TRACE_RING); + } + return 2; +#else + if (log_summary && trailer_physics_hooks_enabled()) { + probe_log("trailer_physics_hook: skip unsupported_arch installed=0"); + } + InterlockedExchange(&g_trailer_physics_hook_attempted, -1); + return 0; +#endif +} + +static void uninstall_gta_trailer_physics_hooks(void) { +#if defined(_M_IX86) || defined(__i386__) + const uintptr_t set_target = + PROBE_GTA_US10_CTRAILER_SET_TOW_LINK_ADDR; + const uintptr_t process_target = + PROBE_GTA_US10_CTRAILER_PROCESS_CONTROL_ADDR; + DWORD old_protect = 0; + DWORD ignored_protect = 0; + int set_ours; + int process_ours; + + if (InterlockedCompareExchange(&g_trailer_physics_hooks_installed, 0, 0) == + 0) { + return; + } + set_ours = trailer_physics_patch_is_ours( + set_target, sizeof(g_trailer_physics_set_tow_link_saved), + (const void *)hook_gta_trailer_set_tow_link); + process_ours = trailer_physics_patch_is_ours( + process_target, sizeof(g_trailer_physics_process_control_saved), + (const void *)hook_gta_trailer_process_control); + + if (process_ours && + VirtualProtect((void *)process_target, + sizeof(g_trailer_physics_process_control_saved), + PAGE_EXECUTE_READWRITE, &old_protect)) { + memcpy((void *)process_target, + g_trailer_physics_process_control_saved, + sizeof(g_trailer_physics_process_control_saved)); + FlushInstructionCache( + GetCurrentProcess(), (const void *)process_target, + sizeof(g_trailer_physics_process_control_saved)); + (void)VirtualProtect( + (void *)process_target, + sizeof(g_trailer_physics_process_control_saved), old_protect, + &ignored_protect); + } + if (set_ours && + VirtualProtect((void *)set_target, + sizeof(g_trailer_physics_set_tow_link_saved), + PAGE_EXECUTE_READWRITE, &old_protect)) { + memcpy((void *)set_target, g_trailer_physics_set_tow_link_saved, + sizeof(g_trailer_physics_set_tow_link_saved)); + FlushInstructionCache( + GetCurrentProcess(), (const void *)set_target, + sizeof(g_trailer_physics_set_tow_link_saved)); + (void)VirtualProtect( + (void *)set_target, sizeof(g_trailer_physics_set_tow_link_saved), + old_protect, &ignored_protect); + } + probe_log( + "trailer_physics_hook: restore set_owned=%d process_owned=%d " + "set_restored=%d process_restored=%d gateway_lifetime=process", + set_ours, process_ours, + set_ours && + memcmp((const void *)set_target, + g_trailer_physics_set_tow_link_saved, + sizeof(g_trailer_physics_set_tow_link_saved)) == 0, + process_ours && + memcmp((const void *)process_target, + g_trailer_physics_process_control_saved, + sizeof(g_trailer_physics_process_control_saved)) == 0); + InterlockedExchange(&g_trailer_physics_hooks_installed, 0); +#endif +} + +static int install_samp_trailer_sync_code_hook(int log_summary) { + static const BYTE expected_epilogue[] = { + 0x5f, 0x5e, 0x5d, 0x5b, 0x83, 0xc4, 0x4c, 0xc2, 0x04, 0x00}; + const DWORD epilogue_rva = PROBE_SAMP_R5_TRAILER_SYNC_APPLY_RVA + 0x303u; + int installed; + + if (!trailer_sync_hooks_enabled()) { + if (log_summary) { + probe_log("trailer_sync_code_hook: disabled by default; enable with " + "SAMP_PROBE_TRAILER_SYNC_HOOKS=1 or %s", + PROBE_TRAILER_SYNC_HOOKS_FLAG); + } + return 0; + } + if (env_flag_enabled("SAMP_PROBE_NO_SAMP_CODE_HOOKS")) { + if (log_summary) { + probe_log("trailer_sync_code_hook: disabled by SAMP_PROBE_NO_SAMP_CODE_HOOKS"); + } + return 0; + } + if (!samp_r5_identity_matches()) { + if (log_summary) { + PIMAGE_NT_HEADERS actual_nt = get_samp_nt_headers(); + probe_log("trailer_sync_code_hook: skip unsupported_identity " + "headers_valid=%d timestamp=0x%08lx entry=0x%08lx " + "header_size=0x%08lx module_size=0x%08lx supported_sha256=" + "b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2 " + "evidence=STATIC_037", + actual_nt != NULL, + (unsigned long)(actual_nt != NULL ? actual_nt->FileHeader.TimeDateStamp : 0u), + (unsigned long)(actual_nt != NULL ? actual_nt->OptionalHeader.AddressOfEntryPoint : 0u), + (unsigned long)(actual_nt != NULL ? actual_nt->OptionalHeader.SizeOfImage : 0u), + (unsigned long)g_samp_size); + } + return 0; + } + if (epilogue_rva > g_samp_size - sizeof(expected_epilogue) || + !memory_is_readable(g_samp_base + epilogue_rva, sizeof(expected_epilogue)) || + memcmp((const void *)(g_samp_base + epilogue_rva), expected_epilogue, + sizeof(expected_epilogue)) != 0) { + if (log_summary) { + probe_log("trailer_sync_code_hook: skip epilogue_mismatch rva=0x%08lx " + "evidence=STATIC_037", + (unsigned long)epilogue_rva); + } + InterlockedExchange(&g_samp_trailer_sync_code_hook.installed, -1); + return 0; + } + + installed = install_samp_code_hook(&g_samp_trailer_sync_code_hook); + if (log_summary) { + probe_log("trailer_sync_code_hook: summary installed=%d requested=1 " + "rva=0x%08lx prologue=83ec4c538b5c2454 epilogue=5f5e5d5b83c44cc20400 " + "supported_sha256=" + "b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2 " + "evidence=STATIC_037", + installed, (unsigned long)PROBE_SAMP_R5_TRAILER_SYNC_APPLY_RVA); + } + return installed; +} + +static int preflight_samp_vehicle_lifecycle_hooks(void) { + static const BYTE vehicle_new_success_tail[] = { + 0x5f, 0xb8, 0x01, 0x00, 0x00, 0x00, 0x5e, 0xc2, 0x04, 0x00}; + static const BYTE vehicle_new_failure_tail[] = { + 0x5f, 0x33, 0xc0, 0x5e, 0xc2, 0x04, 0x00}; + static const BYTE put_direct_tail[] = { + 0x5e, 0x5f, 0x5d, 0x5b, 0xc2, 0x08, 0x00}; + const DWORD vehicle_new_success_rva = + PROBE_SAMP_R5_VEHICLE_POOL_NEW_RVA + 0x191u; + const DWORD vehicle_new_failure_rva = + PROBE_SAMP_R5_VEHICLE_POOL_NEW_RVA + 0x19bu; + const DWORD put_direct_tail_rva = + PROBE_SAMP_R5_PLAYER_PED_PUT_DIRECTLY_IN_VEHICLE_RVA + 0x176u; + size_t i; + + for (i = 0; + i < sizeof(g_samp_vehicle_lifecycle_code_hooks) / + sizeof(g_samp_vehicle_lifecycle_code_hooks[0]); + ++i) { + probe_code_hook *hook = &g_samp_vehicle_lifecycle_code_hooks[i]; + uintptr_t target; + size_t readable_len; + size_t patch_len; + + if (hook->expected_len < 5u || + hook->expected_len > sizeof(hook->expected) || + hook->expected_len > PROBE_INLINE_HOOK_MAX_COPY) { + return 0; + } + readable_len = hook->expected_len > 8u ? hook->expected_len : 8u; + if (readable_len > g_samp_size || hook->rva >= g_samp_size || + hook->rva > g_samp_size - readable_len) { + return 0; + } + target = g_samp_base + hook->rva; + if (!memory_is_readable(target, readable_len) || + memcmp((const void *)target, hook->expected, + hook->expected_len) != 0) { + return 0; + } + patch_len = calculate_patch_length((void *)target); + if (patch_len != hook->expected_len) { + return 0; + } + } + + /* + * STATIC_037: + * Validate both VehiclePool::New returns, the shared PutDirectlyInVehicle + * return before patching the first entry. + */ + return vehicle_new_success_rva <= + g_samp_size - sizeof(vehicle_new_success_tail) && + vehicle_new_failure_rva <= + g_samp_size - sizeof(vehicle_new_failure_tail) && + put_direct_tail_rva <= g_samp_size - sizeof(put_direct_tail) && + memory_is_readable(g_samp_base + vehicle_new_success_rva, + sizeof(vehicle_new_success_tail)) && + memory_is_readable(g_samp_base + vehicle_new_failure_rva, + sizeof(vehicle_new_failure_tail)) && + memory_is_readable(g_samp_base + put_direct_tail_rva, + sizeof(put_direct_tail)) && + memcmp((const void *)(g_samp_base + vehicle_new_success_rva), + vehicle_new_success_tail, + sizeof(vehicle_new_success_tail)) == 0 && + memcmp((const void *)(g_samp_base + vehicle_new_failure_rva), + vehicle_new_failure_tail, + sizeof(vehicle_new_failure_tail)) == 0 && + memcmp((const void *)(g_samp_base + put_direct_tail_rva), + put_direct_tail, sizeof(put_direct_tail)) == 0; +} + +static int install_samp_vehicle_lifecycle_hooks(int log_summary) { + size_t hook_count = sizeof(g_samp_vehicle_lifecycle_code_hooks) / + sizeof(g_samp_vehicle_lifecycle_code_hooks[0]); + size_t already_installed = 0u; + size_t i; + int installed = 0; + + if (!vehicle_lifecycle_hooks_enabled()) { + return 0; + } + if (env_flag_enabled("SAMP_PROBE_NO_SAMP_CODE_HOOKS")) { + if (log_summary) { + probe_log("vehicle_lifecycle_hook: disabled by " + "SAMP_PROBE_NO_SAMP_CODE_HOOKS"); + } + return 0; + } + if (!samp_r5_relocation_identity_matches() || + !gta_us10_identity_matches()) { + if (log_summary) { + probe_log( + "vehicle_lifecycle_hook: skip unsupported_identity installed=0 " + "samp_r5=%d gta_us10=%d " + "supported_sha256=" + "b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2 " + "gta_sha256=" + "a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26 " + "evidence=STATIC_037,GTA_REVERSED_REF", + samp_r5_identity_matches(), gta_us10_identity_matches()); + } + return 0; + } + + for (i = 0u; i < hook_count; ++i) { + LONG state = InterlockedCompareExchange( + &g_samp_vehicle_lifecycle_code_hooks[i].installed, 0, 0); + if (state == 1) { + ++already_installed; + } else if (state != 0) { + return 0; + } + } + if (already_installed == hook_count) { + return 0; + } + if (already_installed != 0u || + !preflight_samp_vehicle_lifecycle_hooks()) { + if (log_summary) { + probe_log( + "vehicle_lifecycle_hook: skip preflight_or_partial_state " + "installed=%u requested=%u evidence=STATIC_037", + (unsigned)already_installed, (unsigned)hook_count); + } + for (i = 0u; i < hook_count; ++i) { + if (InterlockedCompareExchange( + &g_samp_vehicle_lifecycle_code_hooks[i].installed, 0, 0) == + 0) { + InterlockedExchange( + &g_samp_vehicle_lifecycle_code_hooks[i].installed, -1); + } + } + return 0; + } + + for (i = 0u; i < hook_count; ++i) { + installed += + install_samp_code_hook(&g_samp_vehicle_lifecycle_code_hooks[i]); + } + if ((size_t)installed != hook_count) { + probe_log( + "vehicle_lifecycle_hook: incomplete_install installed=%d " + "requested=%u restoring=1", + installed, (unsigned)hook_count); + uninstall_samp_vehicle_lifecycle_hooks(); + return 0; + } + if (log_summary) { + probe_log( + "vehicle_lifecycle_hook: summary installed=%d requested=%u " + "new_rva=0x%08lx new_patch=568b742408 " + "put_rva=0x%08lx put_patch=538bd98b83a4020000 " + "restore=saved_exact_bytes ring=%u supported_sha256=" + "b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2 " + "gta_sha256=" + "a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26 " + "evidence=STATIC_037,GTA_REVERSED_REF,TODO_VERIFY", + installed, (unsigned)hook_count, + (unsigned long)PROBE_SAMP_R5_VEHICLE_POOL_NEW_RVA, + (unsigned long)PROBE_SAMP_R5_PLAYER_PED_PUT_DIRECTLY_IN_VEHICLE_RVA, + (unsigned)PROBE_VEHICLE_LIFECYCLE_TRACE_RING); + } + return installed; +} + +static void uninstall_samp_vehicle_lifecycle_hooks(void) { + size_t hook_count = sizeof(g_samp_vehicle_lifecycle_code_hooks) / + sizeof(g_samp_vehicle_lifecycle_code_hooks[0]); + size_t i; + + for (i = hook_count; i > 0u; --i) { + probe_code_hook *hook = &g_samp_vehicle_lifecycle_code_hooks[i - 1u]; + uintptr_t target; + DWORD old_protect = 0u; + DWORD ignored_protect = 0u; + int owned; + int restored = 0; + + if (InterlockedCompareExchange(&hook->installed, 0, 0) != 1 || + hook->saved_len < 5u || hook->saved_len > sizeof(hook->saved) || + hook->rva >= g_samp_size || + hook->rva > g_samp_size - hook->saved_len) { + continue; + } + target = g_samp_base + hook->rva; + owned = trailer_physics_patch_is_ours( + target, hook->saved_len, hook->replacement); + if (owned && + VirtualProtect((void *)target, hook->saved_len, + PAGE_EXECUTE_READWRITE, &old_protect)) { + memcpy((void *)target, hook->saved, hook->saved_len); + FlushInstructionCache(GetCurrentProcess(), (const void *)target, + hook->saved_len); + (void)VirtualProtect((void *)target, hook->saved_len, old_protect, + &ignored_protect); + restored = memcmp((const void *)target, hook->saved, + hook->saved_len) == 0; + } + probe_log( + "vehicle_lifecycle_hook: restore name=%s rva=0x%08lx owned=%d " + "restored=%d trampoline_lifetime=process", + hook->name, (unsigned long)hook->rva, owned, restored); + InterlockedExchange(&hook->installed, restored ? 0 : -1); + } +} + +static int samp_r5_exact_bytes_match(DWORD rva, const BYTE *bytes, + size_t size) { + return bytes != NULL && size != 0u && size <= g_samp_size && + rva < g_samp_size && + rva <= g_samp_size - size && + memory_is_readable(g_samp_base + rva, size) && + memcmp((const void *)(g_samp_base + rva), bytes, size) == 0; +} + +static int samp_r5_relocated_dword_bytes_match( + DWORD rva, const BYTE *preferred_bytes, size_t size, + size_t operand_offset, DWORD *runtime_operand_out) { + BYTE expected[PROBE_INLINE_HOOK_MAX_COPY]; + DWORD preferred_operand; + DWORD referenced_rva; + DWORD runtime_operand; + + /* + * STATIC_037: + * This helper is intentionally limited to one known PE HIGHLOW DWORD inside + * a short, otherwise exact byte span. It does not wildcard arbitrary image + * bytes. The preferred operand must point back into the identity-checked R5 + * image, and only the module-base delta is applied before comparison. + */ + if (preferred_bytes == NULL || size == 0u || size > sizeof(expected) || + operand_offset > size || + sizeof(preferred_operand) > size - operand_offset) { + return 0; + } + memcpy(expected, preferred_bytes, size); + memcpy(&preferred_operand, preferred_bytes + operand_offset, + sizeof(preferred_operand)); + if (preferred_operand < PROBE_SAMP_R5_PREFERRED_IMAGE_BASE) { + return 0; + } + referenced_rva = + preferred_operand - PROBE_SAMP_R5_PREFERRED_IMAGE_BASE; + if (referenced_rva >= g_samp_size || + g_samp_base > (uintptr_t)(0xffffffffu - referenced_rva)) { + return 0; + } + runtime_operand = (DWORD)(g_samp_base + (uintptr_t)referenced_rva); + memcpy(expected + operand_offset, &runtime_operand, + sizeof(runtime_operand)); + if (!samp_r5_exact_bytes_match(rva, expected, size)) { + return 0; + } + if (runtime_operand_out != NULL) { + *runtime_operand_out = runtime_operand; + } + return 1; +} + +static int preflight_samp_aim_bullet_jetpack_hooks(void) { + static const BYTE aim_restore_entry_preferred[] = { + 0x56, 0x57, 0x8b, 0x3d, 0xc8, 0x39, 0x10, 0x10}; + static const BYTE aim_install_tail[] = { + 0x5f, 0x5e, 0xc2, 0x04, 0x00}; + static const BYTE aim_restore_tail[] = { + 0x5f, 0x5e, 0xc3}; + static const BYTE set_keys_tail[] = { + 0x5f, 0x5e, 0x5d, 0x5b, 0xc2, 0x0c, 0x00}; + static const BYTE shot_return_tail[] = { + 0x5f, 0xc2, 0x04, 0x00}; + static const BYTE shot_local_tail_jump[] = { + 0x5f, 0x89, 0x44, 0x24, 0x04, 0xe9, 0x8c, 0x78, 0xf5, 0xff}; + static const BYTE fire_tail[] = { + 0x5f, 0xb8, 0x01, 0x00, 0x00, 0x00, + 0x5e, 0x83, 0xc4, 0x20, 0xc3}; + static const BYTE jetpack_start_tail[] = { + 0x8b, 0x0d, 0x78, 0x39, 0x11, 0x10, + 0xc6, 0x01, 0x00, 0xc3}; + static const BYTE jetpack_stop_tail[] = { + 0x5e, 0x8b, 0xe5, 0x5d, 0xc3}; + static const BYTE jetpack_query_success_tail[] = { + 0x8b, 0x10, 0x33, 0xc9, 0x81, 0xfa, 0xc4, 0x05, + 0x87, 0x00, 0x0f, 0x94, 0xc1, 0x8b, 0xc1, 0xc3}; + static const BYTE jetpack_query_failure_tail[] = { + 0x33, 0xc0, 0xc3}; + struct probe_tail_check { + DWORD rva; + const BYTE *bytes; + size_t size; + int rebased_operand_offset; + }; + static const struct probe_tail_check tails[] = { + {PROBE_SAMP_R5_AIM_CONTEXT_INSTALL_RVA + 0x1fu, + aim_install_tail, sizeof(aim_install_tail), -1}, + {PROBE_SAMP_R5_AIM_CONTEXT_RESTORE_RVA + 0x14u, + aim_restore_tail, sizeof(aim_restore_tail), -1}, + {PROBE_SAMP_R5_PLAYER_PED_SET_KEYS_RVA + 0x288u, + set_keys_tail, sizeof(set_keys_tail), -1}, + {PROBE_SAMP_R5_PLAYER_PED_SET_SHOT_CONTEXT_RVA + 0x1eu, + shot_return_tail, sizeof(shot_return_tail), -1}, + {PROBE_SAMP_R5_PLAYER_PED_SET_SHOT_CONTEXT_RVA + 0xaau, + shot_local_tail_jump, sizeof(shot_local_tail_jump), -1}, + {PROBE_SAMP_R5_PLAYER_PED_FIRE_REMOTE_RVA + 0x1e7u, + fire_tail, sizeof(fire_tail), -1}, + {PROBE_SAMP_R5_PLAYER_PED_START_JETPACK_RVA + 0x39u, + jetpack_start_tail, sizeof(jetpack_start_tail), 2}, + {PROBE_SAMP_R5_PLAYER_PED_STOP_JETPACK_RVA + 0x53u, + jetpack_stop_tail, sizeof(jetpack_stop_tail), -1}, + {PROBE_SAMP_R5_PLAYER_PED_IS_IN_JETPACK_RVA + 0x22u, + jetpack_query_success_tail, sizeof(jetpack_query_success_tail), -1}, + {PROBE_SAMP_R5_PLAYER_PED_IS_IN_JETPACK_RVA + 0x32u, + jetpack_query_failure_tail, sizeof(jetpack_query_failure_tail), -1}, + }; + size_t hook_count = + sizeof(g_samp_aim_bullet_jetpack_code_hooks) / + sizeof(g_samp_aim_bullet_jetpack_code_hooks[0]); + DWORD aim_restore_runtime_operand = 0u; + DWORD jetpack_runtime_operand = 0u; + size_t i; + + for (i = 0u; i < hook_count; ++i) { + probe_code_hook *hook = &g_samp_aim_bullet_jetpack_code_hooks[i]; + size_t readable_len; + size_t patch_len; + uintptr_t target; + + if (hook->expected_len < 5u || + hook->expected_len > sizeof(hook->expected) || + hook->expected_len > PROBE_INLINE_HOOK_MAX_COPY) { + return 0; + } + readable_len = hook->expected_len > 8u ? hook->expected_len : 8u; + if (hook->rva >= g_samp_size || + hook->rva > g_samp_size - readable_len) { + return 0; + } + target = g_samp_base + hook->rva; + if (!memory_is_readable(target, readable_len)) { + return 0; + } + if (hook->rva == PROBE_SAMP_R5_AIM_CONTEXT_RESTORE_RVA) { + /* + * STATIC_037: + * PE relocation +0x9C964 covers the absolute operand of + * `mov edi,ds:[samp.dll+0x1039C8]`. Seed the generic installer with the + * exact validated runtime bytes so its trampoline replays the loader- + * relocated instruction. + */ + if (hook->expected_len != sizeof(aim_restore_entry_preferred) || + !samp_r5_relocated_dword_bytes_match( + hook->rva, aim_restore_entry_preferred, + sizeof(aim_restore_entry_preferred), 4u, + &aim_restore_runtime_operand)) { + return 0; + } + memcpy(hook->expected, aim_restore_entry_preferred, + sizeof(aim_restore_entry_preferred)); + memcpy(hook->expected + 4u, &aim_restore_runtime_operand, + sizeof(aim_restore_runtime_operand)); + } else if (memcmp((const void *)target, hook->expected, + hook->expected_len) != 0) { + return 0; + } + patch_len = calculate_patch_length((void *)target); + if (patch_len != hook->expected_len) { + return 0; + } + } + for (i = 0u; i < sizeof(tails) / sizeof(tails[0]); ++i) { + if (tails[i].rebased_operand_offset >= 0) { + /* + * STATIC_037: + * PE relocation +0xACD4B covers the samp.dll+0x113978 global used by + * the StartJetpack epilogue. Every surrounding byte remains exact. + */ + if (!samp_r5_relocated_dword_bytes_match( + tails[i].rva, tails[i].bytes, tails[i].size, + (size_t)tails[i].rebased_operand_offset, + &jetpack_runtime_operand)) { + return 0; + } + } else if (!samp_r5_exact_bytes_match( + tails[i].rva, tails[i].bytes, tails[i].size)) { + return 0; + } + } + probe_log( + "aim_bullet_jetpack_hook: relocation_preflight_ok " + "samp_base=0x%08lx delta=0x%08lx " + "operand_9c964=0x%08lx operand_acd4b=0x%08lx " + "guard=identity,samp_relocation_normalized,gta_preferred_base " + "evidence=STATIC_037", + (unsigned long)(DWORD)g_samp_base, + (unsigned long)((DWORD)g_samp_base - + PROBE_SAMP_R5_PREFERRED_IMAGE_BASE), + (unsigned long)aim_restore_runtime_operand, + (unsigned long)jetpack_runtime_operand); + return 1; +} + +static int install_samp_aim_bullet_jetpack_hooks(int log_summary) { + size_t hook_count = + sizeof(g_samp_aim_bullet_jetpack_code_hooks) / + sizeof(g_samp_aim_bullet_jetpack_code_hooks[0]); + size_t already_installed = 0u; + size_t i; + int installed = 0; + + if (!aim_bullet_jetpack_hooks_enabled()) { + return 0; + } + if (env_flag_enabled("SAMP_PROBE_NO_SAMP_CODE_HOOKS")) { + if (log_summary) { + probe_log( + "aim_bullet_jetpack_hook: disabled by " + "SAMP_PROBE_NO_SAMP_CODE_HOOKS"); + } + return 0; + } + /* + * STATIC_037 + GTA_REVERSED_REF: + * R5 has a PE relocation table and is observed at non-preferred bases on + * native Windows. Its two guarded HIGHLOW operands are normalized during + * preflight. GTA-SA 1.0 US has relocations stripped and must remain at its + * fixed preferred base for the direct snapshot addresses below. + */ + if (!samp_r5_identity_matches() || !gta_us10_identity_matches()) { + if (log_summary) { + probe_log( + "aim_bullet_jetpack_hook: skip unsupported_identity installed=0 " + "samp_r5_reloc=%d samp_base=0x%08lx samp_delta=0x%08lx " + "gta_us10=%d " + "guard=identity,samp_relocation_normalized,gta_preferred_base " + "supported_sha256=" + "b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2 " + "gta_sha256=" + "a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26 " + "evidence=STATIC_037,GTA_REVERSED_REF", + samp_r5_relocation_identity_matches(), + (unsigned long)(DWORD)g_samp_base, + (unsigned long)((DWORD)g_samp_base - + PROBE_SAMP_R5_PREFERRED_IMAGE_BASE), + gta_us10_identity_matches()); + } + return 0; + } + + for (i = 0u; i < hook_count; ++i) { + LONG state = InterlockedCompareExchange( + &g_samp_aim_bullet_jetpack_code_hooks[i].installed, 0, 0); + if (state == 1) { + ++already_installed; + } else if (state != 0) { + return 0; + } + } + if (already_installed == hook_count) { + return 0; + } + if (already_installed != 0u || + !preflight_samp_aim_bullet_jetpack_hooks()) { + if (log_summary) { + probe_log( + "aim_bullet_jetpack_hook: skip preflight_or_partial_state " + "installed=%u requested=%u evidence=STATIC_037,TODO_VERIFY", + (unsigned)already_installed, (unsigned)hook_count); + } + for (i = 0u; i < hook_count; ++i) { + if (InterlockedCompareExchange( + &g_samp_aim_bullet_jetpack_code_hooks[i].installed, 0, 0) == + 0) { + InterlockedExchange( + &g_samp_aim_bullet_jetpack_code_hooks[i].installed, -1); + } + } + return 0; + } + + for (i = 0u; i < hook_count; ++i) { + installed += install_samp_code_hook( + &g_samp_aim_bullet_jetpack_code_hooks[i]); + } + if ((size_t)installed != hook_count) { + probe_log( + "aim_bullet_jetpack_hook: incomplete_install installed=%d " + "requested=%u restoring=1", + installed, (unsigned)hook_count); + uninstall_samp_aim_bullet_jetpack_hooks(); + return 0; + } + if (log_summary) { + probe_log( + "aim_bullet_jetpack_hook: summary installed=%d requested=%u " + "samp_base=0x%08lx samp_delta=0x%08lx " + "aim=0x%08lx/0x%08lx keys=0x%08lx " + "shot=0x%08lx fire=0x%08lx jetpack=0x%08lx/0x%08lx/0x%08lx " + "restore=saved_exact_bytes ring=%u " + "guard=identity,samp_relocation_normalized,gta_preferred_base " + "supported_sha256=" + "b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2 " + "evidence=STATIC_037,TODO_VERIFY", + installed, (unsigned)hook_count, + (unsigned long)(DWORD)g_samp_base, + (unsigned long)((DWORD)g_samp_base - + PROBE_SAMP_R5_PREFERRED_IMAGE_BASE), + (unsigned long)PROBE_SAMP_R5_AIM_CONTEXT_INSTALL_RVA, + (unsigned long)PROBE_SAMP_R5_AIM_CONTEXT_RESTORE_RVA, + (unsigned long)PROBE_SAMP_R5_PLAYER_PED_SET_KEYS_RVA, + (unsigned long)PROBE_SAMP_R5_PLAYER_PED_SET_SHOT_CONTEXT_RVA, + (unsigned long)PROBE_SAMP_R5_PLAYER_PED_FIRE_REMOTE_RVA, + (unsigned long)PROBE_SAMP_R5_PLAYER_PED_START_JETPACK_RVA, + (unsigned long)PROBE_SAMP_R5_PLAYER_PED_STOP_JETPACK_RVA, + (unsigned long)PROBE_SAMP_R5_PLAYER_PED_IS_IN_JETPACK_RVA, + (unsigned)PROBE_AIM_BULLET_JETPACK_TRACE_RING); + } + return installed; +} + +static void uninstall_samp_aim_bullet_jetpack_hooks(void) { + size_t hook_count = + sizeof(g_samp_aim_bullet_jetpack_code_hooks) / + sizeof(g_samp_aim_bullet_jetpack_code_hooks[0]); + size_t i; + + for (i = hook_count; i > 0u; --i) { + probe_code_hook *hook = + &g_samp_aim_bullet_jetpack_code_hooks[i - 1u]; + uintptr_t target; + DWORD old_protect = 0u; + DWORD ignored_protect = 0u; + int owned; + int restored = 0; + + if (InterlockedCompareExchange(&hook->installed, 0, 0) != 1 || + hook->saved_len < 5u || hook->saved_len > sizeof(hook->saved) || + hook->rva >= g_samp_size || + hook->rva > g_samp_size - hook->saved_len) { + continue; + } + target = g_samp_base + hook->rva; + owned = trailer_physics_patch_is_ours( + target, hook->saved_len, hook->replacement); + if (owned && + VirtualProtect((void *)target, hook->saved_len, + PAGE_EXECUTE_READWRITE, &old_protect)) { + memcpy((void *)target, hook->saved, hook->saved_len); + FlushInstructionCache(GetCurrentProcess(), (const void *)target, + hook->saved_len); + (void)VirtualProtect((void *)target, hook->saved_len, old_protect, + &ignored_protect); + restored = memcmp((const void *)target, hook->saved, + hook->saved_len) == 0; + } + probe_log( + "aim_bullet_jetpack_hook: restore name=%s rva=0x%08lx owned=%d " + "restored=%d trampoline_lifetime=process", + hook->name, (unsigned long)hook->rva, owned, restored); + InterlockedExchange(&hook->installed, restored ? 0 : -1); + } +} + +static float probe_abs_float(float value) { + return value < 0.0f ? -value : value; +} + +static int capture_trailer_live_snapshot(WORD vehicle_id, + probe_trailer_live_snapshot *snapshot) { + DWORD netgame; + DWORD pools; + DWORD vehicle_pool; + DWORD wrapper; + DWORD entity; + DWORD matrix; + + if (snapshot == NULL) { + return 0; + } + memset(snapshot, 0, sizeof(*snapshot)); + if (vehicle_id == 0u || vehicle_id == 0xffffu || + vehicle_id >= PROBE_SAMP_R5_VEHICLE_POOL_CAPACITY || + g_samp_base == 0u) { + return 0; + } + + netgame = read_u32_or(g_samp_base + PROBE_SAMP_R5_NETGAME_PTR_RVA, 0u); + pools = netgame != 0u + ? read_u32_or((uintptr_t)netgame + PROBE_SAMP_R5_NETGAME_POOLS_OFFSET, 0u) + : 0u; + vehicle_pool = pools != 0u + ? read_u32_or((uintptr_t)pools + PROBE_SAMP_R5_POOLS_VEHICLE_POOL_OFFSET, 0u) + : 0u; + snapshot->vehicle_pool = vehicle_pool; + if (vehicle_pool == 0u) { + return 0; + } + + snapshot->listed = read_u32_or( + (uintptr_t)vehicle_pool + PROBE_SAMP_R5_VEHICLE_POOL_LISTED_OFFSET + + ((uintptr_t)vehicle_id * sizeof(DWORD)), + 0u); + wrapper = read_u32_or( + (uintptr_t)vehicle_pool + PROBE_SAMP_R5_VEHICLE_POOL_WRAPPER_OFFSET + + ((uintptr_t)vehicle_id * sizeof(DWORD)), + 0u); + snapshot->wrapper = wrapper; + if (snapshot->listed == 0u || wrapper == 0u) { + return 0; + } + + entity = read_u32_or((uintptr_t)wrapper + PROBE_SAMP_R5_VEHICLE_WRAPPER_ENTITY_OFFSET, 0u); + snapshot->gta_vehicle = + read_u32_or((uintptr_t)wrapper + PROBE_SAMP_R5_VEHICLE_WRAPPER_GTA_VEHICLE_OFFSET, 0u); + snapshot->entity = entity; + if (entity == 0u) { + return 0; + } + matrix = read_u32_or((uintptr_t)entity + PROBE_GTA_ENTITY_MATRIX_PTR_OFFSET, 0u); + snapshot->matrix = matrix; + if (matrix == 0u || + !memory_is_readable((uintptr_t)matrix + PROBE_GTA_MATRIX_POSITION_OFFSET, + sizeof(snapshot->position)) || + !memory_is_readable((uintptr_t)entity + PROBE_GTA_PHYSICAL_MOVE_SPEED_OFFSET, + sizeof(snapshot->move_speed)) || + !memory_is_readable((uintptr_t)entity + PROBE_GTA_PHYSICAL_TURN_SPEED_OFFSET, + sizeof(snapshot->turn_speed))) { + return 0; + } + + memcpy(snapshot->position, + (const void *)((uintptr_t)matrix + PROBE_GTA_MATRIX_POSITION_OFFSET), + sizeof(snapshot->position)); + memcpy(snapshot->move_speed, + (const void *)((uintptr_t)entity + PROBE_GTA_PHYSICAL_MOVE_SPEED_OFFSET), + sizeof(snapshot->move_speed)); + memcpy(snapshot->turn_speed, + (const void *)((uintptr_t)entity + PROBE_GTA_PHYSICAL_TURN_SPEED_OFFSET), + sizeof(snapshot->turn_speed)); + snapshot->valid = 1u; + return 1; +} + +static void capture_trailer_association( + void *remote_player, const probe_trailer_live_snapshot *target, + DWORD *towing_wrapper, DWORD *towing_gta, DWORD *live_trailer_gta, + BYTE *matches) { + DWORD wrapper = 0u; + DWORD gta = 0u; + DWORD live = 0u; + + if (remote_player != NULL) { + wrapper = read_u32_or( + (uintptr_t)remote_player + PROBE_SAMP_R5_REMOTE_PLAYER_VEHICLE_WRAPPER_OFFSET, + 0u); + } + if (wrapper != 0u) { + gta = read_u32_or( + (uintptr_t)wrapper + PROBE_SAMP_R5_VEHICLE_WRAPPER_GTA_VEHICLE_OFFSET, + 0u); + } + if (gta != 0u) { + live = read_u32_or((uintptr_t)gta + PROBE_GTA_VEHICLE_TRAILER_OFFSET, 0u); + } + if (towing_wrapper != NULL) { + *towing_wrapper = wrapper; + } + if (towing_gta != NULL) { + *towing_gta = gta; + } + if (live_trailer_gta != NULL) { + *live_trailer_gta = live; + } + if (matches != NULL) { + *matches = + target != NULL && target->valid != 0u && target->gta_vehicle != 0u && + live == target->gta_vehicle + ? 1u + : 0u; + } +} + +static BYTE predict_trailer_sync_mode( + const probe_samp_trailer_sync_r5 *sync, + const probe_trailer_live_snapshot *before, BYTE association_pre) { + float dx; + float dy; + float dz; + + if (sync == NULL || before == NULL || before->valid == 0u) { + return 0u; + } + if (association_pre == 0u) { + return 4u; + } + dx = sync->position[0] - before->position[0]; + dy = sync->position[1] - before->position[1]; + dz = sync->position[2] - before->position[2]; + if (probe_abs_float(dx) <= 0.5f && probe_abs_float(dy) <= 0.5f && + probe_abs_float(dz) <= 0.5f) { + return 1u; + } + if (probe_abs_float(dx) > 6.0f || probe_abs_float(dy) > 6.0f || + probe_abs_float(dz) > 3.0f) { + return 3u; + } + return 2u; +} + +static void PROBE_THISCALL hook_samp_trailer_sync_apply( + void *remote_player, const probe_samp_trailer_sync_r5 *sync) { + probe_trailer_sync_trace trace; + probe_trailer_sync_trace *slot; + LONG seq; + + memset(&trace, 0, sizeof(trace)); + seq = InterlockedIncrement(&g_trailer_sync_trace_write_seq); + trace.call_seq = seq; + trace.event_seq = InterlockedIncrement(&g_trailer_trace_event_seq); + trace.tick = GetTickCount(); + trace.remote_player = (DWORD)(uintptr_t)remote_player; + if (sync != NULL && + memory_is_readable((uintptr_t)sync, sizeof(trace.sync))) { + memcpy(&trace.sync, sync, sizeof(trace.sync)); + trace.payload_valid = 1u; + (void)capture_trailer_live_snapshot(trace.sync.vehicle_id, &trace.before); + capture_trailer_association( + remote_player, &trace.before, &trace.towing_wrapper_pre, + &trace.towing_gta_pre, &trace.live_trailer_gta_pre, + &trace.association_pre); + trace.predicted_mode = predict_trailer_sync_mode( + &trace.sync, &trace.before, trace.association_pre); + } + + if (g_orig_samp_trailer_sync_apply != NULL) { + ((probe_samp_trailer_sync_apply_fn)g_orig_samp_trailer_sync_apply)( + remote_player, sync); + } + + if (trace.payload_valid != 0u) { + (void)capture_trailer_live_snapshot(trace.sync.vehicle_id, &trace.after); + capture_trailer_association( + remote_player, &trace.after, &trace.towing_wrapper_post, + &trace.towing_gta_post, &trace.live_trailer_gta_post, + &trace.association_post); + } + + slot = &g_trailer_sync_trace_ring[ + ((unsigned long)(seq - 1)) % PROBE_TRAILER_SYNC_TRACE_RING]; + InterlockedExchange(&slot->committed_seq, 0); + memcpy((BYTE *)slot + sizeof(slot->committed_seq), + (const BYTE *)&trace + sizeof(trace.committed_seq), + sizeof(trace) - sizeof(trace.committed_seq)); + MemoryBarrier(); + InterlockedExchange(&slot->committed_seq, seq); +} + +static const char *trailer_sync_mode_name(BYTE mode) { + switch (mode) { + case 1u: + return "noop"; + case 2u: + return "correct"; + case 3u: + return "snap"; + case 4u: + return "transition"; + default: + return "unknown"; + } +} + +static void flush_trailer_sync_trace_ring(void) { + LONG write_seq = + InterlockedCompareExchange(&g_trailer_sync_trace_write_seq, 0, 0); + LONG pending = write_seq - g_trailer_sync_trace_flushed_seq; + + if (pending > (LONG)PROBE_TRAILER_SYNC_TRACE_RING) { + LONG skipped = pending - (LONG)PROBE_TRAILER_SYNC_TRACE_RING; + g_trailer_sync_trace_flushed_seq += skipped; + g_trailer_sync_trace_overflow_count += skipped; + probe_log("trailer_sync_r5: overflow skipped=%ld total_skipped=%ld " + "write_seq=%ld evidence=PROBE_TRACE", + (long)skipped, (long)g_trailer_sync_trace_overflow_count, + (long)write_seq); + } + + while (g_trailer_sync_trace_flushed_seq < write_seq) { + LONG next_seq = g_trailer_sync_trace_flushed_seq + 1; + probe_trailer_sync_trace *slot = + &g_trailer_sync_trace_ring[ + ((unsigned long)(next_seq - 1)) % PROBE_TRAILER_SYNC_TRACE_RING]; + probe_trailer_sync_trace trace; + LONG committed = InterlockedCompareExchange(&slot->committed_seq, 0, 0); + float pre_delta[3] = {0.0f, 0.0f, 0.0f}; + float post_delta[3] = {0.0f, 0.0f, 0.0f}; + + if (committed != next_seq) { + break; + } + MemoryBarrier(); + memcpy(&trace, slot, sizeof(trace)); + MemoryBarrier(); + if (InterlockedCompareExchange(&slot->committed_seq, 0, 0) != next_seq) { + continue; + } + + if (trace.payload_valid != 0u && trace.before.valid != 0u) { + pre_delta[0] = trace.sync.position[0] - trace.before.position[0]; + pre_delta[1] = trace.sync.position[1] - trace.before.position[1]; + pre_delta[2] = trace.sync.position[2] - trace.before.position[2]; + } + if (trace.payload_valid != 0u && trace.after.valid != 0u) { + post_delta[0] = trace.sync.position[0] - trace.after.position[0]; + post_delta[1] = trace.sync.position[1] - trace.after.position[1]; + post_delta[2] = trace.sync.position[2] - trace.after.position[2]; + } + + probe_log( + "trailer_sync_r5: seq=%ld event=%ld tick=%lu remote=0x%08lx vehicle=%u " + "payload=%u mode=%s association=%u->%u " + "target=(%.6f,%.6f,%.6f) " + "pre_valid=%u pre=(%.6f,%.6f,%.6f) " + "pre_delta=(%.6f,%.6f,%.6f) " + "post_valid=%u post=(%.6f,%.6f,%.6f) " + "post_delta=(%.6f,%.6f,%.6f) " + "target_move=(%.6f,%.6f,%.6f) " + "pre_move=(%.6f,%.6f,%.6f) post_move=(%.6f,%.6f,%.6f) " + "target_turn=(%.6f,%.6f,%.6f) " + "pre_turn=(%.6f,%.6f,%.6f) post_turn=(%.6f,%.6f,%.6f) " + "pool=0x%08lx listed=0x%08lx wrapper=0x%08lx->0x%08lx " + "entity=0x%08lx->0x%08lx matrix=0x%08lx->0x%08lx " + "trailer_gta=0x%08lx->0x%08lx " + "towing_wrapper=0x%08lx->0x%08lx towing_gta=0x%08lx->0x%08lx " + "live_trailer=0x%08lx->0x%08lx " + "evidence=STATIC_037,PROBE_TRACE:samp.dll+0x15C90", + (long)trace.call_seq, (long)trace.event_seq, + (unsigned long)trace.tick, + (unsigned long)trace.remote_player, (unsigned)trace.sync.vehicle_id, + (unsigned)trace.payload_valid, + trailer_sync_mode_name(trace.predicted_mode), + (unsigned)trace.association_pre, (unsigned)trace.association_post, + (double)trace.sync.position[0], (double)trace.sync.position[1], + (double)trace.sync.position[2], (unsigned)trace.before.valid, + (double)trace.before.position[0], (double)trace.before.position[1], + (double)trace.before.position[2], (double)pre_delta[0], + (double)pre_delta[1], (double)pre_delta[2], + (unsigned)trace.after.valid, (double)trace.after.position[0], + (double)trace.after.position[1], (double)trace.after.position[2], + (double)post_delta[0], (double)post_delta[1], + (double)post_delta[2], (double)trace.sync.move_speed[0], + (double)trace.sync.move_speed[1], (double)trace.sync.move_speed[2], + (double)trace.before.move_speed[0], + (double)trace.before.move_speed[1], + (double)trace.before.move_speed[2], + (double)trace.after.move_speed[0], + (double)trace.after.move_speed[1], + (double)trace.after.move_speed[2], + (double)trace.sync.turn_speed[0], + (double)trace.sync.turn_speed[1], + (double)trace.sync.turn_speed[2], + (double)trace.before.turn_speed[0], + (double)trace.before.turn_speed[1], + (double)trace.before.turn_speed[2], + (double)trace.after.turn_speed[0], + (double)trace.after.turn_speed[1], + (double)trace.after.turn_speed[2], + (unsigned long)trace.before.vehicle_pool, + (unsigned long)trace.before.listed, + (unsigned long)trace.before.wrapper, + (unsigned long)trace.after.wrapper, + (unsigned long)trace.before.entity, + (unsigned long)trace.after.entity, + (unsigned long)trace.before.matrix, + (unsigned long)trace.after.matrix, + (unsigned long)trace.before.gta_vehicle, + (unsigned long)trace.after.gta_vehicle, + (unsigned long)trace.towing_wrapper_pre, + (unsigned long)trace.towing_wrapper_post, + (unsigned long)trace.towing_gta_pre, + (unsigned long)trace.towing_gta_post, + (unsigned long)trace.live_trailer_gta_pre, + (unsigned long)trace.live_trailer_gta_post); + g_trailer_sync_trace_flushed_seq = next_seq; + } +} + +static void capture_trailer_physics_timing( + probe_trailer_physics_timing *timing) { + DWORD time_step_bits = 0u; + + if (timing == NULL) { + return; + } + memset(timing, 0, sizeof(*timing)); + timing->tick = GetTickCount(); + timing->thread_id = GetCurrentThreadId(); + if (read_u32_checked(PROBE_GTA_US10_FRAME_COUNTER_ADDR, + &timing->gta_frame)) { + timing->valid_mask |= PROBE_TRAILER_PHYSICS_TIMING_FRAME_VALID; + } + if (read_u32_checked(PROBE_GTA_US10_GAME_TIME_ADDR, &timing->game_time)) { + timing->valid_mask |= PROBE_TRAILER_PHYSICS_TIMING_GAME_TIME_VALID; + } + if (read_u32_checked(PROBE_GTA_US10_TIME_STEP_ADDR, &time_step_bits)) { + memcpy(&timing->time_step, &time_step_bits, sizeof(time_step_bits)); + timing->valid_mask |= PROBE_TRAILER_PHYSICS_TIMING_STEP_VALID; + } +} + +static void capture_trailer_physics_vehicle( + DWORD object, int include_trailer_fields, + probe_trailer_physics_vehicle_state *state) { + DWORD matrix = 0u; + const uintptr_t vehicle = (uintptr_t)object; + + if (state == NULL) { + return; + } + memset(state, 0, sizeof(*state)); + state->object = object; + if (vehicle == 0u || + !memory_is_readable( + vehicle, PROBE_GTA_VEHICLE_TRAILER_OFFSET + sizeof(DWORD))) { + return; + } + + memcpy(&state->vtable, (const void *)vehicle, sizeof(state->vtable)); + memcpy(&matrix, + (const void *)(vehicle + PROBE_GTA_ENTITY_MATRIX_PTR_OFFSET), + sizeof(matrix)); + state->matrix = matrix; + memcpy(&state->flags, + (const void *)(vehicle + PROBE_GTA_ENTITY_FLAGS_OFFSET), + sizeof(state->flags)); + memcpy(&state->status, + (const void *)(vehicle + PROBE_GTA_ENTITY_STATUS_OFFSET), + sizeof(state->status)); + memcpy(&state->move_speed, + (const void *)(vehicle + PROBE_GTA_PHYSICAL_MOVE_SPEED_OFFSET), + sizeof(state->move_speed)); + memcpy(&state->turn_speed, + (const void *)(vehicle + PROBE_GTA_PHYSICAL_TURN_SPEED_OFFSET), + sizeof(state->turn_speed)); + memcpy(&state->fake_physics, + (const void *)(vehicle + PROBE_GTA_PHYSICAL_FAKE_PHYSICS_OFFSET), + sizeof(state->fake_physics)); + memcpy(&state->vehicle_flags, + (const void *)(vehicle + PROBE_GTA_VEHICLE_FLAGS_OFFSET), + sizeof(state->vehicle_flags)); + memcpy(&state->driver, + (const void *)(vehicle + PROBE_GTA_VEHICLE_DRIVER_OFFSET), + sizeof(state->driver)); + memcpy(state->passengers, + (const void *)(vehicle + PROBE_GTA_VEHICLE_DRIVER_OFFSET + + sizeof(state->driver)), + sizeof(state->passengers)); + memcpy(&state->towing_vehicle, + (const void *)(vehicle + PROBE_GTA_TRAILER_TOWING_VEHICLE_OFFSET), + sizeof(state->towing_vehicle)); + memcpy(&state->trailer_vehicle, + (const void *)(vehicle + PROBE_GTA_VEHICLE_TRAILER_OFFSET), + sizeof(state->trailer_vehicle)); + state->valid_mask |= PROBE_TRAILER_PHYSICS_VEHICLE_COMMON_VALID; + + if (matrix != 0u && + memory_is_readable((uintptr_t)matrix, + PROBE_GTA_MATRIX_POSITION_OFFSET + + sizeof(state->position))) { + memcpy(state->right, (const void *)(uintptr_t)matrix, + sizeof(state->right)); + memcpy(state->forward, (const void *)((uintptr_t)matrix + 0x10u), + sizeof(state->forward)); + memcpy(state->up, (const void *)((uintptr_t)matrix + 0x20u), + sizeof(state->up)); + memcpy(state->position, + (const void *)((uintptr_t)matrix + + PROBE_GTA_MATRIX_POSITION_OFFSET), + sizeof(state->position)); + state->valid_mask |= PROBE_TRAILER_PHYSICS_VEHICLE_MATRIX_VALID; + } + + if (include_trailer_fields && + memory_is_readable( + vehicle + PROBE_GTA_AUTOMOBILE_WHEEL_COMPRESSION_OFFSET, + PROBE_GTA_AUTOMOBILE_REAR_HEIGHT_OFFSET + sizeof(float) - + PROBE_GTA_AUTOMOBILE_WHEEL_COMPRESSION_OFFSET) && + memory_is_readable( + vehicle + PROBE_GTA_TRAILER_SUPPORT_RATIO_X_OFFSET, + PROBE_GTA_TRAILER_TOW_RATIO_Y_OFFSET + sizeof(float) - + PROBE_GTA_TRAILER_SUPPORT_RATIO_X_OFFSET)) { + memcpy(state->wheel_compression, + (const void *)(vehicle + + PROBE_GTA_AUTOMOBILE_WHEEL_COMPRESSION_OFFSET), + sizeof(state->wheel_compression)); + memcpy( + state->wheel_compression_previous, + (const void *)(vehicle + + PROBE_GTA_AUTOMOBILE_WHEEL_COMPRESSION_PREV_OFFSET), + sizeof(state->wheel_compression_previous)); + memcpy(state->spring_length, + (const void *)(vehicle + + PROBE_GTA_AUTOMOBILE_SPRING_LENGTH_OFFSET), + sizeof(state->spring_length)); + memcpy(state->line_length, + (const void *)(vehicle + + PROBE_GTA_AUTOMOBILE_LINE_LENGTH_OFFSET), + sizeof(state->line_length)); + memcpy(&state->ride_height[0], + (const void *)(vehicle + + PROBE_GTA_AUTOMOBILE_FRONT_HEIGHT_OFFSET), + sizeof(state->ride_height[0])); + memcpy(&state->ride_height[1], + (const void *)(vehicle + + PROBE_GTA_AUTOMOBILE_REAR_HEIGHT_OFFSET), + sizeof(state->ride_height[1])); + memcpy(state->support, + (const void *)(vehicle + + PROBE_GTA_TRAILER_SUPPORT_RATIO_X_OFFSET), + sizeof(state->support)); + state->valid_mask |= PROBE_TRAILER_PHYSICS_VEHICLE_TRAILER_VALID; + } +} + +static void capture_trailer_physics_pair( + DWORD trailer, DWORD tractor, probe_trailer_physics_pair_state *state) { + if (state == NULL) { + return; + } + memset(state, 0, sizeof(*state)); + capture_trailer_physics_timing(&state->timing); + capture_trailer_physics_vehicle(trailer, 1, &state->trailer); + capture_trailer_physics_vehicle(tractor, 0, &state->tractor); +} + +static void publish_trailer_physics_trace( + probe_trailer_physics_trace *trace) { + probe_trailer_physics_trace *slot; + LONG ring_seq; + + if (trace == NULL) { + return; + } + ring_seq = InterlockedIncrement(&g_trailer_physics_trace_write_seq); + trace->ring_seq = ring_seq; + slot = &g_trailer_physics_trace_ring[ + ((unsigned long)(ring_seq - 1)) % PROBE_TRAILER_PHYSICS_TRACE_RING]; + InterlockedExchange(&slot->committed_seq, 0); + memcpy((BYTE *)slot + sizeof(slot->committed_seq), + (const BYTE *)trace + sizeof(trace->committed_seq), + sizeof(*trace) - sizeof(trace->committed_seq)); + MemoryBarrier(); + InterlockedExchange(&slot->committed_seq, ring_seq); +} + +static LONG arm_trailer_physics_track( + DWORD trailer, DWORD tractor, + const probe_trailer_physics_vehicle_state *trailer_after, + const probe_trailer_physics_vehicle_state *tractor_after) { + LONG generation; + probe_trailer_physics_track *track; + + if (trailer == 0u || tractor == 0u || trailer_after == NULL || + tractor_after == NULL || + (trailer_after->valid_mask & + PROBE_TRAILER_PHYSICS_VEHICLE_COMMON_VALID) == 0u || + (tractor_after->valid_mask & + PROBE_TRAILER_PHYSICS_VEHICLE_COMMON_VALID) == 0u || + trailer_after->vtable != PROBE_GTA_US10_CTRAILER_VTABLE) { + return 0; + } + generation = InterlockedIncrement(&g_trailer_physics_generation); + if (generation <= 0) { + return 0; + } + track = &g_trailer_physics_tracks[ + ((unsigned long)(generation - 1)) % PROBE_TRAILER_PHYSICS_TRACK_SLOTS]; + InterlockedExchange(&track->generation, 0); + track->trailer = trailer; + track->tractor = tractor; + InterlockedExchange(&track->next_frame, 0); + MemoryBarrier(); + InterlockedExchange(&track->generation, generation); + return generation; +} + +static int claim_trailer_physics_frame( + DWORD trailer, DWORD *tractor_out, LONG *generation_out, + DWORD *frame_index_out) { + LONG best_generation = 0; + probe_trailer_physics_track *best = NULL; + size_t i; + + if (trailer == 0u || tractor_out == NULL || generation_out == NULL || + frame_index_out == NULL) { + return 0; + } + for (i = 0; i < PROBE_TRAILER_PHYSICS_TRACK_SLOTS; ++i) { + probe_trailer_physics_track *track = &g_trailer_physics_tracks[i]; + LONG generation = + InterlockedCompareExchange(&track->generation, 0, 0); + MemoryBarrier(); + if (generation > best_generation && track->trailer == trailer) { + best_generation = generation; + best = track; + } + } + if (best == NULL) { + return 0; + } + + *frame_index_out = + (DWORD)(InterlockedIncrement(&best->next_frame) - 1); + MemoryBarrier(); + if (InterlockedCompareExchange(&best->generation, 0, 0) != + best_generation || + best->trailer != trailer) { + return 0; + } + if (*frame_index_out >= PROBE_TRAILER_PHYSICS_FRAME_LIMIT) { + (void)InterlockedCompareExchange(&best->generation, 0, best_generation); + return 0; + } + *tractor_out = best->tractor; + *generation_out = best_generation; + if (*frame_index_out + 1u == PROBE_TRAILER_PHYSICS_FRAME_LIMIT) { + (void)InterlockedCompareExchange(&best->generation, 0, best_generation); + } + return 1; +} + +static void capture_vehicle_lifecycle_pool_state( + DWORD vehicle_pool, WORD vehicle_id, int id_valid, + probe_vehicle_lifecycle_pool_state *state) { + uintptr_t index; + + if (state == NULL) { + return; + } + memset(state, 0, sizeof(*state)); + state->pool = vehicle_pool; + state->id_valid = id_valid ? 1u : 0u; + if (!id_valid || vehicle_pool == 0u || + vehicle_id >= PROBE_SAMP_R5_VEHICLE_POOL_CAPACITY) { + return; + } + + index = (uintptr_t)vehicle_id * sizeof(DWORD); + state->listed = read_u32_or( + (uintptr_t)vehicle_pool + PROBE_SAMP_R5_VEHICLE_POOL_LISTED_OFFSET + + index, + 0u); + state->wrapper = read_u32_or( + (uintptr_t)vehicle_pool + PROBE_SAMP_R5_VEHICLE_POOL_WRAPPER_OFFSET + + index, + 0u); + if (state->wrapper == 0u) { + return; + } + state->wrapper_entity = read_u32_or( + (uintptr_t)state->wrapper + + PROBE_SAMP_R5_VEHICLE_WRAPPER_ENTITY_OFFSET, + 0u); + state->gta_vehicle = read_u32_or( + (uintptr_t)state->wrapper + + PROBE_SAMP_R5_VEHICLE_WRAPPER_GTA_VEHICLE_OFFSET, + 0u); + capture_trailer_physics_vehicle(state->gta_vehicle, 0, &state->vehicle); +} + +static DWORD resolve_gta_vehicle_ref_readonly(DWORD reference) { + DWORD vehicle_pool; + DWORD objects; + DWORD byte_map; + DWORD size; + DWORD index; + BYTE slot_id; + uint64_t object_address; + + /* + * GTA_REVERSED_REF + STATIC_037: + * This is the read-only equivalent of GTA-SA US 1.0 CPool:: + * GetAtRef at gta_sa.exe+0x48E0. The supported binary uses a 0xA18-byte + * vehicle slot and compares byte_map[ref >> 8] with the reference low byte. + */ + vehicle_pool = read_u32_or(PROBE_GTA_US10_VEHICLE_POOL_PTR_ADDR, 0u); + if (vehicle_pool == 0u || + !memory_is_readable((uintptr_t)vehicle_pool, 12u)) { + return 0u; + } + objects = read_u32_or((uintptr_t)vehicle_pool, 0u); + byte_map = read_u32_or((uintptr_t)vehicle_pool + 4u, 0u); + size = read_u32_or((uintptr_t)vehicle_pool + 8u, 0u); + index = reference >> 8; + if (objects == 0u || byte_map == 0u || index >= size || + !memory_is_readable((uintptr_t)byte_map + index, sizeof(slot_id))) { + return 0u; + } + memcpy(&slot_id, (const void *)((uintptr_t)byte_map + index), + sizeof(slot_id)); + if (slot_id != (BYTE)reference) { + return 0u; + } + object_address = + (uint64_t)objects + + ((uint64_t)index * PROBE_GTA_US10_VEHICLE_POOL_OBJECT_SIZE); + if (object_address > UINT32_MAX || + !memory_is_readable((uintptr_t)object_address, + PROBE_GTA_VEHICLE_TRAILER_OFFSET + + sizeof(DWORD))) { + return 0u; + } + return (DWORD)object_address; +} + +static void publish_vehicle_lifecycle_trace( + probe_vehicle_lifecycle_trace *trace) { + probe_vehicle_lifecycle_trace *slot; + LONG ring_seq; + + if (trace == NULL) { + return; + } + ring_seq = InterlockedIncrement(&g_vehicle_lifecycle_trace_write_seq); + trace->ring_seq = ring_seq; + slot = &g_vehicle_lifecycle_trace_ring[ + ((unsigned long)(ring_seq - 1)) % PROBE_VEHICLE_LIFECYCLE_TRACE_RING]; + InterlockedExchange(&slot->committed_seq, 0); + memcpy((BYTE *)slot + sizeof(slot->committed_seq), + (const BYTE *)trace + sizeof(trace->committed_seq), + sizeof(*trace) - sizeof(trace->committed_seq)); + MemoryBarrier(); + InterlockedExchange(&slot->committed_seq, ring_seq); +} + +static BYTE classify_vehicle_lifecycle_source(BYTE kind, DWORD caller_rva) { + if (kind == PROBE_VEHICLE_LIFECYCLE_EVENT_POOL_NEW) { + switch (caller_rva) { + case 0x0000e764u: + case 0x0000e7ccu: + case 0x0000e7ddu: + case 0x0000e7eeu: + return PROBE_VEHICLE_LIFECYCLE_SOURCE_RPC164; + case 0x0001f338u: + case 0x0001f353u: + case 0x0001f35eu: + case 0x0001f369u: + case 0x0001f384u: + case 0x0001f38fu: + case 0x0001f39au: + return PROBE_VEHICLE_LIFECYCLE_SOURCE_POOL_RETRY; + default: + return PROBE_VEHICLE_LIFECYCLE_SOURCE_NONE; + } + } + if (kind == PROBE_VEHICLE_LIFECYCLE_EVENT_PUT_DIRECT) { + switch (caller_rva) { + case 0x000148eau: + return PROBE_VEHICLE_LIFECYCLE_SOURCE_REMOTE_SYNC; + case 0x000196dcu: + return PROBE_VEHICLE_LIFECYCLE_SOURCE_RPC70; + case 0x0009e442u: + return PROBE_VEHICLE_LIFECYCLE_SOURCE_INTERNAL; + default: + return PROBE_VEHICLE_LIFECYCLE_SOURCE_NONE; + } + } + return PROBE_VEHICLE_LIFECYCLE_SOURCE_NONE; +} + +static int PROBE_THISCALL hook_samp_vehicle_pool_new( + void *vehicle_pool, const void *vehicle_info) { + probe_vehicle_lifecycle_trace trace; + int result = 0; + + memset(&trace, 0, sizeof(trace)); + trace.event_seq = InterlockedIncrement(&g_vehicle_lifecycle_event_seq); + trace.tick = GetTickCount(); + trace.thread_id = GetCurrentThreadId(); + trace.caller_rva = samp_rva_from_address(probe_return_address()); + trace.hook_rva = PROBE_SAMP_R5_VEHICLE_POOL_NEW_RVA; + trace.kind = PROBE_VEHICLE_LIFECYCLE_EVENT_POOL_NEW; + trace.source = + classify_vehicle_lifecycle_source(trace.kind, trace.caller_rva); + trace.object = (DWORD)(uintptr_t)vehicle_pool; + trace.input = (DWORD)(uintptr_t)vehicle_info; + trace.vehicle_id = 0xffffu; + if (vehicle_info != NULL && + memory_is_readable((uintptr_t)vehicle_info, + sizeof(trace.vehicle_id))) { + memcpy(&trace.vehicle_id, vehicle_info, sizeof(trace.vehicle_id)); + trace.input_valid = + trace.vehicle_id < PROBE_SAMP_R5_VEHICLE_POOL_CAPACITY ? 1u : 0u; + } + capture_vehicle_lifecycle_pool_state( + trace.object, trace.vehicle_id, trace.input_valid, &trace.before); + if (g_orig_samp_vehicle_pool_new != NULL) { + result = ((probe_samp_vehicle_pool_new_fn)g_orig_samp_vehicle_pool_new)( + vehicle_pool, vehicle_info); + } + trace.result = result != 0 ? 1u : 0u; + capture_vehicle_lifecycle_pool_state( + trace.object, trace.vehicle_id, trace.input_valid, &trace.after); + publish_vehicle_lifecycle_trace(&trace); + return result; +} + +static void PROBE_THISCALL hook_samp_player_ped_put_direct( + void *player_ped, DWORD gta_vehicle_ref, DWORD seat) { + probe_vehicle_lifecycle_trace trace; + DWORD gta_vehicle; + + memset(&trace, 0, sizeof(trace)); + trace.event_seq = InterlockedIncrement(&g_vehicle_lifecycle_event_seq); + trace.tick = GetTickCount(); + trace.thread_id = GetCurrentThreadId(); + trace.caller_rva = samp_rva_from_address(probe_return_address()); + trace.hook_rva = + PROBE_SAMP_R5_PLAYER_PED_PUT_DIRECTLY_IN_VEHICLE_RVA; + trace.kind = PROBE_VEHICLE_LIFECYCLE_EVENT_PUT_DIRECT; + trace.source = + classify_vehicle_lifecycle_source(trace.kind, trace.caller_rva); + trace.object = (DWORD)(uintptr_t)player_ped; + trace.gta_vehicle_ref = gta_vehicle_ref; + trace.seat = seat; + trace.vehicle_id = 0xffffu; + trace.gta_ped = player_ped != NULL + ? read_u32_or( + (uintptr_t)player_ped + + PROBE_SAMP_R5_PLAYER_PED_GTA_PED_OFFSET, + 0u) + : 0u; + if (trace.gta_ped != 0u) { + trace.ped_vehicle_pre = read_u32_or( + (uintptr_t)trace.gta_ped + PROBE_GTA_PED_VEHICLE_OFFSET, 0u); + } + gta_vehicle = resolve_gta_vehicle_ref_readonly(gta_vehicle_ref); + trace.before.gta_vehicle = gta_vehicle; + capture_trailer_physics_vehicle( + gta_vehicle, 0, &trace.before.vehicle); + if (g_orig_samp_player_ped_put_direct != NULL) { + ((probe_samp_player_ped_put_direct_fn) + g_orig_samp_player_ped_put_direct)( + player_ped, gta_vehicle_ref, seat); + } + trace.result = 1u; + if (trace.gta_ped != 0u) { + trace.ped_vehicle_post = read_u32_or( + (uintptr_t)trace.gta_ped + PROBE_GTA_PED_VEHICLE_OFFSET, 0u); + } + gta_vehicle = resolve_gta_vehicle_ref_readonly(gta_vehicle_ref); + trace.after.gta_vehicle = gta_vehicle; + capture_trailer_physics_vehicle( + gta_vehicle, 0, &trace.after.vehicle); + publish_vehicle_lifecycle_trace(&trace); +} + +static BYTE PROBE_THISCALL hook_gta_trailer_set_tow_link( + void *trailer, void *tractor, DWORD set_my_pos_raw) { + probe_trailer_physics_trace trace; + BYTE result = 0u; + + memset(&trace, 0, sizeof(trace)); + trace.event_seq = InterlockedIncrement(&g_trailer_trace_event_seq); + trace.kind = PROBE_TRAILER_PHYSICS_EVENT_SET_TOW_LINK; + trace.frame_index = 0xffffffffu; + trace.trailer = (DWORD)(uintptr_t)trailer; + trace.tractor = (DWORD)(uintptr_t)tractor; + trace.set_my_pos_raw = set_my_pos_raw; + capture_trailer_physics_pair(trace.trailer, trace.tractor, &trace.before); + + if (g_orig_gta_trailer_set_tow_link != NULL) { + result = ((probe_gta_trailer_set_tow_link_fn) + g_orig_gta_trailer_set_tow_link)( + trailer, tractor, set_my_pos_raw); + } + trace.result = result; + capture_trailer_physics_pair(trace.trailer, trace.tractor, &trace.after); + if (result != 0u) { + trace.generation = arm_trailer_physics_track( + trace.trailer, trace.tractor, &trace.after.trailer, + &trace.after.tractor); + } + publish_trailer_physics_trace(&trace); + return result; +} + +static void PROBE_THISCALL hook_gta_trailer_process_control(void *trailer) { + probe_trailer_physics_trace trace; + DWORD tractor = 0u; + DWORD frame_index = 0u; + LONG generation = 0; + + if (!claim_trailer_physics_frame((DWORD)(uintptr_t)trailer, &tractor, + &generation, &frame_index)) { + if (g_orig_gta_trailer_process_control != NULL) { + ((probe_gta_trailer_process_control_fn) + g_orig_gta_trailer_process_control)(trailer); + } + return; + } + + memset(&trace, 0, sizeof(trace)); + trace.event_seq = InterlockedIncrement(&g_trailer_trace_event_seq); + trace.kind = PROBE_TRAILER_PHYSICS_EVENT_PROCESS_CONTROL; + trace.generation = generation; + trace.frame_index = frame_index; + trace.trailer = (DWORD)(uintptr_t)trailer; + trace.tractor = tractor; + capture_trailer_physics_pair(trace.trailer, trace.tractor, &trace.before); + if (g_orig_gta_trailer_process_control != NULL) { + ((probe_gta_trailer_process_control_fn) + g_orig_gta_trailer_process_control)(trailer); + } + capture_trailer_physics_pair(trace.trailer, trace.tractor, &trace.after); + publish_trailer_physics_trace(&trace); +} + +static const char *trailer_physics_event_name(BYTE kind) { + switch (kind) { + case PROBE_TRAILER_PHYSICS_EVENT_SET_TOW_LINK: + return "set_tow_link"; + case PROBE_TRAILER_PHYSICS_EVENT_PROCESS_CONTROL: + return "process_control"; + default: + return "unknown"; + } +} + +static const char *vehicle_lifecycle_source_name(BYTE source) { + switch (source) { + case PROBE_VEHICLE_LIFECYCLE_SOURCE_RPC164: + return "rpc164"; + case PROBE_VEHICLE_LIFECYCLE_SOURCE_POOL_RETRY: + return "pool_retry"; + case PROBE_VEHICLE_LIFECYCLE_SOURCE_REMOTE_SYNC: + return "remote_sync"; + case PROBE_VEHICLE_LIFECYCLE_SOURCE_RPC70: + return "rpc70"; + case PROBE_VEHICLE_LIFECYCLE_SOURCE_INTERNAL: + return "internal"; + default: + return "none"; + } +} + +static void log_trailer_physics_state( + const probe_trailer_physics_trace *trace, const char *phase, + const probe_trailer_physics_pair_state *state) { + const probe_trailer_physics_vehicle_state *t; + const probe_trailer_physics_vehicle_state *r; + + if (trace == NULL || phase == NULL || state == NULL) { + return; + } + t = &state->trailer; + r = &state->tractor; + probe_log( + "trailer_physics_state: seq=%ld event=%ld generation=%ld frame=%lu " + "phase=%s " + "trailer=0x%08lx valid=0x%02x vtable=0x%08lx matrix=0x%08lx " + "basis_r=(%.6f,%.6f,%.6f) basis_f=(%.6f,%.6f,%.6f) " + "basis_u=(%.6f,%.6f,%.6f) pos=(%.6f,%.6f,%.6f) " + "move=(%.6f,%.6f,%.6f) turn=(%.6f,%.6f,%.6f) " + "flags=0x%08lx status=0x%02x fake=0x%02x " + "vehicle_flags=0x%08lx driver=0x%08lx " + "tow=0x%08lx reverse=0x%08lx " + "tractor=0x%08lx valid=0x%02x vtable=0x%08lx matrix=0x%08lx " + "basis_r=(%.6f,%.6f,%.6f) basis_f=(%.6f,%.6f,%.6f) " + "basis_u=(%.6f,%.6f,%.6f) pos=(%.6f,%.6f,%.6f) " + "move=(%.6f,%.6f,%.6f) turn=(%.6f,%.6f,%.6f) " + "flags=0x%08lx status=0x%02x fake=0x%02x " + "vehicle_flags=0x%08lx driver=0x%08lx " + "tow=0x%08lx reverse=0x%08lx", + (long)trace->ring_seq, (long)trace->event_seq, + (long)trace->generation, (unsigned long)trace->frame_index, phase, + (unsigned long)t->object, (unsigned)t->valid_mask, + (unsigned long)t->vtable, (unsigned long)t->matrix, + (double)t->right[0], (double)t->right[1], (double)t->right[2], + (double)t->forward[0], (double)t->forward[1], + (double)t->forward[2], (double)t->up[0], (double)t->up[1], + (double)t->up[2], (double)t->position[0], (double)t->position[1], + (double)t->position[2], (double)t->move_speed[0], + (double)t->move_speed[1], (double)t->move_speed[2], + (double)t->turn_speed[0], (double)t->turn_speed[1], + (double)t->turn_speed[2], (unsigned long)t->flags, + (unsigned)t->status, (unsigned)t->fake_physics, + (unsigned long)t->vehicle_flags, (unsigned long)t->driver, + (unsigned long)t->towing_vehicle, (unsigned long)t->trailer_vehicle, + (unsigned long)r->object, (unsigned)r->valid_mask, + (unsigned long)r->vtable, (unsigned long)r->matrix, + (double)r->right[0], (double)r->right[1], (double)r->right[2], + (double)r->forward[0], (double)r->forward[1], + (double)r->forward[2], (double)r->up[0], (double)r->up[1], + (double)r->up[2], (double)r->position[0], (double)r->position[1], + (double)r->position[2], (double)r->move_speed[0], + (double)r->move_speed[1], (double)r->move_speed[2], + (double)r->turn_speed[0], (double)r->turn_speed[1], + (double)r->turn_speed[2], (unsigned long)r->flags, + (unsigned)r->status, (unsigned)r->fake_physics, + (unsigned long)r->vehicle_flags, (unsigned long)r->driver, + (unsigned long)r->towing_vehicle, (unsigned long)r->trailer_vehicle); + probe_log( + "trailer_physics_detail: seq=%ld event=%ld generation=%ld frame=%lu " + "phase=%s trailer_valid=0x%02x " + "support=(%.6f,%.6f,%.6f,%.6f,%.6f) " + "wheel=(%.6f,%.6f,%.6f,%.6f) " + "wheel_prev=(%.6f,%.6f,%.6f,%.6f) " + "spring=(%.6f,%.6f,%.6f,%.6f) " + "line=(%.6f,%.6f,%.6f,%.6f) ride=(%.6f,%.6f) " + "evidence=GTA_REVERSED_REF,STATIC_037,PROBE_TRACE,TODO_VERIFY", + (long)trace->ring_seq, (long)trace->event_seq, + (long)trace->generation, (unsigned long)trace->frame_index, phase, + (unsigned)t->valid_mask, (double)t->support[0], + (double)t->support[1], (double)t->support[2], + (double)t->support[3], (double)t->support[4], + (double)t->wheel_compression[0], + (double)t->wheel_compression[1], + (double)t->wheel_compression[2], + (double)t->wheel_compression[3], + (double)t->wheel_compression_previous[0], + (double)t->wheel_compression_previous[1], + (double)t->wheel_compression_previous[2], + (double)t->wheel_compression_previous[3], + (double)t->spring_length[0], (double)t->spring_length[1], + (double)t->spring_length[2], (double)t->spring_length[3], + (double)t->line_length[0], (double)t->line_length[1], + (double)t->line_length[2], (double)t->line_length[3], + (double)t->ride_height[0], (double)t->ride_height[1]); +} + +static void flush_trailer_physics_trace_ring(void) { + LONG write_seq = InterlockedCompareExchange( + &g_trailer_physics_trace_write_seq, 0, 0); + LONG pending = write_seq - g_trailer_physics_trace_flushed_seq; + + if (pending > (LONG)PROBE_TRAILER_PHYSICS_TRACE_RING) { + LONG skipped = pending - (LONG)PROBE_TRAILER_PHYSICS_TRACE_RING; + g_trailer_physics_trace_flushed_seq += skipped; + g_trailer_physics_trace_overflow_count += skipped; + probe_log( + "trailer_physics: overflow skipped=%ld total_skipped=%ld " + "write_seq=%ld evidence=PROBE_TRACE", + (long)skipped, (long)g_trailer_physics_trace_overflow_count, + (long)write_seq); + } + + while (g_trailer_physics_trace_flushed_seq < write_seq) { + LONG next_seq = g_trailer_physics_trace_flushed_seq + 1; + probe_trailer_physics_trace *slot = + &g_trailer_physics_trace_ring[ + ((unsigned long)(next_seq - 1)) % + PROBE_TRAILER_PHYSICS_TRACE_RING]; + probe_trailer_physics_trace trace; + LONG committed = InterlockedCompareExchange(&slot->committed_seq, 0, 0); + float trailer_delta[3]; + float tractor_delta[3]; + + if (committed != next_seq) { + break; + } + MemoryBarrier(); + memcpy(&trace, slot, sizeof(trace)); + MemoryBarrier(); + if (InterlockedCompareExchange(&slot->committed_seq, 0, 0) != next_seq) { + continue; + } + trailer_delta[0] = + trace.after.trailer.position[0] - trace.before.trailer.position[0]; + trailer_delta[1] = + trace.after.trailer.position[1] - trace.before.trailer.position[1]; + trailer_delta[2] = + trace.after.trailer.position[2] - trace.before.trailer.position[2]; + tractor_delta[0] = + trace.after.tractor.position[0] - trace.before.tractor.position[0]; + tractor_delta[1] = + trace.after.tractor.position[1] - trace.before.tractor.position[1]; + tractor_delta[2] = + trace.after.tractor.position[2] - trace.before.tractor.position[2]; + + probe_log( + "trailer_physics: seq=%ld event=%ld kind=%s generation=%ld " + "frame=%lu trailer=0x%08lx tractor=0x%08lx " + "set_my_pos_raw=0x%08lx result=%u " + "pre_tick=%lu pre_thread=%lu pre_valid=0x%02x pre_gta_frame=%lu " + "pre_game_ms=%lu pre_timestep=%.6f " + "post_tick=%lu post_thread=%lu post_valid=0x%02x " + "post_gta_frame=%lu post_game_ms=%lu post_timestep=%.6f " + "trailer_dpos=(%.6f,%.6f,%.6f) " + "tractor_dpos=(%.6f,%.6f,%.6f) " + "static_attach_path=rpc148:samp.dll+0x1AE50/opcode0893:" + "gta_sa.exe+0x720BB " + "evidence=GTA_REVERSED_REF,STATIC_037,PROBE_TRACE,TODO_VERIFY", + (long)trace.ring_seq, (long)trace.event_seq, + trailer_physics_event_name(trace.kind), (long)trace.generation, + (unsigned long)trace.frame_index, (unsigned long)trace.trailer, + (unsigned long)trace.tractor, (unsigned long)trace.set_my_pos_raw, + (unsigned)trace.result, (unsigned long)trace.before.timing.tick, + (unsigned long)trace.before.timing.thread_id, + (unsigned)trace.before.timing.valid_mask, + (unsigned long)trace.before.timing.gta_frame, + (unsigned long)trace.before.timing.game_time, + (double)trace.before.timing.time_step, + (unsigned long)trace.after.timing.tick, + (unsigned long)trace.after.timing.thread_id, + (unsigned)trace.after.timing.valid_mask, + (unsigned long)trace.after.timing.gta_frame, + (unsigned long)trace.after.timing.game_time, + (double)trace.after.timing.time_step, (double)trailer_delta[0], + (double)trailer_delta[1], (double)trailer_delta[2], + (double)tractor_delta[0], (double)tractor_delta[1], + (double)tractor_delta[2]); + log_trailer_physics_state(&trace, "pre", &trace.before); + log_trailer_physics_state(&trace, "post", &trace.after); + g_trailer_physics_trace_flushed_seq = next_seq; + } +} + +static const char *vehicle_lifecycle_event_name(BYTE kind) { + switch (kind) { + case PROBE_VEHICLE_LIFECYCLE_EVENT_POOL_NEW: + return "vehicle_pool_new"; + case PROBE_VEHICLE_LIFECYCLE_EVENT_PUT_DIRECT: + return "put_directly_in_vehicle"; + default: + return "unknown"; + } +} + +static void log_vehicle_lifecycle_state( + const probe_vehicle_lifecycle_trace *trace, const char *phase, + const probe_vehicle_lifecycle_pool_state *state) { + const probe_trailer_physics_vehicle_state *vehicle; + + if (trace == NULL || phase == NULL || state == NULL) { + return; + } + vehicle = &state->vehicle; + probe_log( + "vehicle_lifecycle_state_r5: seq=%ld event=%ld phase=%s " + "pool=0x%08lx id_valid=%u listed=0x%08lx wrapper=0x%08lx " + "wrapper_entity=0x%08lx gta_vehicle=0x%08lx " + "valid=0x%02x object=0x%08lx vtable=0x%08lx matrix=0x%08lx " + "entity_flags_1c=0x%08lx status_36=0x%02x fake_b8=0x%02x " + "vehicle_flags_428=0x%08lx driver_460=0x%08lx " + "passengers_464_480=" + "0x%08lx,0x%08lx,0x%08lx,0x%08lx," + "0x%08lx,0x%08lx,0x%08lx,0x%08lx " + "tow_4c4=0x%08lx trailer_4c8=0x%08lx " + "evidence=STATIC_037,PROBE_TRACE,TODO_VERIFY", + (long)trace->ring_seq, (long)trace->event_seq, phase, + (unsigned long)state->pool, (unsigned)state->id_valid, + (unsigned long)state->listed, (unsigned long)state->wrapper, + (unsigned long)state->wrapper_entity, + (unsigned long)state->gta_vehicle, (unsigned)vehicle->valid_mask, + (unsigned long)vehicle->object, (unsigned long)vehicle->vtable, + (unsigned long)vehicle->matrix, (unsigned long)vehicle->flags, + (unsigned)vehicle->status, (unsigned)vehicle->fake_physics, + (unsigned long)vehicle->vehicle_flags, + (unsigned long)vehicle->driver, + (unsigned long)vehicle->passengers[0], + (unsigned long)vehicle->passengers[1], + (unsigned long)vehicle->passengers[2], + (unsigned long)vehicle->passengers[3], + (unsigned long)vehicle->passengers[4], + (unsigned long)vehicle->passengers[5], + (unsigned long)vehicle->passengers[6], + (unsigned long)vehicle->passengers[7], + (unsigned long)vehicle->towing_vehicle, + (unsigned long)vehicle->trailer_vehicle); +} + +static void flush_vehicle_lifecycle_trace_ring(void) { + LONG write_seq = InterlockedCompareExchange( + &g_vehicle_lifecycle_trace_write_seq, 0, 0); + LONG pending = write_seq - g_vehicle_lifecycle_trace_flushed_seq; + + if (pending > (LONG)PROBE_VEHICLE_LIFECYCLE_TRACE_RING) { + LONG skipped = + pending - (LONG)PROBE_VEHICLE_LIFECYCLE_TRACE_RING; + g_vehicle_lifecycle_trace_flushed_seq += skipped; + g_vehicle_lifecycle_trace_overflow_count += skipped; + probe_log( + "vehicle_lifecycle_r5: overflow skipped=%ld total_skipped=%ld " + "write_seq=%ld evidence=PROBE_TRACE", + (long)skipped, (long)g_vehicle_lifecycle_trace_overflow_count, + (long)write_seq); + } + + while (g_vehicle_lifecycle_trace_flushed_seq < write_seq) { + LONG next_seq = g_vehicle_lifecycle_trace_flushed_seq + 1; + probe_vehicle_lifecycle_trace *slot = + &g_vehicle_lifecycle_trace_ring[ + ((unsigned long)(next_seq - 1)) % + PROBE_VEHICLE_LIFECYCLE_TRACE_RING]; + probe_vehicle_lifecycle_trace trace; + LONG committed = + InterlockedCompareExchange(&slot->committed_seq, 0, 0); + DWORD seat_occupant_pre = 0u; + DWORD seat_occupant_post = 0u; + + if (committed != next_seq) { + break; + } + MemoryBarrier(); + memcpy(&trace, slot, sizeof(trace)); + MemoryBarrier(); + if (InterlockedCompareExchange(&slot->committed_seq, 0, 0) != + next_seq) { + continue; + } + if (trace.kind == PROBE_VEHICLE_LIFECYCLE_EVENT_PUT_DIRECT) { + if (trace.seat == 0u) { + seat_occupant_pre = trace.before.vehicle.driver; + seat_occupant_post = trace.after.vehicle.driver; + } else if (trace.seat <= 8u) { + seat_occupant_pre = + trace.before.vehicle.passengers[trace.seat - 1u]; + seat_occupant_post = + trace.after.vehicle.passengers[trace.seat - 1u]; + } + } + + probe_log( + "vehicle_lifecycle_r5: seq=%ld event=%ld tick=%lu thread=%lu " + "kind=%s source=%s caller_rva=0x%08lx hook_rva=0x%08lx " + "object=0x%08lx input=0x%08lx input_valid=%u vehicle_id=%u " + "gta_ref=0x%08lx seat=%lu original_result_or_called=%u " + "gta_ped=0x%08lx ped_vehicle=0x%08lx->0x%08lx " + "seat_occupant=0x%08lx->0x%08lx occupant_matches_ped=%u " + "static_callers=" + "pool_rpc164:+0xE764;pool_retry:+0x1F338;" + "remote_sync:+0x148EA;rpc70:+0x196DC;internal:+0x9E442 " + "evidence=STATIC_037,PROBE_TRACE,TODO_VERIFY", + (long)trace.ring_seq, (long)trace.event_seq, + (unsigned long)trace.tick, (unsigned long)trace.thread_id, + vehicle_lifecycle_event_name(trace.kind), + vehicle_lifecycle_source_name(trace.source), + (unsigned long)trace.caller_rva, (unsigned long)trace.hook_rva, + (unsigned long)trace.object, (unsigned long)trace.input, + (unsigned)trace.input_valid, (unsigned)trace.vehicle_id, + (unsigned long)trace.gta_vehicle_ref, + (unsigned long)trace.seat, (unsigned)trace.result, + (unsigned long)trace.gta_ped, + (unsigned long)trace.ped_vehicle_pre, + (unsigned long)trace.ped_vehicle_post, + (unsigned long)seat_occupant_pre, + (unsigned long)seat_occupant_post, + trace.gta_ped != 0u && seat_occupant_post == trace.gta_ped); + log_vehicle_lifecycle_state(&trace, "pre", &trace.before); + log_vehicle_lifecycle_state(&trace, "post", &trace.after); + g_vehicle_lifecycle_trace_flushed_seq = next_seq; + } +} + +static DWORD resolve_gta_ped_for_player_index_readonly(DWORD player_index) { + uintptr_t slot; + + if (g_samp_base == 0u || + player_index >= PROBE_SAMP_R5_PLAYER_PED_TABLE_CAPACITY) { + return 0u; + } + slot = g_samp_base + PROBE_SAMP_R5_PLAYER_PED_TABLE_RVA + + ((uintptr_t)player_index * sizeof(DWORD)); + return read_u32_or(slot, 0u); +} + +static void capture_combat_shot_input( + const void *input, probe_combat_shot_state *shot) { + uintptr_t address = (uintptr_t)input; + + if (shot == NULL) { + return; + } + memset(shot, 0, sizeof(*shot)); + /* + * STATIC_037: + * samp.dll+0xAF280 consumes a 0x2C-byte internal shot context: DWORD + * header, three vectors at +0x04/+0x10/+0x1C, and resolved target entity at + * +0x28. Semantic meaning of header subfields remains TODO_VERIFY. + */ + if (address == 0u || !memory_is_readable(address, 0x2cu)) { + return; + } + memcpy(&shot->header, (const void *)address, sizeof(shot->header)); + memcpy(shot->origin, (const void *)(address + 0x04u), + sizeof(shot->origin)); + memcpy(shot->hit, (const void *)(address + 0x10u), + sizeof(shot->hit)); + memcpy(shot->offset, (const void *)(address + 0x1cu), + sizeof(shot->offset)); + memcpy(&shot->target, (const void *)(address + 0x28u), + sizeof(shot->target)); + shot->active = 1u; + shot->valid = 1u; +} + +static void capture_combat_ped_state( + DWORD player_ped, DWORD gta_ped_override, DWORD player_index, + int player_index_valid, probe_combat_ped_state *state) { + uintptr_t wrapper = (uintptr_t)player_ped; + uintptr_t ped; + uintptr_t aim_context; + uintptr_t aim_source; + uintptr_t intelligence; + uintptr_t matrix; + uintptr_t weapon; + BYTE slot = 0xffu; + + if (state == NULL) { + return; + } + memset(state, 0, sizeof(*state)); + state->weapon_slot = 0xffu; + state->player_ped = player_ped; + + if (wrapper != 0u && + memory_is_readable( + wrapper, PROBE_SAMP_R5_PLAYER_PED_SHOT_ACTIVE_OFFSET + + sizeof(DWORD))) { + state->valid_mask |= PROBE_COMBAT_STATE_PLAYER_PED_VALID; + if (gta_ped_override == 0u) { + gta_ped_override = read_u32_or( + wrapper + PROBE_SAMP_R5_PLAYER_PED_GTA_PED_OFFSET, 0u); + } + if (!player_index_valid) { + memcpy(&state->player_index, + (const void *)(wrapper + + PROBE_SAMP_R5_PLAYER_PED_INDEX_OFFSET), + sizeof(state->player_index)); + player_index = state->player_index; + player_index_valid = + player_index < PROBE_SAMP_R5_PLAYER_PED_TABLE_CAPACITY; + } + memcpy(&state->stored_shot.header, + (const void *)(wrapper + + PROBE_SAMP_R5_PLAYER_PED_SHOT_CONTEXT_OFFSET), + sizeof(state->stored_shot.header)); + memcpy(state->stored_shot.origin, + (const void *)(wrapper + + PROBE_SAMP_R5_PLAYER_PED_SHOT_ORIGIN_OFFSET), + sizeof(state->stored_shot.origin)); + memcpy(state->stored_shot.hit, + (const void *)(wrapper + + PROBE_SAMP_R5_PLAYER_PED_SHOT_HIT_OFFSET), + sizeof(state->stored_shot.hit)); + memcpy(state->stored_shot.offset, + (const void *)(wrapper + + PROBE_SAMP_R5_PLAYER_PED_SHOT_OFFSET_OFFSET), + sizeof(state->stored_shot.offset)); + memcpy(&state->stored_shot.target, + (const void *)(wrapper + + PROBE_SAMP_R5_PLAYER_PED_SHOT_TARGET_OFFSET), + sizeof(state->stored_shot.target)); + memcpy(&state->stored_shot.active, + (const void *)(wrapper + + PROBE_SAMP_R5_PLAYER_PED_SHOT_ACTIVE_OFFSET), + sizeof(state->stored_shot.active)); + state->stored_shot.valid = 1u; + state->valid_mask |= PROBE_COMBAT_STATE_SHOT_VALID; + } + if (player_index_valid) { + state->player_index = (BYTE)player_index; + } + + if (g_samp_base != 0u) { + state->aim_context = read_u32_or( + g_samp_base + PROBE_SAMP_R5_AIM_CONTEXT_PTR_RVA, 0u); + aim_context = (uintptr_t)state->aim_context; + if (aim_context != 0u && + memory_is_readable( + aim_context, PROBE_SAMP_R5_AIM_CONTEXT_BYTES)) { + memcpy(state->aim_words, (const void *)aim_context, + sizeof(state->aim_words)); + state->valid_mask |= PROBE_COMBAT_STATE_AIM_VALID; + } + if (player_index_valid && + player_index < PROBE_SAMP_R5_PLAYER_PED_TABLE_CAPACITY) { + aim_source = + g_samp_base + PROBE_SAMP_R5_AIM_CONTEXT_ARRAY_RVA + + ((uintptr_t)player_index * PROBE_SAMP_R5_AIM_CONTEXT_BYTES); + state->aim_source = (DWORD)aim_source; + if (memory_is_readable( + aim_source, PROBE_SAMP_R5_AIM_CONTEXT_BYTES)) { + memcpy(state->aim_source_words, (const void *)aim_source, + sizeof(state->aim_source_words)); + state->valid_mask |= PROBE_COMBAT_STATE_AIM_SOURCE_VALID; + } + } + } + + state->gta_ped = gta_ped_override; + ped = (uintptr_t)state->gta_ped; + if (ped == 0u || + !memory_is_readable( + ped, PROBE_GTA_PED_CURRENT_WEAPON_SLOT_OFFSET + sizeof(BYTE))) { + return; + } + state->valid_mask |= PROBE_COMBAT_STATE_GTA_PED_VALID; + memcpy(&state->ped_vtable, (const void *)ped, + sizeof(state->ped_vtable)); + memcpy(&state->matrix, + (const void *)(ped + PROBE_GTA_ENTITY_MATRIX_PTR_OFFSET), + sizeof(state->matrix)); + memcpy(&state->ped_flags, + (const void *)(ped + PROBE_GTA_PED_FLAGS_OFFSET), + sizeof(state->ped_flags)); + memcpy(&state->ped_state, + (const void *)(ped + PROBE_GTA_PED_STATE_598_OFFSET), + sizeof(state->ped_state)); + memcpy(&state->intelligence, + (const void *)(ped + PROBE_GTA_PED_INTELLIGENCE_OFFSET), + sizeof(state->intelligence)); + memcpy(&state->aiming_rotation, + (const void *)(ped + PROBE_GTA_PED_AIMING_ROTATION_OFFSET), + sizeof(state->aiming_rotation)); + memcpy(&slot, + (const void *)(ped + PROBE_GTA_PED_CURRENT_WEAPON_SLOT_OFFSET), + sizeof(slot)); + state->weapon_slot = slot; + + matrix = (uintptr_t)state->matrix; + if (matrix != 0u && + memory_is_readable( + matrix, PROBE_GTA_MATRIX_POSITION_OFFSET + + sizeof(state->matrix_position))) { + memcpy(state->matrix_right, (const void *)matrix, + sizeof(state->matrix_right)); + memcpy(state->matrix_forward, (const void *)(matrix + 0x10u), + sizeof(state->matrix_forward)); + memcpy(state->matrix_up, (const void *)(matrix + 0x20u), + sizeof(state->matrix_up)); + memcpy(state->matrix_position, + (const void *)(matrix + PROBE_GTA_MATRIX_POSITION_OFFSET), + sizeof(state->matrix_position)); + state->valid_mask |= PROBE_COMBAT_STATE_MATRIX_VALID; + } + + intelligence = (uintptr_t)state->intelligence; + if (intelligence != 0u && + memory_is_readable( + intelligence + PROBE_GTA_PED_TASK_ROOTS_OFFSET, + sizeof(state->task_roots))) { + memcpy(state->task_roots, + (const void *)(intelligence + + PROBE_GTA_PED_TASK_ROOTS_OFFSET), + sizeof(state->task_roots)); + state->jetpack_task = read_u32_or( + intelligence + PROBE_GTA_PED_JETPACK_TASK_OFFSET, 0u); + if (state->jetpack_task != 0u) { + state->jetpack_task_vtable = + read_u32_or((uintptr_t)state->jetpack_task, 0u); + } + state->valid_mask |= PROBE_COMBAT_STATE_TASKS_VALID; + } + + if (slot >= 13u) { + return; + } + weapon = ped + PROBE_GTA_PED_WEAPON_SLOTS_OFFSET + + ((uintptr_t)slot * PROBE_GTA_WEAPON_SLOT_SIZE); + if (!memory_is_readable(weapon, PROBE_GTA_WEAPON_SLOT_SIZE)) { + return; + } + state->weapon = (DWORD)weapon; + memcpy(&state->weapon_type, (const void *)weapon, + sizeof(state->weapon_type)); + memcpy(&state->weapon_state, (const void *)(weapon + 0x04u), + sizeof(state->weapon_state)); + memcpy(&state->weapon_ammo_in_clip, + (const void *)(weapon + 0x08u), + sizeof(state->weapon_ammo_in_clip)); + memcpy(&state->weapon_total_ammo, + (const void *)(weapon + 0x0cu), + sizeof(state->weapon_total_ammo)); + state->valid_mask |= PROBE_COMBAT_STATE_WEAPON_VALID; +} + +static void begin_aim_bullet_jetpack_trace( + probe_aim_bullet_jetpack_trace *trace, BYTE kind, DWORD hook_rva, + DWORD object, DWORD input, void *caller) { + if (trace == NULL) { + return; + } + memset(trace, 0, sizeof(*trace)); + trace->event_seq = + InterlockedIncrement(&g_aim_bullet_jetpack_event_seq); + trace->tick = GetTickCount(); + trace->thread_id = GetCurrentThreadId(); + trace->caller_rva = samp_rva_from_address(caller); + trace->hook_rva = hook_rva; + trace->object = object; + trace->input = input; + trace->kind = kind; + if (read_u32_checked(PROBE_GTA_US10_FRAME_COUNTER_ADDR, + &trace->gta_frame)) { + trace->frame_valid = 1u; + } +} + +static void publish_aim_bullet_jetpack_trace( + probe_aim_bullet_jetpack_trace *trace) { + probe_aim_bullet_jetpack_trace *slot; + LONG ring_seq; + + if (trace == NULL) { + return; + } + ring_seq = + InterlockedIncrement(&g_aim_bullet_jetpack_trace_write_seq); + trace->ring_seq = ring_seq; + slot = &g_aim_bullet_jetpack_trace_ring[ + ((unsigned long)(ring_seq - 1)) % + PROBE_AIM_BULLET_JETPACK_TRACE_RING]; + InterlockedExchange(&slot->committed_seq, 0); + memcpy((BYTE *)slot + sizeof(slot->committed_seq), + (const BYTE *)trace + sizeof(trace->committed_seq), + sizeof(*trace) - sizeof(trace->committed_seq)); + MemoryBarrier(); + InterlockedExchange(&slot->committed_seq, ring_seq); +} + +static void WINAPI hook_samp_aim_context_install(DWORD player_index) { + probe_aim_bullet_jetpack_trace trace; + DWORD gta_ped = + resolve_gta_ped_for_player_index_readonly(player_index); + + begin_aim_bullet_jetpack_trace( + &trace, PROBE_AIM_BULLET_JETPACK_EVENT_AIM_INSTALL, + PROBE_SAMP_R5_AIM_CONTEXT_INSTALL_RVA, gta_ped, 0u, + probe_return_address()); + trace.args[0] = player_index; + capture_combat_ped_state( + 0u, gta_ped, player_index, 1, &trace.before); + if (g_orig_samp_aim_context_install != NULL) { + ((probe_samp_aim_context_install_fn) + g_orig_samp_aim_context_install)(player_index); + } + capture_combat_ped_state( + 0u, gta_ped, player_index, 1, &trace.after); + publish_aim_bullet_jetpack_trace(&trace); +} + +static void __cdecl hook_samp_aim_context_restore(void) { + probe_aim_bullet_jetpack_trace trace; + + begin_aim_bullet_jetpack_trace( + &trace, PROBE_AIM_BULLET_JETPACK_EVENT_AIM_RESTORE, + PROBE_SAMP_R5_AIM_CONTEXT_RESTORE_RVA, 0u, 0u, + probe_return_address()); + capture_combat_ped_state(0u, 0u, 0u, 0, &trace.before); + if (g_orig_samp_aim_context_restore != NULL) { + ((probe_samp_aim_context_restore_fn) + g_orig_samp_aim_context_restore)(); + } + capture_combat_ped_state(0u, 0u, 0u, 0, &trace.after); + publish_aim_bullet_jetpack_trace(&trace); +} + +static void PROBE_THISCALL hook_samp_player_ped_set_keys( + void *player_ped, DWORD keys, DWORD left_right, DWORD up_down) { + probe_aim_bullet_jetpack_trace trace; + + begin_aim_bullet_jetpack_trace( + &trace, PROBE_AIM_BULLET_JETPACK_EVENT_SET_KEYS, + PROBE_SAMP_R5_PLAYER_PED_SET_KEYS_RVA, + (DWORD)(uintptr_t)player_ped, 0u, probe_return_address()); + trace.args[0] = keys; + trace.args[1] = left_right; + trace.args[2] = up_down; + capture_combat_ped_state( + trace.object, 0u, 0u, 0, &trace.before); + if (g_orig_samp_player_ped_set_keys != NULL) { + ((probe_samp_player_ped_set_keys_fn) + g_orig_samp_player_ped_set_keys)( + player_ped, keys, left_right, up_down); + } + capture_combat_ped_state( + trace.object, 0u, 0u, 0, &trace.after); + publish_aim_bullet_jetpack_trace(&trace); +} + +static void PROBE_THISCALL hook_samp_player_ped_set_shot_context( + void *player_ped, const void *shot_context) { + probe_aim_bullet_jetpack_trace trace; + + begin_aim_bullet_jetpack_trace( + &trace, PROBE_AIM_BULLET_JETPACK_EVENT_SHOT_CONTEXT, + PROBE_SAMP_R5_PLAYER_PED_SET_SHOT_CONTEXT_RVA, + (DWORD)(uintptr_t)player_ped, (DWORD)(uintptr_t)shot_context, + probe_return_address()); + capture_combat_shot_input(shot_context, &trace.input_shot); + capture_combat_ped_state( + trace.object, 0u, 0u, 0, &trace.before); + if (g_orig_samp_player_ped_set_shot_context != NULL) { + ((probe_samp_player_ped_set_shot_context_fn) + g_orig_samp_player_ped_set_shot_context)( + player_ped, shot_context); + } + capture_combat_ped_state( + trace.object, 0u, 0u, 0, &trace.after); + publish_aim_bullet_jetpack_trace(&trace); +} + +static int PROBE_THISCALL hook_samp_player_ped_fire_remote( + void *player_ped) { + probe_aim_bullet_jetpack_trace trace; + int result = 0; + + begin_aim_bullet_jetpack_trace( + &trace, PROBE_AIM_BULLET_JETPACK_EVENT_FIRE_REMOTE, + PROBE_SAMP_R5_PLAYER_PED_FIRE_REMOTE_RVA, + (DWORD)(uintptr_t)player_ped, 0u, probe_return_address()); + capture_combat_ped_state( + trace.object, 0u, 0u, 0, &trace.before); + if (g_orig_samp_player_ped_fire_remote != NULL) { + result = ((probe_samp_player_ped_fire_remote_fn) + g_orig_samp_player_ped_fire_remote)(player_ped); + } + trace.result = (DWORD)result; + capture_combat_ped_state( + trace.object, 0u, 0u, 0, &trace.after); + publish_aim_bullet_jetpack_trace(&trace); + return result; +} + +static void PROBE_THISCALL hook_samp_player_ped_start_jetpack( + void *player_ped) { + probe_aim_bullet_jetpack_trace trace; + + begin_aim_bullet_jetpack_trace( + &trace, PROBE_AIM_BULLET_JETPACK_EVENT_JETPACK_START, + PROBE_SAMP_R5_PLAYER_PED_START_JETPACK_RVA, + (DWORD)(uintptr_t)player_ped, 0u, probe_return_address()); + capture_combat_ped_state( + trace.object, 0u, 0u, 0, &trace.before); + if (g_orig_samp_player_ped_start_jetpack != NULL) { + ((probe_samp_player_ped_jetpack_void_fn) + g_orig_samp_player_ped_start_jetpack)(player_ped); + } + trace.result = 1u; + capture_combat_ped_state( + trace.object, 0u, 0u, 0, &trace.after); + publish_aim_bullet_jetpack_trace(&trace); +} + +static void PROBE_THISCALL hook_samp_player_ped_stop_jetpack( + void *player_ped) { + probe_aim_bullet_jetpack_trace trace; + + begin_aim_bullet_jetpack_trace( + &trace, PROBE_AIM_BULLET_JETPACK_EVENT_JETPACK_STOP, + PROBE_SAMP_R5_PLAYER_PED_STOP_JETPACK_RVA, + (DWORD)(uintptr_t)player_ped, 0u, probe_return_address()); + capture_combat_ped_state( + trace.object, 0u, 0u, 0, &trace.before); + if (g_orig_samp_player_ped_stop_jetpack != NULL) { + ((probe_samp_player_ped_jetpack_void_fn) + g_orig_samp_player_ped_stop_jetpack)(player_ped); + } + trace.result = 1u; + capture_combat_ped_state( + trace.object, 0u, 0u, 0, &trace.after); + publish_aim_bullet_jetpack_trace(&trace); +} + +static int PROBE_THISCALL hook_samp_player_ped_is_in_jetpack( + void *player_ped) { + probe_aim_bullet_jetpack_trace trace; + int result = 0; + + begin_aim_bullet_jetpack_trace( + &trace, PROBE_AIM_BULLET_JETPACK_EVENT_JETPACK_QUERY, + PROBE_SAMP_R5_PLAYER_PED_IS_IN_JETPACK_RVA, + (DWORD)(uintptr_t)player_ped, 0u, probe_return_address()); + capture_combat_ped_state( + trace.object, 0u, 0u, 0, &trace.before); + if (g_orig_samp_player_ped_is_in_jetpack != NULL) { + result = ((probe_samp_player_ped_jetpack_query_fn) + g_orig_samp_player_ped_is_in_jetpack)(player_ped); + } + trace.result = (DWORD)result; + capture_combat_ped_state( + trace.object, 0u, 0u, 0, &trace.after); + publish_aim_bullet_jetpack_trace(&trace); + return result; +} + +static const char *aim_bullet_jetpack_event_name(BYTE kind) { + switch (kind) { + case PROBE_AIM_BULLET_JETPACK_EVENT_AIM_INSTALL: + return "aim_install"; + case PROBE_AIM_BULLET_JETPACK_EVENT_AIM_RESTORE: + return "aim_restore"; + case PROBE_AIM_BULLET_JETPACK_EVENT_SET_KEYS: + return "set_keys"; + case PROBE_AIM_BULLET_JETPACK_EVENT_SHOT_CONTEXT: + return "shot_context"; + case PROBE_AIM_BULLET_JETPACK_EVENT_FIRE_REMOTE: + return "fire_remote"; + case PROBE_AIM_BULLET_JETPACK_EVENT_JETPACK_START: + return "jetpack_start"; + case PROBE_AIM_BULLET_JETPACK_EVENT_JETPACK_STOP: + return "jetpack_stop"; + case PROBE_AIM_BULLET_JETPACK_EVENT_JETPACK_QUERY: + return "jetpack_query"; + default: + return "unknown"; + } +} + +static void log_aim_bullet_jetpack_state( + const probe_aim_bullet_jetpack_trace *trace, const char *phase, + const probe_combat_ped_state *state) { + float aim[PROBE_SAMP_R5_AIM_CONTEXT_BYTES / sizeof(DWORD)]; + float source[PROBE_SAMP_R5_AIM_CONTEXT_BYTES / sizeof(DWORD)]; + + if (trace == NULL || phase == NULL || state == NULL) { + return; + } + memcpy(aim, state->aim_words, sizeof(aim)); + memcpy(source, state->aim_source_words, sizeof(source)); + probe_log( + "aim_bullet_jetpack_state_r5: seq=%ld event=%ld phase=%s " + "valid=0x%08lx player_ped=0x%08lx player_index=%u " + "gta_ped=0x%08lx vtable=0x%08lx matrix=0x%08lx " + "basis_r=(%.6f,%.6f,%.6f) basis_f=(%.6f,%.6f,%.6f) " + "basis_u=(%.6f,%.6f,%.6f) pos=(%.6f,%.6f,%.6f) " + "ped_flags=0x%08lx ped_state=0x%08lx aiming_rotation=%.6f " + "intelligence=0x%08lx task_roots=" + "(0x%08lx,0x%08lx,0x%08lx,0x%08lx,0x%08lx," + "0x%08lx,0x%08lx,0x%08lx,0x%08lx,0x%08lx,0x%08lx) " + "jetpack_task=0x%08lx jetpack_vtable=0x%08lx " + "weapon_slot=%u weapon=0x%08lx type=%lu state=%lu " + "clip=%lu total=%lu " + "shot_valid=%u shot_active=%lu shot_header=0x%08lx " + "shot_target=0x%08lx origin=(%.6f,%.6f,%.6f) " + "hit=(%.6f,%.6f,%.6f) offset=(%.6f,%.6f,%.6f) " + "evidence=STATIC_037,GTA_REVERSED_REF,PROBE_TRACE,TODO_VERIFY", + (long)trace->ring_seq, (long)trace->event_seq, phase, + (unsigned long)state->valid_mask, + (unsigned long)state->player_ped, (unsigned)state->player_index, + (unsigned long)state->gta_ped, (unsigned long)state->ped_vtable, + (unsigned long)state->matrix, + (double)state->matrix_right[0], (double)state->matrix_right[1], + (double)state->matrix_right[2], + (double)state->matrix_forward[0], + (double)state->matrix_forward[1], + (double)state->matrix_forward[2], + (double)state->matrix_up[0], (double)state->matrix_up[1], + (double)state->matrix_up[2], + (double)state->matrix_position[0], + (double)state->matrix_position[1], + (double)state->matrix_position[2], + (unsigned long)state->ped_flags, + (unsigned long)state->ped_state, + (double)state->aiming_rotation, + (unsigned long)state->intelligence, + (unsigned long)state->task_roots[0], + (unsigned long)state->task_roots[1], + (unsigned long)state->task_roots[2], + (unsigned long)state->task_roots[3], + (unsigned long)state->task_roots[4], + (unsigned long)state->task_roots[5], + (unsigned long)state->task_roots[6], + (unsigned long)state->task_roots[7], + (unsigned long)state->task_roots[8], + (unsigned long)state->task_roots[9], + (unsigned long)state->task_roots[10], + (unsigned long)state->jetpack_task, + (unsigned long)state->jetpack_task_vtable, + (unsigned)state->weapon_slot, (unsigned long)state->weapon, + (unsigned long)state->weapon_type, + (unsigned long)state->weapon_state, + (unsigned long)state->weapon_ammo_in_clip, + (unsigned long)state->weapon_total_ammo, + (unsigned)state->stored_shot.valid, + (unsigned long)state->stored_shot.active, + (unsigned long)state->stored_shot.header, + (unsigned long)state->stored_shot.target, + (double)state->stored_shot.origin[0], + (double)state->stored_shot.origin[1], + (double)state->stored_shot.origin[2], + (double)state->stored_shot.hit[0], + (double)state->stored_shot.hit[1], + (double)state->stored_shot.hit[2], + (double)state->stored_shot.offset[0], + (double)state->stored_shot.offset[1], + (double)state->stored_shot.offset[2]); + probe_log( + "aim_bullet_jetpack_aim_r5: seq=%ld event=%ld phase=%s " + "active=0x%08lx source=0x%08lx " + "active_f=(%.6f,%.6f,%.6f,%.6f,%.6f,%.6f," + "%.6f,%.6f,%.6f,%.6f,%.6f,%.6f) " + "source_f=(%.6f,%.6f,%.6f,%.6f,%.6f,%.6f," + "%.6f,%.6f,%.6f,%.6f,%.6f,%.6f) " + "active_raw=" + "(%08lx,%08lx,%08lx,%08lx,%08lx,%08lx," + "%08lx,%08lx,%08lx,%08lx,%08lx,%08lx) " + "evidence=STATIC_037,PROBE_TRACE,TODO_VERIFY", + (long)trace->ring_seq, (long)trace->event_seq, phase, + (unsigned long)state->aim_context, + (unsigned long)state->aim_source, + (double)aim[0], (double)aim[1], (double)aim[2], + (double)aim[3], (double)aim[4], (double)aim[5], + (double)aim[6], (double)aim[7], (double)aim[8], + (double)aim[9], (double)aim[10], (double)aim[11], + (double)source[0], (double)source[1], (double)source[2], + (double)source[3], (double)source[4], (double)source[5], + (double)source[6], (double)source[7], (double)source[8], + (double)source[9], (double)source[10], (double)source[11], + (unsigned long)state->aim_words[0], + (unsigned long)state->aim_words[1], + (unsigned long)state->aim_words[2], + (unsigned long)state->aim_words[3], + (unsigned long)state->aim_words[4], + (unsigned long)state->aim_words[5], + (unsigned long)state->aim_words[6], + (unsigned long)state->aim_words[7], + (unsigned long)state->aim_words[8], + (unsigned long)state->aim_words[9], + (unsigned long)state->aim_words[10], + (unsigned long)state->aim_words[11]); +} + +static void flush_aim_bullet_jetpack_trace_ring(void) { + LONG write_seq = InterlockedCompareExchange( + &g_aim_bullet_jetpack_trace_write_seq, 0, 0); + LONG pending = + write_seq - g_aim_bullet_jetpack_trace_flushed_seq; + + if (pending > (LONG)PROBE_AIM_BULLET_JETPACK_TRACE_RING) { + LONG skipped = + pending - (LONG)PROBE_AIM_BULLET_JETPACK_TRACE_RING; + g_aim_bullet_jetpack_trace_flushed_seq += skipped; + g_aim_bullet_jetpack_trace_overflow_count += skipped; + probe_log( + "aim_bullet_jetpack_r5: overflow skipped=%ld " + "total_skipped=%ld write_seq=%ld", + (long)skipped, + (long)g_aim_bullet_jetpack_trace_overflow_count, + (long)write_seq); + } + + while (g_aim_bullet_jetpack_trace_flushed_seq < write_seq) { + LONG next_seq = g_aim_bullet_jetpack_trace_flushed_seq + 1; + probe_aim_bullet_jetpack_trace *slot = + &g_aim_bullet_jetpack_trace_ring[ + ((unsigned long)(next_seq - 1)) % + PROBE_AIM_BULLET_JETPACK_TRACE_RING]; + probe_aim_bullet_jetpack_trace trace; + LONG committed = + InterlockedCompareExchange(&slot->committed_seq, 0, 0); + + if (committed != next_seq) { + break; + } + MemoryBarrier(); + memcpy(&trace, slot, sizeof(trace)); + MemoryBarrier(); + if (InterlockedCompareExchange(&slot->committed_seq, 0, 0) != + next_seq) { + continue; + } + probe_log( + "aim_bullet_jetpack_r5: seq=%ld event=%ld tick=%lu thread=%lu " + "frame=%lu frame_valid=%u kind=%s caller_rva=0x%08lx " + "hook_rva=0x%08lx object=0x%08lx input=0x%08lx " + "args=(0x%08lx,0x%08lx,0x%08lx) result=0x%08lx " + "input_shot_valid=%u input_header=0x%08lx " + "input_target=0x%08lx origin=(%.6f,%.6f,%.6f) " + "hit=(%.6f,%.6f,%.6f) offset=(%.6f,%.6f,%.6f) " + "static_callers=" + "packet206_store:+0x1658D;packet206_fire:+0x16598;" + "remote_process_keys:+0x16FA1;" + "fire_aim_install:+0xAFAFA;fire_aim_restore:+0xAFC52 " + "evidence=STATIC_037,PROBE_TRACE,TODO_VERIFY", + (long)trace.ring_seq, (long)trace.event_seq, + (unsigned long)trace.tick, (unsigned long)trace.thread_id, + (unsigned long)trace.gta_frame, (unsigned)trace.frame_valid, + aim_bullet_jetpack_event_name(trace.kind), + (unsigned long)trace.caller_rva, + (unsigned long)trace.hook_rva, + (unsigned long)trace.object, (unsigned long)trace.input, + (unsigned long)trace.args[0], + (unsigned long)trace.args[1], + (unsigned long)trace.args[2], + (unsigned long)trace.result, + (unsigned)trace.input_shot.valid, + (unsigned long)trace.input_shot.header, + (unsigned long)trace.input_shot.target, + (double)trace.input_shot.origin[0], + (double)trace.input_shot.origin[1], + (double)trace.input_shot.origin[2], + (double)trace.input_shot.hit[0], + (double)trace.input_shot.hit[1], + (double)trace.input_shot.hit[2], + (double)trace.input_shot.offset[0], + (double)trace.input_shot.offset[1], + (double)trace.input_shot.offset[2]); + log_aim_bullet_jetpack_state(&trace, "pre", &trace.before); + log_aim_bullet_jetpack_state(&trace, "post", &trace.after); + g_aim_bullet_jetpack_trace_flushed_seq = next_seq; + } +} + +static BYTE PROBE_THISCALL hook_rakclient_rpc_bitstream( + void *rakclient, const BYTE *rpc_id_ptr, probe_raknet_bitstream_prefix *bitstream, + int priority, int reliability, char ordering_channel, + BYTE shift_timestamp) { + BYTE result = 0; + BYTE rpc_id = 0xffu; + int bits = -1; + int bytes = 0; + const BYTE *data = NULL; + char payload[PROBE_PAYLOAD_PREVIEW_BYTES * 3 + 8]; + char command[129]; + void *caller = probe_return_address(); + int dialog_focused; + int pickup_focused; + int focused; + + if (memory_is_readable((uintptr_t)rpc_id_ptr, sizeof(*rpc_id_ptr))) { + rpc_id = *rpc_id_ptr; + } + dialog_focused = + dialog_menu_rpc_hooks_enabled() && + (rpc_id == PROBE_SERVER_COMMAND_RPC || + rpc_id == PROBE_DIALOG_RESPONSE_RPC || + rpc_id == PROBE_MENU_SELECT_RPC || rpc_id == PROBE_MENU_QUIT_RPC); + /* + * The shared vtable hook is installed only by a focused dialog/pickup + * profile. Do not re-read the environment or flag files on this RakNet + * call path; probe_pickup_is_active() is an atomic installed-state check + * and remains false for a dialog-only run. + */ + pickup_focused = + probe_pickup_is_active() && + (rpc_id == PROBE_PICKUP_RPC || rpc_id == PROBE_PICKUP_WEAPON_RPC); + focused = dialog_focused || pickup_focused; + + payload[0] = '\0'; + command[0] = '\0'; + if (focused) { + if (bitstream == NULL) { + bits = 0; + } else if (memory_is_readable((uintptr_t)bitstream, sizeof(*bitstream))) { + bits = bitstream->number_of_bits_used; + data = bitstream->data; + if (bits >= 0 && (unsigned)bits <= PROBE_DIALOG_MENU_RPC_MAX_BITS) { + bytes = (bits + 7) / 8; + payload_to_hex(data, bytes, payload, sizeof(payload)); + } else { + snprintf(payload, sizeof(payload), "invalid_bits"); + } + } else { + snprintf(payload, sizeof(payload), "unreadable_bitstream"); + } + + if (rpc_id == PROBE_SERVER_COMMAND_RPC && bytes >= 4 && + memory_is_readable((uintptr_t)data, (size_t)bytes)) { + int command_length; + size_t preview_length; + size_t i; + memcpy(&command_length, data, sizeof(command_length)); + preview_length = command_length > 0 ? (size_t)command_length : 0u; + if (preview_length > (size_t)(bytes - 4)) { + preview_length = (size_t)(bytes - 4); + } + if (preview_length > sizeof(command) - 1) { + preview_length = sizeof(command) - 1; + } + memcpy(command, data + 4, preview_length); + command[preview_length] = '\0'; + for (i = 0; i < preview_length; ++i) { + if ((unsigned char)command[i] < 0x20u || (unsigned char)command[i] > 0x7eu) { + command[i] = '.'; + } + } + probe_log("dialog_menu_rpc: before rpc=%u name=ServerCommand caller=%p caller_samp_rva=0x%08lx " + "bits=%d bytes=%d command_length=%d command='%s' priority=%d reliability=%d " + "channel=%d shift_timestamp=%u payload='%s' rakclient=%p bitstream=%p " + "overload=short evidence=STATIC_037,PROBE_TRACE,TODO_VERIFY", + (unsigned)rpc_id, caller, samp_rva_from_address(caller), bits, bytes, + command_length, command, priority, reliability, (int)ordering_channel, + (unsigned)shift_timestamp, payload, rakclient, bitstream); + } else if (rpc_id == PROBE_DIALOG_RESPONSE_RPC && bytes >= 6 && + memory_is_readable((uintptr_t)data, (size_t)bytes)) { + short dialog_id; + short list_item; + unsigned input_length = data[5]; + memcpy(&dialog_id, data, sizeof(dialog_id)); + memcpy(&list_item, data + 3, sizeof(list_item)); + probe_log("dialog_menu_rpc: before rpc=%u name=DialogResponse caller=%p caller_samp_rva=0x%08lx " + "bits=%d bytes=%d dialog=%d response=%u list_item=%d input_length=%u " + "priority=%d reliability=%d channel=%d shift_timestamp=%u payload='%s' " + "rakclient=%p bitstream=%p overload=short " + "evidence=STATIC_037,PROBE_TRACE,TODO_VERIFY", + (unsigned)rpc_id, caller, samp_rva_from_address(caller), bits, bytes, + (int)dialog_id, (unsigned)data[2], (int)list_item, input_length, + priority, reliability, (int)ordering_channel, (unsigned)shift_timestamp, + payload, rakclient, bitstream); + } else if (rpc_id == PROBE_MENU_SELECT_RPC && bytes >= 1 && + memory_is_readable((uintptr_t)data, 1)) { + probe_log("dialog_menu_rpc: before rpc=%u name=MenuSelect caller=%p caller_samp_rva=0x%08lx " + "bits=%d bytes=%d row=%u priority=%d reliability=%d channel=%d " + "shift_timestamp=%u payload='%s' rakclient=%p bitstream=%p " + "overload=short evidence=STATIC_037,PROBE_TRACE,TODO_VERIFY", + (unsigned)rpc_id, caller, samp_rva_from_address(caller), bits, bytes, + (unsigned)data[0], priority, reliability, (int)ordering_channel, + (unsigned)shift_timestamp, payload, rakclient, bitstream); + } else if (dialog_focused) { + probe_log("dialog_menu_rpc: before rpc=%u name=%s caller=%p caller_samp_rva=0x%08lx " + "bits=%d bytes=%d priority=%d reliability=%d channel=%d shift_timestamp=%u " + "payload='%s' rakclient=%p bitstream=%p overload=short " + "evidence=STATIC_037,PROBE_TRACE,TODO_VERIFY", + (unsigned)rpc_id, rpc_id == PROBE_MENU_QUIT_RPC ? "MenuQuit" : "focused_invalid", + caller, samp_rva_from_address(caller), bits, bytes, priority, reliability, + (int)ordering_channel, (unsigned)shift_timestamp, payload, rakclient, + bitstream); + } + } + + if (g_orig_rakclient_rpc_bitstream != NULL) { + result = ((probe_rakclient_rpc_bitstream_fn)g_orig_rakclient_rpc_bitstream)( + rakclient, rpc_id_ptr, bitstream, priority, reliability, ordering_channel, + shift_timestamp); + } + if (pickup_focused) { + probe_pickup_observe_rpc( + rpc_id, data, bits, priority, reliability, ordering_channel, + samp_rva_from_address(caller), result); + } + if (dialog_focused) { + probe_log("dialog_menu_rpc: after rpc=%u caller_samp_rva=0x%08lx result=%u " + "evidence=OBSERVED_037,PROBE_TRACE,TODO_VERIFY", + (unsigned)rpc_id, samp_rva_from_address(caller), (unsigned)result); + } + return result; +} + +static int install_dialog_menu_rpc_hook(int log_summary) { + DWORD netgame_value; + DWORD rakclient_value; + void *rakclient; + void **vtable; + void **slot; + void *current; + DWORD old_protect; + DWORD restore_protect; + const char *hook_log_name = + dialog_menu_rpc_hooks_enabled() ? "dialog_menu_rpc_hook" + : "pickup_rpc_hook"; + + if (!dialog_menu_rpc_hooks_enabled() && !pickup_hooks_enabled()) { + if (log_summary) { + probe_log("focused_rpc_hook: disabled; enable dialog/menu or pickup " + "RPC hooks"); + } + return 0; + } + if (!samp_r5_identity_matches()) { + if (log_summary) { + probe_log("%s: skip reason=unsupported_identity", hook_log_name); + } + return 0; + } + if (g_samp_base == 0 || PROBE_SAMP_R5_NETGAME_PTR_RVA > g_samp_size - sizeof(DWORD)) { + return 0; + } + netgame_value = read_u32_or(g_samp_base + PROBE_SAMP_R5_NETGAME_PTR_RVA, 0u); + if (netgame_value < 0x10000u || !memory_is_readable((uintptr_t)netgame_value, sizeof(DWORD))) { + if (log_summary) { + probe_log("%s: waiting reason=netgame_unavailable netgame=%p", + hook_log_name, (void *)(uintptr_t)netgame_value); + } + return 0; + } + rakclient_value = read_u32_or((uintptr_t)netgame_value, 0u); + rakclient = (void *)(uintptr_t)rakclient_value; + if (rakclient_value < 0x10000u || !memory_is_readable((uintptr_t)rakclient, sizeof(void *))) { + if (log_summary) { + probe_log("%s: waiting reason=rakclient_unavailable netgame=%p " + "rakclient=%p", + hook_log_name, (void *)(uintptr_t)netgame_value, rakclient); + } + return 0; + } + vtable = *(void ***)rakclient; + if (vtable == NULL || !memory_is_readable((uintptr_t)&vtable[PROBE_RAKCLIENT_RPC_BITSTREAM_VTBL_INDEX], + sizeof(void *))) { + return 0; + } + slot = &vtable[PROBE_RAKCLIENT_RPC_BITSTREAM_VTBL_INDEX]; + current = *slot; + if (current == (void *)hook_rakclient_rpc_bitstream) { + return 1; + } + /* STATIC_037: + * Ordinary R5 client RPC sends, including CPickupPool::PickedUp at + * samp.dll+0x13440, dispatch through vtable +0x64 (index 25). That short + * BitStream overload is the six-stack-argument wrapper at +0x34620 and + * returns with `ret 0x18`. Index 26/+0x68 is the distinct extended + * NetworkID/reply overload at +0x345B0 and cannot observe this call path. */ + if (!address_in_samp(current) || + samp_rva_from_address(current) != + PROBE_SAMP_R5_RAKCLIENT_RPC_BITSTREAM_RVA) { + if (log_summary) { + probe_log("%s: skip reason=unexpected_short_bitstream_target " + "rakclient=%p vtable=%p index=%u target=%p " + "actual_samp_rva=0x%08lx expected_samp_rva=0x%08lx", + hook_log_name, rakclient, vtable, + (unsigned)PROBE_RAKCLIENT_RPC_BITSTREAM_VTBL_INDEX, current, + samp_rva_from_address(current), + (unsigned long)PROBE_SAMP_R5_RAKCLIENT_RPC_BITSTREAM_RVA); + } + return 0; + } + if (g_orig_rakclient_rpc_bitstream != NULL && current != g_orig_rakclient_rpc_bitstream) { + if (log_summary) { + probe_log("%s: skip reason=vtable_changed rakclient=%p current=%p " + "original=%p", + hook_log_name, rakclient, current, + g_orig_rakclient_rpc_bitstream); + } + return 0; + } + if (!VirtualProtect(slot, sizeof(void *), PAGE_EXECUTE_READWRITE, &old_protect)) { + if (log_summary) { + probe_log("%s: VirtualProtect failed slot=%p err=%lu", + hook_log_name, slot, (unsigned long)GetLastError()); + } + return 0; + } + g_orig_rakclient_rpc_bitstream = current; + *slot = (void *)hook_rakclient_rpc_bitstream; + FlushInstructionCache(GetCurrentProcess(), slot, sizeof(void *)); + (void)VirtualProtect(slot, sizeof(void *), old_protect, &restore_protect); + probe_log("%s: installed netgame=%p rakclient=%p vtable=%p index=%u " + "slot=%p original=%p original_samp_rva=0x%08lx replacement=%p " + "overload=short evidence=STATIC_037,TODO_VERIFY", + hook_log_name, (void *)(uintptr_t)netgame_value, rakclient, vtable, + (unsigned)PROBE_RAKCLIENT_RPC_BITSTREAM_VTBL_INDEX, slot, current, + samp_rva_from_address(current), (void *)hook_rakclient_rpc_bitstream); + return 1; +} + +static int preflight_samp_font5_code_hooks(void) { + size_t i; + + /* STATIC_037 + TODO_VERIFY: + * Treat the two Font 5 hooks as one guarded probe. Validate every target, + * exact overwritten prologue, and decoded patch length before the first + * target is mutated. This avoids leaving only the prepare hook installed + * when the dispatch site is already owned by another module. */ + for (i = 0; i < sizeof(g_samp_font5_code_hooks) / sizeof(g_samp_font5_code_hooks[0]); ++i) { + probe_code_hook *hook = &g_samp_font5_code_hooks[i]; + void *patch_target; + size_t readable_len; + size_t patch_len; + + if (hook->expected_len < 5 || hook->expected_len > sizeof(hook->expected) || + hook->expected_len > PROBE_INLINE_HOOK_MAX_COPY) { + probe_log("font5_code_hook: pair_preflight_failed name=%s rva=0x%08lx reason=invalid_expected_len len=%lu", + hook->name, (unsigned long)hook->rva, (unsigned long)hook->expected_len); + return 0; + } + + readable_len = hook->expected_len > 8 ? hook->expected_len : 8; + if (g_samp_base == 0 || g_samp_size < readable_len || hook->rva >= g_samp_size || + hook->rva > g_samp_size - readable_len) { + probe_log("font5_code_hook: pair_preflight_failed name=%s rva=0x%08lx reason=target_bounds", + hook->name, (unsigned long)hook->rva); return 0; } @@ -4467,11 +8022,18 @@ static DWORD WINAPI probe_worker(LPVOID param) { probe_log("probe: attached build=%s %s", __DATE__, __TIME__); probe_log("probe: options asset_path_hooks=%d asset_read_hooks=%d samp_code_hooks=%d gta_asset_hooks=%d " "object_info=%d custom_object_heavy=%d textdraw_hooks=%d textdraw_verbose=%d textdraw_render=%d " - "font5_hooks=%d actor_hooks=%d actor_heavy=%d rpc_gap_hooks=%d dialog_menu_rpc_hooks=%d", + "font5_hooks=%d actor_hooks=%d actor_heavy=%d rpc_gap_hooks=%d dialog_menu_rpc_hooks=%d " + "trailer_sync_hooks=%d trailer_physics_hooks=%d vehicle_lifecycle_hooks=%d " + "aim_bullet_jetpack_hooks=%d death_cleanup_hooks=%d pickup_hooks=%d " + "ui_latches_hooks=%d", asset_path_hooks_enabled(), asset_read_hooks_enabled(), samp_code_hooks_enabled(), gta_asset_hooks_enabled(), object_info_enabled(), custom_object_heavy_enabled(), textdraw_hooks_enabled(), textdraw_verbose_enabled(), textdraw_render_enabled(), font5_hooks_enabled(), actor_hooks_enabled(), actor_heavy_enabled(), - rpc_gap_hooks_enabled(), dialog_menu_rpc_hooks_enabled()); + rpc_gap_hooks_enabled(), dialog_menu_rpc_hooks_enabled(), trailer_sync_hooks_enabled(), + trailer_physics_hooks_enabled(), vehicle_lifecycle_hooks_enabled(), + aim_bullet_jetpack_hooks_enabled(), + death_cleanup_hooks_enabled(), pickup_hooks_enabled(), + ui_latches_hooks_enabled()); if (custom_object_heavy_enabled()) { probe_log("custom_object_heavy: store_addresses model_info_ptrs=0x%08lx atomic_count=0x%08lx " "time_count=0x%08lx clump_count=0x%08lx low_range=%u-%u high_range=%u-%u " @@ -4520,6 +8082,43 @@ static DWORD WINAPI probe_worker(LPVOID param) { hooks_disabled = hooks_disabled_by_flag(); if (hooks_disabled) { probe_log("hook: disabled by flag/env"); + } else if (trailer_sync_hooks_enabled() || + trailer_physics_hooks_enabled() || + vehicle_lifecycle_hooks_enabled() || + aim_bullet_jetpack_hooks_enabled() || + death_cleanup_hooks_enabled() || + pickup_hooks_enabled() || + ui_latches_hooks_enabled()) { + /* + * STATIC_037 + PROBE_TRACE: + * Focused trailer and vehicle-lifecycle captures deliberately exclude the + * normal IAT, Winsock and unrelated code-hook sets. Their synchronous + * per-call logs would perturb the calls being measured. + */ + probe_log( + "hook: focused_mode=r5_memory sync=%d physics=%d lifecycle=%d " + "aim_bullet_jetpack=%d death_cleanup=%d pickup=%d ui_latches=%d " + "normal_hook_sets=skipped", + trailer_sync_hooks_enabled(), trailer_physics_hooks_enabled(), + vehicle_lifecycle_hooks_enabled(), + aim_bullet_jetpack_hooks_enabled(), + death_cleanup_hooks_enabled(), pickup_hooks_enabled(), + ui_latches_hooks_enabled()); + install_samp_trailer_sync_code_hook(1); + install_samp_vehicle_lifecycle_hooks(1); + install_samp_aim_bullet_jetpack_hooks(1); + (void)probe_death_cleanup_install( + g_samp_module, g_samp_size, death_cleanup_hooks_enabled(), + env_flag_enabled("SAMP_PROBE_NO_SAMP_CODE_HOOKS"), g_stop_event, + probe_log, 1); + (void)probe_pickup_install( + g_samp_module, g_samp_size, pickup_hooks_enabled(), + env_flag_enabled("SAMP_PROBE_NO_SAMP_CODE_HOOKS"), probe_log, 1); + (void)probe_ui_latches_install( + g_samp_module, g_samp_size, ui_latches_hooks_enabled(), + env_flag_enabled("SAMP_PROBE_NO_SAMP_CODE_HOOKS"), probe_log, 1); + install_gta_trailer_physics_hooks(1); + install_dialog_menu_rpc_hook(1); } else { install_iat_hooks(nt, 1); install_inline_hooks(1); @@ -4539,24 +8138,69 @@ static DWORD WINAPI probe_worker(LPVOID param) { while (WaitForSingleObject(g_stop_event, PROBE_WATCH_INTERVAL_MS) == WAIT_TIMEOUT) { if (!hooks_disabled) { - (void)install_iat_hooks(nt, 0); - (void)install_inline_hooks(0); - (void)install_samp_code_hooks(0); - (void)install_samp_font5_code_hooks(0); - (void)install_samp_actor_code_hooks(0); - (void)install_samp_rpc_gap_code_hooks(0); - (void)install_dialog_menu_rpc_hook(0); - (void)install_samp_actor_heavy_code_hooks(0); - (void)install_gta_actor_heavy_code_hooks(0); - (void)install_gta_textdraw_code_hooks(0); - (void)install_gta_asset_code_hooks(0); - (void)install_d3d_device_hooks(0); + if (trailer_sync_hooks_enabled() || + trailer_physics_hooks_enabled() || + vehicle_lifecycle_hooks_enabled() || + aim_bullet_jetpack_hooks_enabled() || + death_cleanup_hooks_enabled() || + pickup_hooks_enabled() || + ui_latches_hooks_enabled()) { + (void)install_samp_trailer_sync_code_hook(0); + (void)install_samp_vehicle_lifecycle_hooks(0); + (void)install_samp_aim_bullet_jetpack_hooks(0); + (void)probe_death_cleanup_install( + g_samp_module, g_samp_size, death_cleanup_hooks_enabled(), + env_flag_enabled("SAMP_PROBE_NO_SAMP_CODE_HOOKS"), g_stop_event, + probe_log, 0); + (void)probe_pickup_install( + g_samp_module, g_samp_size, pickup_hooks_enabled(), + env_flag_enabled("SAMP_PROBE_NO_SAMP_CODE_HOOKS"), probe_log, 0); + (void)probe_ui_latches_install( + g_samp_module, g_samp_size, ui_latches_hooks_enabled(), + env_flag_enabled("SAMP_PROBE_NO_SAMP_CODE_HOOKS"), probe_log, 0); + (void)install_gta_trailer_physics_hooks(0); + (void)install_dialog_menu_rpc_hook(0); + } else { + (void)install_iat_hooks(nt, 0); + (void)install_inline_hooks(0); + (void)install_samp_code_hooks(0); + (void)install_samp_font5_code_hooks(0); + (void)install_samp_actor_code_hooks(0); + (void)install_samp_rpc_gap_code_hooks(0); + (void)install_dialog_menu_rpc_hook(0); + (void)install_samp_actor_heavy_code_hooks(0); + (void)install_gta_actor_heavy_code_hooks(0); + (void)install_gta_textdraw_code_hooks(0); + (void)install_gta_asset_code_hooks(0); + (void)install_d3d_device_hooks(0); + } } + flush_trailer_sync_trace_ring(); + flush_trailer_physics_trace_ring(); + flush_vehicle_lifecycle_trace_ring(); + flush_aim_bullet_jetpack_trace_ring(); + probe_death_cleanup_flush(probe_log); + probe_pickup_flush(probe_log); + probe_ui_latches_flush(probe_log); sample_watchpoints(); sample_transition_state("tick"); } + flush_trailer_sync_trace_ring(); + flush_trailer_physics_trace_ring(); + flush_vehicle_lifecycle_trace_ring(); + flush_aim_bullet_jetpack_trace_ring(); + probe_death_cleanup_flush(probe_log); + probe_pickup_flush(probe_log); + probe_ui_latches_flush(probe_log); + probe_ui_latches_uninstall(probe_log); + probe_pickup_uninstall(probe_log); + probe_death_cleanup_uninstall(probe_log); + uninstall_samp_aim_bullet_jetpack_hooks(); + uninstall_samp_vehicle_lifecycle_hooks(); + uninstall_gta_trailer_physics_hooks(); probe_log("probe: stopping"); + probe_death_cleanup_complete_terminal_drain(probe_log); return 0; } diff --git a/tools/asi_probe/src/samp_probe_death_cleanup.c b/tools/asi_probe/src/samp_probe_death_cleanup.c new file mode 100644 index 0000000..5f9b8fb --- /dev/null +++ b/tools/asi_probe/src/samp_probe_death_cleanup.c @@ -0,0 +1,1875 @@ +#include "samp_probe_death_cleanup.h" + +#include +#include + +#if defined(__GNUC__) && defined(__i386__) +#define PROBE_DC_THISCALL __attribute__((thiscall)) +#else +#define PROBE_DC_THISCALL +#endif + +/* + * STATIC_037 + TODO_VERIFY: + * All SA-MP RVAs, entry/tail guards, and field offsets below were recovered + * from original R5 SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + * GTA absolute addresses are restricted to 1.0 US + * SHA256=a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26. + * See docs/re/death_cleanup_memory_probe_r5_20260728.md. + */ +#define PROBE_DC_R5_TIMESTAMP 0x6372c39eu +#define PROBE_DC_R5_ENTRY_RVA 0x000cbc90u +#define PROBE_DC_R5_IMAGE_SIZE 0x0027e000u +#define PROBE_DC_R5_PREFERRED_BASE 0x10000000u +#define PROBE_DC_R5_SHA256 \ + "b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2" + +#define PROBE_DC_GTA_TIMESTAMP 0x427101cau +#define PROBE_DC_GTA_ENTRY_RVA 0x00424570u +#define PROBE_DC_GTA_IMAGE_SIZE 0x01177000u +#define PROBE_DC_GTA_CHECKSUM 0x00dc5beau +#define PROBE_DC_GTA_PREFERRED_BASE 0x00400000u +#define PROBE_DC_GTA_SHA256 \ + "a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26" + +#define PROBE_DC_LOCAL_PROCESS_RVA 0x000074c0u +#define PROBE_DC_LOCAL_SPAWN_RVA 0x00003c20u +#define PROBE_DC_LOCAL_CLASS_SELECTION_RVA 0x00004080u +#define PROBE_DC_NETGAME_GMX_RESET_RVA 0x0000a540u +#define PROBE_DC_NETGAME_CONNECTION_LOST_RVA 0x0000acf0u +#define PROBE_DC_NETGAME_DESTRUCTOR_RVA 0x00009880u +#define PROBE_DC_CLEAN_QUIT_DESTRUCTOR_CALLER_RVA 0x000c507bu +#define PROBE_DC_EXITPROCESS_CALLSITE_RVA 0x000c508au +#define PROBE_DC_EXITPROCESS_CALLER_RVA 0x000c5091u +#define PROBE_DC_EXITPROCESS_IAT_RVA 0x000e5188u +#define PROBE_DC_TERMINAL_DRAIN_TIMEOUT_MS 1500u + +#define PROBE_DC_NETGAME_PTR_RVA 0x0026eb94u +#define PROBE_DC_SCOREBOARD_PTR_RVA 0x0026eb4cu +#define PROBE_DC_DIALOG_PTR_RVA 0x0026eb50u +#define PROBE_DC_TEXTDRAW_SELECTOR_PTR_RVA 0x0026eb54u +#define PROBE_DC_CHAT_PTR_RVA 0x0026eb84u +#define PROBE_DC_GAME_PTR_RVA 0x0026ebacu +#define PROBE_DC_CLASS_GUI_PTR_RVA 0x0026ec2cu +#define PROBE_DC_REMOVE_BUILDING_COUNT_RVA 0x0014fd88u + +#define PROBE_DC_NETGAME_STATE_OFFSET 0x000003cdu +#define PROBE_DC_NETGAME_POOLS_OFFSET 0x000003deu +#define PROBE_DC_PLAYER_POOL_LOCAL_OFFSET 0x00000026u +#define PROBE_DC_PLAYER_POOL_REMOTE_AUX_OFFSET 0x0000002au +#define PROBE_DC_PLAYER_POOL_REMOTE_WRAPPER_OFFSET 0x00001f8au +#define PROBE_DC_REMOTE_PLAYER_CAPACITY 1004u + +#define PROBE_DC_LOCAL_ACTIVE_OFFSET 0x000000f0u +#define PROBE_DC_LOCAL_WASTED_OFFSET 0x000000f4u +#define PROBE_DC_LOCAL_PED_WRAPPER_OFFSET 0x00000104u +#define PROBE_DC_LOCAL_SPECTATING_OFFSET 0x00000108u +#define PROBE_DC_LOCAL_CLEARED_TO_SPAWN_OFFSET 0x00000143u +#define PROBE_DC_LOCAL_CLASS_TICK_A_OFFSET 0x00000147u +#define PROBE_DC_LOCAL_CLASS_TICK_B_OFFSET 0x0000014bu +#define PROBE_DC_LOCAL_SPAWN_INFO_OFFSET 0x0000014fu +#define PROBE_DC_LOCAL_SPAWN_INFO_SIZE 46u +#define PROBE_DC_LOCAL_HAS_SPAWN_INFO_OFFSET 0x0000017du +#define PROBE_DC_LOCAL_WANTS_CLASS_OFFSET 0x000002fau +#define PROBE_DC_LOCAL_CLASS_SELECTION_OFFSET 0x00000302u +#define PROBE_DC_LOCAL_CLASS_INPUT_OFFSET 0x00000306u +#define PROBE_DC_LOCAL_CLASS_TICK_C_OFFSET 0x0000030au +#define PROBE_DC_LOCAL_VEHICLE_OFFSET 0x00000310u +#define PROBE_DC_LOCAL_SIZE 0x00000324u + +#define PROBE_DC_PLAYER_PED_GTA_PED_OFFSET 0x000002a4u +#define PROBE_DC_GTA_ENTITY_MATRIX_OFFSET 0x00000014u +#define PROBE_DC_GTA_ENTITY_RW_OBJECT_OFFSET 0x00000018u +#define PROBE_DC_GTA_ENTITY_FLAGS_OFFSET 0x0000001cu +#define PROBE_DC_GTA_ENTITY_STATUS_OFFSET 0x00000036u +#define PROBE_DC_GTA_PED_FLAGS_OFFSET 0x0000046cu +#define PROBE_DC_GTA_PED_INTELLIGENCE_OFFSET 0x0000047cu +#define PROBE_DC_GTA_PED_ACTION_OFFSET 0x00000530u +#define PROBE_DC_GTA_PED_HEALTH_OFFSET 0x00000540u +#define PROBE_DC_GTA_PED_STATE_OFFSET 0x00000598u +#define PROBE_DC_GTA_PED_MIN_CAPTURE_SIZE 0x0000059cu +#define PROBE_DC_GTA_TASK_ROOTS_OFFSET 0x00000004u +#define PROBE_DC_GTA_TASK_ROOT_COUNT 11u + +#define PROBE_DC_GTA_FRAME_COUNTER_ADDR 0x00b7cb4cu +#define PROBE_DC_GTA_CAMERA_MODE_ADDR 0x00b6f1a8u +#define PROBE_DC_GTA_CAMERA_MODE2_ADDR 0x00b6f858u +#define PROBE_DC_GTA_FRONTEND_STATE_A_ADDR 0x00ba67a4u +#define PROBE_DC_GTA_FRONTEND_STATE_B_ADDR 0x00ba67a5u +#define PROBE_DC_GTA_FRONTEND_STATE_C_ADDR 0x00ba67a6u + +#define PROBE_DC_POOLS_VEHICLE_OFFSET 0x00u +#define PROBE_DC_POOLS_PLAYER_OFFSET 0x04u +#define PROBE_DC_POOLS_PICKUP_OFFSET 0x08u +#define PROBE_DC_POOLS_OBJECT_OFFSET 0x0cu +#define PROBE_DC_POOLS_ACTOR_OFFSET 0x10u +#define PROBE_DC_POOLS_GANGZONE_OFFSET 0x14u +#define PROBE_DC_POOLS_TEXTDRAW_OFFSET 0x18u +#define PROBE_DC_POOLS_LABEL_OFFSET 0x1cu +#define PROBE_DC_POOLS_MENU_OFFSET 0x20u + +#define PROBE_DC_VEHICLE_WRAPPERS_OFFSET 0x00001134u +#define PROBE_DC_VEHICLE_LISTED_OFFSET 0x00003074u +#define PROBE_DC_VEHICLE_CAPACITY 2000u +#define PROBE_DC_VEHICLE_WRAPPER_ENTITY_OFFSET 0x00000040u +#define PROBE_DC_VEHICLE_WRAPPER_GTA_OFFSET 0x0000004cu +#define PROBE_DC_PICKUP_HANDLE_OFFSET 0x00000004u +#define PROBE_DC_PICKUP_SERVER_ID_OFFSET 0x00004004u +#define PROBE_DC_PICKUP_TIMER_OFFSET 0x00008004u +#define PROBE_DC_PICKUP_CAPACITY 4096u +#define PROBE_DC_OBJECT_WRAPPERS_OFFSET 0x00000004u +#define PROBE_DC_OBJECT_LISTED_OFFSET 0x00000fa4u +#define PROBE_DC_OBJECT_CAPACITY 1000u +#define PROBE_DC_ACTOR_WRAPPERS_OFFSET 0x00000004u +#define PROBE_DC_ACTOR_LISTED_OFFSET 0x00000fa4u +#define PROBE_DC_ACTOR_GTA_PED_OFFSET 0x00001f44u +#define PROBE_DC_ACTOR_CAPACITY 1000u +#define PROBE_DC_GANGZONE_LISTED_OFFSET 0x00001000u +#define PROBE_DC_GANGZONE_CAPACITY 1024u +#define PROBE_DC_TEXTDRAW_LISTED_OFFSET 0x0000e800u +#define PROBE_DC_TEXTDRAW_CAPACITY 2048u +#define PROBE_DC_LABEL_LISTED_OFFSET 0x00002400u +#define PROBE_DC_LABEL_CAPACITY 2304u +#define PROBE_DC_MENU_LISTED_OFFSET 0x00000200u +#define PROBE_DC_MENU_CURRENT_OFFSET 0x00000400u +#define PROBE_DC_MENU_CAPACITY 128u + +#define PROBE_DC_CHAT_ACTIVE_OFFSET 0x000014e0u +#define PROBE_DC_SCOREBOARD_VISIBLE_OFFSET 0x00000000u +#define PROBE_DC_DIALOG_ACTIVE_OFFSET 0x00000028u +#define PROBE_DC_TEXTDRAW_ACTIVE_OFFSET 0x00000024u +#define PROBE_DC_CLASS_GUI_VISIBLE_OFFSET 0x00000013u +#define PROBE_DC_GAME_INPUT_DEPTH_A_OFFSET 0x00000061u +#define PROBE_DC_GAME_INPUT_DEPTH_B_OFFSET 0x00000065u + +#define PROBE_DC_TRACE_RING_SIZE 256u +#define PROBE_DC_PROCESS_HEARTBEAT_MS 1000u + +#define PROBE_DC_EVENT_PROCESS 1u +#define PROBE_DC_EVENT_SPAWN 2u +#define PROBE_DC_EVENT_CLASS_SELECTION 3u +#define PROBE_DC_EVENT_GMX_RESET 4u +#define PROBE_DC_EVENT_CONNECTION_LOST 5u +#define PROBE_DC_EVENT_QUIT_DESTRUCTOR 6u + +typedef int(PROBE_DC_THISCALL *probe_dc_local_int_fn)(void *self); +typedef void(PROBE_DC_THISCALL *probe_dc_local_void_fn)(void *self); +typedef void(PROBE_DC_THISCALL *probe_dc_net_void_fn)(void *self); +typedef void(PROBE_DC_THISCALL *probe_dc_net_packet_fn)(void *self, + void *packet); +typedef VOID(WINAPI *probe_dc_exit_process_fn)(UINT exit_code); + +typedef struct probe_dc_local_state { + DWORD valid_mask; + DWORD netgame; + DWORD netgame_state; + DWORD pools; + DWORD player_pool; + DWORD local_player; + DWORD ped_wrapper; + DWORD gta_ped; + DWORD active; + DWORD wasted; + DWORD spectating; + DWORD cleared_to_spawn; + DWORD has_spawn_info; + DWORD wants_another_class; + DWORD class_selection; + DWORD class_input_owned; + DWORD class_tick_a; + DWORD class_tick_b; + DWORD class_tick_c; + DWORD current_vehicle; + DWORD spawn_info_hash; + DWORD spawn_info_head[4]; + DWORD ped_matrix; + DWORD ped_rw_object; + DWORD ped_entity_flags; + DWORD ped_flags; + DWORD ped_state; + DWORD ped_intelligence; + DWORD ped_health_bits; + DWORD task_roots[PROBE_DC_GTA_TASK_ROOT_COUNT]; + DWORD scoreboard; + DWORD scoreboard_visible; + DWORD dialog; + DWORD dialog_active; + DWORD textdraw_selector; + DWORD textdraw_active; + DWORD chat; + DWORD chat_active; + DWORD class_gui; + DWORD class_gui_visible; + DWORD game; + DWORD game_input_depth_a; + DWORD game_input_depth_b; + WORD camera_mode2; + BYTE action; + BYTE entity_status; + BYTE camera_mode; + BYTE frontend_a; + BYTE frontend_b; + BYTE frontend_c; +} probe_dc_local_state; + +typedef struct probe_dc_cleanup_state { + DWORD valid_mask; + DWORD netgame; + DWORD pools; + DWORD pool_ptrs[9]; + DWORD vehicle_listed; + DWORD vehicle_wrappers; + DWORD remote_aux; + DWORD remote_wrappers; + DWORD pickup_handles; + DWORD pickup_server_ids; + DWORD pickup_timers; + DWORD object_listed; + DWORD object_wrappers; + DWORD actor_listed; + DWORD actor_wrappers; + DWORD gangzone_listed; + DWORD textdraw_listed; + DWORD label_listed; + DWORD menu_listed; + DWORD menu_current; + DWORD remove_building_count; + DWORD first_vehicle_id; + DWORD first_vehicle_wrapper; + DWORD first_vehicle_entity; + DWORD first_vehicle_gta; + DWORD first_vehicle_matrix; + DWORD first_vehicle_rw_object; + DWORD first_vehicle_flags; + DWORD first_object_id; + DWORD first_object_wrapper; + DWORD first_actor_id; + DWORD first_actor_wrapper; + DWORD first_actor_gta_ped; +} probe_dc_cleanup_state; + +typedef struct probe_dc_trace { + volatile LONG committed_seq; + LONG ring_seq; + LONG event_seq; + DWORD tick; + DWORD thread_id; + DWORD gta_frame; + DWORD caller_rva; + DWORD hook_rva; + DWORD object; + DWORD argument; + DWORD result; + BYTE kind; + BYTE cleanup_valid; + probe_dc_local_state before_local; + probe_dc_local_state after_local; + probe_dc_cleanup_state before_cleanup; + probe_dc_cleanup_state after_cleanup; +} probe_dc_trace; + +typedef struct probe_dc_hook { + const char *name; + DWORD rva; + const BYTE *expected; + BYTE length; + void *replacement; + void *trampoline; + BYTE saved[16]; + volatile LONG installed; +} probe_dc_hook; + +static uintptr_t g_dc_samp_base; +static DWORD g_dc_samp_size; +static volatile LONG g_dc_install_state; +static probe_dc_trace g_dc_trace_ring[PROBE_DC_TRACE_RING_SIZE]; +static volatile LONG g_dc_trace_write_seq; +static LONG g_dc_trace_flushed_seq; +static LONG g_dc_trace_overflow_count; +static volatile LONG g_dc_event_seq; +static probe_dc_local_state g_dc_last_process_state; +static DWORD g_dc_last_process_tick; +static LONG g_dc_last_process_valid; +static DWORD g_dc_spawn_runtime_operand; +static DWORD g_dc_exit_runtime_iat_operand; +static probe_dc_exit_process_fn g_dc_original_exit_process; +static HANDLE g_dc_terminal_stop_event; +static HANDLE g_dc_terminal_done_event; +static volatile LONG g_dc_exit_iat_installed; +static volatile LONG g_dc_terminal_state; +static volatile LONG g_dc_clean_quit_destructor_seq; +static DWORD g_dc_terminal_exit_code; +static DWORD g_dc_terminal_caller_rva; +static DWORD g_dc_terminal_thread_id; +static DWORD g_dc_terminal_tick; +static DWORD g_dc_terminal_netgame; +static LONG g_dc_terminal_requested_seq; + +static const BYTE g_dc_process_entry[] = { + 0x83, 0xec, 0x10, 0x53, 0x55, 0x56, 0x8b, 0xf1}; +static const BYTE g_dc_spawn_entry[] = { + 0x64, 0xa1, 0x00, 0x00, 0x00, 0x00, 0x6a, + 0xff, 0x68, 0x0b, 0xff, 0x0d, 0x10}; +static const BYTE g_dc_class_entry[] = { + 0x56, 0x8b, 0xf1, 0x8b, 0x8e, 0x04, 0x01, 0x00, 0x00}; +static const BYTE g_dc_gmx_entry[] = { + 0x53, 0x55, 0x56, 0x57, 0x33, 0xdb, + 0x33, 0xff, 0x8b, 0xf1, 0x33, 0xed}; +static const BYTE g_dc_lost_entry[] = { + 0x57, 0x8b, 0xf9, 0x8b, 0x0f, 0x85, 0xc9}; +static const BYTE g_dc_destructor_entry[] = { + 0x53, 0x56, 0x57, 0x8b, 0xf1, 0x8b, 0x0e}; +static const BYTE g_dc_exit_callsite[] = { + 0x57, 0xff, 0x15, 0x88, 0x51, 0x0e, + 0x10, 0x61, 0x5f, 0x5e, 0x5b, 0xc3}; + +static int PROBE_DC_THISCALL hook_dc_local_process(void *self); +static int PROBE_DC_THISCALL hook_dc_local_spawn(void *self); +static void PROBE_DC_THISCALL hook_dc_class_selection(void *self); +static void PROBE_DC_THISCALL hook_dc_gmx_reset(void *self); +static void PROBE_DC_THISCALL hook_dc_connection_lost(void *self, + void *packet); +static void PROBE_DC_THISCALL hook_dc_destructor(void *self); +static VOID WINAPI hook_dc_exit_process(UINT exit_code); + +static probe_dc_hook g_dc_hooks[] = { + {"CLocalPlayer::Process", PROBE_DC_LOCAL_PROCESS_RVA, + g_dc_process_entry, (BYTE)sizeof(g_dc_process_entry), + (void *)hook_dc_local_process, NULL, {0}, 0}, + {"CLocalPlayer::Spawn", PROBE_DC_LOCAL_SPAWN_RVA, + g_dc_spawn_entry, (BYTE)sizeof(g_dc_spawn_entry), + (void *)hook_dc_local_spawn, NULL, {0}, 0}, + {"CLocalPlayer::HandleClassSelection", + PROBE_DC_LOCAL_CLASS_SELECTION_RVA, g_dc_class_entry, + (BYTE)sizeof(g_dc_class_entry), (void *)hook_dc_class_selection, + NULL, {0}, 0}, + {"CNetGame::ShutdownForGameModeRestart", + PROBE_DC_NETGAME_GMX_RESET_RVA, g_dc_gmx_entry, + (BYTE)sizeof(g_dc_gmx_entry), (void *)hook_dc_gmx_reset, + NULL, {0}, 0}, + {"CNetGame::Packet_ConnectionLost", + PROBE_DC_NETGAME_CONNECTION_LOST_RVA, g_dc_lost_entry, + (BYTE)sizeof(g_dc_lost_entry), (void *)hook_dc_connection_lost, + NULL, {0}, 0}, + {"CNetGame::~CNetGame", PROBE_DC_NETGAME_DESTRUCTOR_RVA, + g_dc_destructor_entry, (BYTE)sizeof(g_dc_destructor_entry), + (void *)hook_dc_destructor, NULL, {0}, 0}, +}; + +static int dc_memory_is_readable(uintptr_t address, size_t size) { + uintptr_t cursor; + uintptr_t end; + + if (address == 0u || size == 0u || address > UINTPTR_MAX - size) { + return 0; + } + cursor = address; + end = address + size; + while (cursor < end) { + MEMORY_BASIC_INFORMATION mbi; + uintptr_t region_end; + DWORD protect; + if (VirtualQuery((const void *)cursor, &mbi, sizeof(mbi)) != sizeof(mbi) || + mbi.State != MEM_COMMIT) { + return 0; + } + protect = mbi.Protect & 0xffu; + if ((mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0u || + protect == PAGE_NOACCESS) { + return 0; + } + region_end = (uintptr_t)mbi.BaseAddress + mbi.RegionSize; + if (region_end <= cursor) { + return 0; + } + cursor = region_end < end ? region_end : end; + } + return 1; +} + +static DWORD dc_read_u32(uintptr_t address, DWORD fallback) { + DWORD value; + if (!dc_memory_is_readable(address, sizeof(value))) { + return fallback; + } + memcpy(&value, (const void *)address, sizeof(value)); + return value; +} + +static BYTE dc_read_u8(uintptr_t address, BYTE fallback) { + BYTE value; + if (!dc_memory_is_readable(address, sizeof(value))) { + return fallback; + } + memcpy(&value, (const void *)address, sizeof(value)); + return value; +} + +static DWORD dc_load_u32(uintptr_t address) { + DWORD value; + memcpy(&value, (const void *)address, sizeof(value)); + return value; +} + +static BYTE dc_load_u8(uintptr_t address) { + BYTE value; + memcpy(&value, (const void *)address, sizeof(value)); + return value; +} + +static DWORD dc_fnv1a(const BYTE *data, size_t size) { + DWORD hash = 2166136261u; + size_t i; + for (i = 0u; i < size; ++i) { + hash ^= data[i]; + hash *= 16777619u; + } + return hash; +} + +static DWORD dc_count_u32(uintptr_t address, DWORD capacity, + DWORD ignored_value, int count_not_equal) { + const DWORD *values; + DWORD count = 0u; + DWORD i; + if (capacity == 0u || + !dc_memory_is_readable(address, (size_t)capacity * sizeof(DWORD))) { + return 0xffffffffu; + } + values = (const DWORD *)address; + for (i = 0u; i < capacity; ++i) { + if (count_not_equal ? values[i] != ignored_value + : values[i] == ignored_value) { + ++count; + } + } + return count; +} + +static DWORD dc_find_first_pair(uintptr_t listed_address, + uintptr_t pointer_address, DWORD capacity, + DWORD *pointer_value) { + const DWORD *listed; + const DWORD *pointers; + DWORD i; + if (pointer_value != NULL) { + *pointer_value = 0u; + } + if (!dc_memory_is_readable( + listed_address, (size_t)capacity * sizeof(DWORD)) || + !dc_memory_is_readable( + pointer_address, (size_t)capacity * sizeof(DWORD))) { + return 0xffffffffu; + } + listed = (const DWORD *)listed_address; + pointers = (const DWORD *)pointer_address; + for (i = 0u; i < capacity; ++i) { + if (listed[i] != 0u || pointers[i] != 0u) { + if (pointer_value != NULL) { + *pointer_value = pointers[i]; + } + return i; + } + } + return 0xffffffffu; +} + +static int dc_pe_identity_matches(HMODULE module, DWORD timestamp, + DWORD entry_rva, DWORD image_size, + DWORD preferred_base, DWORD checksum, + int check_checksum, + int require_relocs_stripped) { + PIMAGE_DOS_HEADER dos; + PIMAGE_NT_HEADERS nt; + if (module == NULL || image_size < sizeof(IMAGE_NT_HEADERS)) { + return 0; + } + dos = (PIMAGE_DOS_HEADER)module; + if (!dc_memory_is_readable((uintptr_t)dos, sizeof(*dos)) || + dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew <= 0 || + (DWORD)dos->e_lfanew > + image_size - (DWORD)sizeof(IMAGE_NT_HEADERS)) { + return 0; + } + nt = (PIMAGE_NT_HEADERS)((BYTE *)module + dos->e_lfanew); + if (!dc_memory_is_readable((uintptr_t)nt, sizeof(*nt)) || + nt->Signature != IMAGE_NT_SIGNATURE || + nt->FileHeader.Machine != IMAGE_FILE_MACHINE_I386 || + nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR32_MAGIC || + nt->OptionalHeader.NumberOfRvaAndSizes <= + IMAGE_DIRECTORY_ENTRY_BASERELOC) { + return 0; + } + return nt->FileHeader.TimeDateStamp == timestamp && + nt->OptionalHeader.ImageBase == preferred_base && + nt->OptionalHeader.AddressOfEntryPoint == entry_rva && + nt->OptionalHeader.SizeOfImage == image_size && + (!check_checksum || nt->OptionalHeader.CheckSum == checksum) && + (require_relocs_stripped + ? (nt->FileHeader.Characteristics & + IMAGE_FILE_RELOCS_STRIPPED) != 0 + : ((nt->FileHeader.Characteristics & + IMAGE_FILE_RELOCS_STRIPPED) == 0 && + nt->OptionalHeader + .DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC] + .VirtualAddress != 0u && + nt->OptionalHeader + .DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC] + .Size != 0u)); +} + +static DWORD dc_caller_rva(void *caller) { + uintptr_t value = (uintptr_t)caller; + if (g_dc_samp_base == 0u || value < g_dc_samp_base || + value >= g_dc_samp_base + g_dc_samp_size) { + return 0xffffffffu; + } + return (DWORD)(value - g_dc_samp_base); +} + +static void dc_capture_ui(probe_dc_local_state *state) { + uintptr_t pointer; + state->scoreboard = + dc_load_u32(g_dc_samp_base + PROBE_DC_SCOREBOARD_PTR_RVA); + pointer = (uintptr_t)state->scoreboard; + state->scoreboard_visible = + dc_read_u32(pointer + PROBE_DC_SCOREBOARD_VISIBLE_OFFSET, 0xffffffffu); + + state->dialog = + dc_load_u32(g_dc_samp_base + PROBE_DC_DIALOG_PTR_RVA); + pointer = (uintptr_t)state->dialog; + state->dialog_active = + dc_read_u32(pointer + PROBE_DC_DIALOG_ACTIVE_OFFSET, 0xffffffffu); + + state->textdraw_selector = + dc_load_u32(g_dc_samp_base + PROBE_DC_TEXTDRAW_SELECTOR_PTR_RVA); + pointer = (uintptr_t)state->textdraw_selector; + state->textdraw_active = + dc_read_u32(pointer + PROBE_DC_TEXTDRAW_ACTIVE_OFFSET, 0xffffffffu); + + state->chat = dc_load_u32(g_dc_samp_base + PROBE_DC_CHAT_PTR_RVA); + pointer = (uintptr_t)state->chat; + state->chat_active = + dc_read_u32(pointer + PROBE_DC_CHAT_ACTIVE_OFFSET, 0xffffffffu); + + state->class_gui = + dc_load_u32(g_dc_samp_base + PROBE_DC_CLASS_GUI_PTR_RVA); + pointer = (uintptr_t)state->class_gui; + state->class_gui_visible = + dc_read_u8(pointer + PROBE_DC_CLASS_GUI_VISIBLE_OFFSET, 0xffu); + + state->game = dc_load_u32(g_dc_samp_base + PROBE_DC_GAME_PTR_RVA); + pointer = (uintptr_t)state->game; + state->game_input_depth_a = + dc_read_u32(pointer + PROBE_DC_GAME_INPUT_DEPTH_A_OFFSET, 0xffffffffu); + state->game_input_depth_b = + dc_read_u32(pointer + PROBE_DC_GAME_INPUT_DEPTH_B_OFFSET, 0xffffffffu); + + state->camera_mode = dc_load_u8(PROBE_DC_GTA_CAMERA_MODE_ADDR); + memcpy(&state->camera_mode2, + (const void *)PROBE_DC_GTA_CAMERA_MODE2_ADDR, + sizeof(state->camera_mode2)); + state->frontend_a = dc_load_u8(PROBE_DC_GTA_FRONTEND_STATE_A_ADDR); + state->frontend_b = dc_load_u8(PROBE_DC_GTA_FRONTEND_STATE_B_ADDR); + state->frontend_c = dc_load_u8(PROBE_DC_GTA_FRONTEND_STATE_C_ADDR); +} + +static void dc_capture_local(DWORD local_override, DWORD netgame_override, + probe_dc_local_state *state) { + uintptr_t local; + uintptr_t wrapper; + uintptr_t ped; + uintptr_t intelligence; + BYTE spawn_info[PROBE_DC_LOCAL_SPAWN_INFO_SIZE]; + + memset(state, 0, sizeof(*state)); + state->camera_mode = 0xffu; + state->camera_mode2 = 0xffffu; + state->frontend_a = 0xffu; + state->frontend_b = 0xffu; + state->frontend_c = 0xffu; + state->netgame_state = 0xffffffffu; + state->netgame = netgame_override != 0u + ? netgame_override + : dc_load_u32(g_dc_samp_base + + PROBE_DC_NETGAME_PTR_RVA); + if (state->netgame != 0u && + dc_memory_is_readable((uintptr_t)state->netgame, + PROBE_DC_NETGAME_POOLS_OFFSET + + sizeof(DWORD))) { + state->valid_mask |= 0x01u; + state->netgame_state = + dc_load_u32((uintptr_t)state->netgame + + PROBE_DC_NETGAME_STATE_OFFSET); + state->pools = + dc_load_u32((uintptr_t)state->netgame + + PROBE_DC_NETGAME_POOLS_OFFSET); + } + if (state->pools != 0u) { + state->player_pool = + dc_read_u32((uintptr_t)state->pools + + PROBE_DC_POOLS_PLAYER_OFFSET, + 0u); + } + state->local_player = local_override; + if (state->local_player == 0u && state->player_pool != 0u) { + state->local_player = + dc_read_u32((uintptr_t)state->player_pool + + PROBE_DC_PLAYER_POOL_LOCAL_OFFSET, + 0u); + } + local = (uintptr_t)state->local_player; + if (local != 0u && dc_memory_is_readable(local, PROBE_DC_LOCAL_SIZE)) { + state->valid_mask |= 0x02u; + state->active = dc_load_u32(local + PROBE_DC_LOCAL_ACTIVE_OFFSET); + state->wasted = dc_load_u32(local + PROBE_DC_LOCAL_WASTED_OFFSET); + state->ped_wrapper = + dc_load_u32(local + PROBE_DC_LOCAL_PED_WRAPPER_OFFSET); + state->spectating = + dc_load_u32(local + PROBE_DC_LOCAL_SPECTATING_OFFSET); + state->cleared_to_spawn = + dc_load_u32(local + PROBE_DC_LOCAL_CLEARED_TO_SPAWN_OFFSET); + state->has_spawn_info = + dc_load_u32(local + PROBE_DC_LOCAL_HAS_SPAWN_INFO_OFFSET); + state->wants_another_class = + dc_load_u32(local + PROBE_DC_LOCAL_WANTS_CLASS_OFFSET); + state->class_selection = + dc_load_u32(local + PROBE_DC_LOCAL_CLASS_SELECTION_OFFSET); + state->class_input_owned = + dc_load_u32(local + PROBE_DC_LOCAL_CLASS_INPUT_OFFSET); + state->class_tick_a = + dc_load_u32(local + PROBE_DC_LOCAL_CLASS_TICK_A_OFFSET); + state->class_tick_b = + dc_load_u32(local + PROBE_DC_LOCAL_CLASS_TICK_B_OFFSET); + state->class_tick_c = + dc_load_u32(local + PROBE_DC_LOCAL_CLASS_TICK_C_OFFSET); + state->current_vehicle = + dc_load_u32(local + PROBE_DC_LOCAL_VEHICLE_OFFSET); + memcpy(spawn_info, + (const void *)(local + PROBE_DC_LOCAL_SPAWN_INFO_OFFSET), + sizeof(spawn_info)); + state->spawn_info_hash = dc_fnv1a(spawn_info, sizeof(spawn_info)); + memcpy(state->spawn_info_head, spawn_info, + sizeof(state->spawn_info_head)); + } + + wrapper = (uintptr_t)state->ped_wrapper; + if (wrapper != 0u && + dc_memory_is_readable( + wrapper, PROBE_DC_PLAYER_PED_GTA_PED_OFFSET + sizeof(DWORD))) { + state->valid_mask |= 0x04u; + state->gta_ped = + dc_load_u32(wrapper + PROBE_DC_PLAYER_PED_GTA_PED_OFFSET); + } + ped = (uintptr_t)state->gta_ped; + if (ped != 0u && + dc_memory_is_readable(ped, PROBE_DC_GTA_PED_MIN_CAPTURE_SIZE)) { + state->valid_mask |= 0x08u; + state->ped_matrix = + dc_load_u32(ped + PROBE_DC_GTA_ENTITY_MATRIX_OFFSET); + state->ped_rw_object = + dc_load_u32(ped + PROBE_DC_GTA_ENTITY_RW_OBJECT_OFFSET); + state->ped_entity_flags = + dc_load_u32(ped + PROBE_DC_GTA_ENTITY_FLAGS_OFFSET); + state->entity_status = + dc_load_u8(ped + PROBE_DC_GTA_ENTITY_STATUS_OFFSET); + state->ped_flags = + dc_load_u32(ped + PROBE_DC_GTA_PED_FLAGS_OFFSET); + state->ped_intelligence = + dc_load_u32(ped + PROBE_DC_GTA_PED_INTELLIGENCE_OFFSET); + state->action = + dc_load_u8(ped + PROBE_DC_GTA_PED_ACTION_OFFSET); + state->ped_health_bits = + dc_load_u32(ped + PROBE_DC_GTA_PED_HEALTH_OFFSET); + state->ped_state = + dc_load_u32(ped + PROBE_DC_GTA_PED_STATE_OFFSET); + } else { + state->action = 0xffu; + state->entity_status = 0xffu; + } + intelligence = (uintptr_t)state->ped_intelligence; + if (intelligence != 0u && + dc_memory_is_readable( + intelligence + PROBE_DC_GTA_TASK_ROOTS_OFFSET, + sizeof(state->task_roots))) { + memcpy(state->task_roots, + (const void *)(intelligence + PROBE_DC_GTA_TASK_ROOTS_OFFSET), + sizeof(state->task_roots)); + state->valid_mask |= 0x10u; + } + dc_capture_ui(state); +} + +static void dc_capture_cleanup(DWORD netgame, + probe_dc_cleanup_state *state) { + uintptr_t pools; + uintptr_t pool; + DWORD pointer_value; + + memset(state, 0, sizeof(*state)); + state->first_vehicle_id = 0xffffffffu; + state->first_object_id = 0xffffffffu; + state->first_actor_id = 0xffffffffu; + state->vehicle_listed = 0xffffffffu; + state->vehicle_wrappers = 0xffffffffu; + state->remote_aux = 0xffffffffu; + state->remote_wrappers = 0xffffffffu; + state->pickup_handles = 0xffffffffu; + state->pickup_server_ids = 0xffffffffu; + state->pickup_timers = 0xffffffffu; + state->object_listed = 0xffffffffu; + state->object_wrappers = 0xffffffffu; + state->actor_listed = 0xffffffffu; + state->actor_wrappers = 0xffffffffu; + state->gangzone_listed = 0xffffffffu; + state->textdraw_listed = 0xffffffffu; + state->label_listed = 0xffffffffu; + state->menu_listed = 0xffffffffu; + state->menu_current = 0xffffffffu; + state->netgame = netgame; + if (netgame == 0u || + !dc_memory_is_readable((uintptr_t)netgame, + PROBE_DC_NETGAME_POOLS_OFFSET + + sizeof(DWORD))) { + return; + } + state->valid_mask |= 0x01u; + state->pools = + dc_load_u32((uintptr_t)netgame + PROBE_DC_NETGAME_POOLS_OFFSET); + pools = (uintptr_t)state->pools; + if (pools == 0u || !dc_memory_is_readable(pools, 9u * sizeof(DWORD))) { + state->remove_building_count = + dc_read_u32(g_dc_samp_base + + PROBE_DC_REMOVE_BUILDING_COUNT_RVA, + 0u); + return; + } + memcpy(state->pool_ptrs, (const void *)pools, sizeof(state->pool_ptrs)); + state->valid_mask |= 0x02u; + + pool = (uintptr_t)state->pool_ptrs[0]; + if (pool != 0u) { + state->vehicle_listed = + dc_count_u32(pool + PROBE_DC_VEHICLE_LISTED_OFFSET, + PROBE_DC_VEHICLE_CAPACITY, 0u, 1); + state->vehicle_wrappers = + dc_count_u32(pool + PROBE_DC_VEHICLE_WRAPPERS_OFFSET, + PROBE_DC_VEHICLE_CAPACITY, 0u, 1); + state->first_vehicle_id = + dc_find_first_pair(pool + PROBE_DC_VEHICLE_LISTED_OFFSET, + pool + PROBE_DC_VEHICLE_WRAPPERS_OFFSET, + PROBE_DC_VEHICLE_CAPACITY, &pointer_value); + state->first_vehicle_wrapper = pointer_value; + if (pointer_value != 0u) { + state->first_vehicle_entity = + dc_read_u32((uintptr_t)pointer_value + + PROBE_DC_VEHICLE_WRAPPER_ENTITY_OFFSET, + 0u); + state->first_vehicle_gta = + dc_read_u32((uintptr_t)pointer_value + + PROBE_DC_VEHICLE_WRAPPER_GTA_OFFSET, + 0u); + } + if (state->first_vehicle_gta != 0u) { + uintptr_t entity = (uintptr_t)state->first_vehicle_gta; + state->first_vehicle_matrix = + dc_read_u32(entity + PROBE_DC_GTA_ENTITY_MATRIX_OFFSET, 0u); + state->first_vehicle_rw_object = + dc_read_u32(entity + PROBE_DC_GTA_ENTITY_RW_OBJECT_OFFSET, 0u); + state->first_vehicle_flags = + dc_read_u32(entity + PROBE_DC_GTA_ENTITY_FLAGS_OFFSET, 0u); + } + } + + pool = (uintptr_t)state->pool_ptrs[1]; + if (pool != 0u) { + state->remote_aux = + dc_count_u32(pool + PROBE_DC_PLAYER_POOL_REMOTE_AUX_OFFSET, + PROBE_DC_REMOTE_PLAYER_CAPACITY, 0u, 1); + state->remote_wrappers = + dc_count_u32(pool + PROBE_DC_PLAYER_POOL_REMOTE_WRAPPER_OFFSET, + PROBE_DC_REMOTE_PLAYER_CAPACITY, 0u, 1); + } + + pool = (uintptr_t)state->pool_ptrs[2]; + if (pool != 0u) { + state->pickup_handles = + dc_count_u32(pool + PROBE_DC_PICKUP_HANDLE_OFFSET, + PROBE_DC_PICKUP_CAPACITY, 0u, 1); + state->pickup_server_ids = + dc_count_u32(pool + PROBE_DC_PICKUP_SERVER_ID_OFFSET, + PROBE_DC_PICKUP_CAPACITY, 0xffffffffu, 1); + state->pickup_timers = + dc_count_u32(pool + PROBE_DC_PICKUP_TIMER_OFFSET, + PROBE_DC_PICKUP_CAPACITY, 0u, 1); + } + + pool = (uintptr_t)state->pool_ptrs[3]; + if (pool != 0u) { + state->object_listed = + dc_count_u32(pool + PROBE_DC_OBJECT_LISTED_OFFSET, + PROBE_DC_OBJECT_CAPACITY, 0u, 1); + state->object_wrappers = + dc_count_u32(pool + PROBE_DC_OBJECT_WRAPPERS_OFFSET, + PROBE_DC_OBJECT_CAPACITY, 0u, 1); + state->first_object_id = + dc_find_first_pair(pool + PROBE_DC_OBJECT_LISTED_OFFSET, + pool + PROBE_DC_OBJECT_WRAPPERS_OFFSET, + PROBE_DC_OBJECT_CAPACITY, &pointer_value); + state->first_object_wrapper = pointer_value; + } + + pool = (uintptr_t)state->pool_ptrs[4]; + if (pool != 0u) { + state->actor_listed = + dc_count_u32(pool + PROBE_DC_ACTOR_LISTED_OFFSET, + PROBE_DC_ACTOR_CAPACITY, 0u, 1); + state->actor_wrappers = + dc_count_u32(pool + PROBE_DC_ACTOR_WRAPPERS_OFFSET, + PROBE_DC_ACTOR_CAPACITY, 0u, 1); + state->first_actor_id = + dc_find_first_pair(pool + PROBE_DC_ACTOR_LISTED_OFFSET, + pool + PROBE_DC_ACTOR_WRAPPERS_OFFSET, + PROBE_DC_ACTOR_CAPACITY, &pointer_value); + state->first_actor_wrapper = pointer_value; + if (state->first_actor_id != 0xffffffffu) { + state->first_actor_gta_ped = + dc_read_u32(pool + PROBE_DC_ACTOR_GTA_PED_OFFSET + + state->first_actor_id * sizeof(DWORD), + 0u); + } + } + + pool = (uintptr_t)state->pool_ptrs[5]; + if (pool != 0u) { + state->gangzone_listed = + dc_count_u32(pool + PROBE_DC_GANGZONE_LISTED_OFFSET, + PROBE_DC_GANGZONE_CAPACITY, 0u, 1); + } + pool = (uintptr_t)state->pool_ptrs[6]; + if (pool != 0u) { + state->textdraw_listed = + dc_count_u32(pool + PROBE_DC_TEXTDRAW_LISTED_OFFSET, + PROBE_DC_TEXTDRAW_CAPACITY, 0u, 1); + } + pool = (uintptr_t)state->pool_ptrs[7]; + if (pool != 0u) { + state->label_listed = + dc_count_u32(pool + PROBE_DC_LABEL_LISTED_OFFSET, + PROBE_DC_LABEL_CAPACITY, 0u, 1); + } + pool = (uintptr_t)state->pool_ptrs[8]; + if (pool != 0u) { + state->menu_listed = + dc_count_u32(pool + PROBE_DC_MENU_LISTED_OFFSET, + PROBE_DC_MENU_CAPACITY, 0u, 1); + state->menu_current = + dc_read_u8(pool + PROBE_DC_MENU_CURRENT_OFFSET, 0xffu); + } + state->remove_building_count = + dc_read_u32(g_dc_samp_base + PROBE_DC_REMOVE_BUILDING_COUNT_RVA, + 0u); +} + +static void dc_begin_trace(probe_dc_trace *trace, BYTE kind, DWORD hook_rva, + DWORD object, DWORD argument, void *caller) { + memset(trace, 0, sizeof(*trace)); + trace->event_seq = InterlockedIncrement(&g_dc_event_seq); + trace->tick = GetTickCount(); + trace->thread_id = GetCurrentThreadId(); + trace->gta_frame = dc_load_u32(PROBE_DC_GTA_FRAME_COUNTER_ADDR); + trace->caller_rva = dc_caller_rva(caller); + trace->hook_rva = hook_rva; + trace->object = object; + trace->argument = argument; + trace->kind = kind; +} + +static void dc_publish_trace(probe_dc_trace *trace) { + probe_dc_trace *slot; + LONG ring_seq; + if (trace == NULL) { + return; + } + ring_seq = InterlockedIncrement(&g_dc_trace_write_seq); + trace->ring_seq = ring_seq; + trace->committed_seq = 0; + slot = &g_dc_trace_ring[ + ((DWORD)ring_seq - 1u) % PROBE_DC_TRACE_RING_SIZE]; + InterlockedExchange(&slot->committed_seq, 0); + *slot = *trace; + MemoryBarrier(); + InterlockedExchange(&slot->committed_seq, ring_seq); +} + +static int PROBE_DC_THISCALL hook_dc_local_process(void *self) { + probe_dc_trace trace; + int result; + int publish; + dc_begin_trace(&trace, PROBE_DC_EVENT_PROCESS, + PROBE_DC_LOCAL_PROCESS_RVA, (DWORD)(uintptr_t)self, 0u, + __builtin_return_address(0)); + dc_capture_local((DWORD)(uintptr_t)self, 0u, &trace.before_local); + result = ((probe_dc_local_int_fn)g_dc_hooks[0].trampoline)(self); + trace.result = (DWORD)result; + dc_capture_local((DWORD)(uintptr_t)self, 0u, &trace.after_local); + publish = + memcmp(&trace.before_local, &trace.after_local, + sizeof(trace.before_local)) != 0 || + !g_dc_last_process_valid || + memcmp(&g_dc_last_process_state, &trace.after_local, + sizeof(g_dc_last_process_state)) != 0 || + (DWORD)(trace.tick - g_dc_last_process_tick) >= + PROBE_DC_PROCESS_HEARTBEAT_MS; + if (publish) { + g_dc_last_process_state = trace.after_local; + g_dc_last_process_tick = trace.tick; + g_dc_last_process_valid = 1; + dc_publish_trace(&trace); + } + return result; +} + +static int PROBE_DC_THISCALL hook_dc_local_spawn(void *self) { + probe_dc_trace trace; + int result; + dc_begin_trace(&trace, PROBE_DC_EVENT_SPAWN, + PROBE_DC_LOCAL_SPAWN_RVA, (DWORD)(uintptr_t)self, 0u, + __builtin_return_address(0)); + dc_capture_local((DWORD)(uintptr_t)self, 0u, &trace.before_local); + result = ((probe_dc_local_int_fn)g_dc_hooks[1].trampoline)(self); + trace.result = (DWORD)result; + dc_capture_local((DWORD)(uintptr_t)self, 0u, &trace.after_local); + dc_publish_trace(&trace); + return result; +} + +static void PROBE_DC_THISCALL hook_dc_class_selection(void *self) { + probe_dc_trace trace; + dc_begin_trace(&trace, PROBE_DC_EVENT_CLASS_SELECTION, + PROBE_DC_LOCAL_CLASS_SELECTION_RVA, + (DWORD)(uintptr_t)self, 0u, + __builtin_return_address(0)); + dc_capture_local((DWORD)(uintptr_t)self, 0u, &trace.before_local); + ((probe_dc_local_void_fn)g_dc_hooks[2].trampoline)(self); + dc_capture_local((DWORD)(uintptr_t)self, 0u, &trace.after_local); + dc_publish_trace(&trace); +} + +static void dc_run_cleanup_hook(BYTE kind, DWORD hook_rva, size_t hook_index, + void *self, void *caller) { + probe_dc_trace trace; + dc_begin_trace(&trace, kind, hook_rva, (DWORD)(uintptr_t)self, 0u, + caller); + trace.cleanup_valid = 1u; + dc_capture_local(0u, (DWORD)(uintptr_t)self, &trace.before_local); + dc_capture_cleanup((DWORD)(uintptr_t)self, &trace.before_cleanup); + ((probe_dc_net_void_fn)g_dc_hooks[hook_index].trampoline)(self); + dc_capture_local(0u, (DWORD)(uintptr_t)self, &trace.after_local); + dc_capture_cleanup((DWORD)(uintptr_t)self, &trace.after_cleanup); + dc_publish_trace(&trace); + if (kind == PROBE_DC_EVENT_QUIT_DESTRUCTOR && + trace.caller_rva == PROBE_DC_CLEAN_QUIT_DESTRUCTOR_CALLER_RVA) { + InterlockedExchange(&g_dc_clean_quit_destructor_seq, trace.ring_seq); + } +} + +static void PROBE_DC_THISCALL hook_dc_gmx_reset(void *self) { + dc_run_cleanup_hook(PROBE_DC_EVENT_GMX_RESET, + PROBE_DC_NETGAME_GMX_RESET_RVA, 3u, self, + __builtin_return_address(0)); +} + +static void PROBE_DC_THISCALL hook_dc_connection_lost(void *self, + void *packet) { + probe_dc_trace trace; + dc_begin_trace(&trace, PROBE_DC_EVENT_CONNECTION_LOST, + PROBE_DC_NETGAME_CONNECTION_LOST_RVA, + (DWORD)(uintptr_t)self, (DWORD)(uintptr_t)packet, + __builtin_return_address(0)); + trace.cleanup_valid = 1u; + dc_capture_local(0u, (DWORD)(uintptr_t)self, &trace.before_local); + dc_capture_cleanup((DWORD)(uintptr_t)self, &trace.before_cleanup); + ((probe_dc_net_packet_fn)g_dc_hooks[4].trampoline)(self, packet); + dc_capture_local(0u, (DWORD)(uintptr_t)self, &trace.after_local); + dc_capture_cleanup((DWORD)(uintptr_t)self, &trace.after_cleanup); + dc_publish_trace(&trace); +} + +static void PROBE_DC_THISCALL hook_dc_destructor(void *self) { + dc_run_cleanup_hook(PROBE_DC_EVENT_QUIT_DESTRUCTOR, + PROBE_DC_NETGAME_DESTRUCTOR_RVA, 5u, self, + __builtin_return_address(0)); +} + +static void dc_invoke_original_exit_process(UINT exit_code) { + probe_dc_exit_process_fn original = g_dc_original_exit_process; + if (original != NULL) { + original(exit_code); + } + ExitProcess(exit_code); + for (;;) { + Sleep(INFINITE); + } +} + +static VOID WINAPI hook_dc_exit_process(UINT exit_code) { + DWORD caller_rva = dc_caller_rva(__builtin_return_address(0)); + DWORD wait_result; + + /* + * STATIC_037: + * The clean R5 main-loop path calls the KERNEL32 ExitProcess import at + * samp.dll+0xC508B. Its return address is exactly +0xC5091. Other calls + * through the shared import slot must remain transparent. + */ + if (caller_rva != PROBE_DC_EXITPROCESS_CALLER_RVA || + InterlockedCompareExchange(&g_dc_install_state, 0, 0) != 1 || + g_dc_terminal_stop_event == NULL || + g_dc_terminal_done_event == NULL || + InterlockedCompareExchange(&g_dc_terminal_state, -1, 0) != 0) { + dc_invoke_original_exit_process(exit_code); + } + + if (!ResetEvent(g_dc_terminal_done_event)) { + dc_invoke_original_exit_process(exit_code); + } + g_dc_terminal_exit_code = (DWORD)exit_code; + g_dc_terminal_caller_rva = caller_rva; + g_dc_terminal_thread_id = GetCurrentThreadId(); + g_dc_terminal_tick = GetTickCount(); + g_dc_terminal_netgame = + dc_load_u32(g_dc_samp_base + PROBE_DC_NETGAME_PTR_RVA); + g_dc_terminal_requested_seq = + InterlockedCompareExchange(&g_dc_trace_write_seq, 0, 0); + MemoryBarrier(); + InterlockedExchange(&g_dc_terminal_state, 1); + + if (!SetEvent(g_dc_terminal_stop_event)) { + dc_invoke_original_exit_process(exit_code); + } + wait_result = WaitForSingleObject(g_dc_terminal_done_event, + PROBE_DC_TERMINAL_DRAIN_TIMEOUT_MS); + if (wait_result != WAIT_OBJECT_0) { + OutputDebugStringA( + "[samp_probe] death_cleanup_exit_r5 terminal drain timed out\n"); + } + dc_invoke_original_exit_process(exit_code); +} + +static int dc_bytes_match(DWORD rva, const BYTE *bytes, size_t size) { + return rva <= g_dc_samp_size && + size <= (size_t)(g_dc_samp_size - rva) && + dc_memory_is_readable(g_dc_samp_base + rva, size) && + memcmp((const void *)(g_dc_samp_base + rva), bytes, size) == 0; +} + +static int dc_relocated_dword_bytes_match( + DWORD rva, const BYTE *preferred_bytes, size_t size, + size_t operand_offset, DWORD *runtime_operand_out) { + BYTE expected[64]; + DWORD preferred_operand; + DWORD referenced_rva; + DWORD runtime_operand; + + /* + * STATIC_037: + * Normalize exactly one known PE HIGHLOW operand while keeping every + * surrounding byte exact. CLocalPlayer::Spawn's SEH registration entry + * contains the R5 image pointer covered by relocation RVA +0x3C29. + */ + if (preferred_bytes == NULL || size == 0u || size > sizeof(expected) || + operand_offset > size || + sizeof(preferred_operand) > size - operand_offset) { + return 0; + } + memcpy(expected, preferred_bytes, size); + memcpy(&preferred_operand, preferred_bytes + operand_offset, + sizeof(preferred_operand)); + if (preferred_operand < PROBE_DC_R5_PREFERRED_BASE) { + return 0; + } + referenced_rva = preferred_operand - PROBE_DC_R5_PREFERRED_BASE; + if (referenced_rva >= g_dc_samp_size || + g_dc_samp_base > (uintptr_t)(0xffffffffu - referenced_rva)) { + return 0; + } + runtime_operand = (DWORD)(g_dc_samp_base + (uintptr_t)referenced_rva); + memcpy(expected + operand_offset, &runtime_operand, + sizeof(runtime_operand)); + if (!dc_bytes_match(rva, expected, size)) { + return 0; + } + if (runtime_operand_out != NULL) { + *runtime_operand_out = runtime_operand; + } + return 1; +} + +static void **dc_exit_iat_slot(void) { + if (g_dc_samp_size < (DWORD)sizeof(void *) || + PROBE_DC_EXITPROCESS_IAT_RVA > + g_dc_samp_size - (DWORD)sizeof(void *) || + !dc_memory_is_readable( + g_dc_samp_base + PROBE_DC_EXITPROCESS_IAT_RVA, + sizeof(void *))) { + return NULL; + } + return (void **)(g_dc_samp_base + PROBE_DC_EXITPROCESS_IAT_RVA); +} + +static int dc_exit_iat_target_matches(void) { + HMODULE kernel32; + FARPROC exit_process; + void **slot = dc_exit_iat_slot(); + if (slot == NULL) { + return 0; + } + kernel32 = GetModuleHandleA("KERNEL32.dll"); + if (kernel32 == NULL) { + return 0; + } + exit_process = GetProcAddress(kernel32, "ExitProcess"); + return exit_process != NULL && *slot == (void *)(uintptr_t)exit_process; +} + +static int dc_preflight(void) { + static const BYTE process_tail[] = { + 0x5e, 0x5d, 0xb8, 0x01, 0x00, 0x00, + 0x00, 0x5b, 0x83, 0xc4, 0x10, 0xc3}; + static const BYTE spawn_tail[] = { + 0x8b, 0x8c, 0x24, 0x20, 0x01, 0x00, 0x00, + 0x5f, 0x5e, 0xb8, 0x01, 0x00, 0x00, 0x00, + 0x5b, 0x64, 0x89, 0x0d, 0x00, 0x00, 0x00, 0x00, + 0x81, 0xc4, 0x20, 0x01, 0x00, 0x00, 0xc3}; + static const BYTE class_tail[] = { + 0x5f, 0x89, 0x86, 0x47, 0x01, 0x00, 0x00, 0x5e, 0xc3}; + static const BYTE gmx_tail[] = {0x5f, 0x5e, 0x5d, 0x5b, 0xc3}; + static const BYTE lost_tail[] = { + 0xc7, 0x87, 0xcd, 0x03, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x5f, 0xc2, 0x04, 0x00}; + static const BYTE destructor_tail[] = {0x5f, 0x5e, 0x5b, 0xc3}; + struct dc_tail { + DWORD rva; + const BYTE *bytes; + size_t size; + }; + static const struct dc_tail tails[] = { + {0x00007e37u, process_tail, sizeof(process_tail)}, + {0x00003ea7u, spawn_tail, sizeof(spawn_tail)}, + {0x000040cau, class_tail, sizeof(class_tail)}, + {0x0000a730u, gmx_tail, sizeof(gmx_tail)}, + {0x0000ad6eu, lost_tail, sizeof(lost_tail)}, + {0x00009a31u, destructor_tail, sizeof(destructor_tail)}, + }; + size_t i; + if (!dc_relocated_dword_bytes_match( + PROBE_DC_EXITPROCESS_CALLSITE_RVA, g_dc_exit_callsite, + sizeof(g_dc_exit_callsite), 3u, + &g_dc_exit_runtime_iat_operand) || + g_dc_exit_runtime_iat_operand != + (DWORD)(g_dc_samp_base + PROBE_DC_EXITPROCESS_IAT_RVA) || + !dc_exit_iat_target_matches()) { + return 0; + } + for (i = 0u; i < sizeof(g_dc_hooks) / sizeof(g_dc_hooks[0]); ++i) { + int matched; + if (g_dc_hooks[i].rva == PROBE_DC_LOCAL_SPAWN_RVA) { + matched = dc_relocated_dword_bytes_match( + g_dc_hooks[i].rva, g_dc_hooks[i].expected, + g_dc_hooks[i].length, 9u, &g_dc_spawn_runtime_operand); + } else { + matched = dc_bytes_match(g_dc_hooks[i].rva, + g_dc_hooks[i].expected, + g_dc_hooks[i].length); + } + if (!matched) { + return 0; + } + } + for (i = 0u; i < sizeof(tails) / sizeof(tails[0]); ++i) { + if (!dc_bytes_match(tails[i].rva, tails[i].bytes, tails[i].size)) { + return 0; + } + } + return 1; +} + +static int dc_rel32(void *from_after, void *to, LONG *relative) { + intptr_t delta = (BYTE *)to - (BYTE *)from_after; + if (delta < INT32_MIN || delta > INT32_MAX) { + return 0; + } + *relative = (LONG)delta; + return 1; +} + +static int dc_prepare_trampoline(probe_dc_hook *hook) { + BYTE *trampoline; + LONG back_rel; + uintptr_t target; + if (hook == NULL || hook->length < 5u || + hook->length > sizeof(hook->saved)) { + return 0; + } + target = g_dc_samp_base + hook->rva; + trampoline = (BYTE *)VirtualAlloc( + NULL, (SIZE_T)hook->length + 5u, MEM_COMMIT | MEM_RESERVE, + PAGE_EXECUTE_READWRITE); + if (trampoline == NULL) { + return 0; + } + memcpy(hook->saved, (const void *)target, hook->length); + memcpy(trampoline, hook->saved, hook->length); + if (!dc_rel32(trampoline + hook->length + 5u, + (void *)(target + hook->length), &back_rel)) { + VirtualFree(trampoline, 0u, MEM_RELEASE); + return 0; + } + trampoline[hook->length] = 0xe9u; + memcpy(trampoline + hook->length + 1u, &back_rel, sizeof(back_rel)); + FlushInstructionCache(GetCurrentProcess(), trampoline, + (SIZE_T)hook->length + 5u); + hook->trampoline = trampoline; + return 1; +} + +static int dc_install_one(probe_dc_hook *hook) { + uintptr_t target; + BYTE patch[16]; + LONG replacement_rel; + DWORD old_protect; + DWORD ignored_protect; + if (hook == NULL || hook->trampoline == NULL || + hook->length > sizeof(patch)) { + return 0; + } + target = g_dc_samp_base + hook->rva; + if (!dc_memory_is_readable(target, hook->length) || + memcmp((const void *)target, hook->saved, hook->length) != 0) { + return 0; + } + if (!dc_rel32((void *)(target + 5u), hook->replacement, + &replacement_rel)) { + return 0; + } + memset(patch, 0x90, hook->length); + patch[0] = 0xe9u; + memcpy(patch + 1u, &replacement_rel, sizeof(replacement_rel)); + if (!VirtualProtect((void *)target, hook->length, + PAGE_EXECUTE_READWRITE, &old_protect)) { + return 0; + } + memcpy((void *)target, patch, hook->length); + FlushInstructionCache(GetCurrentProcess(), (const void *)target, + hook->length); + (void)VirtualProtect((void *)target, hook->length, old_protect, + &ignored_protect); + InterlockedExchange(&hook->installed, 1); + return 1; +} + +static int dc_patch_is_owned(const probe_dc_hook *hook) { + BYTE expected[16]; + LONG relative; + uintptr_t target; + if (hook == NULL || hook->length > sizeof(expected)) { + return 0; + } + target = g_dc_samp_base + hook->rva; + if (!dc_rel32((void *)(target + 5u), hook->replacement, &relative)) { + return 0; + } + memset(expected, 0x90, hook->length); + expected[0] = 0xe9u; + memcpy(expected + 1u, &relative, sizeof(relative)); + return dc_memory_is_readable(target, hook->length) && + memcmp((const void *)target, expected, hook->length) == 0; +} + +static int dc_restore_one(probe_dc_hook *hook) { + uintptr_t target; + DWORD old_protect; + DWORD ignored_protect; + int owned; + if (hook == NULL || + InterlockedCompareExchange(&hook->installed, 0, 0) != 1) { + return 0; + } + owned = dc_patch_is_owned(hook); + if (!owned) { + InterlockedExchange(&hook->installed, -1); + return 0; + } + target = g_dc_samp_base + hook->rva; + if (!VirtualProtect((void *)target, hook->length, + PAGE_EXECUTE_READWRITE, &old_protect)) { + return 0; + } + memcpy((void *)target, hook->saved, hook->length); + FlushInstructionCache(GetCurrentProcess(), (const void *)target, + hook->length); + (void)VirtualProtect((void *)target, hook->length, old_protect, + &ignored_protect); + InterlockedExchange(&hook->installed, 0); + return 1; +} + +static int dc_install_exit_iat(void) { + void **slot = dc_exit_iat_slot(); + void *replacement = (void *)hook_dc_exit_process; + void *original; + void *previous; + DWORD old_protect; + DWORD ignored_protect; + BOOL protect_restored; + + if (slot == NULL || + InterlockedCompareExchange(&g_dc_exit_iat_installed, 0, 0) != 0) { + return 0; + } + original = *slot; + if (original == NULL || !dc_exit_iat_target_matches()) { + return 0; + } + if (!VirtualProtect(slot, sizeof(*slot), PAGE_EXECUTE_READWRITE, + &old_protect)) { + return 0; + } + g_dc_original_exit_process = (probe_dc_exit_process_fn)original; + previous = InterlockedCompareExchangePointer( + (PVOID volatile *)slot, replacement, original); + protect_restored = VirtualProtect( + slot, sizeof(*slot), old_protect, &ignored_protect); + if (previous != original || !protect_restored) { + if (previous == original) { + (void)InterlockedCompareExchangePointer( + (PVOID volatile *)slot, original, replacement); + } + if (!protect_restored) { + (void)VirtualProtect(slot, sizeof(*slot), old_protect, + &ignored_protect); + } + g_dc_original_exit_process = NULL; + return 0; + } + InterlockedExchange(&g_dc_exit_iat_installed, 1); + return 1; +} + +static int dc_restore_exit_iat(void) { + void **slot = dc_exit_iat_slot(); + void *replacement = (void *)hook_dc_exit_process; + void *original = (void *)g_dc_original_exit_process; + void *previous; + DWORD old_protect; + DWORD ignored_protect; + BOOL protect_restored; + + if (slot == NULL || original == NULL || + InterlockedCompareExchange(&g_dc_exit_iat_installed, 0, 0) != 1) { + return 0; + } + if (!VirtualProtect(slot, sizeof(*slot), PAGE_EXECUTE_READWRITE, + &old_protect)) { + return 0; + } + previous = InterlockedCompareExchangePointer( + (PVOID volatile *)slot, original, replacement); + protect_restored = VirtualProtect( + slot, sizeof(*slot), old_protect, &ignored_protect); + if (previous != replacement || !protect_restored) { + if (!protect_restored) { + (void)VirtualProtect(slot, sizeof(*slot), old_protect, + &ignored_protect); + } + InterlockedExchange(&g_dc_exit_iat_installed, -1); + return 0; + } + InterlockedExchange(&g_dc_exit_iat_installed, 0); + return 1; +} + +static void dc_close_terminal_done_event( + probe_death_cleanup_log_fn log_fn) { + HANDLE done_event = g_dc_terminal_done_event; + g_dc_terminal_done_event = NULL; + if (done_event != NULL && !CloseHandle(done_event) && log_fn != NULL) { + log_fn("death_cleanup_hook: terminal_event_close_failed error=%lu", + (unsigned long)GetLastError()); + } +} + +int probe_death_cleanup_install(HMODULE samp_module, DWORD samp_size, + int enabled, int code_hooks_disabled, + HANDLE worker_stop_event, + probe_death_cleanup_log_fn log_fn, + int log_summary) { + HMODULE gta_module; + size_t hook_count = sizeof(g_dc_hooks) / sizeof(g_dc_hooks[0]); + size_t requested_count = hook_count + 1u; + size_t i; + int installed = 0; + + if (!enabled) { + if (log_summary && log_fn != NULL) { + log_fn("death_cleanup_hook: disabled by default; enable with " + "SAMP_PROBE_DEATH_CLEANUP_HOOKS=1 or " + "samp_probe_death_cleanup_hooks.flag"); + } + return 0; + } + if (code_hooks_disabled) { + if (log_summary && log_fn != NULL) { + log_fn("death_cleanup_hook: disabled by " + "SAMP_PROBE_NO_SAMP_CODE_HOOKS"); + } + return 0; + } + if (InterlockedCompareExchange(&g_dc_install_state, 0, 0) == 1) { + return (int)requested_count; + } + if (InterlockedCompareExchange(&g_dc_install_state, 0, 0) < 0) { + return 0; + } + + if (worker_stop_event == NULL) { + if (log_summary && log_fn != NULL) { + log_fn("death_cleanup_hook: skip missing_worker_stop_event " + "installed=0 requested=%u", + (unsigned)requested_count); + } + InterlockedExchange(&g_dc_install_state, -1); + return 0; + } + + g_dc_samp_base = (uintptr_t)samp_module; + g_dc_samp_size = samp_size; + g_dc_terminal_stop_event = worker_stop_event; + gta_module = GetModuleHandleA(NULL); + if (g_dc_samp_size != PROBE_DC_R5_IMAGE_SIZE || + !dc_pe_identity_matches(samp_module, PROBE_DC_R5_TIMESTAMP, + PROBE_DC_R5_ENTRY_RVA, + PROBE_DC_R5_IMAGE_SIZE, + PROBE_DC_R5_PREFERRED_BASE, 0u, 0, 0) || + (uintptr_t)gta_module != PROBE_DC_GTA_PREFERRED_BASE || + !dc_pe_identity_matches(gta_module, PROBE_DC_GTA_TIMESTAMP, + PROBE_DC_GTA_ENTRY_RVA, + PROBE_DC_GTA_IMAGE_SIZE, + PROBE_DC_GTA_PREFERRED_BASE, + PROBE_DC_GTA_CHECKSUM, 1, 1)) { + if (log_summary && log_fn != NULL) { + log_fn("death_cleanup_hook: skip unsupported_identity installed=0 " + "samp_base=0x%08lx samp_size=0x%08lx " + "samp_delta=0x%08lx samp_sha256=%s " + "gta_base=0x%08lx gta_sha256=%s " + "evidence=STATIC_037", + (unsigned long)g_dc_samp_base, + (unsigned long)g_dc_samp_size, + (unsigned long)(g_dc_samp_base - + PROBE_DC_R5_PREFERRED_BASE), + PROBE_DC_R5_SHA256, + (unsigned long)(uintptr_t)gta_module, PROBE_DC_GTA_SHA256); + } + InterlockedExchange(&g_dc_install_state, -1); + return 0; + } + if (!dc_preflight()) { + if (log_summary && log_fn != NULL) { + log_fn("death_cleanup_hook: skip preflight_mismatch installed=0 " + "requested=%u evidence=STATIC_037", + (unsigned)requested_count); + } + InterlockedExchange(&g_dc_install_state, -1); + return 0; + } + + g_dc_terminal_done_event = CreateEventA(NULL, TRUE, FALSE, NULL); + if (g_dc_terminal_done_event == NULL) { + if (log_summary && log_fn != NULL) { + log_fn("death_cleanup_hook: terminal_event_failed installed=0 " + "requested=%u error=%lu", + (unsigned)requested_count, + (unsigned long)GetLastError()); + } + InterlockedExchange(&g_dc_install_state, -1); + return 0; + } + + for (i = 0u; i < hook_count; ++i) { + if (!dc_prepare_trampoline(&g_dc_hooks[i])) { + break; + } + } + if (i != hook_count) { + size_t j; + for (j = 0u; j < hook_count; ++j) { + if (g_dc_hooks[j].trampoline != NULL) { + VirtualFree(g_dc_hooks[j].trampoline, 0u, MEM_RELEASE); + g_dc_hooks[j].trampoline = NULL; + } + } + if (log_summary && log_fn != NULL) { + log_fn("death_cleanup_hook: trampoline_allocation_failed " + "prepared=%u requested=%u installed=0", + (unsigned)i, (unsigned)requested_count); + } + dc_close_terminal_done_event(log_fn); + InterlockedExchange(&g_dc_install_state, -1); + return 0; + } + + for (i = 0u; i < hook_count; ++i) { + if (!dc_install_one(&g_dc_hooks[i])) { + break; + } + ++installed; + } + if ((size_t)installed != hook_count) { + while (installed > 0) { + --installed; + (void)dc_restore_one(&g_dc_hooks[installed]); + } + if (log_summary && log_fn != NULL) { + log_fn("death_cleanup_hook: incomplete_install installed=0 " + "requested=%u; run_invalid=1", + (unsigned)requested_count); + } + InterlockedExchange(&g_dc_install_state, -1); + dc_close_terminal_done_event(log_fn); + return 0; + } + if (!dc_install_exit_iat()) { + while (installed > 0) { + --installed; + (void)dc_restore_one(&g_dc_hooks[installed]); + } + if (log_summary && log_fn != NULL) { + log_fn("death_cleanup_hook: exit_iat_install_failed installed=0 " + "requested=%u; run_invalid=1", + (unsigned)requested_count); + } + dc_close_terminal_done_event(log_fn); + InterlockedExchange(&g_dc_install_state, -1); + return 0; + } + ++installed; + InterlockedExchange(&g_dc_install_state, 1); + if (log_summary && log_fn != NULL) { + log_fn("death_cleanup_hook: summary installed=%u requested=%u " + "rvas=0x74c0,0x3c20,0x4080,0xa540,0xacf0,0x9880 " + "exit_iat_rva=0x000e5188 exit_caller_rva=0x000c5091 " + "samp_base=0x%08lx samp_delta=0x%08lx " + "operand_3c29=0x%08lx operand_c508d=0x%08lx " + "samp_sha256=%s gta_sha256=%s " + "guard=identity,samp_relocation_normalized,gta_preferred_base," + "entry_bytes,tails,exit_callsite,exit_export,all_or_nothing " + "evidence=STATIC_037,TODO_VERIFY", + (unsigned)installed, (unsigned)requested_count, + (unsigned long)g_dc_samp_base, + (unsigned long)(g_dc_samp_base - + PROBE_DC_R5_PREFERRED_BASE), + (unsigned long)g_dc_spawn_runtime_operand, + (unsigned long)g_dc_exit_runtime_iat_operand, + PROBE_DC_R5_SHA256, PROBE_DC_GTA_SHA256); + } + return (int)requested_count; +} + +static const char *dc_event_name(BYTE kind) { + switch (kind) { + case PROBE_DC_EVENT_PROCESS: + return "local_process"; + case PROBE_DC_EVENT_SPAWN: + return "spawn"; + case PROBE_DC_EVENT_CLASS_SELECTION: + return "class_selection"; + case PROBE_DC_EVENT_GMX_RESET: + return "gmx_reset"; + case PROBE_DC_EVENT_CONNECTION_LOST: + return "connection_lost"; + case PROBE_DC_EVENT_QUIT_DESTRUCTOR: + return "quit_destructor"; + default: + return "unknown"; + } +} + +static void dc_log_local(probe_death_cleanup_log_fn log_fn, + const probe_dc_trace *trace, const char *phase, + const probe_dc_local_state *state) { + union { + DWORD bits; + float value; + } health; + int dead_or_wasted; + health.bits = state->ped_health_bits; + dead_or_wasted = + state->wasted != 0u || state->action == 54u || + state->action == 55u || + ((state->valid_mask & 0x08u) != 0u && health.value <= 0.0f); + log_fn( + "death_cleanup_local_r5: seq=%ld event=%ld phase=%s " + "valid=0x%08lx netgame=0x%08lx state=%lu pools=0x%08lx " + "player_pool=0x%08lx local=0x%08lx active=%lu wasted=%lu " + "spectating=%lu cleared_spawn=%lu has_spawn=%lu wants_class=%lu " + "class_selection=%lu class_input=%lu class_ticks=%lu,%lu,%lu " + "vehicle=0x%08lx ped_wrapper=0x%08lx gta_ped=0x%08lx " + "action=%u dead_or_wasted=%d health_bits=0x%08lx " + "ped_state=0x%08lx entity_status=%u entity_flags=0x%08lx " + "ped_flags=0x%08lx matrix=0x%08lx rw=0x%08lx " + "spawn_hash=0x%08lx spawn_head=%08lx,%08lx,%08lx,%08lx", + (long)trace->ring_seq, (long)trace->event_seq, phase, + (unsigned long)state->valid_mask, (unsigned long)state->netgame, + (unsigned long)state->netgame_state, (unsigned long)state->pools, + (unsigned long)state->player_pool, + (unsigned long)state->local_player, (unsigned long)state->active, + (unsigned long)state->wasted, (unsigned long)state->spectating, + (unsigned long)state->cleared_to_spawn, + (unsigned long)state->has_spawn_info, + (unsigned long)state->wants_another_class, + (unsigned long)state->class_selection, + (unsigned long)state->class_input_owned, + (unsigned long)state->class_tick_a, + (unsigned long)state->class_tick_b, + (unsigned long)state->class_tick_c, + (unsigned long)state->current_vehicle, + (unsigned long)state->ped_wrapper, + (unsigned long)state->gta_ped, (unsigned)state->action, + dead_or_wasted, (unsigned long)state->ped_health_bits, + (unsigned long)state->ped_state, (unsigned)state->entity_status, + (unsigned long)state->ped_entity_flags, + (unsigned long)state->ped_flags, (unsigned long)state->ped_matrix, + (unsigned long)state->ped_rw_object, + (unsigned long)state->spawn_info_hash, + (unsigned long)state->spawn_info_head[0], + (unsigned long)state->spawn_info_head[1], + (unsigned long)state->spawn_info_head[2], + (unsigned long)state->spawn_info_head[3]); + log_fn( + "death_cleanup_tasks_r5: seq=%ld event=%ld phase=%s " + "intelligence=0x%08lx roots=%08lx,%08lx,%08lx,%08lx,%08lx," + "%08lx,%08lx,%08lx,%08lx,%08lx,%08lx", + (long)trace->ring_seq, (long)trace->event_seq, phase, + (unsigned long)state->ped_intelligence, + (unsigned long)state->task_roots[0], + (unsigned long)state->task_roots[1], + (unsigned long)state->task_roots[2], + (unsigned long)state->task_roots[3], + (unsigned long)state->task_roots[4], + (unsigned long)state->task_roots[5], + (unsigned long)state->task_roots[6], + (unsigned long)state->task_roots[7], + (unsigned long)state->task_roots[8], + (unsigned long)state->task_roots[9], + (unsigned long)state->task_roots[10]); + log_fn( + "death_cleanup_ui_r5: seq=%ld event=%ld phase=%s " + "scoreboard=0x%08lx visible=%lu dialog=0x%08lx active=%lu " + "selector=0x%08lx active=%lu chat=0x%08lx active=%lu " + "class_gui=0x%08lx visible=%lu game=0x%08lx " + "input_depth=%lu,%lu camera=%u,%u frontend=%u,%u,%u", + (long)trace->ring_seq, (long)trace->event_seq, phase, + (unsigned long)state->scoreboard, + (unsigned long)state->scoreboard_visible, + (unsigned long)state->dialog, (unsigned long)state->dialog_active, + (unsigned long)state->textdraw_selector, + (unsigned long)state->textdraw_active, + (unsigned long)state->chat, (unsigned long)state->chat_active, + (unsigned long)state->class_gui, + (unsigned long)state->class_gui_visible, + (unsigned long)state->game, + (unsigned long)state->game_input_depth_a, + (unsigned long)state->game_input_depth_b, + (unsigned)state->camera_mode, (unsigned)state->camera_mode2, + (unsigned)state->frontend_a, (unsigned)state->frontend_b, + (unsigned)state->frontend_c); +} + +static void dc_log_cleanup(probe_death_cleanup_log_fn log_fn, + const probe_dc_trace *trace, const char *phase, + const probe_dc_cleanup_state *state) { + log_fn( + "death_cleanup_pools_r5: seq=%ld event=%ld phase=%s " + "valid=0x%08lx netgame=0x%08lx pools=0x%08lx " + "pool_ptrs=%08lx,%08lx,%08lx,%08lx,%08lx,%08lx,%08lx,%08lx,%08lx " + "vehicle=%lu/%lu remote=%lu/%lu pickup_raw=%lu/%lu/%lu " + "object=%lu/%lu actor=%lu/%lu gangzone=%lu textdraw=%lu " + "label=%lu menu=%lu current=%lu remove_building_count=%lu", + (long)trace->ring_seq, (long)trace->event_seq, phase, + (unsigned long)state->valid_mask, (unsigned long)state->netgame, + (unsigned long)state->pools, + (unsigned long)state->pool_ptrs[0], + (unsigned long)state->pool_ptrs[1], + (unsigned long)state->pool_ptrs[2], + (unsigned long)state->pool_ptrs[3], + (unsigned long)state->pool_ptrs[4], + (unsigned long)state->pool_ptrs[5], + (unsigned long)state->pool_ptrs[6], + (unsigned long)state->pool_ptrs[7], + (unsigned long)state->pool_ptrs[8], + (unsigned long)state->vehicle_listed, + (unsigned long)state->vehicle_wrappers, + (unsigned long)state->remote_aux, + (unsigned long)state->remote_wrappers, + (unsigned long)state->pickup_handles, + (unsigned long)state->pickup_server_ids, + (unsigned long)state->pickup_timers, + (unsigned long)state->object_listed, + (unsigned long)state->object_wrappers, + (unsigned long)state->actor_listed, + (unsigned long)state->actor_wrappers, + (unsigned long)state->gangzone_listed, + (unsigned long)state->textdraw_listed, + (unsigned long)state->label_listed, + (unsigned long)state->menu_listed, + (unsigned long)state->menu_current, + (unsigned long)state->remove_building_count); + log_fn( + "death_cleanup_entities_r5: seq=%ld event=%ld phase=%s " + "vehicle=id:%lu wrapper:0x%08lx entity:0x%08lx gta:0x%08lx " + "matrix:0x%08lx rw:0x%08lx flags:0x%08lx " + "object=id:%lu wrapper:0x%08lx " + "actor=id:%lu wrapper:0x%08lx gta_ped:0x%08lx", + (long)trace->ring_seq, (long)trace->event_seq, phase, + (unsigned long)state->first_vehicle_id, + (unsigned long)state->first_vehicle_wrapper, + (unsigned long)state->first_vehicle_entity, + (unsigned long)state->first_vehicle_gta, + (unsigned long)state->first_vehicle_matrix, + (unsigned long)state->first_vehicle_rw_object, + (unsigned long)state->first_vehicle_flags, + (unsigned long)state->first_object_id, + (unsigned long)state->first_object_wrapper, + (unsigned long)state->first_actor_id, + (unsigned long)state->first_actor_wrapper, + (unsigned long)state->first_actor_gta_ped); +} + +void probe_death_cleanup_flush(probe_death_cleanup_log_fn log_fn) { + LONG write_seq; + LONG pending; + if (log_fn == NULL) { + return; + } + write_seq = InterlockedCompareExchange(&g_dc_trace_write_seq, 0, 0); + pending = write_seq - g_dc_trace_flushed_seq; + if (pending > (LONG)PROBE_DC_TRACE_RING_SIZE) { + LONG skipped = pending - (LONG)PROBE_DC_TRACE_RING_SIZE; + g_dc_trace_flushed_seq += skipped; + g_dc_trace_overflow_count += skipped; + log_fn("death_cleanup_r5: overflow skipped=%ld total_skipped=%ld " + "ring=%u", + (long)skipped, (long)g_dc_trace_overflow_count, + (unsigned)PROBE_DC_TRACE_RING_SIZE); + } + while (g_dc_trace_flushed_seq < write_seq) { + LONG next_seq = g_dc_trace_flushed_seq + 1; + probe_dc_trace *slot = + &g_dc_trace_ring[ + ((DWORD)next_seq - 1u) % PROBE_DC_TRACE_RING_SIZE]; + probe_dc_trace trace; + if (InterlockedCompareExchange(&slot->committed_seq, 0, 0) != + next_seq) { + break; + } + MemoryBarrier(); + trace = *slot; + MemoryBarrier(); + if (InterlockedCompareExchange(&slot->committed_seq, 0, 0) != + next_seq) { + continue; + } + log_fn( + "death_cleanup_r5: seq=%ld event=%ld tick=%lu thread=%lu " + "frame=%lu kind=%s caller_rva=0x%08lx hook_rva=0x%08lx " + "object=0x%08lx argument=0x%08lx result=0x%08lx " + "cleanup=%u evidence=STATIC_037,TODO_VERIFY", + (long)trace.ring_seq, (long)trace.event_seq, + (unsigned long)trace.tick, (unsigned long)trace.thread_id, + (unsigned long)trace.gta_frame, dc_event_name(trace.kind), + (unsigned long)trace.caller_rva, + (unsigned long)trace.hook_rva, (unsigned long)trace.object, + (unsigned long)trace.argument, (unsigned long)trace.result, + (unsigned)trace.cleanup_valid); + dc_log_local(log_fn, &trace, "pre", &trace.before_local); + dc_log_local(log_fn, &trace, "post", &trace.after_local); + if (trace.cleanup_valid) { + dc_log_cleanup(log_fn, &trace, "pre", &trace.before_cleanup); + dc_log_cleanup(log_fn, &trace, "post", &trace.after_cleanup); + } + g_dc_trace_flushed_seq = next_seq; + } +} + +void probe_death_cleanup_complete_terminal_drain( + probe_death_cleanup_log_fn log_fn) { + LONG requested_seq; + LONG flushed_seq; + LONG destructor_seq; + int drain_complete; + + if (InterlockedCompareExchange(&g_dc_terminal_state, 0, 0) != 1) { + return; + } + + /* + * The caller is the probe worker after its stop event was signaled by the + * exact R5 ExitProcess callsite. Keep file I/O on this worker, then release + * the game thread only after the append-and-close log path has completed. + */ + probe_death_cleanup_flush(log_fn); + requested_seq = g_dc_terminal_requested_seq; + flushed_seq = g_dc_trace_flushed_seq; + destructor_seq = + InterlockedCompareExchange(&g_dc_clean_quit_destructor_seq, 0, 0); + drain_complete = flushed_seq >= requested_seq; + + if (log_fn != NULL) { + log_fn( + "death_cleanup_exit_r5: tick=%lu thread=%lu " + "caller_rva=0x%08lx exit_code=%lu netgame=0x%08lx " + "destructor_seq=%ld requested_seq=%ld flushed_seq=%ld " + "drain=%s timeout_ms=%u exit_iat_restored=%d " + "evidence=STATIC_037,TODO_VERIFY", + (unsigned long)g_dc_terminal_tick, + (unsigned long)g_dc_terminal_thread_id, + (unsigned long)g_dc_terminal_caller_rva, + (unsigned long)g_dc_terminal_exit_code, + (unsigned long)g_dc_terminal_netgame, (long)destructor_seq, + (long)requested_seq, (long)flushed_seq, + drain_complete ? "completed" : "partial", + (unsigned)PROBE_DC_TERMINAL_DRAIN_TIMEOUT_MS, + InterlockedCompareExchange(&g_dc_exit_iat_installed, 0, 0) == 0); + } + + MemoryBarrier(); + InterlockedExchange(&g_dc_terminal_state, 2); + if (g_dc_terminal_done_event != NULL) { + if (!SetEvent(g_dc_terminal_done_event)) { + OutputDebugStringA( + "[samp_probe] death_cleanup_exit_r5 completion signal failed\n"); + } + } +} + +void probe_death_cleanup_uninstall(probe_death_cleanup_log_fn log_fn) { + size_t hook_count = sizeof(g_dc_hooks) / sizeof(g_dc_hooks[0]); + size_t requested_count = hook_count + 1u; + size_t i; + int restored = 0; + if (InterlockedCompareExchange(&g_dc_install_state, 0, 0) != 1) { + return; + } + restored += dc_restore_exit_iat(); + for (i = hook_count; i > 0u; --i) { + restored += dc_restore_one(&g_dc_hooks[i - 1u]); + } + if (log_fn != NULL) { + log_fn("death_cleanup_hook: restore restored=%d requested=%u " + "gateway_lifetime=process", + restored, (unsigned)requested_count); + } + InterlockedExchange(&g_dc_install_state, + restored == (int)requested_count ? 0 : -1); +} diff --git a/tools/asi_probe/src/samp_probe_death_cleanup.h b/tools/asi_probe/src/samp_probe_death_cleanup.h new file mode 100644 index 0000000..e04ab75 --- /dev/null +++ b/tools/asi_probe/src/samp_probe_death_cleanup.h @@ -0,0 +1,26 @@ +#ifndef SAMP_PROBE_DEATH_CLEANUP_H +#define SAMP_PROBE_DEATH_CLEANUP_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void (*probe_death_cleanup_log_fn)(const char *format, ...); + +int probe_death_cleanup_install(HMODULE samp_module, DWORD samp_size, + int enabled, int code_hooks_disabled, + HANDLE worker_stop_event, + probe_death_cleanup_log_fn log_fn, + int log_summary); +void probe_death_cleanup_flush(probe_death_cleanup_log_fn log_fn); +void probe_death_cleanup_uninstall(probe_death_cleanup_log_fn log_fn); +void probe_death_cleanup_complete_terminal_drain( + probe_death_cleanup_log_fn log_fn); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/tools/asi_probe/src/samp_probe_pickup.c b/tools/asi_probe/src/samp_probe_pickup.c new file mode 100644 index 0000000..0fa7942 --- /dev/null +++ b/tools/asi_probe/src/samp_probe_pickup.c @@ -0,0 +1,962 @@ +#include "samp_probe_pickup.h" + +#include +#include + +#if defined(__GNUC__) && defined(__i386__) +#define PROBE_PICKUP_THISCALL __attribute__((thiscall)) +#else +#define PROBE_PICKUP_THISCALL +#endif + +/* + * STATIC_037 + TODO_VERIFY: + * The two methods, pool layout, process gate, and byte guards below come from + * original SA-MP 0.3.7-R5 + * SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + * Hook-side code publishes only to a bounded ring. Runtime semantics remain + * TODO_VERIFY until a controlled original run exercises each pickup type. + * See docs/re/pickup_memory_probe_r5_20260728.md. + */ +#define PROBE_PICKUP_R5_TIMESTAMP 0x6372c39eu +#define PROBE_PICKUP_R5_ENTRY_RVA 0x000cbc90u +#define PROBE_PICKUP_R5_IMAGE_SIZE 0x0027e000u +#define PROBE_PICKUP_R5_PREFERRED_BASE 0x10000000u +#define PROBE_PICKUP_R5_SHA256 \ + "b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2" + +#define PROBE_PICKUP_GTA_TIMESTAMP 0x427101cau +#define PROBE_PICKUP_GTA_ENTRY_RVA 0x00424570u +#define PROBE_PICKUP_GTA_IMAGE_SIZE 0x01177000u +#define PROBE_PICKUP_GTA_CHECKSUM 0x00dc5beau +#define PROBE_PICKUP_GTA_PREFERRED_BASE 0x00400000u +#define PROBE_PICKUP_GTA_SHA256 \ + "a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26" + +#define PROBE_PICKUP_PICKED_UP_RVA 0x00013440u +#define PROBE_PICKUP_PROCESS_RVA 0x00013520u +#define PROBE_PICKUP_PROCESS_GATE_RVA 0x00118a10u +#define PROBE_PICKUP_NETGAME_PTR_RVA 0x0026eb94u +#define PROBE_PICKUP_NETGAME_POOLS_OFFSET 0x000003deu +#define PROBE_PICKUP_POOLS_PICKUP_OFFSET 0x00000008u + +#define PROBE_PICKUP_COUNT_OFFSET 0x00000000u +#define PROBE_PICKUP_HANDLE_OFFSET 0x00000004u +#define PROBE_PICKUP_RAW_GTA_INDEX_OFFSET 0x00004004u +#define PROBE_PICKUP_TIMER_OFFSET 0x00008004u +#define PROBE_PICKUP_DROPPED_OFFSET 0x0000c004u +#define PROBE_PICKUP_DROPPED_STRIDE 3u +#define PROBE_PICKUP_DROPPED_PLAYER_OFFSET 1u +#define PROBE_PICKUP_DATA_OFFSET 0x0000f004u +#define PROBE_PICKUP_DATA_STRIDE 0x14u +#define PROBE_PICKUP_DATA_MODEL_OFFSET 0x00u +#define PROBE_PICKUP_DATA_TYPE_OFFSET 0x04u +#define PROBE_PICKUP_DATA_POS_OFFSET 0x08u +#define PROBE_PICKUP_CAPACITY 4096u + +#define PROBE_PICKUP_GTA_FRAME_COUNTER_ADDR 0x00b7cb4cu +#define PROBE_PICKUP_SAMPLE_SLOTS 8u +#define PROBE_PICKUP_TRACE_RING_SIZE 256u + +#define PROBE_PICKUP_EVENT_PICKED_UP 1u +#define PROBE_PICKUP_EVENT_PROCESS 2u +#define PROBE_PICKUP_EVENT_RPC_131 3u +#define PROBE_PICKUP_EVENT_RPC_97 4u + +#define PROBE_PICKUP_RPC_PICKED_UP 131u +#define PROBE_PICKUP_RPC_WEAPON_PICKED_UP 97u + +typedef void(PROBE_PICKUP_THISCALL *probe_pickup_void_fn)(void *self); +typedef void(PROBE_PICKUP_THISCALL *probe_pickup_raw_fn)(void *self, + DWORD raw_index); + +typedef struct probe_pickup_slot_state { + DWORD valid_mask; + DWORD slot; + DWORD handle; + DWORD raw_gta_index; + DWORD timer; + DWORD from_player; + DWORD model; + DWORD type; + DWORD pos_bits[3]; + BYTE dropped; +} probe_pickup_slot_state; + +typedef struct probe_pickup_pool_state { + DWORD valid_mask; + DWORD count; + DWORD active_count; + DWORD captured_count; + probe_pickup_slot_state slots[PROBE_PICKUP_SAMPLE_SLOTS]; +} probe_pickup_pool_state; + +typedef struct probe_pickup_trace { + volatile LONG committed_seq; + LONG ring_seq; + LONG event_seq; + DWORD tick; + DWORD thread_id; + DWORD gta_frame; + DWORD caller_rva; + DWORD hook_rva; + DWORD pool; + DWORD raw_argument; + DWORD process_ordinal; + DWORD process_gate_before; + DWORD process_gate_after; + DWORD process_tick_delta; + DWORD process_frame_delta; + DWORD rpc_id; + DWORD rpc_bits; + DWORD rpc_payload; + DWORD rpc_payload_valid; + DWORD rpc_priority; + DWORD rpc_reliability; + DWORD rpc_channel; + DWORD rpc_result; + BYTE kind; + probe_pickup_pool_state before; + probe_pickup_pool_state after; +} probe_pickup_trace; + +typedef struct probe_pickup_hook { + const char *name; + DWORD rva; + const BYTE *expected; + BYTE length; + void *replacement; + void *trampoline; + BYTE saved[16]; + volatile LONG installed; +} probe_pickup_hook; + +static uintptr_t g_pickup_samp_base; +static DWORD g_pickup_samp_size; +static volatile LONG g_pickup_install_state; +static probe_pickup_trace + g_pickup_trace_ring[PROBE_PICKUP_TRACE_RING_SIZE]; +static volatile LONG g_pickup_trace_write_seq; +static LONG g_pickup_trace_flushed_seq; +static LONG g_pickup_trace_overflow_count; +static volatile LONG g_pickup_event_seq; +static volatile LONG g_pickup_process_ordinal; +static volatile LONG g_pickup_last_process_tick; +static volatile LONG g_pickup_last_process_frame; + +static const BYTE g_pickup_picked_up_entry[] = { + 0x64, 0xa1, 0x00, 0x00, 0x00, 0x00}; +static const BYTE g_pickup_process_entry[] = { + 0x64, 0xa1, 0x00, 0x00, 0x00, 0x00}; + +static void PROBE_PICKUP_THISCALL hook_pickup_picked_up(void *self, + DWORD raw_index); +static void PROBE_PICKUP_THISCALL hook_pickup_process(void *self); + +static probe_pickup_hook g_pickup_hooks[] = { + {"CPickupPool::PickedUp", PROBE_PICKUP_PICKED_UP_RVA, + g_pickup_picked_up_entry, (BYTE)sizeof(g_pickup_picked_up_entry), + (void *)hook_pickup_picked_up, NULL, {0}, 0}, + {"CPickupPool::Process", PROBE_PICKUP_PROCESS_RVA, + g_pickup_process_entry, (BYTE)sizeof(g_pickup_process_entry), + (void *)hook_pickup_process, NULL, {0}, 0}, +}; + +static int pickup_memory_is_readable(uintptr_t address, size_t size) { + uintptr_t cursor; + uintptr_t end; + + if (address == 0u || size == 0u || address > UINTPTR_MAX - size) { + return 0; + } + cursor = address; + end = address + size; + while (cursor < end) { + MEMORY_BASIC_INFORMATION mbi; + uintptr_t region_end; + DWORD protect; + if (VirtualQuery((const void *)cursor, &mbi, sizeof(mbi)) != sizeof(mbi) || + mbi.State != MEM_COMMIT) { + return 0; + } + protect = mbi.Protect & 0xffu; + if ((mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0u || + protect == PAGE_NOACCESS) { + return 0; + } + region_end = (uintptr_t)mbi.BaseAddress + mbi.RegionSize; + if (region_end <= cursor) { + return 0; + } + cursor = region_end < end ? region_end : end; + } + return 1; +} + +static DWORD pickup_read_u32(uintptr_t address, DWORD fallback) { + DWORD value; + if (!pickup_memory_is_readable(address, sizeof(value))) { + return fallback; + } + memcpy(&value, (const void *)address, sizeof(value)); + return value; +} + +static WORD pickup_read_u16(uintptr_t address, WORD fallback) { + WORD value; + if (!pickup_memory_is_readable(address, sizeof(value))) { + return fallback; + } + memcpy(&value, (const void *)address, sizeof(value)); + return value; +} + +static BYTE pickup_read_u8(uintptr_t address, BYTE fallback) { + BYTE value; + if (!pickup_memory_is_readable(address, sizeof(value))) { + return fallback; + } + memcpy(&value, (const void *)address, sizeof(value)); + return value; +} + +static int pickup_pe_identity_matches(HMODULE module, DWORD timestamp, + DWORD entry_rva, DWORD image_size, + DWORD preferred_base, DWORD checksum, + int check_checksum, + int require_relocs_stripped) { + PIMAGE_DOS_HEADER dos; + PIMAGE_NT_HEADERS nt; + if (module == NULL || image_size < sizeof(IMAGE_NT_HEADERS)) { + return 0; + } + dos = (PIMAGE_DOS_HEADER)module; + if (!pickup_memory_is_readable((uintptr_t)dos, sizeof(*dos)) || + dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew <= 0 || + (DWORD)dos->e_lfanew > + image_size - (DWORD)sizeof(IMAGE_NT_HEADERS)) { + return 0; + } + nt = (PIMAGE_NT_HEADERS)((BYTE *)module + dos->e_lfanew); + if (!pickup_memory_is_readable((uintptr_t)nt, sizeof(*nt)) || + nt->Signature != IMAGE_NT_SIGNATURE || + nt->FileHeader.Machine != IMAGE_FILE_MACHINE_I386 || + nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR32_MAGIC || + nt->OptionalHeader.NumberOfRvaAndSizes <= + IMAGE_DIRECTORY_ENTRY_BASERELOC) { + return 0; + } + return nt->FileHeader.TimeDateStamp == timestamp && + nt->OptionalHeader.ImageBase == preferred_base && + nt->OptionalHeader.AddressOfEntryPoint == entry_rva && + nt->OptionalHeader.SizeOfImage == image_size && + (!check_checksum || nt->OptionalHeader.CheckSum == checksum) && + (require_relocs_stripped + ? (nt->FileHeader.Characteristics & + IMAGE_FILE_RELOCS_STRIPPED) != 0 + : ((nt->FileHeader.Characteristics & + IMAGE_FILE_RELOCS_STRIPPED) == 0 && + nt->OptionalHeader + .DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC] + .VirtualAddress != 0u && + nt->OptionalHeader + .DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC] + .Size != 0u)); +} + +static DWORD pickup_caller_rva(void *caller) { + uintptr_t value = (uintptr_t)caller; + if (g_pickup_samp_base == 0u || value < g_pickup_samp_base || + value >= g_pickup_samp_base + g_pickup_samp_size) { + return 0xffffffffu; + } + return (DWORD)(value - g_pickup_samp_base); +} + +static void pickup_capture_slot(uintptr_t pool, DWORD slot, + probe_pickup_slot_state *state) { + uintptr_t handle; + uintptr_t raw_index; + uintptr_t timer; + uintptr_t dropped; + uintptr_t data; + memset(state, 0, sizeof(*state)); + state->slot = slot; + state->raw_gta_index = 0xffffffffu; + state->from_player = 0xffffu; + if (pool == 0u || slot >= PROBE_PICKUP_CAPACITY) { + return; + } + handle = pool + PROBE_PICKUP_HANDLE_OFFSET + slot * sizeof(DWORD); + raw_index = + pool + PROBE_PICKUP_RAW_GTA_INDEX_OFFSET + slot * sizeof(DWORD); + timer = pool + PROBE_PICKUP_TIMER_OFFSET + slot * sizeof(DWORD); + dropped = pool + PROBE_PICKUP_DROPPED_OFFSET + + slot * PROBE_PICKUP_DROPPED_STRIDE; + data = pool + PROBE_PICKUP_DATA_OFFSET + + slot * PROBE_PICKUP_DATA_STRIDE; + if (pickup_memory_is_readable(handle, sizeof(DWORD))) { + state->handle = pickup_read_u32(handle, 0u); + state->valid_mask |= 0x01u; + } + if (pickup_memory_is_readable(raw_index, sizeof(DWORD))) { + state->raw_gta_index = pickup_read_u32(raw_index, 0xffffffffu); + state->valid_mask |= 0x02u; + } + if (pickup_memory_is_readable(timer, sizeof(DWORD))) { + state->timer = pickup_read_u32(timer, 0u); + state->valid_mask |= 0x04u; + } + if (pickup_memory_is_readable(dropped, PROBE_PICKUP_DROPPED_STRIDE)) { + state->dropped = pickup_read_u8(dropped, 0xffu); + state->from_player = + pickup_read_u16(dropped + PROBE_PICKUP_DROPPED_PLAYER_OFFSET, + 0xffffu); + state->valid_mask |= 0x08u; + } else { + state->dropped = 0xffu; + } + if (pickup_memory_is_readable(data, PROBE_PICKUP_DATA_STRIDE)) { + state->model = + pickup_read_u32(data + PROBE_PICKUP_DATA_MODEL_OFFSET, 0xffffffffu); + state->type = + pickup_read_u32(data + PROBE_PICKUP_DATA_TYPE_OFFSET, 0xffffffffu); + state->pos_bits[0] = + pickup_read_u32(data + PROBE_PICKUP_DATA_POS_OFFSET, 0xffffffffu); + state->pos_bits[1] = pickup_read_u32( + data + PROBE_PICKUP_DATA_POS_OFFSET + 4u, 0xffffffffu); + state->pos_bits[2] = pickup_read_u32( + data + PROBE_PICKUP_DATA_POS_OFFSET + 8u, 0xffffffffu); + state->valid_mask |= 0x10u; + } else { + state->model = 0xffffffffu; + state->type = 0xffffffffu; + state->pos_bits[0] = 0xffffffffu; + state->pos_bits[1] = 0xffffffffu; + state->pos_bits[2] = 0xffffffffu; + } +} + +static int pickup_state_has_slot(const probe_pickup_pool_state *state, + DWORD slot) { + DWORD i; + for (i = 0u; i < state->captured_count; ++i) { + if (state->slots[i].slot == slot) { + return 1; + } + } + return 0; +} + +static void pickup_add_slot(uintptr_t pool, DWORD slot, + probe_pickup_pool_state *state) { + if (slot >= PROBE_PICKUP_CAPACITY || + state->captured_count >= PROBE_PICKUP_SAMPLE_SLOTS || + pickup_state_has_slot(state, slot)) { + return; + } + pickup_capture_slot(pool, slot, &state->slots[state->captured_count]); + ++state->captured_count; +} + +static void pickup_capture_pool(uintptr_t pool, DWORD focus_slot, + DWORD focus_raw, + probe_pickup_pool_state *state) { + const DWORD *handles; + const DWORD *raw_indices; + DWORD i; + memset(state, 0, sizeof(*state)); + if (pool == 0u || + !pickup_memory_is_readable( + pool + PROBE_PICKUP_HANDLE_OFFSET, + PROBE_PICKUP_CAPACITY * sizeof(DWORD)) || + !pickup_memory_is_readable( + pool + PROBE_PICKUP_RAW_GTA_INDEX_OFFSET, + PROBE_PICKUP_CAPACITY * sizeof(DWORD))) { + return; + } + state->valid_mask = 0x01u; + state->count = + pickup_read_u32(pool + PROBE_PICKUP_COUNT_OFFSET, 0xffffffffu); + handles = (const DWORD *)(pool + PROBE_PICKUP_HANDLE_OFFSET); + raw_indices = + (const DWORD *)(pool + PROBE_PICKUP_RAW_GTA_INDEX_OFFSET); + + if (focus_slot < PROBE_PICKUP_CAPACITY && handles[focus_slot] != 0u) { + pickup_add_slot(pool, focus_slot, state); + } + if (focus_raw != 0xffffffffu) { + for (i = 0u; i < PROBE_PICKUP_CAPACITY; ++i) { + if (handles[i] != 0u && raw_indices[i] == focus_raw) { + pickup_add_slot(pool, i, state); + break; + } + } + } + for (i = 0u; i < PROBE_PICKUP_CAPACITY; ++i) { + if (handles[i] == 0u) { + continue; + } + ++state->active_count; + pickup_add_slot(pool, i, state); + } +} + +static uintptr_t pickup_resolve_pool(void) { + DWORD netgame; + DWORD pools; + netgame = pickup_read_u32( + g_pickup_samp_base + PROBE_PICKUP_NETGAME_PTR_RVA, 0u); + if (netgame == 0u) { + return 0u; + } + pools = pickup_read_u32( + (uintptr_t)netgame + PROBE_PICKUP_NETGAME_POOLS_OFFSET, 0u); + if (pools == 0u) { + return 0u; + } + return (uintptr_t)pickup_read_u32( + (uintptr_t)pools + PROBE_PICKUP_POOLS_PICKUP_OFFSET, 0u); +} + +static void pickup_begin_trace(probe_pickup_trace *trace, BYTE kind, + DWORD hook_rva, DWORD pool, + DWORD raw_argument, DWORD caller_rva) { + memset(trace, 0, sizeof(*trace)); + trace->event_seq = InterlockedIncrement(&g_pickup_event_seq); + trace->tick = GetTickCount(); + trace->thread_id = GetCurrentThreadId(); + trace->gta_frame = + pickup_read_u32(PROBE_PICKUP_GTA_FRAME_COUNTER_ADDR, 0xffffffffu); + trace->caller_rva = caller_rva; + trace->hook_rva = hook_rva; + trace->pool = pool; + trace->raw_argument = raw_argument; + trace->kind = kind; +} + +static void pickup_publish_trace(probe_pickup_trace *trace) { + probe_pickup_trace *slot; + LONG ring_seq; + ring_seq = InterlockedIncrement(&g_pickup_trace_write_seq); + trace->ring_seq = ring_seq; + trace->committed_seq = 0; + slot = &g_pickup_trace_ring[ + ((DWORD)ring_seq - 1u) % PROBE_PICKUP_TRACE_RING_SIZE]; + InterlockedExchange(&slot->committed_seq, 0); + *slot = *trace; + MemoryBarrier(); + InterlockedExchange(&slot->committed_seq, ring_seq); +} + +static void PROBE_PICKUP_THISCALL hook_pickup_picked_up(void *self, + DWORD raw_index) { + probe_pickup_trace trace; + pickup_begin_trace(&trace, PROBE_PICKUP_EVENT_PICKED_UP, + PROBE_PICKUP_PICKED_UP_RVA, (DWORD)(uintptr_t)self, + raw_index, + pickup_caller_rva(__builtin_return_address(0))); + trace.process_ordinal = + (DWORD)InterlockedCompareExchange(&g_pickup_process_ordinal, 0, 0); + trace.process_gate_before = pickup_read_u32( + g_pickup_samp_base + PROBE_PICKUP_PROCESS_GATE_RVA, 0xffffffffu); + pickup_capture_pool((uintptr_t)self, 0xffffffffu, raw_index, + &trace.before); + ((probe_pickup_raw_fn)g_pickup_hooks[0].trampoline)(self, raw_index); + trace.process_gate_after = pickup_read_u32( + g_pickup_samp_base + PROBE_PICKUP_PROCESS_GATE_RVA, 0xffffffffu); + pickup_capture_pool((uintptr_t)self, 0xffffffffu, raw_index, + &trace.after); + pickup_publish_trace(&trace); +} + +static void PROBE_PICKUP_THISCALL hook_pickup_process(void *self) { + probe_pickup_trace trace; + LONG previous_tick; + LONG previous_frame; + pickup_begin_trace(&trace, PROBE_PICKUP_EVENT_PROCESS, + PROBE_PICKUP_PROCESS_RVA, (DWORD)(uintptr_t)self, + 0xffffffffu, + pickup_caller_rva(__builtin_return_address(0))); + trace.process_ordinal = + (DWORD)InterlockedIncrement(&g_pickup_process_ordinal); + previous_tick = + InterlockedExchange(&g_pickup_last_process_tick, (LONG)trace.tick); + previous_frame = + InterlockedExchange(&g_pickup_last_process_frame, + (LONG)trace.gta_frame); + if (previous_tick != 0) { + trace.process_tick_delta = trace.tick - (DWORD)previous_tick; + } + if (previous_frame != 0 && trace.gta_frame != 0xffffffffu) { + trace.process_frame_delta = trace.gta_frame - (DWORD)previous_frame; + } + trace.process_gate_before = pickup_read_u32( + g_pickup_samp_base + PROBE_PICKUP_PROCESS_GATE_RVA, 0xffffffffu); + pickup_capture_pool((uintptr_t)self, 0xffffffffu, 0xffffffffu, + &trace.before); + ((probe_pickup_void_fn)g_pickup_hooks[1].trampoline)(self); + trace.process_gate_after = pickup_read_u32( + g_pickup_samp_base + PROBE_PICKUP_PROCESS_GATE_RVA, 0xffffffffu); + pickup_capture_pool((uintptr_t)self, 0xffffffffu, 0xffffffffu, + &trace.after); + pickup_publish_trace(&trace); +} + +void probe_pickup_observe_rpc(BYTE rpc_id, const BYTE *payload, int bits, + int priority, int reliability, + char ordering_channel, DWORD caller_rva, + BYTE result) { + probe_pickup_trace trace; + DWORD focus_slot = 0xffffffffu; + uintptr_t pool; + if (InterlockedCompareExchange(&g_pickup_install_state, 0, 0) != 1 || + (rpc_id != PROBE_PICKUP_RPC_PICKED_UP && + rpc_id != PROBE_PICKUP_RPC_WEAPON_PICKED_UP)) { + return; + } + pool = pickup_resolve_pool(); + pickup_begin_trace( + &trace, + rpc_id == PROBE_PICKUP_RPC_PICKED_UP ? PROBE_PICKUP_EVENT_RPC_131 + : PROBE_PICKUP_EVENT_RPC_97, + 0u, (DWORD)pool, 0xffffffffu, caller_rva); + trace.process_ordinal = + (DWORD)InterlockedCompareExchange(&g_pickup_process_ordinal, 0, 0); + trace.process_gate_before = pickup_read_u32( + g_pickup_samp_base + PROBE_PICKUP_PROCESS_GATE_RVA, 0xffffffffu); + trace.process_gate_after = trace.process_gate_before; + trace.rpc_id = rpc_id; + trace.rpc_bits = bits >= 0 ? (DWORD)bits : 0xffffffffu; + trace.rpc_priority = (DWORD)priority; + trace.rpc_reliability = (DWORD)reliability; + trace.rpc_channel = (DWORD)(BYTE)ordering_channel; + trace.rpc_result = result; + if (rpc_id == PROBE_PICKUP_RPC_PICKED_UP && bits >= 32 && + pickup_memory_is_readable((uintptr_t)payload, sizeof(DWORD))) { + memcpy(&trace.rpc_payload, payload, sizeof(DWORD)); + trace.rpc_payload_valid = 1u; + focus_slot = trace.rpc_payload; + } else if (rpc_id == PROBE_PICKUP_RPC_WEAPON_PICKED_UP && bits >= 16 && + pickup_memory_is_readable((uintptr_t)payload, sizeof(WORD))) { + WORD from_player; + memcpy(&from_player, payload, sizeof(from_player)); + trace.rpc_payload = from_player; + trace.rpc_payload_valid = 1u; + } + pickup_capture_pool(pool, focus_slot, 0xffffffffu, &trace.before); + pickup_publish_trace(&trace); +} + +static int pickup_bytes_match(DWORD rva, const BYTE *bytes, size_t size) { + return rva <= g_pickup_samp_size && + size <= (size_t)(g_pickup_samp_size - rva) && + pickup_memory_is_readable(g_pickup_samp_base + rva, size) && + memcmp((const void *)(g_pickup_samp_base + rva), bytes, size) == 0; +} + +static int pickup_process_gate_bytes_match(void) { + BYTE expected[] = { + 0x83, 0x3d, 0x00, 0x00, 0x00, 0x00, 0x05, 0x7e, 0x46}; + DWORD relocated_gate = + (DWORD)(g_pickup_samp_base + PROBE_PICKUP_PROCESS_GATE_RVA); + memcpy(expected + 2u, &relocated_gate, sizeof(relocated_gate)); + return pickup_bytes_match(0x00008c8eu, expected, sizeof(expected)); +} + +static int pickup_preflight(void) { + static const BYTE picked_up_tail[] = { + 0x8b, 0x8c, 0x24, 0x20, 0x01, 0x00, 0x00, 0x5f, 0x5e, + 0x64, 0x89, 0x0d, 0x00, 0x00, 0x00, 0x00, 0x81, 0xc4, + 0x24, 0x01, 0x00, 0x00, 0xc2, 0x04, 0x00}; + static const BYTE process_tail[] = { + 0x8b, 0x8c, 0x24, 0x2c, 0x01, 0x00, 0x00, 0x5f, 0x5e, + 0x5d, 0x5b, 0x64, 0x89, 0x0d, 0x00, 0x00, 0x00, 0x00, + 0x81, 0xc4, 0x28, 0x01, 0x00, 0x00, 0xc3}; + size_t i; + for (i = 0u; i < sizeof(g_pickup_hooks) / sizeof(g_pickup_hooks[0]); + ++i) { + if (!pickup_bytes_match(g_pickup_hooks[i].rva, + g_pickup_hooks[i].expected, + g_pickup_hooks[i].length)) { + return 0; + } + } + return pickup_bytes_match(0x00013500u, picked_up_tail, + sizeof(picked_up_tail)) && + pickup_bytes_match(0x00013655u, process_tail, + sizeof(process_tail)) && + pickup_process_gate_bytes_match(); +} + +static int pickup_rel32(void *from_after, void *to, LONG *relative) { + intptr_t delta = (BYTE *)to - (BYTE *)from_after; + if (delta < INT32_MIN || delta > INT32_MAX) { + return 0; + } + *relative = (LONG)delta; + return 1; +} + +static int pickup_prepare_trampoline(probe_pickup_hook *hook) { + BYTE *trampoline; + LONG back_rel; + uintptr_t target; + if (hook == NULL || hook->length < 5u || + hook->length > sizeof(hook->saved)) { + return 0; + } + target = g_pickup_samp_base + hook->rva; + trampoline = (BYTE *)VirtualAlloc( + NULL, (SIZE_T)hook->length + 5u, MEM_COMMIT | MEM_RESERVE, + PAGE_EXECUTE_READWRITE); + if (trampoline == NULL) { + return 0; + } + memcpy(hook->saved, (const void *)target, hook->length); + memcpy(trampoline, hook->saved, hook->length); + if (!pickup_rel32(trampoline + hook->length + 5u, + (void *)(target + hook->length), &back_rel)) { + VirtualFree(trampoline, 0u, MEM_RELEASE); + return 0; + } + trampoline[hook->length] = 0xe9u; + memcpy(trampoline + hook->length + 1u, &back_rel, sizeof(back_rel)); + FlushInstructionCache(GetCurrentProcess(), trampoline, + (SIZE_T)hook->length + 5u); + hook->trampoline = trampoline; + return 1; +} + +static int pickup_install_one(probe_pickup_hook *hook) { + uintptr_t target; + BYTE patch[16]; + LONG replacement_rel; + DWORD old_protect; + DWORD restore_protect; + if (hook == NULL || hook->trampoline == NULL || + hook->length > sizeof(patch)) { + return 0; + } + target = g_pickup_samp_base + hook->rva; + if (!pickup_memory_is_readable(target, hook->length) || + memcmp((const void *)target, hook->saved, hook->length) != 0 || + !pickup_rel32((void *)(target + 5u), hook->replacement, + &replacement_rel)) { + return 0; + } + memset(patch, 0x90, hook->length); + patch[0] = 0xe9u; + memcpy(patch + 1u, &replacement_rel, sizeof(replacement_rel)); + if (!VirtualProtect((void *)target, hook->length, + PAGE_EXECUTE_READWRITE, &old_protect)) { + return 0; + } + memcpy((void *)target, patch, hook->length); + FlushInstructionCache(GetCurrentProcess(), (const void *)target, + hook->length); + (void)VirtualProtect((void *)target, hook->length, old_protect, + &restore_protect); + InterlockedExchange(&hook->installed, 1); + return 1; +} + +static int pickup_patch_is_owned(const probe_pickup_hook *hook) { + BYTE expected[16]; + LONG relative; + uintptr_t target; + if (hook == NULL || hook->length > sizeof(expected)) { + return 0; + } + target = g_pickup_samp_base + hook->rva; + if (!pickup_rel32((void *)(target + 5u), hook->replacement, &relative)) { + return 0; + } + memset(expected, 0x90, hook->length); + expected[0] = 0xe9u; + memcpy(expected + 1u, &relative, sizeof(relative)); + return pickup_memory_is_readable(target, hook->length) && + memcmp((const void *)target, expected, hook->length) == 0; +} + +static int pickup_restore_one(probe_pickup_hook *hook) { + uintptr_t target; + DWORD old_protect; + DWORD restore_protect; + if (hook == NULL || + InterlockedCompareExchange(&hook->installed, 0, 0) != 1 || + !pickup_patch_is_owned(hook)) { + return 0; + } + target = g_pickup_samp_base + hook->rva; + if (!VirtualProtect((void *)target, hook->length, + PAGE_EXECUTE_READWRITE, &old_protect)) { + return 0; + } + memcpy((void *)target, hook->saved, hook->length); + FlushInstructionCache(GetCurrentProcess(), (const void *)target, + hook->length); + (void)VirtualProtect((void *)target, hook->length, old_protect, + &restore_protect); + InterlockedExchange(&hook->installed, 0); + return 1; +} + +int probe_pickup_install(HMODULE samp_module, DWORD samp_size, int enabled, + int code_hooks_disabled, + probe_pickup_log_fn log_fn, int log_summary) { + HMODULE gta_module; + size_t hook_count = sizeof(g_pickup_hooks) / sizeof(g_pickup_hooks[0]); + size_t i; + int installed = 0; + + if (!enabled) { + if (log_summary && log_fn != NULL) { + log_fn("pickup_hook: disabled by default; enable with " + "SAMP_PROBE_PICKUP_HOOKS=1 or samp_probe_pickup_hooks.flag"); + } + return 0; + } + if (code_hooks_disabled) { + if (log_summary && log_fn != NULL) { + log_fn("pickup_hook: disabled by SAMP_PROBE_NO_SAMP_CODE_HOOKS"); + } + return 0; + } + if (InterlockedCompareExchange(&g_pickup_install_state, 0, 0) == 1) { + return (int)hook_count; + } + if (InterlockedCompareExchange(&g_pickup_install_state, 0, 0) < 0) { + return 0; + } + + g_pickup_samp_base = (uintptr_t)samp_module; + g_pickup_samp_size = samp_size; + gta_module = GetModuleHandleA(NULL); + if (g_pickup_samp_size != PROBE_PICKUP_R5_IMAGE_SIZE || + !pickup_pe_identity_matches( + samp_module, PROBE_PICKUP_R5_TIMESTAMP, + PROBE_PICKUP_R5_ENTRY_RVA, PROBE_PICKUP_R5_IMAGE_SIZE, + PROBE_PICKUP_R5_PREFERRED_BASE, 0u, 0, 0) || + (uintptr_t)gta_module != PROBE_PICKUP_GTA_PREFERRED_BASE || + !pickup_pe_identity_matches( + gta_module, PROBE_PICKUP_GTA_TIMESTAMP, + PROBE_PICKUP_GTA_ENTRY_RVA, PROBE_PICKUP_GTA_IMAGE_SIZE, + PROBE_PICKUP_GTA_PREFERRED_BASE, PROBE_PICKUP_GTA_CHECKSUM, + 1, 1)) { + if (log_summary && log_fn != NULL) { + log_fn("pickup_hook: skip unsupported_identity installed=0 " + "samp_base=0x%08lx samp_size=0x%08lx samp_sha256=%s " + "gta_base=0x%08lx gta_sha256=%s evidence=STATIC_037", + (unsigned long)g_pickup_samp_base, + (unsigned long)g_pickup_samp_size, PROBE_PICKUP_R5_SHA256, + (unsigned long)(uintptr_t)gta_module, + PROBE_PICKUP_GTA_SHA256); + } + InterlockedExchange(&g_pickup_install_state, -1); + return 0; + } + if (!pickup_preflight()) { + if (log_summary && log_fn != NULL) { + log_fn("pickup_hook: skip preflight_mismatch installed=0 requested=%u " + "evidence=STATIC_037", + (unsigned)hook_count); + } + InterlockedExchange(&g_pickup_install_state, -1); + return 0; + } + for (i = 0u; i < hook_count; ++i) { + if (!pickup_prepare_trampoline(&g_pickup_hooks[i])) { + break; + } + } + if (i != hook_count) { + size_t j; + for (j = 0u; j < hook_count; ++j) { + if (g_pickup_hooks[j].trampoline != NULL) { + VirtualFree(g_pickup_hooks[j].trampoline, 0u, MEM_RELEASE); + g_pickup_hooks[j].trampoline = NULL; + } + } + if (log_summary && log_fn != NULL) { + log_fn("pickup_hook: trampoline_allocation_failed prepared=%u " + "requested=%u installed=0", + (unsigned)i, (unsigned)hook_count); + } + InterlockedExchange(&g_pickup_install_state, -1); + return 0; + } + for (i = 0u; i < hook_count; ++i) { + if (!pickup_install_one(&g_pickup_hooks[i])) { + break; + } + ++installed; + } + if ((size_t)installed != hook_count) { + while (installed > 0) { + --installed; + (void)pickup_restore_one(&g_pickup_hooks[installed]); + } + if (log_summary && log_fn != NULL) { + log_fn("pickup_hook: incomplete_install installed=0 requested=%u " + "run_invalid=1", + (unsigned)hook_count); + } + InterlockedExchange(&g_pickup_install_state, -1); + return 0; + } + InterlockedExchange(&g_pickup_install_state, 1); + if (log_summary && log_fn != NULL) { + log_fn("pickup_hook: summary installed=%u requested=%u " + "rvas=0x13440,0x13520 process_gate_rva=0x118a10 " + "pool_layout=handle:0x4,raw_gta_index:0x4004,timer:0x8004," + "dropped:0xc004,type:0xf008 " + "samp_base=0x%08lx samp_sha256=%s gta_sha256=%s " + "guard=identity,gta_preferred_base,entry_bytes,tails," + "all_or_nothing evidence=STATIC_037,TODO_VERIFY", + (unsigned)hook_count, (unsigned)hook_count, + (unsigned long)g_pickup_samp_base, PROBE_PICKUP_R5_SHA256, + PROBE_PICKUP_GTA_SHA256); + } + return (int)hook_count; +} + +int probe_pickup_is_active(void) { + return InterlockedCompareExchange(&g_pickup_install_state, 0, 0) == 1; +} + +static const char *pickup_event_name(BYTE kind) { + switch (kind) { + case PROBE_PICKUP_EVENT_PICKED_UP: + return "picked_up"; + case PROBE_PICKUP_EVENT_PROCESS: + return "process"; + case PROBE_PICKUP_EVENT_RPC_131: + return "rpc_131"; + case PROBE_PICKUP_EVENT_RPC_97: + return "rpc_97"; + default: + return "unknown"; + } +} + +static void pickup_log_pool(probe_pickup_log_fn log_fn, + const probe_pickup_trace *trace, + const char *phase, + const probe_pickup_pool_state *state) { + DWORD i; + log_fn("pickup_pool_r5: seq=%ld event=%ld phase=%s valid=0x%08lx " + "pool=0x%08lx count=%lu active=%lu captured=%lu", + (long)trace->ring_seq, (long)trace->event_seq, phase, + (unsigned long)state->valid_mask, (unsigned long)trace->pool, + (unsigned long)state->count, (unsigned long)state->active_count, + (unsigned long)state->captured_count); + for (i = 0u; i < state->captured_count; ++i) { + const probe_pickup_slot_state *slot = &state->slots[i]; + log_fn("pickup_slot_r5: seq=%ld event=%ld phase=%s sample=%lu " + "valid=0x%08lx slot=%lu handle=0x%08lx raw_gta_index=%lu " + "timer=%lu dropped=%u from_player=%lu model=%ld type=%ld " + "pos_bits=%08lx,%08lx,%08lx", + (long)trace->ring_seq, (long)trace->event_seq, phase, + (unsigned long)i, (unsigned long)slot->valid_mask, + (unsigned long)slot->slot, (unsigned long)slot->handle, + (unsigned long)slot->raw_gta_index, + (unsigned long)slot->timer, (unsigned)slot->dropped, + (unsigned long)slot->from_player, (long)slot->model, + (long)slot->type, (unsigned long)slot->pos_bits[0], + (unsigned long)slot->pos_bits[1], + (unsigned long)slot->pos_bits[2]); + } +} + +void probe_pickup_flush(probe_pickup_log_fn log_fn) { + LONG write_seq; + LONG pending; + if (log_fn == NULL) { + return; + } + write_seq = InterlockedCompareExchange(&g_pickup_trace_write_seq, 0, 0); + pending = write_seq - g_pickup_trace_flushed_seq; + if (pending > (LONG)PROBE_PICKUP_TRACE_RING_SIZE) { + LONG skipped = pending - (LONG)PROBE_PICKUP_TRACE_RING_SIZE; + g_pickup_trace_flushed_seq += skipped; + g_pickup_trace_overflow_count += skipped; + log_fn("pickup_r5: overflow skipped=%ld total_skipped=%ld ring=%u", + (long)skipped, (long)g_pickup_trace_overflow_count, + (unsigned)PROBE_PICKUP_TRACE_RING_SIZE); + } + while (g_pickup_trace_flushed_seq < write_seq) { + LONG next_seq = g_pickup_trace_flushed_seq + 1; + probe_pickup_trace *slot = + &g_pickup_trace_ring[ + ((DWORD)next_seq - 1u) % PROBE_PICKUP_TRACE_RING_SIZE]; + probe_pickup_trace trace; + if (InterlockedCompareExchange(&slot->committed_seq, 0, 0) != + next_seq) { + break; + } + MemoryBarrier(); + trace = *slot; + MemoryBarrier(); + if (InterlockedCompareExchange(&slot->committed_seq, 0, 0) != + next_seq) { + continue; + } + log_fn("pickup_r5: seq=%ld event=%ld tick=%lu thread=%lu frame=%lu " + "kind=%s caller_rva=0x%08lx hook_rva=0x%08lx " + "pool=0x%08lx raw_argument=%lu process_ordinal=%lu " + "process_gate=%lu,%lu cadence_delta=%lu_ms/%lu_frames " + "rpc=%lu bits=%lu payload_valid=%lu payload=%lu " + "priority=%lu reliability=%lu channel=%lu result=%lu " + "evidence=STATIC_037,TODO_VERIFY", + (long)trace.ring_seq, (long)trace.event_seq, + (unsigned long)trace.tick, (unsigned long)trace.thread_id, + (unsigned long)trace.gta_frame, + pickup_event_name(trace.kind), + (unsigned long)trace.caller_rva, + (unsigned long)trace.hook_rva, (unsigned long)trace.pool, + (unsigned long)trace.raw_argument, + (unsigned long)trace.process_ordinal, + (unsigned long)trace.process_gate_before, + (unsigned long)trace.process_gate_after, + (unsigned long)trace.process_tick_delta, + (unsigned long)trace.process_frame_delta, + (unsigned long)trace.rpc_id, (unsigned long)trace.rpc_bits, + (unsigned long)trace.rpc_payload_valid, + (unsigned long)trace.rpc_payload, + (unsigned long)trace.rpc_priority, + (unsigned long)trace.rpc_reliability, + (unsigned long)trace.rpc_channel, + (unsigned long)trace.rpc_result); + pickup_log_pool(log_fn, &trace, + trace.kind == PROBE_PICKUP_EVENT_RPC_131 || + trace.kind == PROBE_PICKUP_EVENT_RPC_97 + ? "rpc" + : "pre", + &trace.before); + if (trace.kind == PROBE_PICKUP_EVENT_PICKED_UP || + trace.kind == PROBE_PICKUP_EVENT_PROCESS) { + pickup_log_pool(log_fn, &trace, "post", &trace.after); + } + g_pickup_trace_flushed_seq = next_seq; + } +} + +void probe_pickup_uninstall(probe_pickup_log_fn log_fn) { + size_t hook_count = sizeof(g_pickup_hooks) / sizeof(g_pickup_hooks[0]); + size_t i; + int restored = 0; + if (InterlockedCompareExchange(&g_pickup_install_state, 0, 0) != 1) { + return; + } + for (i = hook_count; i > 0u; --i) { + restored += pickup_restore_one(&g_pickup_hooks[i - 1u]); + } + if (log_fn != NULL) { + log_fn("pickup_hook: restore restored=%d requested=%u " + "gateway_lifetime=process", + restored, (unsigned)hook_count); + } + InterlockedExchange(&g_pickup_install_state, + restored == (int)hook_count ? 0 : -1); +} diff --git a/tools/asi_probe/src/samp_probe_pickup.h b/tools/asi_probe/src/samp_probe_pickup.h new file mode 100644 index 0000000..74cdee1 --- /dev/null +++ b/tools/asi_probe/src/samp_probe_pickup.h @@ -0,0 +1,27 @@ +#ifndef SAMP_PROBE_PICKUP_H +#define SAMP_PROBE_PICKUP_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void (*probe_pickup_log_fn)(const char *format, ...); + +int probe_pickup_install(HMODULE samp_module, DWORD samp_size, int enabled, + int code_hooks_disabled, + probe_pickup_log_fn log_fn, int log_summary); +int probe_pickup_is_active(void); +void probe_pickup_observe_rpc(BYTE rpc_id, const BYTE *payload, int bits, + int priority, int reliability, + char ordering_channel, DWORD caller_rva, + BYTE result); +void probe_pickup_flush(probe_pickup_log_fn log_fn); +void probe_pickup_uninstall(probe_pickup_log_fn log_fn); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/tools/asi_probe/src/samp_probe_ui_latches.c b/tools/asi_probe/src/samp_probe_ui_latches.c new file mode 100644 index 0000000..e60dc7a --- /dev/null +++ b/tools/asi_probe/src/samp_probe_ui_latches.c @@ -0,0 +1,1301 @@ +#include "samp_probe_ui_latches.h" + +#include +#include + +#if defined(__GNUC__) && defined(__i386__) +#define PROBE_UL_THISCALL __attribute__((thiscall)) +#else +#define PROBE_UL_THISCALL +#endif + +/* + * STATIC_037 + TODO_VERIFY: + * The SA-MP RVAs, code guards, and object offsets in this file were recovered + * from original 0.3.7-R5 + * SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. + * GTA absolute addresses are accepted only for the exact 1.0-US image + * SHA256=a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26. + * Runtime meaning remains TODO_VERIFY until an original-client trace exists. + * See docs/re/ui_latch_memory_probe_r5_20260728.md. + */ +#define PROBE_UL_R5_TIMESTAMP 0x6372c39eu +#define PROBE_UL_R5_ENTRY_RVA 0x000cbc90u +#define PROBE_UL_R5_IMAGE_SIZE 0x0027e000u +#define PROBE_UL_R5_PREFERRED_BASE 0x10000000u +#define PROBE_UL_R5_SHA256 \ + "b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2" + +#define PROBE_UL_GTA_TIMESTAMP 0x427101cau +#define PROBE_UL_GTA_ENTRY_RVA 0x00424570u +#define PROBE_UL_GTA_IMAGE_SIZE 0x01177000u +#define PROBE_UL_GTA_CHECKSUM 0x00dc5beau +#define PROBE_UL_GTA_PREFERRED_BASE 0x00400000u +#define PROBE_UL_GTA_SHA256 \ + "a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26" + +#define PROBE_UL_CHAT_MODE_TOGGLE_RVA 0x000612c0u +#define PROBE_UL_CHAT_OPEN_RVA 0x00069480u +#define PROBE_UL_CHAT_CLOSE_RVA 0x00069580u +#define PROBE_UL_SCOREBOARD_HIDE_RVA 0x0006e9e0u +#define PROBE_UL_SCOREBOARD_SHOW_RVA 0x0006f3d0u +#define PROBE_UL_CURSOR_RESTORE_RVA 0x000a05d0u +#define PROBE_UL_CURSOR_MODE_RVA 0x000a06f0u +#define PROBE_UL_MENU_QUERY_RVA 0x000a0920u +#define PROBE_UL_REMOTE_PROCESS_RVA 0x000166b0u + +#define PROBE_UL_SCOREBOARD_PTR_RVA 0x0026eb4cu +#define PROBE_UL_CHAT_DISPLAY_PTR_RVA 0x0026eb80u +#define PROBE_UL_CHAT_PTR_RVA 0x0026eb84u +#define PROBE_UL_GAME_PTR_RVA 0x0026ebacu + +#define PROBE_UL_SCOREBOARD_VISIBLE_OFFSET 0x00000000u +#define PROBE_UL_CHAT_DISPLAY_MODE_OFFSET 0x00000008u +#define PROBE_UL_CHAT_DISPLAY_DIRTY_OFFSET 0x000063dau +#define PROBE_UL_CHAT_ACTIVE_OFFSET 0x000014e0u +#define PROBE_UL_GAME_CURSOR_MODE_OFFSET 0x00000061u +#define PROBE_UL_GAME_CURSOR_RESTORE_OFFSET 0x00000065u + +#define PROBE_UL_REMOTE_SYNC_STATE_OFFSET 0x0000010au +#define PROBE_UL_REMOTE_LAST_SYNC_TICK_OFFSET 0x000001b9u +#define PROBE_UL_REMOTE_AFK_STATE_OFFSET 0x000001c5u +#define PROBE_UL_REMOTE_PED_WRAPPER_OFFSET 0x000001ddu + +#define PROBE_UL_GTA_INPUT_GATE_ADDR 0x00541df5u +#define PROBE_UL_GTA_MOUSE_GATE_ADDR 0x006194a0u +#define PROBE_UL_GTA_FRAME_COUNTER_ADDR 0x00b7cb4cu +#define PROBE_UL_GTA_PAUSE_RAW_ADDR 0x00b7cb49u +#define PROBE_UL_GTA_FRONTEND_RAW_ADDR 0x00ba67a4u + +#define PROBE_UL_TRACE_RING_SIZE 256u +#define PROBE_UL_REMOTE_TRACKER_SIZE 128u +#define PROBE_UL_REMOTE_HEARTBEAT_MS 1000u + +enum probe_ul_event_kind { + PROBE_UL_EVENT_CHAT_MODE_TOGGLE = 1, + PROBE_UL_EVENT_CHAT_OPEN = 2, + PROBE_UL_EVENT_CHAT_CLOSE = 3, + PROBE_UL_EVENT_SCOREBOARD_HIDE = 4, + PROBE_UL_EVENT_SCOREBOARD_SHOW = 5, + PROBE_UL_EVENT_CURSOR_RESTORE = 6, + PROBE_UL_EVENT_CURSOR_MODE = 7, + PROBE_UL_EVENT_MENU_EDGE = 8, + PROBE_UL_EVENT_REMOTE_AFK = 9 +}; + +enum probe_ul_reason { + PROBE_UL_REASON_CALL = 0x01, + PROBE_UL_REASON_BASELINE = 0x02, + PROBE_UL_REASON_STATE_EDGE = 0x04, + PROBE_UL_REASON_HEARTBEAT = 0x08, + PROBE_UL_REASON_INPUT_EDGE = 0x10 +}; + +enum probe_ul_hook_index { + PROBE_UL_HOOK_CHAT_MODE_TOGGLE = 0, + PROBE_UL_HOOK_CHAT_OPEN, + PROBE_UL_HOOK_CHAT_CLOSE, + PROBE_UL_HOOK_SCOREBOARD_HIDE, + PROBE_UL_HOOK_SCOREBOARD_SHOW, + PROBE_UL_HOOK_CURSOR_RESTORE, + PROBE_UL_HOOK_CURSOR_MODE, + PROBE_UL_HOOK_MENU_QUERY, + PROBE_UL_HOOK_REMOTE_PROCESS +}; + +typedef struct probe_ul_ui_state { + DWORD scoreboard; + DWORD scoreboard_visible; + DWORD chat_display; + DWORD chat_display_mode; + DWORD chat_display_dirty; + DWORD chat; + DWORD chat_active; + DWORD game; + DWORD cursor_mode_raw; + DWORD cursor_restore_raw; + DWORD frontend_raw; + DWORD input_gate_head; + DWORD foreground_window; + DWORD focus_window; + DWORD capture_window; + DWORD cursor_flags; + BYTE frontend_a; + BYTE frontend_b; + BYTE frontend_c; + BYTE pause_raw_b7cb49; + BYTE input_gate_tail; + BYTE mouse_gate_byte; +} probe_ul_ui_state; + +typedef struct probe_ul_remote_state { + DWORD afk_state; + DWORD last_sync_tick; + DWORD ped_wrapper; + BYTE sync_state; +} probe_ul_remote_state; + +typedef struct probe_ul_trace { + volatile LONG committed_seq; + LONG ring_seq; + LONG event_seq; + DWORD tick; + DWORD thread_id; + DWORD gta_frame; + DWORD caller; + DWORD caller_rva; + DWORD hook_rva; + DWORD object; + DWORD arg0; + DWORD arg1; + DWORD result; + DWORD value_before; + DWORD value_after; + DWORD aux_before; + DWORD aux_after; + DWORD elapsed_before; + DWORD elapsed_after; + BYTE kind; + BYTE reason; + BYTE before_ui_valid; + BYTE after_ui_valid; + probe_ul_remote_state remote_before; + probe_ul_remote_state remote_after; + probe_ul_ui_state before_ui; + probe_ul_ui_state after_ui; +} probe_ul_trace; + +typedef struct probe_ul_remote_tracker { + DWORD object; + DWORD last_emit_tick; + DWORD afk_state; + BYTE sync_state; + BYTE used; +} probe_ul_remote_tracker; + +typedef struct probe_ul_hook { + const char *name; + DWORD rva; + const BYTE *expected; + BYTE length; + void *replacement; + void *trampoline; + BYTE saved[16]; + volatile LONG installed; +} probe_ul_hook; + +typedef void(PROBE_UL_THISCALL *probe_ul_this_void_fn)(void *self); +typedef void(PROBE_UL_THISCALL *probe_ul_this_arg_fn)(void *self, DWORD arg0); +typedef void(PROBE_UL_THISCALL *probe_ul_cursor_mode_fn)(void *self, + DWORD mode, + DWORD delayed_restore); +typedef int(PROBE_UL_THISCALL *probe_ul_this_int_fn)(void *self); + +static void PROBE_UL_THISCALL hook_ul_chat_mode_toggle(void *self); +static void PROBE_UL_THISCALL hook_ul_chat_open(void *self); +static void PROBE_UL_THISCALL hook_ul_chat_close(void *self); +static void PROBE_UL_THISCALL hook_ul_scoreboard_hide(void *self, DWORD arg0); +static void PROBE_UL_THISCALL hook_ul_scoreboard_show(void *self); +static void PROBE_UL_THISCALL hook_ul_cursor_restore(void *self); +static void PROBE_UL_THISCALL hook_ul_cursor_mode(void *self, DWORD mode, + DWORD delayed_restore); +static int PROBE_UL_THISCALL hook_ul_menu_query(void *self); +static void PROBE_UL_THISCALL hook_ul_remote_process(void *self); + +static const BYTE g_ul_chat_mode_entry[] = {0x8b, 0x41, 0x08, 0x85, 0xc0}; +static const BYTE g_ul_chat_open_entry[] = { + 0x83, 0xec, 0x10, 0x56, 0x8b, 0xf1}; +static const BYTE g_ul_chat_close_entry[] = { + 0x56, 0x8b, 0xf1, 0x8b, 0x86, 0xe0, 0x14, 0x00, 0x00}; +static const BYTE g_ul_scoreboard_hide_entry[] = { + 0x56, 0x8b, 0xf1, 0x83, 0x3e, 0x00}; +static const BYTE g_ul_scoreboard_show_entry[] = { + 0x56, 0x8b, 0xf1, 0x83, 0x3e, 0x00}; +static const BYTE g_ul_cursor_restore_entry[] = { + 0x56, 0x8b, 0xf1, 0x8b, 0x46, 0x61, 0x57}; +static const BYTE g_ul_cursor_mode_entry[] = { + 0x55, 0x8b, 0xec, 0x8b, 0x45, 0x08}; +static const BYTE g_ul_menu_query_entry[] = { + 0x8b, 0x0d, 0xa4, 0x67, 0xba, 0x00}; +static const BYTE g_ul_remote_process_entry[] = { + 0x81, 0xec, 0x90, 0x00, 0x00, 0x00}; + +static probe_ul_hook g_ul_hooks[] = { + {"CChatWindow::CycleMode", PROBE_UL_CHAT_MODE_TOGGLE_RVA, + g_ul_chat_mode_entry, (BYTE)sizeof(g_ul_chat_mode_entry), + (void *)hook_ul_chat_mode_toggle, NULL, {0}, 0}, + {"CChat::Open", PROBE_UL_CHAT_OPEN_RVA, g_ul_chat_open_entry, + (BYTE)sizeof(g_ul_chat_open_entry), (void *)hook_ul_chat_open, + NULL, {0}, 0}, + {"CChat::Close", PROBE_UL_CHAT_CLOSE_RVA, g_ul_chat_close_entry, + (BYTE)sizeof(g_ul_chat_close_entry), (void *)hook_ul_chat_close, + NULL, {0}, 0}, + {"CScoreboard::Hide", PROBE_UL_SCOREBOARD_HIDE_RVA, + g_ul_scoreboard_hide_entry, (BYTE)sizeof(g_ul_scoreboard_hide_entry), + (void *)hook_ul_scoreboard_hide, NULL, {0}, 0}, + {"CScoreboard::Show", PROBE_UL_SCOREBOARD_SHOW_RVA, + g_ul_scoreboard_show_entry, (BYTE)sizeof(g_ul_scoreboard_show_entry), + (void *)hook_ul_scoreboard_show, NULL, {0}, 0}, + {"CGame::RestoreInput", PROBE_UL_CURSOR_RESTORE_RVA, + g_ul_cursor_restore_entry, (BYTE)sizeof(g_ul_cursor_restore_entry), + (void *)hook_ul_cursor_restore, NULL, {0}, 0}, + {"CGame::SetCursorMode", PROBE_UL_CURSOR_MODE_RVA, + g_ul_cursor_mode_entry, (BYTE)sizeof(g_ul_cursor_mode_entry), + (void *)hook_ul_cursor_mode, NULL, {0}, 0}, + {"CGame::IsMenuActive", PROBE_UL_MENU_QUERY_RVA, + g_ul_menu_query_entry, (BYTE)sizeof(g_ul_menu_query_entry), + (void *)hook_ul_menu_query, NULL, {0}, 0}, + {"CRemotePlayer::Process", PROBE_UL_REMOTE_PROCESS_RVA, + g_ul_remote_process_entry, (BYTE)sizeof(g_ul_remote_process_entry), + (void *)hook_ul_remote_process, NULL, {0}, 0}, +}; + +static uintptr_t g_ul_samp_base; +static DWORD g_ul_samp_size; +static volatile LONG g_ul_install_state; +static probe_ul_trace g_ul_trace_ring[PROBE_UL_TRACE_RING_SIZE]; +static volatile LONG g_ul_trace_write_seq; +static LONG g_ul_trace_flushed_seq; +static LONG g_ul_trace_overflow_count; +static volatile LONG g_ul_event_seq; +static volatile LONG g_ul_last_menu_plus_one; +static probe_ul_remote_tracker + g_ul_remote_trackers[PROBE_UL_REMOTE_TRACKER_SIZE]; +static volatile LONG g_ul_remote_tracker_overflow; +static LONG g_ul_remote_tracker_overflow_flushed; + +static int ul_memory_is_readable(uintptr_t address, size_t size) { + uintptr_t cursor; + uintptr_t end; + if (address == 0u || size == 0u || address > UINTPTR_MAX - size) { + return 0; + } + cursor = address; + end = address + size; + while (cursor < end) { + MEMORY_BASIC_INFORMATION mbi; + uintptr_t region_end; + DWORD protect; + if (VirtualQuery((const void *)cursor, &mbi, sizeof(mbi)) != sizeof(mbi) || + mbi.State != MEM_COMMIT) { + return 0; + } + protect = mbi.Protect & 0xffu; + if ((mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0u || + protect == PAGE_NOACCESS) { + return 0; + } + region_end = (uintptr_t)mbi.BaseAddress + mbi.RegionSize; + if (region_end <= cursor) { + return 0; + } + cursor = region_end < end ? region_end : end; + } + return 1; +} + +static DWORD ul_read_u32(uintptr_t address, DWORD fallback) { + DWORD value; + if (!ul_memory_is_readable(address, sizeof(value))) { + return fallback; + } + memcpy(&value, (const void *)address, sizeof(value)); + return value; +} + +static BYTE ul_read_u8(uintptr_t address, BYTE fallback) { + BYTE value; + if (!ul_memory_is_readable(address, sizeof(value))) { + return fallback; + } + memcpy(&value, (const void *)address, sizeof(value)); + return value; +} + +static DWORD ul_load_u32(uintptr_t address) { + DWORD value; + memcpy(&value, (const void *)address, sizeof(value)); + return value; +} + +static BYTE ul_load_u8(uintptr_t address) { + BYTE value; + memcpy(&value, (const void *)address, sizeof(value)); + return value; +} + +static int ul_pe_identity_matches(HMODULE module, DWORD timestamp, + DWORD entry_rva, DWORD image_size, + DWORD preferred_base, DWORD checksum, + int check_checksum, + int require_relocs_stripped) { + PIMAGE_DOS_HEADER dos; + PIMAGE_NT_HEADERS nt; + if (module == NULL || image_size < sizeof(IMAGE_NT_HEADERS)) { + return 0; + } + dos = (PIMAGE_DOS_HEADER)module; + if (!ul_memory_is_readable((uintptr_t)dos, sizeof(*dos)) || + dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew <= 0 || + (DWORD)dos->e_lfanew > + image_size - (DWORD)sizeof(IMAGE_NT_HEADERS)) { + return 0; + } + nt = (PIMAGE_NT_HEADERS)((BYTE *)module + dos->e_lfanew); + if (!ul_memory_is_readable((uintptr_t)nt, sizeof(*nt)) || + nt->Signature != IMAGE_NT_SIGNATURE || + nt->FileHeader.Machine != IMAGE_FILE_MACHINE_I386 || + nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR32_MAGIC || + nt->OptionalHeader.NumberOfRvaAndSizes <= + IMAGE_DIRECTORY_ENTRY_BASERELOC) { + return 0; + } + return nt->FileHeader.TimeDateStamp == timestamp && + nt->OptionalHeader.ImageBase == preferred_base && + nt->OptionalHeader.AddressOfEntryPoint == entry_rva && + nt->OptionalHeader.SizeOfImage == image_size && + (!check_checksum || nt->OptionalHeader.CheckSum == checksum) && + (require_relocs_stripped + ? (nt->FileHeader.Characteristics & + IMAGE_FILE_RELOCS_STRIPPED) != 0 + : ((nt->FileHeader.Characteristics & + IMAGE_FILE_RELOCS_STRIPPED) == 0 && + nt->OptionalHeader + .DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC] + .VirtualAddress != 0u && + nt->OptionalHeader + .DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC] + .Size != 0u)); +} + +static DWORD ul_caller_rva(void *caller) { + uintptr_t address = (uintptr_t)caller; + if (address < g_ul_samp_base || + address >= g_ul_samp_base + g_ul_samp_size) { + return 0xffffffffu; + } + return (DWORD)(address - g_ul_samp_base); +} + +static void ul_capture_ui_state(probe_ul_ui_state *state) { + DWORD object; + CURSORINFO cursor_info; + memset(state, 0, sizeof(*state)); + + state->scoreboard = + ul_read_u32(g_ul_samp_base + PROBE_UL_SCOREBOARD_PTR_RVA, 0u); + object = state->scoreboard; + state->scoreboard_visible = + ul_read_u32((uintptr_t)object + PROBE_UL_SCOREBOARD_VISIBLE_OFFSET, + 0xffffffffu); + + state->chat_display = + ul_read_u32(g_ul_samp_base + PROBE_UL_CHAT_DISPLAY_PTR_RVA, 0u); + object = state->chat_display; + state->chat_display_mode = + ul_read_u32((uintptr_t)object + PROBE_UL_CHAT_DISPLAY_MODE_OFFSET, + 0xffffffffu); + state->chat_display_dirty = + ul_read_u32((uintptr_t)object + PROBE_UL_CHAT_DISPLAY_DIRTY_OFFSET, + 0xffffffffu); + + state->chat = ul_read_u32(g_ul_samp_base + PROBE_UL_CHAT_PTR_RVA, 0u); + object = state->chat; + state->chat_active = + ul_read_u32((uintptr_t)object + PROBE_UL_CHAT_ACTIVE_OFFSET, + 0xffffffffu); + + state->game = ul_read_u32(g_ul_samp_base + PROBE_UL_GAME_PTR_RVA, 0u); + object = state->game; + state->cursor_mode_raw = + ul_read_u32((uintptr_t)object + PROBE_UL_GAME_CURSOR_MODE_OFFSET, + 0xffffffffu); + state->cursor_restore_raw = + ul_read_u32((uintptr_t)object + PROBE_UL_GAME_CURSOR_RESTORE_OFFSET, + 0xffffffffu); + + state->frontend_raw = ul_load_u32(PROBE_UL_GTA_FRONTEND_RAW_ADDR); + state->frontend_a = ul_load_u8(PROBE_UL_GTA_FRONTEND_RAW_ADDR); + state->frontend_b = ul_load_u8(PROBE_UL_GTA_FRONTEND_RAW_ADDR + 1u); + state->frontend_c = ul_load_u8(PROBE_UL_GTA_FRONTEND_RAW_ADDR + 2u); + state->pause_raw_b7cb49 = ul_load_u8(PROBE_UL_GTA_PAUSE_RAW_ADDR); + state->input_gate_head = ul_load_u32(PROBE_UL_GTA_INPUT_GATE_ADDR); + state->input_gate_tail = ul_load_u8(PROBE_UL_GTA_INPUT_GATE_ADDR + 4u); + state->mouse_gate_byte = ul_load_u8(PROBE_UL_GTA_MOUSE_GATE_ADDR); + state->foreground_window = (DWORD)(uintptr_t)GetForegroundWindow(); + state->focus_window = (DWORD)(uintptr_t)GetFocus(); + state->capture_window = (DWORD)(uintptr_t)GetCapture(); + memset(&cursor_info, 0, sizeof(cursor_info)); + cursor_info.cbSize = sizeof(cursor_info); + state->cursor_flags = + GetCursorInfo(&cursor_info) ? cursor_info.flags : 0xffffffffu; +} + +static void ul_capture_remote(void *self, probe_ul_remote_state *state, + DWORD now) { + uintptr_t object = (uintptr_t)self; + memset(state, 0, sizeof(*state)); + state->sync_state = + ul_read_u8(object + PROBE_UL_REMOTE_SYNC_STATE_OFFSET, 0xffu); + state->last_sync_tick = + ul_read_u32(object + PROBE_UL_REMOTE_LAST_SYNC_TICK_OFFSET, now); + state->afk_state = + ul_read_u32(object + PROBE_UL_REMOTE_AFK_STATE_OFFSET, 0xffffffffu); + state->ped_wrapper = + ul_read_u32(object + PROBE_UL_REMOTE_PED_WRAPPER_OFFSET, 0u); +} + +static void ul_begin_trace(probe_ul_trace *trace, BYTE kind, BYTE reason, + DWORD hook_rva, void *self, DWORD arg0, + DWORD arg1, void *caller) { + memset(trace, 0, sizeof(*trace)); + trace->event_seq = InterlockedIncrement(&g_ul_event_seq); + trace->tick = GetTickCount(); + trace->thread_id = GetCurrentThreadId(); + trace->gta_frame = ul_load_u32(PROBE_UL_GTA_FRAME_COUNTER_ADDR); + trace->caller = (DWORD)(uintptr_t)caller; + trace->caller_rva = ul_caller_rva(caller); + trace->hook_rva = hook_rva; + trace->object = (DWORD)(uintptr_t)self; + trace->arg0 = arg0; + trace->arg1 = arg1; + trace->kind = kind; + trace->reason = reason; +} + +static void ul_publish_trace(probe_ul_trace *trace) { + LONG ring_seq; + probe_ul_trace *slot; + if (trace == NULL) { + return; + } + ring_seq = InterlockedIncrement(&g_ul_trace_write_seq); + trace->ring_seq = ring_seq; + trace->committed_seq = 0; + slot = &g_ul_trace_ring[ + ((DWORD)ring_seq - 1u) % PROBE_UL_TRACE_RING_SIZE]; + InterlockedExchange(&slot->committed_seq, 0); + *slot = *trace; + MemoryBarrier(); + InterlockedExchange(&slot->committed_seq, ring_seq); +} + +static void ul_run_simple_void_hook(BYTE kind, DWORD hook_rva, + size_t hook_index, void *self, + void *caller) { + probe_ul_trace trace; + ul_begin_trace(&trace, kind, PROBE_UL_REASON_CALL, hook_rva, self, 0u, 0u, + caller); + trace.before_ui_valid = 1u; + ul_capture_ui_state(&trace.before_ui); + ((probe_ul_this_void_fn)g_ul_hooks[hook_index].trampoline)(self); + trace.after_ui_valid = 1u; + ul_capture_ui_state(&trace.after_ui); + ul_publish_trace(&trace); +} + +static void PROBE_UL_THISCALL hook_ul_chat_mode_toggle(void *self) { + ul_run_simple_void_hook(PROBE_UL_EVENT_CHAT_MODE_TOGGLE, + PROBE_UL_CHAT_MODE_TOGGLE_RVA, + PROBE_UL_HOOK_CHAT_MODE_TOGGLE, self, + __builtin_return_address(0)); +} + +static void PROBE_UL_THISCALL hook_ul_chat_open(void *self) { + ul_run_simple_void_hook(PROBE_UL_EVENT_CHAT_OPEN, PROBE_UL_CHAT_OPEN_RVA, + PROBE_UL_HOOK_CHAT_OPEN, self, + __builtin_return_address(0)); +} + +static void PROBE_UL_THISCALL hook_ul_chat_close(void *self) { + ul_run_simple_void_hook(PROBE_UL_EVENT_CHAT_CLOSE, PROBE_UL_CHAT_CLOSE_RVA, + PROBE_UL_HOOK_CHAT_CLOSE, self, + __builtin_return_address(0)); +} + +static void PROBE_UL_THISCALL hook_ul_scoreboard_hide(void *self, DWORD arg0) { + probe_ul_trace trace; + ul_begin_trace(&trace, PROBE_UL_EVENT_SCOREBOARD_HIDE, + PROBE_UL_REASON_CALL, PROBE_UL_SCOREBOARD_HIDE_RVA, self, + arg0, 0u, __builtin_return_address(0)); + trace.before_ui_valid = 1u; + ul_capture_ui_state(&trace.before_ui); + ((probe_ul_this_arg_fn) + g_ul_hooks[PROBE_UL_HOOK_SCOREBOARD_HIDE].trampoline)(self, arg0); + trace.after_ui_valid = 1u; + ul_capture_ui_state(&trace.after_ui); + ul_publish_trace(&trace); +} + +static void PROBE_UL_THISCALL hook_ul_scoreboard_show(void *self) { + ul_run_simple_void_hook(PROBE_UL_EVENT_SCOREBOARD_SHOW, + PROBE_UL_SCOREBOARD_SHOW_RVA, + PROBE_UL_HOOK_SCOREBOARD_SHOW, self, + __builtin_return_address(0)); +} + +static void PROBE_UL_THISCALL hook_ul_cursor_restore(void *self) { + probe_ul_trace trace; + DWORD mode_before = + ul_read_u32((uintptr_t)self + PROBE_UL_GAME_CURSOR_MODE_OFFSET, + 0xffffffffu); + DWORD restore_before = + ul_read_u32((uintptr_t)self + PROBE_UL_GAME_CURSOR_RESTORE_OFFSET, + 0xffffffffu); + DWORD gate_before = ul_load_u32(PROBE_UL_GTA_INPUT_GATE_ADDR); + BYTE gate_tail_before = ul_load_u8(PROBE_UL_GTA_INPUT_GATE_ADDR + 4u); + BYTE mouse_before = ul_load_u8(PROBE_UL_GTA_MOUSE_GATE_ADDR); + DWORD mode_after; + DWORD restore_after; + DWORD gate_after; + BYTE gate_tail_after; + BYTE mouse_after; + + ((probe_ul_this_void_fn) + g_ul_hooks[PROBE_UL_HOOK_CURSOR_RESTORE].trampoline)(self); + mode_after = + ul_read_u32((uintptr_t)self + PROBE_UL_GAME_CURSOR_MODE_OFFSET, + 0xffffffffu); + restore_after = + ul_read_u32((uintptr_t)self + PROBE_UL_GAME_CURSOR_RESTORE_OFFSET, + 0xffffffffu); + gate_after = ul_load_u32(PROBE_UL_GTA_INPUT_GATE_ADDR); + gate_tail_after = ul_load_u8(PROBE_UL_GTA_INPUT_GATE_ADDR + 4u); + mouse_after = ul_load_u8(PROBE_UL_GTA_MOUSE_GATE_ADDR); + if (mode_before == mode_after && restore_before == restore_after && + gate_before == gate_after && gate_tail_before == gate_tail_after && + mouse_before == mouse_after) { + return; + } + + ul_begin_trace(&trace, PROBE_UL_EVENT_CURSOR_RESTORE, + PROBE_UL_REASON_INPUT_EDGE, PROBE_UL_CURSOR_RESTORE_RVA, + self, 0u, 0u, __builtin_return_address(0)); + trace.value_before = mode_before; + trace.value_after = mode_after; + trace.aux_before = restore_before; + trace.aux_after = restore_after; + trace.after_ui_valid = 1u; + ul_capture_ui_state(&trace.after_ui); + /* + * Keep the per-frame no-change path light: reconstruct the changed raw + * pre-state from values captured before the original call, and copy only + * the unchanged context fields from the post snapshot. This preserves an + * exact before/after record for every field RestoreInput itself can mutate + * without issuing the Win32 UI queries twice on every game frame. + */ + trace.before_ui_valid = 1u; + trace.before_ui = trace.after_ui; + trace.before_ui.cursor_mode_raw = mode_before; + trace.before_ui.cursor_restore_raw = restore_before; + trace.before_ui.input_gate_head = gate_before; + trace.before_ui.input_gate_tail = gate_tail_before; + trace.before_ui.mouse_gate_byte = mouse_before; + ul_publish_trace(&trace); +} + +static void PROBE_UL_THISCALL hook_ul_cursor_mode(void *self, DWORD mode, + DWORD delayed_restore) { + probe_ul_trace trace; + ul_begin_trace(&trace, PROBE_UL_EVENT_CURSOR_MODE, PROBE_UL_REASON_CALL, + PROBE_UL_CURSOR_MODE_RVA, self, mode, delayed_restore, + __builtin_return_address(0)); + trace.before_ui_valid = 1u; + ul_capture_ui_state(&trace.before_ui); + ((probe_ul_cursor_mode_fn) + g_ul_hooks[PROBE_UL_HOOK_CURSOR_MODE].trampoline)( + self, mode, delayed_restore); + trace.after_ui_valid = 1u; + ul_capture_ui_state(&trace.after_ui); + trace.value_before = trace.before_ui.cursor_mode_raw; + trace.value_after = trace.after_ui.cursor_mode_raw; + trace.aux_before = trace.before_ui.cursor_restore_raw; + trace.aux_after = trace.after_ui.cursor_restore_raw; + ul_publish_trace(&trace); +} + +static int PROBE_UL_THISCALL hook_ul_menu_query(void *self) { + probe_ul_trace trace; + DWORD raw_before = ul_load_u32(PROBE_UL_GTA_FRONTEND_RAW_ADDR); + int result = ((probe_ul_this_int_fn) + g_ul_hooks[PROBE_UL_HOOK_MENU_QUERY].trampoline)(self); + DWORD raw_after = ul_load_u32(PROBE_UL_GTA_FRONTEND_RAW_ADDR); + LONG encoded = (LONG)((DWORD)result + 1u); + LONG previous = InterlockedExchange(&g_ul_last_menu_plus_one, encoded); + if (previous == encoded) { + return result; + } + ul_begin_trace(&trace, PROBE_UL_EVENT_MENU_EDGE, + previous == 0 ? PROBE_UL_REASON_BASELINE + : PROBE_UL_REASON_STATE_EDGE, + PROBE_UL_MENU_QUERY_RVA, self, 0u, 0u, + __builtin_return_address(0)); + trace.result = (DWORD)result; + trace.value_before = raw_before; + trace.value_after = raw_after; + trace.aux_before = + previous == 0 ? 0xffffffffu : (DWORD)(previous - 1); + trace.aux_after = (DWORD)result; + trace.after_ui_valid = 1u; + ul_capture_ui_state(&trace.after_ui); + ul_publish_trace(&trace); + return result; +} + +static probe_ul_remote_tracker *ul_find_remote_tracker( + DWORD object, const probe_ul_remote_state *before, DWORD now, + int *is_first) { + size_t i; + probe_ul_remote_tracker *free_slot = NULL; + *is_first = 0; + for (i = 0u; i < PROBE_UL_REMOTE_TRACKER_SIZE; ++i) { + probe_ul_remote_tracker *tracker = &g_ul_remote_trackers[i]; + if (tracker->used && tracker->object == object) { + return tracker; + } + if (!tracker->used && free_slot == NULL) { + free_slot = tracker; + } + } + if (free_slot == NULL) { + InterlockedIncrement(&g_ul_remote_tracker_overflow); + return NULL; + } + free_slot->object = object; + free_slot->last_emit_tick = now; + free_slot->afk_state = before->afk_state; + free_slot->sync_state = before->sync_state; + free_slot->used = 1u; + *is_first = 1; + return free_slot; +} + +static void PROBE_UL_THISCALL hook_ul_remote_process(void *self) { + probe_ul_trace trace; + probe_ul_ui_state before_ui; + probe_ul_remote_state before; + probe_ul_remote_state after; + probe_ul_remote_tracker *tracker; + DWORD now = GetTickCount(); + DWORD reason = 0u; + int is_first = 0; + int heartbeat_due = 0; + int capture_before_ui = 0; + + ul_capture_remote(self, &before, now); + tracker = + ul_find_remote_tracker((DWORD)(uintptr_t)self, &before, now, &is_first); + if (tracker != NULL && !is_first && + (DWORD)(now - tracker->last_emit_tick) >= + PROBE_UL_REMOTE_HEARTBEAT_MS) { + heartbeat_due = 1; + } + capture_before_ui = is_first || heartbeat_due; + if (capture_before_ui) { + ul_capture_ui_state(&before_ui); + } + + ((probe_ul_this_void_fn) + g_ul_hooks[PROBE_UL_HOOK_REMOTE_PROCESS].trampoline)(self); + ul_capture_remote(self, &after, GetTickCount()); + + if (is_first) { + reason |= PROBE_UL_REASON_BASELINE; + } + if (before.afk_state != after.afk_state || + before.sync_state != after.sync_state || + (tracker != NULL && + (tracker->afk_state != after.afk_state || + tracker->sync_state != after.sync_state))) { + reason |= PROBE_UL_REASON_STATE_EDGE; + } + if (heartbeat_due) { + reason |= PROBE_UL_REASON_HEARTBEAT; + } + if (reason == 0u) { + return; + } + + ul_begin_trace(&trace, PROBE_UL_EVENT_REMOTE_AFK, (BYTE)reason, + PROBE_UL_REMOTE_PROCESS_RVA, self, 0u, 0u, + __builtin_return_address(0)); + if (capture_before_ui) { + trace.before_ui_valid = 1u; + trace.before_ui = before_ui; + } + trace.remote_before = before; + trace.remote_after = after; + trace.elapsed_before = now - before.last_sync_tick; + trace.elapsed_after = trace.tick - after.last_sync_tick; + trace.value_before = before.afk_state; + trace.value_after = after.afk_state; + trace.aux_before = before.sync_state; + trace.aux_after = after.sync_state; + trace.after_ui_valid = 1u; + ul_capture_ui_state(&trace.after_ui); + ul_publish_trace(&trace); + + if (tracker != NULL) { + tracker->last_emit_tick = trace.tick; + tracker->afk_state = after.afk_state; + tracker->sync_state = after.sync_state; + } +} + +static int ul_bytes_match(DWORD rva, const BYTE *bytes, size_t size) { + return rva <= g_ul_samp_size && + size <= (size_t)(g_ul_samp_size - rva) && + ul_memory_is_readable(g_ul_samp_base + rva, size) && + memcmp((const void *)(g_ul_samp_base + rva), bytes, size) == 0; +} + +typedef struct probe_ul_guard { + DWORD rva; + const BYTE *bytes; + size_t size; + int rebased_operand_offset; +} probe_ul_guard; + +static int ul_guard_matches(const probe_ul_guard *guard) { + BYTE expected[64]; + DWORD preferred_operand; + DWORD rebased_operand; + size_t offset; + if (guard == NULL || guard->size > sizeof(expected)) { + return 0; + } + memcpy(expected, guard->bytes, guard->size); + if (guard->rebased_operand_offset >= 0) { + offset = (size_t)guard->rebased_operand_offset; + if (offset > guard->size || sizeof(DWORD) > guard->size - offset) { + return 0; + } + memcpy(&preferred_operand, expected + offset, sizeof(preferred_operand)); + if (preferred_operand < PROBE_UL_R5_PREFERRED_BASE || + preferred_operand >= + PROBE_UL_R5_PREFERRED_BASE + PROBE_UL_R5_IMAGE_SIZE) { + return 0; + } + if (g_ul_samp_base > + (uintptr_t)(0xffffffffu - + (preferred_operand - PROBE_UL_R5_PREFERRED_BASE))) { + return 0; + } + rebased_operand = + (DWORD)g_ul_samp_base + + (preferred_operand - PROBE_UL_R5_PREFERRED_BASE); + memcpy(expected + offset, &rebased_operand, sizeof(rebased_operand)); + } + return ul_bytes_match(guard->rva, expected, guard->size); +} + +static int ul_preflight(void) { + static const BYTE chat_mode_tail[] = { + 0x75, 0x08, 0xc7, 0x41, 0x08, 0x02, 0x00, 0x00, + 0x00, 0xc3, 0x3b, 0xc2, 0x75, 0x08, 0xc7, 0x41, + 0x08, 0x00, 0x00, 0x00, 0x00, 0xc3, 0x83, 0xf8, + 0x02, 0x75, 0x03, 0x89, 0x51, 0x08, 0xc3}; + static const BYTE chat_open_tail[] = { + 0x89, 0x88, 0x22, 0x01, 0x00, 0x00, 0x5f, 0xc7, + 0x86, 0xe0, 0x14, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x5e, 0x83, 0xc4, 0x10, 0xc3}; + static const BYTE chat_close_tail[] = { + 0x8b, 0x0d, 0xac, 0xeb, 0x26, 0x10, 0x6a, 0x01, + 0x6a, 0x00, 0xe8, 0x34, 0x71, 0x03, 0x00, 0xc7, + 0x86, 0xe0, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x5e, 0xc3}; + static const BYTE scoreboard_hide_tail[] = { + 0x8a, 0x44, 0x24, 0x08, 0x84, 0xc0, 0x74, 0x0f, + 0x8b, 0x0d, 0xac, 0xeb, 0x26, 0x10, 0x6a, 0x00, + 0x6a, 0x00, 0xe8, 0xd2, 0x1c, 0x03, 0x00, 0xc7, + 0x06, 0x00, 0x00, 0x00, 0x00, 0x5e, 0xc2, 0x04, + 0x00}; + static const BYTE scoreboard_show_tail[] = { + 0x8b, 0xce, 0xe8, 0x2a, 0xf9, 0xff, 0xff, 0x8b, + 0x0d, 0xac, 0xeb, 0x26, 0x10, 0x6a, 0x00, 0x6a, + 0x03, 0xe8, 0xdb, 0x12, 0x03, 0x00, 0xc7, 0x06, + 0x01, 0x00, 0x00, 0x00, 0x5e, 0xc3}; + static const BYTE cursor_restore_tail_a[] = { + 0x8b, 0x46, 0x65, 0x48, 0x89, 0x46, 0x65, 0x5f, 0x5e, 0xc3}; + static const BYTE cursor_restore_tail_b[] = { + 0x7e, 0x04, 0x48, 0x89, 0x46, 0x65, 0x5f, 0x5e, 0xc3}; + static const BYTE cursor_mode_tail_2[] = { + 0xc7, 0x46, 0x61, 0x02, 0x00, 0x00, 0x00, + 0x5f, 0x5e, 0x5d, 0xc2, 0x08, 0x00}; + static const BYTE cursor_mode_tail_1[] = { + 0x89, 0x7e, 0x61, 0x5f, 0x5e, 0x5d, 0xc2, 0x08, 0x00}; + static const BYTE cursor_mode_tail_3[] = { + 0xc7, 0x46, 0x61, 0x03, 0x00, 0x00, 0x00, + 0x5f, 0x5e, 0x5d, 0xc2, 0x08, 0x00}; + static const BYTE cursor_mode_tail_4[] = { + 0xc7, 0x46, 0x61, 0x04, 0x00, 0x00, 0x00, + 0x5f, 0x5e, 0x5d, 0xc2, 0x08, 0x00}; + static const BYTE cursor_mode_tail_0[] = { + 0x89, 0x7e, 0x61, 0x5f, 0x5e, 0x5d, 0xc2, 0x08, 0x00}; + static const BYTE menu_query_tail[] = { + 0x33, 0xc0, 0x85, 0xc9, 0x0f, 0x95, 0xc0, 0xc3}; + static const BYTE remote_onfoot_afk[] = { + 0x39, 0x9d, 0xc5, 0x01, 0x00, 0x00, 0x74, 0x1e, + 0x81, 0xfe, 0xb8, 0x0b, 0x00, 0x00, 0xeb, 0x0e, + 0x39, 0x9d, 0xc5, 0x01, 0x00, 0x00, 0x74, 0x0e, + 0x81, 0xfe, 0xdc, 0x05, 0x00, 0x00, 0x7c, 0x06, + 0x89, 0x9d, 0xc5, 0x01, 0x00, 0x00}; + static const BYTE remote_driver_afk[] = { + 0x39, 0x9d, 0xc5, 0x01, 0x00, 0x00, 0x74, 0x1e, + 0x81, 0xfe, 0xb8, 0x0b, 0x00, 0x00, 0xeb, 0x0e, + 0x39, 0x9d, 0xc5, 0x01, 0x00, 0x00, 0x74, 0x0e, + 0x81, 0xfe, 0xdc, 0x05, 0x00, 0x00, 0x7e, 0x06, + 0x89, 0x9d, 0xc5, 0x01, 0x00, 0x00}; + static const BYTE remote_passenger_afk[] = { + 0x39, 0x9d, 0xc5, 0x01, 0x00, 0x00, 0x74, 0x0e, + 0x81, 0xfe, 0xb8, 0x0b, 0x00, 0x00, 0x7c, 0x06, + 0x89, 0x9d, 0xc5, 0x01, 0x00, 0x00}; + static const BYTE remote_clear_afk_tail[] = { + 0x39, 0x9d, 0xc5, 0x01, 0x00, 0x00, 0x75, 0x3c, + 0x81, 0xfe, 0xdc, 0x05, 0x00, 0x00, 0x7d, 0x34, + 0x89, 0xbd, 0xc5, 0x01, 0x00, 0x00, 0x5f, 0x5e, + 0x5d, 0x5b, 0x81, 0xc4, 0x90, 0x00, 0x00, 0x00, + 0xc3}; + static const BYTE remote_final_tail[] = { + 0x5f, 0x5e, 0x5d, 0x5b, 0x81, 0xc4, + 0x90, 0x00, 0x00, 0x00, 0xc3}; + static const probe_ul_guard guards[] = { + {0x000612d0u, chat_mode_tail, sizeof(chat_mode_tail), -1}, + {0x00069560u, chat_open_tail, sizeof(chat_open_tail), -1}, + {0x000695adu, chat_close_tail, sizeof(chat_close_tail), 2}, + {0x0006ea07u, scoreboard_hide_tail, sizeof(scoreboard_hide_tail), 10}, + {0x0006f3ffu, scoreboard_show_tail, sizeof(scoreboard_show_tail), 9}, + {0x000a06d8u, cursor_restore_tail_a, sizeof(cursor_restore_tail_a), -1}, + {0x000a06e2u, cursor_restore_tail_b, sizeof(cursor_restore_tail_b), -1}, + {0x000a0761u, cursor_mode_tail_2, sizeof(cursor_mode_tail_2), -1}, + {0x000a079eu, cursor_mode_tail_1, sizeof(cursor_mode_tail_1), -1}, + {0x000a07fau, cursor_mode_tail_3, sizeof(cursor_mode_tail_3), -1}, + {0x000a0849u, cursor_mode_tail_4, sizeof(cursor_mode_tail_4), -1}, + {0x000a087eu, cursor_mode_tail_0, sizeof(cursor_mode_tail_0), -1}, + {0x000a0926u, menu_query_tail, sizeof(menu_query_tail), -1}, + {0x00016da0u, remote_onfoot_afk, sizeof(remote_onfoot_afk), -1}, + {0x00016fe2u, remote_driver_afk, sizeof(remote_driver_afk), -1}, + {0x0001704eu, remote_passenger_afk, sizeof(remote_passenger_afk), -1}, + {0x000170d6u, remote_clear_afk_tail, sizeof(remote_clear_afk_tail), -1}, + {0x0001711au, remote_final_tail, sizeof(remote_final_tail), -1}, + }; + size_t i; + for (i = 0u; i < sizeof(g_ul_hooks) / sizeof(g_ul_hooks[0]); ++i) { + if (!ul_bytes_match(g_ul_hooks[i].rva, g_ul_hooks[i].expected, + g_ul_hooks[i].length)) { + return 0; + } + } + for (i = 0u; i < sizeof(guards) / sizeof(guards[0]); ++i) { + if (!ul_guard_matches(&guards[i])) { + return 0; + } + } + return 1; +} + +static int ul_rel32(void *from_after, void *to, LONG *relative) { + intptr_t delta = (BYTE *)to - (BYTE *)from_after; + if (delta < INT32_MIN || delta > INT32_MAX) { + return 0; + } + *relative = (LONG)delta; + return 1; +} + +static int ul_prepare_trampoline(probe_ul_hook *hook) { + BYTE *trampoline; + LONG back_rel; + uintptr_t target; + if (hook == NULL || hook->length < 5u || + hook->length > sizeof(hook->saved)) { + return 0; + } + target = g_ul_samp_base + hook->rva; + trampoline = (BYTE *)VirtualAlloc( + NULL, (SIZE_T)hook->length + 5u, MEM_COMMIT | MEM_RESERVE, + PAGE_EXECUTE_READWRITE); + if (trampoline == NULL) { + return 0; + } + memcpy(hook->saved, (const void *)target, hook->length); + memcpy(trampoline, hook->saved, hook->length); + if (!ul_rel32(trampoline + hook->length + 5u, + (void *)(target + hook->length), &back_rel)) { + VirtualFree(trampoline, 0u, MEM_RELEASE); + return 0; + } + trampoline[hook->length] = 0xe9u; + memcpy(trampoline + hook->length + 1u, &back_rel, sizeof(back_rel)); + FlushInstructionCache(GetCurrentProcess(), trampoline, + (SIZE_T)hook->length + 5u); + hook->trampoline = trampoline; + return 1; +} + +static int ul_install_one(probe_ul_hook *hook) { + uintptr_t target; + BYTE patch[16]; + LONG replacement_rel; + DWORD old_protect; + DWORD ignored_protect; + if (hook == NULL || hook->trampoline == NULL || + hook->length > sizeof(patch)) { + return 0; + } + target = g_ul_samp_base + hook->rva; + if (!ul_rel32((void *)(target + 5u), hook->replacement, + &replacement_rel)) { + return 0; + } + memset(patch, 0x90, hook->length); + patch[0] = 0xe9u; + memcpy(patch + 1u, &replacement_rel, sizeof(replacement_rel)); + if (!VirtualProtect((void *)target, hook->length, + PAGE_EXECUTE_READWRITE, &old_protect)) { + return 0; + } + memcpy((void *)target, patch, hook->length); + FlushInstructionCache(GetCurrentProcess(), (const void *)target, + hook->length); + (void)VirtualProtect((void *)target, hook->length, old_protect, + &ignored_protect); + InterlockedExchange(&hook->installed, 1); + return 1; +} + +static int ul_patch_is_owned(const probe_ul_hook *hook) { + BYTE expected[16]; + LONG relative; + uintptr_t target; + if (hook == NULL || hook->length > sizeof(expected)) { + return 0; + } + target = g_ul_samp_base + hook->rva; + if (!ul_rel32((void *)(target + 5u), hook->replacement, &relative)) { + return 0; + } + memset(expected, 0x90, hook->length); + expected[0] = 0xe9u; + memcpy(expected + 1u, &relative, sizeof(relative)); + return ul_memory_is_readable(target, hook->length) && + memcmp((const void *)target, expected, hook->length) == 0; +} + +static int ul_restore_one(probe_ul_hook *hook) { + uintptr_t target; + DWORD old_protect; + DWORD ignored_protect; + if (hook == NULL || + InterlockedCompareExchange(&hook->installed, 0, 0) != 1 || + !ul_patch_is_owned(hook)) { + return 0; + } + target = g_ul_samp_base + hook->rva; + if (!VirtualProtect((void *)target, hook->length, + PAGE_EXECUTE_READWRITE, &old_protect)) { + return 0; + } + memcpy((void *)target, hook->saved, hook->length); + FlushInstructionCache(GetCurrentProcess(), (const void *)target, + hook->length); + (void)VirtualProtect((void *)target, hook->length, old_protect, + &ignored_protect); + InterlockedExchange(&hook->installed, 0); + return 1; +} + +int probe_ui_latches_install(HMODULE samp_module, DWORD samp_size, + int enabled, int code_hooks_disabled, + probe_ui_latches_log_fn log_fn, + int log_summary) { + HMODULE gta_module; + size_t hook_count = sizeof(g_ul_hooks) / sizeof(g_ul_hooks[0]); + size_t i; + int installed = 0; + + if (!enabled) { + if (log_summary && log_fn != NULL) { + log_fn("ui_latches_hook: disabled by default; enable with " + "SAMP_PROBE_UI_LATCHES_HOOKS=1 or " + "samp_probe_ui_latches_hooks.flag"); + } + return 0; + } + if (code_hooks_disabled) { + if (log_summary && log_fn != NULL) { + log_fn("ui_latches_hook: disabled by " + "SAMP_PROBE_NO_SAMP_CODE_HOOKS"); + } + return 0; + } + if (InterlockedCompareExchange(&g_ul_install_state, 0, 0) == 1) { + return (int)hook_count; + } + if (InterlockedCompareExchange(&g_ul_install_state, 0, 0) < 0) { + return 0; + } + + g_ul_samp_base = (uintptr_t)samp_module; + g_ul_samp_size = samp_size; + gta_module = GetModuleHandleA(NULL); + if (g_ul_samp_size != PROBE_UL_R5_IMAGE_SIZE || + !ul_pe_identity_matches(samp_module, PROBE_UL_R5_TIMESTAMP, + PROBE_UL_R5_ENTRY_RVA, + PROBE_UL_R5_IMAGE_SIZE, + PROBE_UL_R5_PREFERRED_BASE, 0u, 0, 0) || + (uintptr_t)gta_module != PROBE_UL_GTA_PREFERRED_BASE || + !ul_pe_identity_matches(gta_module, PROBE_UL_GTA_TIMESTAMP, + PROBE_UL_GTA_ENTRY_RVA, + PROBE_UL_GTA_IMAGE_SIZE, + PROBE_UL_GTA_PREFERRED_BASE, + PROBE_UL_GTA_CHECKSUM, 1, 1)) { + if (log_summary && log_fn != NULL) { + log_fn("ui_latches_hook: skip unsupported_identity installed=0 " + "samp_base=0x%08lx samp_size=0x%08lx " + "samp_delta=0x%08lx samp_sha256=%s " + "gta_base=0x%08lx gta_sha256=%s " + "evidence=STATIC_037", + (unsigned long)g_ul_samp_base, + (unsigned long)g_ul_samp_size, + (unsigned long)(g_ul_samp_base - + PROBE_UL_R5_PREFERRED_BASE), + PROBE_UL_R5_SHA256, + (unsigned long)(uintptr_t)gta_module, PROBE_UL_GTA_SHA256); + } + InterlockedExchange(&g_ul_install_state, -1); + return 0; + } + if (!ul_preflight()) { + if (log_summary && log_fn != NULL) { + log_fn("ui_latches_hook: skip preflight_mismatch installed=0 " + "requested=%u evidence=STATIC_037", + (unsigned)hook_count); + } + InterlockedExchange(&g_ul_install_state, -1); + return 0; + } + + for (i = 0u; i < hook_count; ++i) { + if (!ul_prepare_trampoline(&g_ul_hooks[i])) { + break; + } + } + if (i != hook_count) { + size_t j; + for (j = 0u; j < hook_count; ++j) { + if (g_ul_hooks[j].trampoline != NULL) { + VirtualFree(g_ul_hooks[j].trampoline, 0u, MEM_RELEASE); + g_ul_hooks[j].trampoline = NULL; + } + } + if (log_summary && log_fn != NULL) { + log_fn("ui_latches_hook: trampoline_allocation_failed " + "prepared=%u requested=%u installed=0", + (unsigned)i, (unsigned)hook_count); + } + InterlockedExchange(&g_ul_install_state, -1); + return 0; + } + + for (i = 0u; i < hook_count; ++i) { + if (!ul_install_one(&g_ul_hooks[i])) { + break; + } + ++installed; + } + if ((size_t)installed != hook_count) { + while (installed > 0) { + --installed; + (void)ul_restore_one(&g_ul_hooks[installed]); + } + if (log_summary && log_fn != NULL) { + log_fn("ui_latches_hook: incomplete_install installed=0 " + "requested=%u run_invalid=1", + (unsigned)hook_count); + } + InterlockedExchange(&g_ul_install_state, -1); + return 0; + } + + InterlockedExchange(&g_ul_install_state, 1); + if (log_summary && log_fn != NULL) { + log_fn("ui_latches_hook: summary installed=%u requested=%u " + "rvas=0x612c0,0x69480,0x69580,0x6e9e0,0x6f3d0," + "0xa05d0,0xa06f0,0xa0920,0x166b0 " + "samp_base=0x%08lx samp_delta=0x%08lx " + "samp_sha256=%s gta_sha256=%s " + "guard=identity,samp_relocation_normalized,gta_preferred_base," + "entry_bytes,critical_bytes,tails,all_or_nothing " + "evidence=STATIC_037,TODO_VERIFY", + (unsigned)hook_count, (unsigned)hook_count, + (unsigned long)g_ul_samp_base, + (unsigned long)(g_ul_samp_base - PROBE_UL_R5_PREFERRED_BASE), + PROBE_UL_R5_SHA256, PROBE_UL_GTA_SHA256); + } + return (int)hook_count; +} + +static const char *ul_event_name(BYTE kind) { + switch (kind) { + case PROBE_UL_EVENT_CHAT_MODE_TOGGLE: + return "chat_mode_toggle"; + case PROBE_UL_EVENT_CHAT_OPEN: + return "chat_open"; + case PROBE_UL_EVENT_CHAT_CLOSE: + return "chat_close"; + case PROBE_UL_EVENT_SCOREBOARD_HIDE: + return "scoreboard_hide"; + case PROBE_UL_EVENT_SCOREBOARD_SHOW: + return "scoreboard_show"; + case PROBE_UL_EVENT_CURSOR_RESTORE: + return "cursor_restore"; + case PROBE_UL_EVENT_CURSOR_MODE: + return "cursor_mode"; + case PROBE_UL_EVENT_MENU_EDGE: + return "menu_edge"; + case PROBE_UL_EVENT_REMOTE_AFK: + return "remote_afk"; + default: + return "unknown"; + } +} + +static void ul_log_ui_state(probe_ui_latches_log_fn log_fn, + const probe_ul_trace *trace, const char *phase, + const probe_ul_ui_state *state) { + log_fn( + "ui_latch_state_r5: seq=%ld event=%ld phase=%s " + "scoreboard=0x%08lx scoreboard_visible=0x%08lx " + "chat_display=0x%08lx chat_mode=0x%08lx chat_dirty=0x%08lx " + "chat=0x%08lx chat_active=0x%08lx game=0x%08lx " + "cursor_mode_raw=0x%08lx cursor_restore_raw=0x%08lx " + "frontend_raw=0x%08lx frontend_bytes=%02x,%02x,%02x " + "pause_raw_b7cb49=%02x input_gate=%08lx,%02x " + "mouse_gate=%02x foreground=0x%08lx focus=0x%08lx " + "capture=0x%08lx cursor_flags=0x%08lx " + "evidence=STATIC_037,WIN32_API,TODO_VERIFY", + (long)trace->ring_seq, (long)trace->event_seq, phase, + (unsigned long)state->scoreboard, + (unsigned long)state->scoreboard_visible, + (unsigned long)state->chat_display, + (unsigned long)state->chat_display_mode, + (unsigned long)state->chat_display_dirty, + (unsigned long)state->chat, (unsigned long)state->chat_active, + (unsigned long)state->game, + (unsigned long)state->cursor_mode_raw, + (unsigned long)state->cursor_restore_raw, + (unsigned long)state->frontend_raw, (unsigned)state->frontend_a, + (unsigned)state->frontend_b, (unsigned)state->frontend_c, + (unsigned)state->pause_raw_b7cb49, + (unsigned long)state->input_gate_head, + (unsigned)state->input_gate_tail, + (unsigned)state->mouse_gate_byte, + (unsigned long)state->foreground_window, + (unsigned long)state->focus_window, + (unsigned long)state->capture_window, + (unsigned long)state->cursor_flags); +} + +void probe_ui_latches_flush(probe_ui_latches_log_fn log_fn) { + LONG write_seq; + LONG pending; + LONG tracker_overflow; + if (log_fn == NULL) { + return; + } + + tracker_overflow = + InterlockedCompareExchange(&g_ul_remote_tracker_overflow, 0, 0); + if (tracker_overflow != g_ul_remote_tracker_overflow_flushed) { + log_fn("ui_latches_r5: remote_tracker_overflow total=%ld capacity=%u " + "heartbeat_suppressed=1", + (long)tracker_overflow, (unsigned)PROBE_UL_REMOTE_TRACKER_SIZE); + g_ul_remote_tracker_overflow_flushed = tracker_overflow; + } + + write_seq = InterlockedCompareExchange(&g_ul_trace_write_seq, 0, 0); + pending = write_seq - g_ul_trace_flushed_seq; + if (pending > (LONG)PROBE_UL_TRACE_RING_SIZE) { + LONG skipped = pending - (LONG)PROBE_UL_TRACE_RING_SIZE; + g_ul_trace_flushed_seq += skipped; + g_ul_trace_overflow_count += skipped; + log_fn("ui_latches_r5: overflow skipped=%ld total_skipped=%ld ring=%u", + (long)skipped, (long)g_ul_trace_overflow_count, + (unsigned)PROBE_UL_TRACE_RING_SIZE); + } + while (g_ul_trace_flushed_seq < write_seq) { + LONG next_seq = g_ul_trace_flushed_seq + 1; + probe_ul_trace *slot = + &g_ul_trace_ring[ + ((DWORD)next_seq - 1u) % PROBE_UL_TRACE_RING_SIZE]; + probe_ul_trace trace; + if (InterlockedCompareExchange(&slot->committed_seq, 0, 0) != + next_seq) { + break; + } + MemoryBarrier(); + trace = *slot; + MemoryBarrier(); + if (InterlockedCompareExchange(&slot->committed_seq, 0, 0) != + next_seq) { + continue; + } + log_fn( + "ui_latches_r5: seq=%ld event=%ld tick=%lu thread=%lu frame=%lu " + "kind=%s reason=0x%02x caller=0x%08lx caller_rva=0x%08lx " + "hook_rva=0x%08lx object=0x%08lx arg0=0x%08lx arg1=0x%08lx " + "result=0x%08lx value=%08lx->%08lx aux=%08lx->%08lx " + "remote_sync=%02x->%02x remote_afk=%08lx->%08lx " + "remote_last_sync=%08lx->%08lx elapsed=%lu->%lu " + "remote_ped=0x%08lx->0x%08lx " + "evidence=STATIC_037,TODO_VERIFY", + (long)trace.ring_seq, (long)trace.event_seq, + (unsigned long)trace.tick, (unsigned long)trace.thread_id, + (unsigned long)trace.gta_frame, ul_event_name(trace.kind), + (unsigned)trace.reason, (unsigned long)trace.caller, + (unsigned long)trace.caller_rva, + (unsigned long)trace.hook_rva, (unsigned long)trace.object, + (unsigned long)trace.arg0, (unsigned long)trace.arg1, + (unsigned long)trace.result, (unsigned long)trace.value_before, + (unsigned long)trace.value_after, + (unsigned long)trace.aux_before, + (unsigned long)trace.aux_after, + (unsigned)trace.remote_before.sync_state, + (unsigned)trace.remote_after.sync_state, + (unsigned long)trace.remote_before.afk_state, + (unsigned long)trace.remote_after.afk_state, + (unsigned long)trace.remote_before.last_sync_tick, + (unsigned long)trace.remote_after.last_sync_tick, + (unsigned long)trace.elapsed_before, + (unsigned long)trace.elapsed_after, + (unsigned long)trace.remote_before.ped_wrapper, + (unsigned long)trace.remote_after.ped_wrapper); + if (trace.before_ui_valid) { + ul_log_ui_state(log_fn, &trace, "pre", &trace.before_ui); + } + if (trace.after_ui_valid) { + ul_log_ui_state(log_fn, &trace, "post", &trace.after_ui); + } + g_ul_trace_flushed_seq = next_seq; + } +} + +void probe_ui_latches_uninstall(probe_ui_latches_log_fn log_fn) { + size_t hook_count = sizeof(g_ul_hooks) / sizeof(g_ul_hooks[0]); + size_t i; + int restored = 0; + if (InterlockedCompareExchange(&g_ul_install_state, 0, 0) != 1) { + return; + } + for (i = hook_count; i > 0u; --i) { + restored += ul_restore_one(&g_ul_hooks[i - 1u]); + } + if (log_fn != NULL) { + log_fn("ui_latches_hook: restore restored=%d requested=%u " + "gateway_lifetime=process", + restored, (unsigned)hook_count); + } + InterlockedExchange(&g_ul_install_state, + restored == (int)hook_count ? 0 : -1); +} diff --git a/tools/asi_probe/src/samp_probe_ui_latches.h b/tools/asi_probe/src/samp_probe_ui_latches.h new file mode 100644 index 0000000..7aef84b --- /dev/null +++ b/tools/asi_probe/src/samp_probe_ui_latches.h @@ -0,0 +1,23 @@ +#ifndef SAMP_PROBE_UI_LATCHES_H +#define SAMP_PROBE_UI_LATCHES_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void (*probe_ui_latches_log_fn)(const char *format, ...); + +int probe_ui_latches_install(HMODULE samp_module, DWORD samp_size, + int enabled, int code_hooks_disabled, + probe_ui_latches_log_fn log_fn, + int log_summary); +void probe_ui_latches_flush(probe_ui_latches_log_fn log_fn); +void probe_ui_latches_uninstall(probe_ui_latches_log_fn log_fn); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/tools/asi_probe/tests/test_death_cleanup_exit_source_contract.py b/tools/asi_probe/tests/test_death_cleanup_exit_source_contract.py new file mode 100644 index 0000000..ab7cda3 --- /dev/null +++ b/tools/asi_probe/tests/test_death_cleanup_exit_source_contract.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Source-contract tests for the original-R5 terminal cleanup checkpoint.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +PROBE_ROOT = Path(__file__).resolve().parents[1] +DEATH_CLEANUP_SOURCE = ( + PROBE_ROOT / "src" / "samp_probe_death_cleanup.c" +) +ASI_SOURCE = PROBE_ROOT / "src" / "samp_probe_asi.c" + + +def definition_body(source: str, declaration: str, next_declaration: str) -> str: + cursor = 0 + while True: + candidate = source.index(declaration, cursor) + brace = source.index("{", candidate) + semicolon = source.index(";", candidate) + if brace < semicolon: + start = candidate + break + cursor = candidate + len(declaration) + end = source.index(next_declaration, start) + return source[start:end] + + +class DeathCleanupExitSourceContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.death_cleanup = DEATH_CLEANUP_SOURCE.read_text(encoding="utf-8") + cls.asi = ASI_SOURCE.read_text(encoding="utf-8") + + def test_r5_exit_callsite_and_iat_are_exactly_guarded(self) -> None: + source = self.death_cleanup + self.assertIn( + "#define PROBE_DC_EXITPROCESS_CALLSITE_RVA 0x000c508au", + source, + ) + self.assertIn( + "#define PROBE_DC_EXITPROCESS_CALLER_RVA 0x000c5091u", + source, + ) + self.assertIn( + "#define PROBE_DC_EXITPROCESS_IAT_RVA 0x000e5188u", + source, + ) + self.assertIn( + "0x57, 0xff, 0x15, 0x88, 0x51, 0x0e,\n" + " 0x10, 0x61, 0x5f, 0x5e, 0x5b, 0xc3", + source, + ) + preflight = definition_body( + source, + "static int dc_preflight(void)", + "static int dc_rel32(", + ) + self.assertIn("PROBE_DC_EXITPROCESS_CALLSITE_RVA", preflight) + self.assertIn("PROBE_DC_EXITPROCESS_IAT_RVA", preflight) + self.assertIn("dc_exit_iat_target_matches()", preflight) + + def test_only_clean_quit_caller_requests_bounded_worker_drain(self) -> None: + body = definition_body( + self.death_cleanup, + "static VOID WINAPI hook_dc_exit_process(UINT exit_code)", + "static int dc_bytes_match(", + ) + self.assertIn( + "caller_rva != PROBE_DC_EXITPROCESS_CALLER_RVA", + body, + ) + self.assertIn( + "InterlockedCompareExchange(&g_dc_install_state, 0, 0) != 1", + body, + ) + preparing = body.index( + "InterlockedCompareExchange(&g_dc_terminal_state, -1, 0)" + ) + ready = body.index( + "InterlockedExchange(&g_dc_terminal_state, 1)" + ) + self.assertIn( + "#define PROBE_DC_TERMINAL_DRAIN_TIMEOUT_MS 1500u", + self.death_cleanup, + ) + signal = body.index("SetEvent(g_dc_terminal_stop_event)") + wait = body.index("WaitForSingleObject(g_dc_terminal_done_event") + final_exit = body.rindex("dc_invoke_original_exit_process(exit_code)") + self.assertLess(preparing, ready) + self.assertLess(ready, signal) + self.assertLess(signal, wait) + self.assertLess(wait, final_exit) + self.assertNotIn("probe_log(", body) + self.assertNotIn("log_fn(", body) + + def test_iat_patch_is_owned_and_all_or_nothing(self) -> None: + source = self.death_cleanup + install = definition_body( + source, + "int probe_death_cleanup_install(HMODULE samp_module", + "static const char *dc_event_name(", + ) + self.assertIn("if (!dc_install_exit_iat())", install) + failure = install[install.index("if (!dc_install_exit_iat())") :] + self.assertIn("dc_restore_one(&g_dc_hooks[installed])", failure) + self.assertIn("dc_close_terminal_done_event(log_fn)", failure) + + restore = definition_body( + source, + "static int dc_restore_exit_iat(void)", + "int probe_death_cleanup_install(HMODULE samp_module", + ) + self.assertIn("InterlockedCompareExchangePointer(", restore) + self.assertIn("original, replacement", restore) + + def test_worker_flushes_restores_and_acknowledges_before_exit(self) -> None: + worker_start = self.asi.index("static DWORD WINAPI probe_worker(") + worker_end = self.asi.index( + "static int WINAPI hook_samp_socketlayer_sendto(", + worker_start, + ) + worker = self.asi[worker_start:worker_end] + + final_flush = worker.rindex("probe_death_cleanup_flush(probe_log)") + uninstall = worker.rindex( + "probe_death_cleanup_uninstall(probe_log)" + ) + stopping = worker.rindex('probe_log("probe: stopping")') + complete = worker.rindex( + "probe_death_cleanup_complete_terminal_drain(probe_log)" + ) + self.assertLess(final_flush, uninstall) + self.assertLess(uninstall, stopping) + self.assertLess(stopping, complete) + self.assertEqual( + 2, + worker.count( + 'env_flag_enabled("SAMP_PROBE_NO_SAMP_CODE_HOOKS"), ' + "g_stop_event," + ), + ) + + def test_terminal_marker_proves_ring_boundary_and_final_pointer(self) -> None: + complete = self.death_cleanup[ + self.death_cleanup.index( + "void probe_death_cleanup_complete_terminal_drain(" + ) : + ] + self.assertIn('"death_cleanup_exit_r5:', complete) + self.assertIn("destructor_seq=%ld", complete) + self.assertIn("requested_seq=%ld flushed_seq=%ld", complete) + self.assertIn('drain=%s timeout_ms=%u', complete) + self.assertIn("exit_iat_restored=%d", complete) + self.assertIn("SetEvent(g_dc_terminal_done_event)", complete) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/compare_runtime_traces.sh b/tools/compare_runtime_traces.sh index 2cda826..8ee9e68 100755 --- a/tools/compare_runtime_traces.sh +++ b/tools/compare_runtime_traces.sh @@ -1,14 +1,50 @@ #!/usr/bin/env bash set -euo pipefail -if [[ $# -lt 2 || $# -gt 3 ]]; then - echo "Usage: $0 [out-dir]" >&2 +usage() { + echo "Usage: $0 [--report-only] [out-dir]" >&2 + echo " --report-only Write reports but suppress exit status 5 for failed checks." >&2 +} + +REPORT_ONLY=0 +POSITIONAL=() +while [[ $# -gt 0 ]]; do + case "$1" in + --report-only) + REPORT_ONLY=1 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + while [[ $# -gt 0 ]]; do + POSITIONAL+=("$1") + shift + done + break + ;; + -*) + echo "Unknown option: $1" >&2 + usage + exit 1 + ;; + *) + POSITIONAL+=("$1") + ;; + esac + shift +done + +if [[ ${#POSITIONAL[@]} -lt 2 || ${#POSITIONAL[@]} -gt 3 ]]; then + usage exit 1 fi -REF_LOG="$1" -CAND_LOG="$2" -OUT_DIR="${3:-/tmp/samp-runtime-trace-diff}" +REF_LOG="${POSITIONAL[0]}" +CAND_LOG="${POSITIONAL[1]}" +OUT_DIR="${POSITIONAL[2]:-/tmp/samp-runtime-trace-diff}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TRIM_SCRIPT="${SCRIPT_DIR}/trim_wine_trace.sh" @@ -19,7 +55,7 @@ require_cmd() { fi } -for dep in rg awk sed sort diff wc mkdir cut; do +for dep in rg awk sed sort diff wc mkdir cut tr; do require_cmd "$dep" done @@ -101,14 +137,23 @@ emit_checks() { local samp_window_file="${trim_dir}/samp_lifecycle_window.log" local call_file="${trim_dir}/call_focus.log" local focus_file="${trim_dir}/focus_lines.log" + local samp_module_base="" + local samp_module_pattern='[[:xdigit:]]+' + + if [[ -f "${trim_dir}/samp_module_base.txt" ]]; then + samp_module_base="$(tr -d '\r\n' <"${trim_dir}/samp_module_base.txt")" + fi + if [[ "$samp_module_base" =~ ^[[:xdigit:]]+$ ]]; then + samp_module_pattern="$samp_module_base" + fi { printf "key\tseverity\tmin\tcount\tdescription\n" printf "samp_dll_loaded_native\tcritical\t1\t%s\tsamp.dll loaded as native module\n" "$(count_matches "$loaddll_file" 'Loaded L".*samp\.dll".*: native')" printf "samp_process_attach_start\tcritical\t1\t%s\tsamp.dll process_attach START marker\n" "$(count_matches "$samp_window_file" 'process_attach \(L"samp\.dll",00000000\) - START')" printf "samp_process_attach_end\tcritical\t1\t%s\tsamp.dll process_attach END marker\n" "$(count_matches "$samp_window_file" 'process_attach \(L"samp\.dll",00000000\) - END')" - printf "samp_process_attach_call\tcritical\t1\t%s\tsamp.dll DllMain PROCESS_ATTACH call\n" "$(count_matches "$samp_window_file" 'Call PE DLL \(proc=.*module=02380000 L"samp\.dll",reason=PROCESS_ATTACH')" - printf "samp_process_detach_call\tcritical\t1\t%s\tsamp.dll DllMain PROCESS_DETACH call\n" "$(count_matches "$samp_window_file" 'Call PE DLL \(proc=.*module=02380000 L"samp\.dll",reason=PROCESS_DETACH')" + printf "samp_process_attach_call\tcritical\t1\t%s\tsamp.dll DllMain PROCESS_ATTACH call\n" "$(count_matches "$samp_window_file" "Call PE DLL \\(proc=.*module=${samp_module_pattern} L\"samp\\.dll\",reason=PROCESS_ATTACH")" + printf "samp_process_detach_call\tcritical\t1\t%s\tsamp.dll DllMain PROCESS_DETACH call\n" "$(count_matches "$samp_window_file" "Call PE DLL \\(proc=.*module=${samp_module_pattern} L\"samp\\.dll\",reason=PROCESS_DETACH")" printf "ws2_32_dll_load\thigh\t1\t%s\tWS2_32.dll loaded\n" "$(count_matches "$loaddll_file" 'Loaded L".*WS2_32\.dll"')" printf "wsock32_dll_load\thigh\t1\t%s\tWSOCK32.dll loaded\n" "$(count_matches "$loaddll_file" 'Loaded L".*WSOCK32\.dll"')" printf "d3dx9_25_dll_load\tmedium\t0\t%s\td3dx9_25.dll loaded (external dependency, non-blocking)\n" "$(count_matches "$loaddll_file" 'Loaded L".*d3dx9_25\.dll"')" @@ -211,3 +256,18 @@ echo "- ${OUT_DIR}/counts.diff" echo "- ${OUT_DIR}/reference_checks.tsv" echo "- ${OUT_DIR}/candidate_checks.tsv" echo "- ${OUT_DIR}/check_report.tsv" + +if [[ "$FAIL_COUNT" -gt 0 ]]; then + if [[ "$REPORT_ONLY" -eq 1 ]]; then + echo + echo "Result: FAIL (report-only; exit status suppressed)" + exit 0 + fi + echo + echo "Result: FAIL" + echo "Use --report-only to generate a report without failing the command." + exit 5 +fi + +echo +echo "Result: PASS" diff --git a/tools/ghidra/ExportSampAnalysis.java b/tools/ghidra/ExportSampAnalysis.java new file mode 100644 index 0000000..e702cff --- /dev/null +++ b/tools/ghidra/ExportSampAnalysis.java @@ -0,0 +1,440 @@ +// Ghidra headless export script for the original SA-MP 0.3.7 samp.dll. +// STATIC_037: emits metadata and cross-reference summaries only; it does not +// export decompiler pseudocode or copied proprietary control-flow bodies. + +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.address.AddressSetView; +import ghidra.program.model.address.AddressSpace; +import ghidra.program.model.data.DataType; +import ghidra.program.model.listing.CodeUnit; +import ghidra.program.model.listing.Data; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.FunctionIterator; +import ghidra.program.model.listing.Instruction; +import ghidra.program.model.listing.InstructionIterator; +import ghidra.program.model.listing.Listing; +import ghidra.program.model.mem.MemoryBlock; +import ghidra.program.model.symbol.ExternalLocation; +import ghidra.program.model.symbol.ExternalManager; +import ghidra.program.model.symbol.Reference; +import ghidra.program.model.symbol.ReferenceIterator; +import ghidra.program.model.symbol.ReferenceManager; +import ghidra.program.model.symbol.RefType; +import ghidra.program.model.symbol.SourceType; +import ghidra.program.model.symbol.Symbol; +import ghidra.program.model.symbol.SymbolIterator; +import ghidra.program.model.symbol.SymbolTable; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class ExportSampAnalysis extends GhidraScript { + private static final long IMAGE_BASE = 0x10000000L; + + private static final Set FOCUS_IMPORTS = new HashSet(Arrays.asList( + "WSAStartup", "WSACleanup", "socket", "connect", "bind", "listen", + "accept", "send", "recv", "sendto", "recvfrom", "closesocket", + "ioctlsocket", "setsockopt", "getsockname", "gethostbyname", + "gethostname", "inet_addr", "inet_ntoa", "htons", "ntohs", + "Direct3DCreate9", "D3DXCreateFontA", "D3DXCreateSprite", + "D3DXCreateTextureFromFileInMemory", "D3DXCreateTextureFromFileA", + "BASS_Init", "BASS_StreamCreateURL", "BASS_StreamFree", + "BASS_ChannelPlay", "BASS_ChannelStop", "BASS_ChannelSetAttribute", + "CreateFileA", "ReadFile", "WriteFile", "CreateThread", + "VirtualProtect", "FlushInstructionCache", "LoadLibraryA", + "GetProcAddress", "CreateWindowExA", "PeekMessageA", "GetAsyncKeyState" + )); + + private File outDir; + private Listing listing; + private ReferenceManager refman; + + @Override + protected void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length < 1) { + throw new IllegalArgumentException("usage: ExportSampAnalysis.java "); + } + outDir = new File(args[0]); + Files.createDirectories(outDir.toPath()); + listing = currentProgram.getListing(); + refman = currentProgram.getReferenceManager(); + + exportSummary(); + exportMemoryBlocks(); + exportFunctions(); + exportImportsAndXrefs(); + exportStrings(); + exportFocusCallers(); + exportCallgraphSlices(); + } + + private void exportSummary() throws Exception { + BufferedWriter w = writer("summary.md"); + w.write("# Ghidra Static Analysis Summary\n\n"); + w.write("- Evidence: `STATIC_037`\n"); + w.write("- Program: `" + esc(currentProgram.getName()) + "`\n"); + w.write("- Executable SHA-256: `" + sha256(new File(currentProgram.getExecutablePath())) + "`\n"); + w.write("- Ghidra language: `" + esc(currentProgram.getLanguageID().toString()) + "`\n"); + w.write("- Compiler spec: `" + esc(currentProgram.getCompilerSpec().getCompilerSpecID().toString()) + "`\n"); + w.write("- Image base: `" + currentProgram.getImageBase() + "`\n"); + w.write("- Entry point: `" + currentProgram.getSymbolTable().getExternalEntryPointIterator().hasNext() + "` external-entry marker present\n"); + w.write("- Min address: `" + currentProgram.getMinAddress() + "`\n"); + w.write("- Max address: `" + currentProgram.getMaxAddress() + "`\n"); + w.write("- Function count: `" + countFunctions() + "`\n"); + w.write("- Symbol count: `" + countSymbols() + "`\n"); + w.write("\nNotes:\n"); + w.write("- No decompiler pseudocode is exported by this script.\n"); + w.write("- RVAs assume the original preferred image base `0x10000000`.\n"); + w.close(); + } + + private void exportMemoryBlocks() throws Exception { + BufferedWriter w = writer("memory_blocks.tsv"); + w.write("name\tstart\tend\trva_start\trva_end\tsize\trwx\tinitialized\n"); + for (MemoryBlock block : currentProgram.getMemory().getBlocks()) { + String rwx = (block.isRead() ? "r" : "-") + (block.isWrite() ? "w" : "-") + (block.isExecute() ? "x" : "-"); + w.write(tsv(block.getName(), block.getStart(), block.getEnd(), rva(block.getStart()), rva(block.getEnd()), + block.getSize(), rwx, block.isInitialized())); + } + w.close(); + } + + private void exportFunctions() throws Exception { + BufferedWriter all = writer("functions.tsv"); + BufferedWriter top = writer("top_functions.tsv"); + all.write("entry\trva\tname\tsize\tbody_ranges\tcall_in\tcall_out\tdata_refs_from\tis_thunk\tcalling_convention\n"); + List rows = new ArrayList(); + FunctionIterator it = currentProgram.getFunctionManager().getFunctions(true); + while (it.hasNext() && !monitor.isCancelled()) { + Function f = it.next(); + FunctionRow row = functionRow(f); + rows.add(row); + all.write(row.toTsv()); + } + all.close(); + + Collections.sort(rows, new Comparator() { + public int compare(FunctionRow a, FunctionRow b) { + return Long.compare(b.size, a.size); + } + }); + top.write("entry\trva\tname\tsize\tbody_ranges\tcall_in\tcall_out\tdata_refs_from\tis_thunk\tcalling_convention\n"); + for (int i = 0; i < Math.min(250, rows.size()); i++) { + top.write(rows.get(i).toTsv()); + } + top.close(); + } + + private void exportImportsAndXrefs() throws Exception { + BufferedWriter imports = writer("imports.tsv"); + BufferedWriter xrefs = writer("import_xrefs.tsv"); + imports.write("library\tlabel\taddress\trva\tref_count\tfocus\n"); + xrefs.write("library\tlabel\timport_address\tfrom_function\tfrom_entry\tfrom_rva\tref_type\toperand_index\n"); + + ExternalManager em = currentProgram.getExternalManager(); + SymbolTable st = currentProgram.getSymbolTable(); + SymbolIterator symbols = st.getExternalSymbols(); + while (symbols.hasNext() && !monitor.isCancelled()) { + Symbol sym = symbols.next(); + Address addr = sym.getAddress(); + if (addr == null) { + continue; + } + String lib = ""; + ExternalLocation loc = em.getExternalLocation(sym); + if (loc != null && loc.getLibraryName() != null) { + lib = loc.getLibraryName(); + } + String label = sym.getName(); + ReferenceIterator refs = refman.getReferencesTo(addr); + List refList = new ArrayList(); + while (refs.hasNext()) { + refList.add(refs.next()); + } + imports.write(tsv(lib, label, addr, rva(addr), refList.size(), FOCUS_IMPORTS.contains(label))); + for (Reference ref : refList) { + Address from = ref.getFromAddress(); + Function f = currentProgram.getFunctionManager().getFunctionContaining(from); + xrefs.write(tsv(lib, label, addr, f == null ? "" : f.getName(), f == null ? "" : f.getEntryPoint(), + rva(from), ref.getReferenceType(), ref.getOperandIndex())); + } + } + imports.close(); + xrefs.close(); + } + + private void exportStrings() throws Exception { + BufferedWriter strings = writer("strings.tsv"); + BufferedWriter refs = writer("string_xrefs.tsv"); + strings.write("address\trva\tlength\tdatatype\tvalue\n"); + refs.write("string_address\tstring_rva\tfrom_function\tfrom_entry\tfrom_rva\tref_type\tvalue\n"); + + Iterator it = listing.getDefinedData(true); + while (it.hasNext() && !monitor.isCancelled()) { + Data data = it.next(); + if (!isStringData(data)) { + continue; + } + String value = String.valueOf(data.getValue()); + if (value.length() < 4) { + continue; + } + strings.write(tsv(data.getAddress(), rva(data.getAddress()), data.getLength(), + data.getDataType().getName(), value)); + ReferenceIterator rit = refman.getReferencesTo(data.getAddress()); + while (rit.hasNext()) { + Reference ref = rit.next(); + Address from = ref.getFromAddress(); + Function f = currentProgram.getFunctionManager().getFunctionContaining(from); + refs.write(tsv(data.getAddress(), rva(data.getAddress()), f == null ? "" : f.getName(), + f == null ? "" : f.getEntryPoint(), rva(from), ref.getReferenceType(), value)); + } + } + strings.close(); + refs.close(); + } + + private void exportFocusCallers() throws Exception { + BufferedWriter w = writer("focus_import_callers.tsv"); + w.write("focus\tlibrary\tcaller\tcaller_entry\tcaller_rva\tcallsite\tcallsite_rva\tref_type\n"); + SymbolIterator symbols = currentProgram.getSymbolTable().getExternalSymbols(); + while (symbols.hasNext() && !monitor.isCancelled()) { + Symbol sym = symbols.next(); + if (!FOCUS_IMPORTS.contains(sym.getName())) { + continue; + } + ExternalLocation loc = currentProgram.getExternalManager().getExternalLocation(sym); + String lib = (loc == null || loc.getLibraryName() == null) ? "" : loc.getLibraryName(); + for (Reference ref : refman.getReferencesTo(sym.getAddress())) { + Function f = currentProgram.getFunctionManager().getFunctionContaining(ref.getFromAddress()); + w.write(tsv(sym.getName(), lib, f == null ? "" : f.getName(), f == null ? "" : f.getEntryPoint(), + f == null ? "" : rva(f.getEntryPoint()), ref.getFromAddress(), rva(ref.getFromAddress()), + ref.getReferenceType())); + } + } + w.close(); + } + + private void exportCallgraphSlices() throws Exception { + BufferedWriter w = writer("priority_callgraph.tsv"); + w.write("seed\tseed_rva\tdirection\tdepth\tfunction\tfunction_entry\tfunction_rva\tcallee_or_caller\tpeer_entry\tpeer_rva\tref_type\n"); + long[] seeds = new long[] { + 0x100cbc90L, 0x100cbb0fL, 0x100c5270L, 0x100c50c0L, + 0x10053820L, 0x10053850L, 0x10053870L, 0x100538b0L, + 0x100539c0L, 0x10053a00L, 0x10053ab0L, 0x10053b40L, + 0x10053b70L, 0x10053bf0L, 0x10055e60L, 0x10055ff0L, + 0x10056880L, 0x10095d10L, 0x1006b8e0L, 0x10092370L, + 0x1007fcf0L, 0x10066480L + }; + for (long seed : seeds) { + Function f = functionAt(seed); + if (f == null) { + continue; + } + emitOutgoing(w, f, f, 0, new HashSet
()); + emitIncoming(w, f, f, 0, new HashSet
()); + } + w.close(); + } + + private void emitOutgoing(BufferedWriter w, Function seed, Function f, int depth, Set
seen) throws Exception { + if (depth > 2 || f == null || !seen.add(f.getEntryPoint())) { + return; + } + InstructionIterator it = listing.getInstructions(f.getBody(), true); + while (it.hasNext() && !monitor.isCancelled()) { + Instruction insn = it.next(); + for (Reference ref : insn.getReferencesFrom()) { + if (!ref.getReferenceType().isCall()) { + continue; + } + Function callee = currentProgram.getFunctionManager().getFunctionAt(ref.getToAddress()); + if (callee == null) { + callee = currentProgram.getFunctionManager().getFunctionContaining(ref.getToAddress()); + } + if (callee == null) { + continue; + } + w.write(tsv(seed.getName(), rva(seed.getEntryPoint()), "out", depth, f.getName(), f.getEntryPoint(), + rva(f.getEntryPoint()), callee.getName(), callee.getEntryPoint(), rva(callee.getEntryPoint()), + ref.getReferenceType())); + emitOutgoing(w, seed, callee, depth + 1, seen); + } + } + } + + private void emitIncoming(BufferedWriter w, Function seed, Function f, int depth, Set
seen) throws Exception { + if (depth > 2 || f == null || !seen.add(f.getEntryPoint())) { + return; + } + ReferenceIterator refs = refman.getReferencesTo(f.getEntryPoint()); + while (refs.hasNext() && !monitor.isCancelled()) { + Reference ref = refs.next(); + if (!ref.getReferenceType().isCall()) { + continue; + } + Function caller = currentProgram.getFunctionManager().getFunctionContaining(ref.getFromAddress()); + if (caller == null) { + continue; + } + w.write(tsv(seed.getName(), rva(seed.getEntryPoint()), "in", depth, f.getName(), f.getEntryPoint(), + rva(f.getEntryPoint()), caller.getName(), caller.getEntryPoint(), rva(caller.getEntryPoint()), + ref.getReferenceType())); + emitIncoming(w, seed, caller, depth + 1, seen); + } + } + + private FunctionRow functionRow(Function f) { + AddressSetView body = f.getBody(); + int callOut = 0; + int dataRefsFrom = 0; + InstructionIterator it = listing.getInstructions(body, true); + while (it.hasNext() && !monitor.isCancelled()) { + Instruction insn = it.next(); + for (Reference ref : insn.getReferencesFrom()) { + RefType type = ref.getReferenceType(); + if (type.isCall()) { + callOut++; + } else if (type.isData()) { + dataRefsFrom++; + } + } + } + int callIn = 0; + ReferenceIterator refs = refman.getReferencesTo(f.getEntryPoint()); + while (refs.hasNext()) { + if (refs.next().getReferenceType().isCall()) { + callIn++; + } + } + return new FunctionRow(f.getEntryPoint(), rva(f.getEntryPoint()), f.getName(), body.getNumAddresses(), + body.toString(), callIn, callOut, dataRefsFrom, f.isThunk(), f.getCallingConventionName()); + } + + private Function functionAt(long absolute) { + AddressSpace space = currentProgram.getAddressFactory().getDefaultAddressSpace(); + Address addr = space.getAddress(absolute); + Function f = currentProgram.getFunctionManager().getFunctionAt(addr); + if (f == null) { + f = currentProgram.getFunctionManager().getFunctionContaining(addr); + } + return f; + } + + private boolean isStringData(Data data) { + DataType dt = data.getDataType(); + String name = dt == null ? "" : dt.getName().toLowerCase(); + return name.contains("string") || name.contains("unicode"); + } + + private long countFunctions() { + long count = 0; + FunctionIterator it = currentProgram.getFunctionManager().getFunctions(true); + while (it.hasNext()) { + it.next(); + count++; + } + return count; + } + + private long countSymbols() { + long count = 0; + SymbolIterator it = currentProgram.getSymbolTable().getAllSymbols(true); + while (it.hasNext()) { + it.next(); + count++; + } + return count; + } + + private BufferedWriter writer(String name) throws Exception { + return new BufferedWriter(new FileWriter(new File(outDir, name))); + } + + private static String sha256(File f) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] data = Files.readAllBytes(f.toPath()); + byte[] digest = md.digest(data); + StringBuilder sb = new StringBuilder(); + for (byte b : digest) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } + + private static String rva(Address addr) { + if (addr == null) { + return ""; + } + long off = addr.getOffset() - IMAGE_BASE; + if (off < 0) { + return ""; + } + return String.format("0x%08x", off); + } + + private static String tsv(Object... fields) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < fields.length; i++) { + if (i > 0) { + sb.append('\t'); + } + sb.append(esc(String.valueOf(fields[i]))); + } + sb.append('\n'); + return sb.toString(); + } + + private static String esc(String s) { + if (s == null) { + return ""; + } + return s.replace("\\", "\\\\").replace("\t", "\\t").replace("\r", "\\r").replace("\n", "\\n"); + } + + private static class FunctionRow { + final Address entry; + final String rva; + final String name; + final long size; + final String bodyRanges; + final int callIn; + final int callOut; + final int dataRefsFrom; + final boolean thunk; + final String callingConvention; + + FunctionRow(Address entry, String rva, String name, long size, String bodyRanges, + int callIn, int callOut, int dataRefsFrom, boolean thunk, String callingConvention) { + this.entry = entry; + this.rva = rva; + this.name = name; + this.size = size; + this.bodyRanges = bodyRanges; + this.callIn = callIn; + this.callOut = callOut; + this.dataRefsFrom = dataRefsFrom; + this.thunk = thunk; + this.callingConvention = callingConvention; + } + + String toTsv() { + return tsv(entry, rva, name, size, bodyRanges, callIn, callOut, dataRefsFrom, thunk, callingConvention); + } + } +} diff --git a/tools/ghidra/InspectCameraSpawnPaths.java b/tools/ghidra/InspectCameraSpawnPaths.java new file mode 100644 index 0000000..0184d08 --- /dev/null +++ b/tools/ghidra/InspectCameraSpawnPaths.java @@ -0,0 +1,226 @@ +// STATIC_037 focused metadata export for SA-MP camera/spawn path triage. +// This script intentionally emits compact call/data/reference metadata only. + +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.Instruction; +import ghidra.program.model.listing.InstructionIterator; +import ghidra.program.model.scalar.Scalar; +import ghidra.program.model.symbol.Reference; +import ghidra.program.model.symbol.ReferenceIterator; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public class InspectCameraSpawnPaths extends GhidraScript { + private static final long IMAGE_BASE = 0x10000000L; + private File outDir; + + private static final long[] FOCUS_FUNCTIONS = new long[] { + 0x1009e8a0L, // string xrefs: CreateObject/CreateVehicle/SetPlayerCameraPos/LookAt registration area + 0x1009e4b0L, // helper reached by multiple script native name branches + 0x1009e500L, + 0x1009e610L, + 0x1009e720L, // SetPlayerCameraLookAt branch target candidate + 0x1009e790L, + 0x1009e850L, + 0x100c5620L, // string xrefs: Spawn UI area + 0x10003730L, // GetTickCount-heavy runtime/update loop candidate + 0x10060160L, // State Information/Ped Context diagnostic string + 0x100b3e20L, + 0x100a9650L, + 0x100a9bd0L + }; + + private static final Set FOCUS_CONSTANTS = new HashSet(Arrays.asList( + 0x00b6f1a8L, // GTA camera_mode observed by ASI probe + 0x00b6f858L, // GTA camera_mode2 observed by ASI probe + 0x0053bfc8L, // CTheScripts::Process callsite observed by ASI probe/replacement hook + 0x0046a000L, // original CTheScripts::Process target observed by ASI probe + 0x0053e230L, // graphics loop target observed by ASI probe + 0x0053bed1L, // process storage/hook area used by replacement + 0x0000015aL, // restore_camera opcode + 0x0000015fL, // set_camera_position opcode + 0x00000160L, // point_camera opcode + 0x000002ebL, // restore_camera_jumpcut opcode + 0x00000373L // set_camera_behind_player opcode + )); + + @Override + protected void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length < 1) { + throw new IllegalArgumentException("usage: InspectCameraSpawnPaths.java "); + } + outDir = new File(args[0]); + Files.createDirectories(outDir.toPath()); + + exportFunctionSummaries(); + exportFocusConstantRefs(); + exportStringReferenceNeighborhoods(); + } + + private void exportFunctionSummaries() throws Exception { + BufferedWriter w = writer("camera_spawn_function_summaries.tsv"); + w.write("function\tfunction_rva\tsize\tcall_out\tdata_refs_from\tcallsite_rva\ttarget\ttarget_rva\tref_type\n"); + for (long addrValue : FOCUS_FUNCTIONS) { + Function f = getFunctionContaining(toAddr(addrValue)); + if (f == null) { + w.write("missing\t" + rva(addrValue) + "\t0\t0\t0\t\t\t\t\n"); + continue; + } + int calls = 0; + int dataRefs = 0; + InstructionIterator it = currentProgram.getListing().getInstructions(f.getBody(), true); + while (it.hasNext() && !monitor.isCancelled()) { + Instruction insn = it.next(); + for (Reference ref : insn.getReferencesFrom()) { + if (ref.getReferenceType().isCall()) { + calls++; + } else if (ref.getReferenceType().isData()) { + dataRefs++; + } + } + } + it = currentProgram.getListing().getInstructions(f.getBody(), true); + while (it.hasNext() && !monitor.isCancelled()) { + Instruction insn = it.next(); + for (Reference ref : insn.getReferencesFrom()) { + if (!ref.getReferenceType().isCall()) { + continue; + } + Function target = getFunctionContaining(ref.getToAddress()); + w.write(tsv(f.getName(), rva(f.getEntryPoint().getOffset()), f.getBody().getNumAddresses(), calls, + dataRefs, rva(insn.getAddress().getOffset()), target == null ? ref.getToAddress().toString() : target.getName(), + rva(ref.getToAddress().getOffset()), ref.getReferenceType().toString())); + } + } + } + w.close(); + } + + private void exportFocusConstantRefs() throws Exception { + BufferedWriter w = writer("camera_spawn_focus_constant_refs.tsv"); + w.write("function\tfunction_rva\tinsn_rva\tmnemonic\tconstant\toperand_index\toperand\n"); + InstructionIterator it = currentProgram.getListing().getInstructions(true); + while (it.hasNext() && !monitor.isCancelled()) { + Instruction insn = it.next(); + for (int op = 0; op < insn.getNumOperands(); op++) { + Object[] objects = insn.getOpObjects(op); + for (Object object : objects) { + if (!(object instanceof Scalar)) { + continue; + } + long value = ((Scalar)object).getUnsignedValue(); + if (!FOCUS_CONSTANTS.contains(value)) { + continue; + } + Function f = getFunctionContaining(insn.getAddress()); + w.write(tsv(f == null ? "" : f.getName(), f == null ? "" : rva(f.getEntryPoint().getOffset()), + rva(insn.getAddress().getOffset()), insn.getMnemonicString(), hex(value), op, + insn.getDefaultOperandRepresentation(op))); + } + } + } + w.close(); + } + + private void exportStringReferenceNeighborhoods() throws Exception { + BufferedWriter w = writer("camera_spawn_string_ref_neighborhoods.tsv"); + w.write("string\tstring_rva\tfunction\tfunction_rva\tref_rva\tinsn_rva\tmnemonic\toperand0\toperand1\n"); + String[] needles = new String[] { + "SetPlayerCameraPos", + "SetPlayerCameraLookAt", + "Spawn", + "Camera", + "Joypad: %d LocalContext: %u UpdateCameraAim: %f %f %f" + }; + for (String needle : needles) { + Address stringAddress = findStringAddress(needle); + if (stringAddress == null) { + w.write(needle + "\tmissing\t\t\t\t\t\t\t\n"); + continue; + } + ReferenceIterator refs = currentProgram.getReferenceManager().getReferencesTo(stringAddress); + while (refs.hasNext() && !monitor.isCancelled()) { + Reference ref = refs.next(); + Function f = getFunctionContaining(ref.getFromAddress()); + for (int delta = -4; delta <= 4; delta++) { + Instruction insn = instructionNear(ref.getFromAddress(), delta); + if (insn == null) { + continue; + } + w.write(tsv(needle, rva(stringAddress.getOffset()), f == null ? "" : f.getName(), + f == null ? "" : rva(f.getEntryPoint().getOffset()), rva(ref.getFromAddress().getOffset()), + rva(insn.getAddress().getOffset()), insn.getMnemonicString(), operand(insn, 0), operand(insn, 1))); + } + } + } + w.close(); + } + + private Address findStringAddress(String needle) { + var it = currentProgram.getListing().getDefinedData(true); + while (it.hasNext() && !monitor.isCancelled()) { + var data = it.next(); + Object value = data.getValue(); + if (value != null && needle.equals(String.valueOf(value))) { + return data.getAddress(); + } + } + return null; + } + + private Instruction instructionNear(Address address, int delta) { + Instruction insn = currentProgram.getListing().getInstructionContaining(address); + if (insn == null) { + insn = currentProgram.getListing().getInstructionBefore(address); + } + while (insn != null && delta < 0) { + insn = currentProgram.getListing().getInstructionBefore(insn.getAddress()); + delta++; + } + while (insn != null && delta > 0) { + insn = currentProgram.getListing().getInstructionAfter(insn.getAddress()); + delta--; + } + return insn; + } + + private String operand(Instruction insn, int index) { + if (index >= insn.getNumOperands()) { + return ""; + } + return insn.getDefaultOperandRepresentation(index); + } + + private BufferedWriter writer(String name) throws Exception { + return new BufferedWriter(new FileWriter(new File(outDir, name))); + } + + private String tsv(Object... cols) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < cols.length; i++) { + if (i > 0) { + sb.append('\t'); + } + sb.append(String.valueOf(cols[i]).replace("\t", " ").replace("\n", "\\n")); + } + sb.append('\n'); + return sb.toString(); + } + + private String rva(long address) { + return String.format("0x%08x", address - IMAGE_BASE); + } + + private String hex(long value) { + return String.format("0x%08x", value); + } +} diff --git a/tools/ghidra/InspectCustomModelInfo.java b/tools/ghidra/InspectCustomModelInfo.java new file mode 100644 index 0000000..98f1bc6 --- /dev/null +++ b/tools/ghidra/InspectCustomModelInfo.java @@ -0,0 +1,658 @@ +// STATIC_037 focused metadata export for the SA-MP 0.3.7-R5 custom +// ModelInfo implementation. The output is deliberately limited to +// instructions, references, constants, patch records, and bounded memory +// metadata; it does not emit decompiler pseudocode. + +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.Instruction; +import ghidra.program.model.listing.InstructionIterator; +import ghidra.program.model.mem.MemoryBlock; +import ghidra.program.model.scalar.Scalar; +import ghidra.program.model.symbol.Reference; +import ghidra.program.model.symbol.ReferenceIterator; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.nio.file.Files; + +public class InspectCustomModelInfo extends GhidraScript { + private static final long SAMP_IMAGE_BASE = 0x10000000L; + + private static final long MODEL_POINTER_PATCH_TABLE_US1 = 0x10114b10L; + private static final long MODEL_POINTER_PATCH_TABLE_VARIANT2 = 0x101158e0L; + private static final int MODEL_POINTER_PATCH_COUNT = 707; + private static final int MODEL_POINTER_PATCH_RECORD_SIZE = 5; + + private static final long ATOMIC_STORE_PATCH_TABLE = 0x10116720L; + private static final int ATOMIC_STORE_PATCH_COUNT = 14; + private static final long VANILLA_MODEL_POINTER_BASE = 0x00a9b0c8L; + private static final long VANILLA_ATOMIC_STORE_BASE = 0x00aae954L; + private static final long RELOCATED_MODEL_POINTER_STORAGE = 0x101625b0L; + private static final long RELOCATED_MODEL_POINTER_ORIGIN = 0x101825acL; + private static final long RELOCATED_ATOMIC_STORE = 0x101cebd8L; + + private static final RawSpan[] SAMP_RAW_SPANS = new RawSpan[] { + new RawSpan("custom_model_release_tick", 0x1000d1e0L, 0x55), + new RawSpan( + "negative_model_id_guard_trampoline", 0x100a5e90L, 0x15), + new RawSpan("free_cloned_model_info", 0x100a7b30L, 0x29), + new RawSpan("bootstrap_calls_pregame_patch_entry", 0x100c3a80L, 0x50) + }; + + private static final FixedGuard[] GTA_FIXED_GUARDS = + new FixedGuard[] { + new FixedGuard( + "negative_model_id_guard_hook", 0x004087eaL, + "56578d7cad00"), + new FixedGuard( + "ped_model_info_store_operand", 0x004c67adL, + "fc78b400"), + new FixedGuard( + "txd_store_capacity_operand", 0x00731f60L, + "88130000") + }; + + private static final Focus[] SAMP_FOCUS = new Focus[] { + new Focus("custom_model_manager_init", 0x1000bd60L), + new Focus("custom_download_ped_loader", 0x1000c650L), + new Focus("custom_download_atomic_loader", 0x1000c770L), + new Focus("custom_model_release_tick", 0x1000d1e0L), + new Focus("negative_model_id_guard_trampoline", 0x100a5e90L), + new Focus("negative_model_id_guard_installer", 0x100a6ff0L), + new Focus("install_model_pointer_patches", 0x100a7970L), + new Focus("initialize_model_pointer_arena", 0x100a7a00L), + new Focus("get_model_info_compat", 0x100a7a40L), + new Focus("clone_ped_model_info", 0x100a7a80L), + new Focus("clone_atomic_model_info", 0x100a7ad0L), + new Focus("free_cloned_model_info", 0x100a7b30L), + new Focus("load_or_bind_txd", 0x100a7b60L), + new Focus("load_custom_ped_model", 0x100a7bd0L), + new Focus("load_custom_atomic_model", 0x100a7c30L), + new Focus("load_custom_collision", 0x100a7cc0L), + new Focus("register_custom_collision_handler", 0x100a7d90L), + new Focus("pregame_patch_entry", 0x100a08e0L), + new Focus("pregame_limit_patch_group", 0x100aa590L), + new Focus("initialize_ped_model_store_319", 0x100aa9c0L), + new Focus("initialize_atomic_model_store_20000", 0x100aaa10L), + new Focus("initialize_world_sector_store_not_modelinfo", 0x100aaa80L), + new Focus("install_pregame_patch_set", 0x100aaeb0L), + new Focus("bootstrap_calls_pregame_patch_entry", 0x100c3a80L), + new Focus("txd_find_slot_wrapper", 0x100b3880L), + new Focus("txd_add_slot_wrapper", 0x100b38a0L), + new Focus("txd_load_wrapper", 0x100b38c0L), + new Focus("txd_add_ref_wrapper", 0x100b38f0L), + new Focus("txd_remove_ref_wrapper", 0x100b3900L), + new Focus("txd_push_current_wrapper", 0x100b3910L), + new Focus("txd_pop_current_wrapper", 0x100b3920L), + new Focus("txd_set_current_wrapper", 0x100b3930L), + new Focus("txd_remove_named_slot_wrapper", 0x100b3980L), + new Focus("txd_load_slot_wrapper", 0x100b39b0L), + new Focus("txd_remove_slot_wrapper", 0x100b39d0L), + new Focus("txd_get_wrapper", 0x100b39f0L), + new Focus("validate_ped_clone_source", 0x100b3dd0L), + new Focus("validate_atomic_clone_source", 0x100b44e0L), + new Focus("set_model_txd_index", 0x100b4660L), + new Focus("get_model_txd_index", 0x100b4680L), + new Focus("release_model_txd_if_unreferenced", 0x100b2040L) + }; + + private static final Region[] SAMP_REGIONS = new Region[] { + new Region( + "model_pointer_patch_table_us1", MODEL_POINTER_PATCH_TABLE_US1, + MODEL_POINTER_PATCH_COUNT * MODEL_POINTER_PATCH_RECORD_SIZE, 5, + MODEL_POINTER_PATCH_COUNT), + new Region( + "model_pointer_patch_table_variant2", + MODEL_POINTER_PATCH_TABLE_VARIANT2, + MODEL_POINTER_PATCH_COUNT * MODEL_POINTER_PATCH_RECORD_SIZE, 5, + MODEL_POINTER_PATCH_COUNT), + new Region( + "atomic_store_patch_table", ATOMIC_STORE_PATCH_TABLE, + ATOMIC_STORE_PATCH_COUNT * 4L, 4, ATOMIC_STORE_PATCH_COUNT), + new Region( + "model_pointer_storage", RELOCATED_MODEL_POINTER_STORAGE, + 65535L * 4L, 4, 65535), + new Region( + "model_pointer_effective_origin", RELOCATED_MODEL_POINTER_ORIGIN, + 4, 4, 1), + new Region( + "atomic_model_info_store", RELOCATED_ATOMIC_STORE, + 20000L * 0x20L, 0x20, 20000), + new Region( + "ped_model_info_store", 0x101c9718L, 319L * 0x44L, 0x44, 319), + new Region( + "world_sector_store_not_modelinfo", 0x101a2618L, + 20000L * 8L, 8, 20000) + }; + + private static final StringAnchor[] STRING_ANCHORS = + new StringAnchor[] { + new StringAnchor("download_model_path_dff", 0x100e5bd8L), + new StringAnchor("download_model_path_txd", 0x100e5be8L), + new StringAnchor("download_txd_warning", 0x100e5c30L), + new StringAnchor("download_dff_path", 0x100e5ca8L), + new StringAnchor("download_txd_path", 0x100e5cb4L), + new StringAnchor("loader_failed_texture", 0x100ecc1cL), + new StringAnchor("loader_failed_model", 0x100ecc4cL), + new StringAnchor("loader_failed_collision_alloc", 0x100ecc70L), + new StringAnchor("loader_failed_collision_load", 0x100ecca4L), + new StringAnchor("loader_not_col3", 0x100eccd0L), + new StringAnchor("fallback_txd_name", 0x100ed3e4L), + new StringAnchor("samp_asset_directory", 0x100ed3eaL) + }; + + private File outDir; + + private static class Focus { + final String label; + final long address; + + Focus(String label, long address) { + this.label = label; + this.address = address; + } + } + + private static class RawSpan { + final String label; + final long address; + final int size; + + RawSpan(String label, long address, int size) { + this.label = label; + this.address = address; + this.size = size; + } + } + + private static class FixedGuard { + final String label; + final long address; + final String expectedBytes; + + FixedGuard(String label, long address, String expectedBytes) { + this.label = label; + this.address = address; + this.expectedBytes = expectedBytes; + } + } + + private static class Region { + final String label; + final long start; + final long size; + final long stride; + final long count; + + Region(String label, long start, long size, long stride, long count) { + this.label = label; + this.start = start; + this.size = size; + this.stride = stride; + this.count = count; + } + } + + private static class StringAnchor { + final String label; + final long address; + + StringAnchor(String label, long address) { + this.label = label; + this.address = address; + } + } + + @Override + protected void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length < 2) { + throw new IllegalArgumentException( + "usage: InspectCustomModelInfo.java " + + " [patch-table.tsv]"); + } + + outDir = new File(args[1]); + Files.createDirectories(outDir.toPath()); + exportIdentity(); + + if ("export-samp".equals(args[0])) { + exportSampMetadata(); + exportPatchTables(); + exportRegions(); + exportStrings(); + exportRawSpans(); + return; + } + if ("verify-gta".equals(args[0])) { + if (args.length < 3) { + throw new IllegalArgumentException( + "verify-gta requires patch-table.tsv"); + } + verifyGtaPatchGuards(new File(args[2])); + return; + } + throw new IllegalArgumentException("unknown mode: " + args[0]); + } + + private void exportIdentity() throws Exception { + BufferedWriter out = writer("identity.tsv"); + out.write("property\tvalue\n"); + out.write(tsv("program_name", currentProgram.getName())); + out.write(tsv("executable_path", currentProgram.getExecutablePath())); + out.write(tsv("executable_format", currentProgram.getExecutableFormat())); + out.write(tsv("executable_md5", currentProgram.getExecutableMD5())); + out.write(tsv("executable_sha256", currentProgram.getExecutableSHA256())); + out.write(tsv("image_base", currentProgram.getImageBase())); + out.write(tsv("language_id", currentProgram.getLanguageID())); + out.close(); + } + + private void exportSampMetadata() throws Exception { + BufferedWriter functions = writer("functions.tsv"); + BufferedWriter instructions = writer("instructions.tsv"); + BufferedWriter references = writer("references.tsv"); + BufferedWriter callers = writer("callers.tsv"); + BufferedWriter scalars = writer("scalars.tsv"); + + functions.write( + "label\trequested_rva\tentry_rva\tname\tsize\tinstruction_count" + + "\tcalling_convention\tentry_bytes_16" + + "\tdirect_reference_count\n"); + instructions.write( + "label\tfunction_rva\tinsn_rva\tbytes\tmnemonic\tinstruction" + + "\tflow_type\n"); + references.write( + "label\tfunction_rva\tinsn_rva\tref_type\ttarget\ttarget_rva" + + "\ttarget_function\ttarget_function_rva\n"); + callers.write( + "label\tfunction_rva\tcaller_rva\tref_type\tcaller_function" + + "\tcaller_function_rva\n"); + scalars.write( + "label\tfunction_rva\tinsn_rva\tmnemonic\toperand_index\tvalue" + + "\toperand\n"); + + for (Focus focus : SAMP_FOCUS) { + Function function = getFunctionContaining(toAddr(focus.address)); + Address referenceTarget = + function == null + ? toAddr(focus.address) + : function.getEntryPoint(); + int directReferenceCount = 0; + ReferenceIterator referenceIterator = + currentProgram.getReferenceManager().getReferencesTo( + referenceTarget); + while (referenceIterator.hasNext()) { + Reference reference = referenceIterator.next(); + Function caller = + getFunctionContaining(reference.getFromAddress()); + callers.write(tsv( + focus.label, sampRva(referenceTarget.getOffset()), + sampRva(reference.getFromAddress().getOffset()), + reference.getReferenceType(), + caller == null ? "" : caller.getName(), + caller == null + ? "" + : sampRva(caller.getEntryPoint().getOffset()))); + directReferenceCount++; + } + + if (function == null) { + functions.write(tsv( + focus.label, sampRva(focus.address), "", "missing", 0, 0, + "", "", directReferenceCount)); + continue; + } + + int instructionCount = 0; + InstructionIterator iterator = + currentProgram.getListing().getInstructions( + function.getBody(), true); + while (iterator.hasNext() && !monitor.isCancelled()) { + Instruction instruction = iterator.next(); + instructionCount++; + instructions.write(tsv( + focus.label, + sampRva(function.getEntryPoint().getOffset()), + sampRva(instruction.getAddress().getOffset()), + bytes(instruction), instruction.getMnemonicString(), + instruction, instruction.getFlowType())); + + for (Reference reference : instruction.getReferencesFrom()) { + Function target = + getFunctionContaining(reference.getToAddress()); + references.write(tsv( + focus.label, + sampRva(function.getEntryPoint().getOffset()), + sampRva(instruction.getAddress().getOffset()), + reference.getReferenceType(), reference.getToAddress(), + sampRva(reference.getToAddress().getOffset()), + target == null ? "" : target.getName(), + target == null + ? "" + : sampRva(target.getEntryPoint().getOffset()))); + } + + for (int operandIndex = 0; + operandIndex < instruction.getNumOperands(); + operandIndex++) { + for (Object object : + instruction.getOpObjects(operandIndex)) { + if (object instanceof Scalar) { + Scalar scalar = (Scalar)object; + scalars.write(tsv( + focus.label, + sampRva(function.getEntryPoint().getOffset()), + sampRva(instruction.getAddress().getOffset()), + instruction.getMnemonicString(), operandIndex, + String.format( + "0x%x", scalar.getUnsignedValue()), + instruction.getDefaultOperandRepresentation( + operandIndex))); + } + } + } + } + + functions.write(tsv( + focus.label, sampRva(focus.address), + sampRva(function.getEntryPoint().getOffset()), + function.getName(), function.getBody().getNumAddresses(), + instructionCount, function.getCallingConventionName(), + bytes(function.getEntryPoint(), 16), directReferenceCount)); + } + + functions.close(); + instructions.close(); + references.close(); + callers.close(); + scalars.close(); + } + + private void exportPatchTables() throws Exception { + BufferedWriter out = writer("patch_tables.tsv"); + out.write( + "table\tindex\ttable_rva\ttarget_va\texpected_opcode" + + "\toperand_offset\toriginal_value\tpatched_value" + + "\tpatch_width\n"); + exportModelPointerPatchTable( + out, "model_pointer_us1", MODEL_POINTER_PATCH_TABLE_US1); + exportModelPointerPatchTable( + out, "model_pointer_variant2", + MODEL_POINTER_PATCH_TABLE_VARIANT2); + + for (int index = 0; index < ATOMIC_STORE_PATCH_COUNT; index++) { + Address record = + toAddr(ATOMIC_STORE_PATCH_TABLE + index * 4L); + long target = unsignedInt(record); + out.write(tsv( + "atomic_store_us1", index, + sampRva(record.getOffset()), va(target), "", 0, + va(VANILLA_ATOMIC_STORE_BASE), + sampRva(RELOCATED_ATOMIC_STORE), 4)); + } + out.close(); + } + + private void exportModelPointerPatchTable( + BufferedWriter out, String label, long tableAddress) + throws Exception { + for (int index = 0; index < MODEL_POINTER_PATCH_COUNT; index++) { + Address record = + toAddr( + tableAddress + + index * (long)MODEL_POINTER_PATCH_RECORD_SIZE); + long target = unsignedInt(record); + int opcode = + currentProgram.getMemory().getByte(record.add(4)) & 0xff; + int operandOffset = operandOffsetForOpcode(opcode); + out.write(tsv( + label, index, sampRva(record.getOffset()), va(target), + String.format("%02x", opcode), operandOffset, + va(VANILLA_MODEL_POINTER_BASE), + sampRva(RELOCATED_MODEL_POINTER_ORIGIN), 4)); + } + } + + private void exportRegions() throws Exception { + BufferedWriter out = writer("regions.tsv"); + out.write( + "label\tstart_rva\tend_exclusive_rva\tsize\tstride\tcount" + + "\tmemory_block\tinitialized\tread\twrite\texecute\n"); + for (Region region : SAMP_REGIONS) { + Address start = toAddr(region.start); + MemoryBlock block = + currentProgram.getMemory().getBlock(start); + out.write(tsv( + region.label, sampRva(region.start), + sampRva(region.start + region.size), hex(region.size), + hex(region.stride), region.count, + block == null ? "" : block.getName(), + block != null && block.isInitialized(), + block != null && block.isRead(), + block != null && block.isWrite(), + block != null && block.isExecute())); + } + out.close(); + + BufferedWriter blocks = writer("memory_blocks.tsv"); + blocks.write( + "name\tstart\tend\tsize\tinitialized\tread\twrite\texecute\n"); + for (MemoryBlock block : currentProgram.getMemory().getBlocks()) { + blocks.write(tsv( + block.getName(), block.getStart(), block.getEnd(), + hex(block.getSize()), block.isInitialized(), block.isRead(), + block.isWrite(), block.isExecute())); + } + blocks.close(); + } + + private void exportStrings() throws Exception { + BufferedWriter out = writer("strings.tsv"); + out.write("label\trva\tascii\n"); + for (StringAnchor anchor : STRING_ANCHORS) { + out.write(tsv( + anchor.label, sampRva(anchor.address), + asciiZ(toAddr(anchor.address), 160))); + } + out.close(); + } + + private void exportRawSpans() throws Exception { + BufferedWriter out = writer("raw_spans.tsv"); + out.write("label\tstart_rva\tsize\tbytes\n"); + for (RawSpan span : SAMP_RAW_SPANS) { + out.write(tsv( + span.label, sampRva(span.address), hex(span.size), + bytes(toAddr(span.address), span.size))); + } + out.close(); + } + + private void verifyGtaPatchGuards(File patchTable) throws Exception { + BufferedWriter out = writer("gta_patch_guards.tsv"); + out.write( + "table\tindex\ttarget_va\texpected_opcode\tactual_opcode" + + "\topcode_match\toperand_va\texpected_original_value" + + "\tactual_original_value\tvalue_match\tinstruction_va" + + "\tinstruction_bytes\tinstruction\traw_guard_bytes\n"); + + BufferedReader in = new BufferedReader(new FileReader(patchTable)); + String line = in.readLine(); + while ((line = in.readLine()) != null && !monitor.isCancelled()) { + String[] columns = line.split("\\t", -1); + if (columns.length < 9) { + continue; + } + String table = columns[0]; + if (!"model_pointer_us1".equals(table) + && !"atomic_store_us1".equals(table)) { + continue; + } + + int index = Integer.parseInt(columns[1]); + long target = parseHex(columns[3]); + String expectedOpcode = columns[4]; + int operandOffset = Integer.parseInt(columns[5]); + long expectedValue = parseHex(columns[6]); + Address targetAddress = toAddr(target); + + if ("atomic_store_us1".equals(table)) { + Instruction instruction = + currentProgram.getListing().getInstructionContaining( + targetAddress); + long actualValue = unsignedInt(targetAddress); + out.write(tsv( + table, index, va(target), "", "", true, va(target), + va(expectedValue), va(actualValue), + actualValue == expectedValue, + instruction == null ? "" : va( + instruction.getAddress().getOffset()), + instruction == null ? "" : bytes(instruction), + instruction == null ? "" : instruction, + bytes(targetAddress, 4))); + continue; + } + + int expectedOpcodeValue = + Integer.parseInt(expectedOpcode, 16); + int actualOpcode = + currentProgram.getMemory().getByte(targetAddress) & 0xff; + Address operandAddress = targetAddress.add(operandOffset); + long actualValue = unsignedInt(operandAddress); + Instruction instruction = + currentProgram.getListing().getInstructionAt(targetAddress); + out.write(tsv( + table, index, va(target), expectedOpcode, + String.format("%02x", actualOpcode), + actualOpcode == expectedOpcodeValue, va( + operandAddress.getOffset()), va(expectedValue), + va(actualValue), actualValue == expectedValue, + instruction == null ? "" : va( + instruction.getAddress().getOffset()), + instruction == null ? "" : bytes(instruction), + instruction == null ? "" : instruction, + bytes(targetAddress, 8))); + } + in.close(); + out.close(); + + BufferedWriter fixed = writer("gta_fixed_guards.tsv"); + fixed.write( + "label\ttarget_va\texpected_bytes\tactual_bytes\tmatch\n"); + for (FixedGuard guard : GTA_FIXED_GUARDS) { + Address address = toAddr(guard.address); + int byteCount = guard.expectedBytes.length() / 2; + String actual = bytes(address, byteCount); + fixed.write(tsv( + guard.label, va(guard.address), guard.expectedBytes, actual, + guard.expectedBytes.equals(actual))); + } + fixed.close(); + } + + private int operandOffsetForOpcode(int opcode) { + if (opcode == 0x8b || opcode == 0x89 || opcode == 0x39) { + return 3; + } + if (opcode == 0xbe || opcode == 0xbf) { + return 1; + } + return -1; + } + + private long unsignedInt(Address address) throws Exception { + return currentProgram.getMemory().getInt(address) & 0xffffffffL; + } + + private String asciiZ(Address address, int limit) throws Exception { + StringBuilder out = new StringBuilder(); + for (int index = 0; index < limit; index++) { + int value = + currentProgram.getMemory().getByte(address.add(index)) & 0xff; + if (value == 0) { + break; + } + if (value >= 0x20 && value <= 0x7e) { + out.append((char)value); + } + else { + out.append(String.format("\\x%02x", value)); + } + } + return out.toString(); + } + + private BufferedWriter writer(String name) throws Exception { + return new BufferedWriter(new FileWriter(new File(outDir, name))); + } + + private String bytes(Instruction instruction) throws Exception { + return hexBytes(instruction.getBytes()); + } + + private String bytes(Address address, int count) throws Exception { + byte[] values = new byte[count]; + currentProgram.getMemory().getBytes(address, values); + return hexBytes(values); + } + + private String hexBytes(byte[] values) { + StringBuilder out = new StringBuilder(values.length * 2); + for (byte value : values) { + out.append(String.format("%02x", value & 0xff)); + } + return out.toString(); + } + + private String tsv(Object... columns) { + StringBuilder out = new StringBuilder(); + for (int index = 0; index < columns.length; index++) { + if (index > 0) { + out.append('\t'); + } + out.append(String.valueOf(columns[index]) + .replace("\t", " ") + .replace("\n", "\\n") + .replace("\r", "\\r")); + } + out.append('\n'); + return out.toString(); + } + + private long parseHex(String value) { + String normalized = value.trim().toLowerCase(); + if (normalized.startsWith("external:")) { + normalized = normalized.substring("external:".length()); + } + if (normalized.startsWith("samp.dll+")) { + return SAMP_IMAGE_BASE + + Long.parseUnsignedLong( + normalized.substring("samp.dll+".length() + 2), 16); + } + if (normalized.startsWith("0x")) { + normalized = normalized.substring(2); + } + return Long.parseUnsignedLong(normalized, 16); + } + + private String sampRva(long address) { + if (address < SAMP_IMAGE_BASE || address >= 0x20000000L) { + return "external:" + va(address); + } + return String.format("samp.dll+0x%08x", address - SAMP_IMAGE_BASE); + } + + private String va(long address) { + return String.format("0x%08x", address); + } + + private String hex(long value) { + return String.format("0x%x", value); + } +} diff --git a/tools/ghidra/README.md b/tools/ghidra/README.md new file mode 100644 index 0000000..4a8b8f9 --- /dev/null +++ b/tools/ghidra/README.md @@ -0,0 +1,28 @@ +# Local Ghidra Setup + +Persistent Ghidra install: + +```bash +/home/chairman/Projects/sa-mp.dll-rebuild/tools/ghidra/run-ghidra.sh +``` + +Headless analyzer: + +```bash +/home/chairman/Projects/sa-mp.dll-rebuild/tools/ghidra/analyze-headless.sh +``` + +GhidraMCP extension ZIP inside the install: + +```bash +/home/chairman/Projects/sa-mp.dll-rebuild/tools/ghidra/ghidra_12.1.2_PUBLIC/ghidra_12.1.2_PUBLIC/Extensions/Ghidra/GhidraMCP-2.0-ghidra12.zip +``` + +The wrappers set `JAVA_HOME` to the local JDK 21 copy: + +```bash +/home/chairman/Projects/sa-mp.dll-rebuild/tools/ghidra/jdk-21.0.11+10 +``` + +They also keep Ghidra config/cache local to `tools/ghidra/.config` and +`tools/ghidra/.cache`. diff --git a/tools/ghidra/run-ghidra.sh b/tools/ghidra/run-ghidra.sh new file mode 100755 index 0000000..8ec727c --- /dev/null +++ b/tools/ghidra/run-ghidra.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export JAVA_HOME="$SCRIPT_DIR/jdk-21.0.11+10" +export XDG_CONFIG_HOME="$SCRIPT_DIR/.config" +export XDG_CACHE_HOME="$SCRIPT_DIR/.cache" +mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" +exec "$SCRIPT_DIR/ghidra_12.1.2_PUBLIC/ghidra_12.1.2_PUBLIC/ghidraRun" "$@" diff --git a/tools/openmp_rpc73_gametext_fixture/.gitignore b/tools/openmp_rpc73_gametext_fixture/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/tools/openmp_rpc73_gametext_fixture/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/tools/openmp_rpc73_gametext_fixture/CMakeLists.txt b/tools/openmp_rpc73_gametext_fixture/CMakeLists.txt new file mode 100644 index 0000000..c38735b --- /dev/null +++ b/tools/openmp_rpc73_gametext_fixture/CMakeLists.txt @@ -0,0 +1,57 @@ +cmake_minimum_required(VERSION 3.19) + +project(openmp_rpc73_gametext_fixture LANGUAGES CXX VERSION 0.1.0) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(OMP_SDK_DIR "" CACHE PATH "Path to a local open.mp SDK checkout") +if(NOT OMP_SDK_DIR) + foreach(candidate + "/home/chairman/Projects/omp-ipv6/open.mp/SDK" + "/home/chairman/Projects/LastBedStanding/deps/omp-sdk") + if(EXISTS "${candidate}/include/sdk.hpp") + set(OMP_SDK_DIR "${candidate}") + break() + endif() + endforeach() +endif() + +if(NOT EXISTS "${OMP_SDK_DIR}/include/sdk.hpp") + message(FATAL_ERROR + "OMP_SDK_DIR must point to a local open.mp SDK checkout containing include/sdk.hpp") +endif() + +if(NOT CMAKE_SIZEOF_VOID_P EQUAL 4) + message(FATAL_ERROR + "The local open.mp server is i386. Configure this fixture with a 32-bit compiler " + "(the supplied build.sh passes -m32).") +endif() + +add_subdirectory("${OMP_SDK_DIR}" "${CMAKE_BINARY_DIR}/omp-sdk" EXCLUDE_FROM_ALL) + +add_library(rpc73_gametext_fixture SHARED src/rpc73_gametext_fixture.cpp) +target_link_libraries(rpc73_gametext_fixture PRIVATE OMP-SDK) +target_include_directories(rpc73_gametext_fixture SYSTEM PRIVATE + "${OMP_SDK_DIR}/include" + "${OMP_SDK_DIR}/lib/glm/glm/.." + "${OMP_SDK_DIR}/lib/robin-hood-hashing/src/include" + "${OMP_SDK_DIR}/lib/span-lite/include" + "${OMP_SDK_DIR}/lib/string-view-lite/include" +) + +target_compile_options(rpc73_gametext_fixture PRIVATE + $<$:-m32;-Wall;-Wextra;-Wpedantic;-Werror> +) +target_link_options(rpc73_gametext_fixture PRIVATE + $<$:-m32> +) + +set_target_properties(rpc73_gametext_fixture PROPERTIES + PREFIX "" + OUTPUT_NAME "rpc73_gametext_fixture" + POSITION_INDEPENDENT_CODE ON + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN YES +) diff --git a/tools/openmp_rpc73_gametext_fixture/README.md b/tools/openmp_rpc73_gametext_fixture/README.md new file mode 100644 index 0000000..0f3842f --- /dev/null +++ b/tools/openmp_rpc73_gametext_fixture/README.md @@ -0,0 +1,123 @@ +# RPC73 GameText replacement fixture + +This deliberately narrow open.mp laboratory component sends two fixed raw +SA-MP RPC 73 (`ScrDisplayGameText`) payloads to one eligible test player: + +1. style 5, duration 5000 ms, text `RPC73_STYLE5_FIRST`; +2. 350 ms later, style 3, duration 5000 ms, text + `RPC73_STYLE3_SECOND`. + +Enter exactly `/rpc73replace` to start the one-shot sequence. The command +accepts no arguments. Bots, non-0.3.7 clients, non-legacy transports, +uninitialised players, and repeated triggers on the same connection are +rejected. + +This is not a general raw-RPC tool. The RPC ID, styles, durations, texts, +payload bytes, delay, ordering channel, and sequence are compiled in. Do not +install this fixture on a public or production server. + +## Fixed payloads + +RPC 73 serialises a little-endian `int32` style, `int32` display time, +`int32` byte length, and the raw text bytes. + +| Phase | Style | Time | Length | Payload bytes | +| --- | ---: | ---: | ---: | --- | +| `first` | 5 | 5000 | 18 | `05 00 00 00 88 13 00 00 12 00 00 00 52 50 43 37 33 5f 53 54 59 4c 45 35 5f 46 49 52 53 54` | +| `replacement` | 3 | 5000 | 19 | `03 00 00 00 88 13 00 00 13 00 00 00 52 50 43 37 33 5f 53 54 59 4c 45 33 5f 53 45 43 4f 4e 44` | + +Both calls use `OrderingChannel_SyncRPC` and `dispatchEvents=false`. +Consequently the component sends the raw RPC directly through LegacyNetwork; +open.mp outgoing hooks and the Fixes component's GameText-to-TextDraw path do +not rewrite the vectors. + +## Evidence + +`STATIC_037`: for original SA-MP 0.3.7-R5 DLL SHA256 +`b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2`, +RPC 73 starts at `samp.dll+0x198F0` and calls +`CGame::DisplayGameText` at `samp.dll+0xA0CE0` from +`samp.dll+0x199C8`. The accepted-message path executes GTA opcode +`0x00BE` (`text_clear_all`) through the descriptor at +`samp.dll+0xEC724` before it displays the new text. See +[`../../docs/re/scoreboard_gametext_render_semantics_r5_20260728.md`](../../docs/re/scoreboard_gametext_render_semantics_r5_20260728.md). + +`OPENMP_REF`: the component targets local open.mp SDK commit +`3ee7bc4ab20c22359c34c08c38f93815b44bffd5`. Relevant API contracts: + +- `SDK/include/player.hpp`: `PlayerTextEventHandler`, + `PlayerConnectEventHandler`, `IPlayer::sendRPC`; +- `SDK/include/network.hpp`: the `Span` length passed to `sendRPC` + is a bit count; +- `SDK/include/Server/Components/Timers/timers.hpp`: + `ITimersComponent::create` and `TimerTimeOutHandler`. + +The matching local LegacyNetwork implementation creates a bitstream with the +exact supplied bit count and uses reliable-ordered delivery for +`OrderingChannel_SyncRPC`. + +## Build + +The local test server is an i386 ELF binary. Build with: + +```sh +tools/openmp_rpc73_gametext_fixture/build.sh +``` + +The output is: + +```text +tools/openmp_rpc73_gametext_fixture/build/rpc73_gametext_fixture.so +``` + +To select another local SDK checkout: + +```sh +OMP_SDK_DIR=/home/chairman/Projects/LastBedStanding/deps/omp-sdk \ + tools/openmp_rpc73_gametext_fixture/build.sh +``` + +The build wrapper verifies that the result is an ELF32 i386 shared object and +prints its SHA256. It does not install or load the component. + +## Controlled load and trigger + +Stop the isolated test server, copy only this component, and restart: + +```sh +cp tools/openmp_rpc73_gametext_fixture/build/rpc73_gametext_fixture.so \ + omp-server-bare/components/rpc73_gametext_fixture.so +cd omp-server-bare +./omp-server +``` + +The normal `Timers.so` component must be present. Connect the replacement +0.3.7 client and enter: + +```text +/rpc73replace +``` + +The server log must contain one `phase=first` line and, about 350 ms later, +one `phase=replacement` line, both with `sent=1`. A matching replacement +client runtime trace should contain this transition: + +```text +game_text: clear_all ... cleared=0 reason=replace_before_show +game_text: show ... style=5 ... +game_text: clear_all ... cleared=1 reason=replace_before_show +game_text: show ... style=3 ... +``` + +For the existing deterministic UI runner, the optional control-client trigger +can be enabled without changing the ordinary scenario: + +```sh +SAMP_RELOOP_RPC73_REPLACE=1 \ + python3 tools/reloop/reloop.py run --client replacement --group ui \ + --delay 1000 --no-build --no-deploy --interaction +``` + +Reconnect before another run. To return the test server to its prior +component set, stop it and remove only +`omp-server-bare/components/rpc73_gametext_fixture.so`. diff --git a/tools/openmp_rpc73_gametext_fixture/build.sh b/tools/openmp_rpc73_gametext_fixture/build.sh new file mode 100755 index 0000000..c4d7463 --- /dev/null +++ b/tools/openmp_rpc73_gametext_fixture/build.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +fixture_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +build_dir="${fixture_dir}/build" +sdk_dir="${OMP_SDK_DIR:-/home/chairman/Projects/omp-ipv6/open.mp/SDK}" + +if [[ ! -f "${sdk_dir}/include/sdk.hpp" ]]; then + echo "open.mp SDK not found at: ${sdk_dir}" >&2 + echo "Set OMP_SDK_DIR to a local open.mp SDK checkout." >&2 + exit 1 +fi + +CCACHE_DISABLE=1 cmake --fresh \ + -S "${fixture_dir}" \ + -B "${build_dir}" \ + -DOMP_SDK_DIR="${sdk_dir}" \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DCMAKE_CXX_FLAGS=-m32 \ + -DCMAKE_SHARED_LINKER_FLAGS=-m32 + +CCACHE_DISABLE=1 cmake --build "${build_dir}" --parallel + +artifact="${build_dir}/rpc73_gametext_fixture.so" +if [[ ! -f "${artifact}" ]]; then + echo "Expected artifact was not produced: ${artifact}" >&2 + exit 1 +fi + +if ! file "${artifact}" | rg -q "ELF 32-bit.*Intel (80386|i386)"; then + file "${artifact}" >&2 + echo "Refusing non-i386 fixture artifact." >&2 + exit 1 +fi + +file "${artifact}" +sha256sum "${artifact}" diff --git a/tools/openmp_rpc73_gametext_fixture/src/rpc73_gametext_fixture.cpp b/tools/openmp_rpc73_gametext_fixture/src/rpc73_gametext_fixture.cpp new file mode 100644 index 0000000..9b35d31 --- /dev/null +++ b/tools/openmp_rpc73_gametext_fixture/src/rpc73_gametext_fixture.cpp @@ -0,0 +1,522 @@ +#include +#include + +#include +#include +#include +#include + +namespace +{ +constexpr int kRpcDisplayGameText = 73; +constexpr StringView kCommand = "/rpc73replace"; +constexpr int32_t kDisplayTimeMs = 5000; +constexpr int kReplacementDelayMs = 350; +constexpr char kFirstText[] = "RPC73_STYLE5_FIRST"; +constexpr char kReplacementText[] = "RPC73_STYLE3_SECOND"; +constexpr char kFirstPayloadHex[] = + "05000000881300001200000052504337335f5354594c45355f4649525354"; +constexpr char kReplacementPayloadHex[] = + "03000000881300001300000052504337335f5354594c45335f5345434f4e44"; + +// STATIC_037: +// SA-MP 0.3.7-R5, SHA256 +// b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2. +// RPC 73 at samp.dll+0x198F0 reads int32 style, int32 time, int32 byte +// length, and the raw text bytes. Its call at samp.dll+0x199C8 enters +// CGame::DisplayGameText at samp.dll+0xA0CE0, which first executes GTA +// opcode 0x00BE (text_clear_all) through the descriptor at +// samp.dll+0xEC724. +// +// These are closed payloads. Command/player data cannot influence any byte. +constexpr std::array kFirstPayload { + 0x05, 0x00, 0x00, 0x00, + 0x88, 0x13, 0x00, 0x00, + 0x12, 0x00, 0x00, 0x00, + 0x52, 0x50, 0x43, 0x37, 0x33, 0x5F, 0x53, 0x54, 0x59, + 0x4C, 0x45, 0x35, 0x5F, 0x46, 0x49, 0x52, 0x53, 0x54, +}; + +constexpr std::array kReplacementPayload { + 0x03, 0x00, 0x00, 0x00, + 0x88, 0x13, 0x00, 0x00, + 0x13, 0x00, 0x00, 0x00, + 0x52, 0x50, 0x43, 0x37, 0x33, 0x5F, 0x53, 0x54, 0x59, + 0x4C, 0x45, 0x33, 0x5F, 0x53, 0x45, 0x43, 0x4F, 0x4E, 0x44, +}; + +constexpr uint32_t readLittleEndian32(const uint8_t* data) +{ + return static_cast(data[0]) + | (static_cast(data[1]) << 8u) + | (static_cast(data[2]) << 16u) + | (static_cast(data[3]) << 24u); +} + +template +constexpr bool payloadMatches( + const std::array& payload, + int32_t style, + int32_t timeMs, + const char (&text)[TextSize]) +{ + if (PayloadSize != 12u + TextSize - 1u + || readLittleEndian32(payload.data()) != static_cast(style) + || readLittleEndian32(payload.data() + 4u) != static_cast(timeMs) + || readLittleEndian32(payload.data() + 8u) != TextSize - 1u) + { + return false; + } + for (std::size_t index = 0; index + 1u < TextSize; ++index) + { + if (payload[12u + index] != static_cast(text[index])) + { + return false; + } + } + return true; +} + +static_assert(kReplacementDelayMs > 0); +static_assert(kReplacementDelayMs < kDisplayTimeMs); +static_assert(payloadMatches(kFirstPayload, 5, kDisplayTimeMs, kFirstText)); +static_assert(payloadMatches( + kReplacementPayload, 3, kDisplayTimeMs, kReplacementText)); + +class Rpc73GameTextFixture; + +class ReplacementTimer final : public TimerTimeOutHandler +{ +public: + ReplacementTimer( + Rpc73GameTextFixture& owner, + int playerID, + uint32_t generation) + : owner_(owner) + , playerID_(playerID) + , generation_(generation) + { + } + + void timeout(ITimer& timer) override; + + void free(ITimer&) override + { + delete this; + } + +private: + Rpc73GameTextFixture& owner_; + int playerID_; + uint32_t generation_; +}; + +class Rpc73GameTextFixture final + : public IComponent + , public PlayerTextEventHandler + , public PlayerConnectEventHandler +{ +public: + PROVIDE_UID(0x5250433733524550); + + ~Rpc73GameTextFixture() override + { + cancelAll(); + detach(); + } + + StringView componentName() const override + { + return "RPC73 GameText replacement fixture"; + } + + SemanticVersion componentVersion() const override + { + return SemanticVersion(0, 1, 0, 0); + } + + void onLoad(ICore* core) override + { + core_ = core; + if (core_ == nullptr) + { + return; + } + + core_->getPlayers().getPlayerTextDispatcher().addEventHandler(this); + core_->getPlayers().getPlayerConnectDispatcher().addEventHandler(this); + attached_ = true; + core_->printLn( + "[rpc73_gametext_fixture] loaded command=/rpc73replace rpc=73 " + "delay_ms=%d dispatchEvents=0 channel=SyncRPC", + kReplacementDelayMs); + } + + void onInit(IComponentList* components) override + { + timers_ = components == nullptr + ? nullptr + : components->queryComponent(); + if (core_ != nullptr) + { + core_->printLn( + "[rpc73_gametext_fixture] timers_component=%s", + timers_ == nullptr ? "missing" : "ready"); + } + } + + void onFree(IComponent* component) override + { + if (component == timers_) + { + cancelAll(); + timers_ = nullptr; + } + } + + void free() override + { + delete this; + } + + void reset() override + { + cancelAll(); + fired_.fill(false); + } + + /// Equivalent callback contract: + /// https://open.mp/docs/scripting/callbacks/OnPlayerDisconnect + void onPlayerDisconnect(IPlayer& player, PeerDisconnectReason) override + { + const int playerID = player.getID(); + if (!validPlayerID(playerID)) + { + return; + } + cancelPending(playerID); + fired_[static_cast(playerID)] = false; + } + + /// Equivalent callback contract: + /// https://open.mp/docs/scripting/callbacks/OnPlayerCommandText + bool onPlayerCommandText(IPlayer& player, StringView message) override + { + if (message != kCommand) + { + return false; + } + + const int playerID = player.getID(); + if (!validPlayerID(playerID)) + { + reject(player, "RPC73 fixture rejected an invalid player ID"); + return true; + } + + const char* failure = eligibilityFailure(player); + if (failure != nullptr) + { + reject(player, failure); + return true; + } + if (timers_ == nullptr) + { + reject(player, "RPC73 fixture requires the Timers component"); + return true; + } + + const std::size_t index = static_cast(playerID); + if (fired_[index] || pending_[index] != nullptr) + { + reject( + player, + "RPC73 fixture is one-shot per connection; reconnect to rerun"); + return true; + } + + const uint32_t generation = bumpGeneration(index); + ReplacementTimer* handler = + new (std::nothrow) ReplacementTimer(*this, playerID, generation); + if (handler == nullptr) + { + reject(player, "RPC73 fixture could not allocate its delay handler"); + return true; + } + + ITimer* timer = nullptr; + try + { + timer = timers_->create( + handler, Milliseconds(kReplacementDelayMs), false); + } + catch (...) + { + // Do not permit allocation failures inside the timer component to + // escape this component callback boundary. + timer = nullptr; + } + if (timer == nullptr) + { + delete handler; + reject(player, "RPC73 fixture could not create its delay timer"); + return true; + } + + pending_[index] = timer; + fired_[index] = true; + + const bool sent = sendPayload( + player, + "first", + 5, + kFirstText, + kFirstPayload, + kFirstPayloadHex); + if (!sent) + { + timer->kill(); + pending_[index] = nullptr; + reject( + player, + "RPC73 first payload transport failed; inspect server log"); + return true; + } + + if (core_ != nullptr) + { + core_->printLn( + "[rpc73_gametext_fixture] scheduled player=%d " + "replacement_delay_ms=%d generation=%u", + playerID, + kReplacementDelayMs, + static_cast(generation)); + } + player.sendClientMessage( + Colour::White(), + "RPC73 style 5 sent; fixed style 3 replacement scheduled."); + return true; + } + +private: + friend class ReplacementTimer; + + static bool validPlayerID(int playerID) + { + return playerID >= 0 && playerID < PLAYER_POOL_SIZE; + } + + static const char* eligibilityFailure(const IPlayer& player) + { + if (player.isBot()) + { + return "RPC73 fixture does not accept bots"; + } + if (player.getClientVersion() != ClientVersion::ClientVersion_SAMP_037) + { + return "RPC73 fixture requires the SA-MP 0.3.7 protocol"; + } + if (player.getState() == PlayerState_None) + { + return "RPC73 fixture requires an initialized player"; + } + const PeerNetworkData& networkData = player.getNetworkData(); + if (networkData.network == nullptr + || networkData.network->getNetworkType() != ENetworkType_RakNetLegacy) + { + return "RPC73 fixture requires the legacy RakNet transport"; + } + return nullptr; + } + + template + bool sendPayload( + IPlayer& player, + const char* phase, + int style, + const char* text, + const std::array& fixedPayload, + const char* payloadHex) + { + // OPENMP_REF: + // SDK/include/network.hpp defines Span::size as a count of bits here. + // A local copy keeps the non-owning transport buffer mutable and alive + // for the complete synchronous sendRPC call. + std::array payload = fixedPayload; + const bool sent = player.sendRPC( + kRpcDisplayGameText, + Span(payload.data(), payload.size() * 8u), + OrderingChannel_SyncRPC, + false); + + if (core_ != nullptr) + { + core_->printLn( + "[rpc73_gametext_fixture] phase=%s player=%d rpc=73 " + "style=%d time_ms=%d text_len=%u payload_bits=%u " + "payload=%s text=%s dispatchEvents=0 channel=%d sent=%d", + phase, + player.getID(), + style, + static_cast(kDisplayTimeMs), + static_cast(PayloadSize - 12u), + static_cast(PayloadSize * 8u), + payloadHex, + text, + static_cast(OrderingChannel_SyncRPC), + sent ? 1 : 0); + } + return sent; + } + + void onReplacementTimer( + ITimer& timer, + int playerID, + uint32_t generation) + { + if (!validPlayerID(playerID)) + { + return; + } + const std::size_t index = static_cast(playerID); + if (pending_[index] != &timer || generations_[index] != generation) + { + if (core_ != nullptr) + { + core_->printLn( + "[rpc73_gametext_fixture] stale_timer player=%d " + "generation=%u current_generation=%u", + playerID, + static_cast(generation), + static_cast(generations_[index])); + } + return; + } + pending_[index] = nullptr; + + IPlayer* player = core_ == nullptr + ? nullptr + : core_->getPlayers().get(playerID); + if (player == nullptr) + { + logTimerAbort(playerID, generation, "player_missing"); + return; + } + const char* failure = eligibilityFailure(*player); + if (failure != nullptr) + { + logTimerAbort(playerID, generation, failure); + return; + } + + const bool sent = sendPayload( + *player, + "replacement", + 3, + kReplacementText, + kReplacementPayload, + kReplacementPayloadHex); + if (sent) + { + player->sendClientMessage( + Colour::White(), + "RPC73 fixed style 3 replacement sent."); + } + else + { + player->sendClientMessage( + Colour::White(), + "RPC73 replacement transport failed; inspect server log."); + } + } + + void logTimerAbort( + int playerID, + uint32_t generation, + const char* reason) + { + if (core_ != nullptr) + { + core_->printLn( + "[rpc73_gametext_fixture] timer_abort player=%d " + "generation=%u reason=%s", + playerID, + static_cast(generation), + reason); + } + } + + uint32_t bumpGeneration(std::size_t index) + { + ++generations_[index]; + if (generations_[index] == 0u) + { + ++generations_[index]; + } + return generations_[index]; + } + + void cancelPending(int playerID) + { + const std::size_t index = static_cast(playerID); + ITimer* timer = pending_[index]; + if (timer != nullptr) + { + if (timers_ != nullptr) + { + timer->kill(); + } + pending_[index] = nullptr; + } + (void)bumpGeneration(index); + } + + void cancelAll() + { + for (int playerID = 0; playerID < PLAYER_POOL_SIZE; ++playerID) + { + cancelPending(playerID); + } + } + + void reject(IPlayer& player, const char* reason) + { + if (core_ != nullptr) + { + core_->printLn( + "[rpc73_gametext_fixture] reject player=%d reason=%s", + player.getID(), + reason); + } + player.sendClientMessage(Colour::White(), reason); + } + + void detach() + { + if (core_ != nullptr && attached_) + { + core_->getPlayers().getPlayerTextDispatcher().removeEventHandler(this); + core_->getPlayers().getPlayerConnectDispatcher().removeEventHandler(this); + } + attached_ = false; + core_ = nullptr; + } + + ICore* core_ = nullptr; + ITimersComponent* timers_ = nullptr; + std::array fired_ {}; + std::array pending_ {}; + std::array generations_ {}; + bool attached_ = false; +}; + +void ReplacementTimer::timeout(ITimer& timer) +{ + owner_.onReplacementTimer(timer, playerID_, generation_); +} +} // namespace + +COMPONENT_ENTRY_POINT() +{ + return new (std::nothrow) Rpc73GameTextFixture(); +} diff --git a/tools/reloop/README.md b/tools/reloop/README.md index 8462215..41ca7fd 100644 --- a/tools/reloop/README.md +++ b/tools/reloop/README.md @@ -58,7 +58,8 @@ post-reset render count or streamed companion ped. A missing `filterscripts/sync_pair.pwn` assigns the fixed test nicknames `SyncPilot` and `SyncObserver`. Once both are spawned, the pilot-side control ASI can drive the -on-foot firearm, car, and Rustler-driver scenarios: +on-foot firearm, car, Rustler-driver, passenger, driverless-unoccupied and +articulated-trailer scenarios: ```bash python3 tools/reloop/sync_pair_client.py all \ @@ -79,6 +80,271 @@ python3 tools/reloop/sync_pair_client.py rustler \ Each screenshot is taken before the pilot key is released. Labels receive a two-digit suffix when the count is greater than one. +The three edge-state cases can also be run as focused probes: + +```bash +python3 tools/reloop/sync_pair_client.py passenger \ + --output artifacts/runs//passenger-pilot-states.json +python3 tools/reloop/sync_pair_client.py unoccupied \ + --output artifacts/runs//unoccupied-pilot-states.json +python3 tools/reloop/sync_pair_client.py trailer \ + --output artifacts/runs//trailer-pilot-states.json +``` + +`passenger` places the pilot in seat 1. `unoccupied` adds deterministic +server velocity to the same driverless setup so the first-player-passenger +authority path emits Packet 209. `trailer` attaches trailer model 435 to +tractor model 515 and holds forward input on the pilot, exercising Packet 210 +after both vehicles have been confirmed streamed to both clients. + +`passenger_g` is the separate client-originated entry case. It leaves a new +two-seat vehicle empty, places the on-foot pilot 2.4 units from its passenger +side, waits for that vehicle to be streamed to both clients, and then presses +VK_G for 150 ms: + +```bash +python3 tools/reloop/sync_pair_client.py passenger_g \ + --output artifacts/runs//passenger-g-pilot-states.json +``` + +It is also selectable explicitly in the coordinated two-prefix runner: + +```bash +python3 tools/reloop/sync_edge_probe.py run --scenario passenger_g \ + --server-mode replace --client-mode replace +``` + +The server writes a request-scoped `PASSENGER_ENTER_REQUEST` from +`OnPlayerEnterVehicle` (the semantic legacy RPC 26 receipt) and does not pass +the driver until `PASSENGER_ENTRY_RESULT` confirms the expected vehicle, seat +1, and `PLAYER_STATE_PASSENGER`. `PILOT_SYNC state=3` and observer Packet 211 +application are separate verdict checks. The established `all` scenario list +is intentionally unchanged; request `passenger_g` explicitly. + +For the one-host-prefix plus native-Windows-observer topology: + +```bash +python3 tools/reloop/windows_sync_edge_probe.py --scenario passenger_g +``` + +The default remains host Original R5 as `SyncPilot` and native Windows as +`SyncObserver`. The Windows screenshot burst starts after the server has +verified the physical seat. + +Use the explicit role swap to test local G handling in the Windows replacement: + +```bash +python3 tools/reloop/windows_sync_edge_probe.py \ + --scenario passenger_g \ + --windows-role pilot \ + --deploy-windows-dll build-win32/samp.dll +``` + +This mode is rejected for every other scenario, including `all`. Windows +starts as `SyncPilot`; exactly one host prefix starts Original R5 as +`SyncObserver`. The host observer does not use the `reloop_control` API during +launch, setup, or the passenger action. After request-scoped `EDGE_SETUP`, the +runner invokes only the fixed `samp_lab.sh key PASSENGER` action, which holds +VK_G across multiple DirectInput frames, and then waits for +`PASSENGER_ENTRY_RESULT`. + +Omit `--deploy-windows-dll` only when the intended replacement is already +installed on Windows. The fetched manifest must identify a non-original DLL; +an Original-R5 Windows pilot fails this replacement-probe verdict. The hard +checks are semantic RPC 26 receipt, the expected vehicle, seat 1, state 3, +both-client crash markers, candidate/installed hashes, and unchanged host +hashes. Because the observer is Original R5, its internal Packet 211 apply path +is unavailable; a successful swapped run is therefore reported as +`SERVER_TRACE_PASS_OBSERVER_INTERNALS_UNAVAILABLE`, with visual parity still +`TODO_VERIFY`. The Windows input action captures the pilot. After the verified +result the runner separately captures the host Original-observer desktop under +`observer/screenshots/passenger_g-after-entry.png`; failure to produce that +file fails the swapped run. Capture first tries the compositor and an input-free +X11 window grab. Only after those fail does the runner open the host control +endpoint and ask Original R5 to consume its strictly identity- and +byte-guarded screenshot-request flag; an F8 edge remains the compatibility +fallback for an older control ASI. This happens after the server has verified +the seat and therefore cannot contribute to the earlier Windows G action. Its +log slices are retained under `observer/client`. + +Neither server callbacks nor screenshots are a raw wire capture, so retain +client net traces when the exact RPC 26 serialization itself is the question. + +## Distributed Original-pilot sync runner + +`distributed_sync_runner.py` reuses `sync_pair_client.py` for a conservative +one-host/one-Windows topology: + +```text +local Original R5 + reloop_control = SyncPilot +native Windows = SyncObserver +``` + +The focused scenario set is `pistol`, `m4`, `sniper`, `angles`, `jetpack`, +`death`, and `pickup`; `all` runs exactly that order. Trailer is deliberately +outside this runner. GMX is a separate, explicit Windows-pilot scenario and is +not included in `all`. `ui_latches` is likewise an explicit Windows-pilot-only +scenario and is not included in `all`. + +```bash +python3 tools/reloop/distributed_sync_runner.py --scenario all + +python3 tools/reloop/distributed_sync_runner.py \ + --scenario angles \ + --screenshot-count 40 \ + --screenshot-interval 0.05 +``` + +`angles` does not use absolute desktop cursor positions. Those positions were +observed to change while the sampled GTA `CCamera::InternalAim` camera +position stayed unchanged in the paired 20260728 Original-observer and +Replacement-observer runs. The current control ASI injects bounded relative +mouse pulses, and the driver stops on measured `CCamera::InternalAim` headings +approximately 35 degrees left and right of the post-setup baseline. If the +front vector is unavailable or does not move, the scenario fails before +labeling the screenshots as an angle comparison. The recorded states also +retain the local ped's matrix-forward heading, so camera-direction and +ped-direction differences remain separable. + +By default the runner does not deploy a Windows DLL or ASI and does not change +probe-profile flags. Any pre-existing unmanaged probe flag is a preflight +failure, so the run cannot silently inherit an old invasive profile. Mutations +require their corresponding explicit options: + +```bash +python3 tools/reloop/distributed_sync_runner.py \ + --scenario pistol \ + --deploy-windows-dll build-win32/samp.dll + +python3 tools/reloop/distributed_sync_runner.py \ + --scenario jetpack \ + --deploy-windows-probe build-asi-probe/samp_probe.asi \ + --windows-probe-profile aim-bullet-jetpack + +python3 tools/reloop/distributed_sync_runner.py \ + --scenario death \ + --windows-probe-profile death-cleanup + +python3 tools/reloop/distributed_sync_runner.py \ + --scenario death \ + --windows-role pilot \ + --death-f4 \ + --windows-probe-profile death-cleanup + +python3 tools/reloop/distributed_sync_runner.py \ + --scenario gmx \ + --windows-role pilot \ + --windows-probe-profile death-cleanup \ + --screenshot-count 120 \ + --screenshot-interval 0.05 + +python3 tools/reloop/distributed_sync_runner.py \ + --scenario ui_latches \ + --windows-role pilot \ + --windows-probe-profile ui-latches-r5 +``` + +The death, GMX, and UI-latch forms are the supported reversed-role runs: +native Windows starts as `SyncPilot`, while the sole local Original-R5 prefix +starts as `SyncObserver` without using the control API. The death driver begins +a Windows screenshot burst, queues the existing request-file `death` scenario, +and waits for the request-scoped `PLAYER_DEATH` server event. The optional +`--death-f4` mode first sends the existing allowlisted Windows-lab `CLASS` +action, records its start/completion timestamps and a 0.75-second settle edge, +then captures and queues death normally. It is rejected unless both +`--scenario death` and `--windows-role pilot` are active. Forced respawn +remains outside this driver. + +The GMX driver starts open.mp with the same stdin-owning process wrapper already +used by `lifecycle_probe.py`, captures Windows before and through the +transition, sends the existing `gmx` server-console command, waits for the new +gamemode banner and `SyncPilot` rejoin, then captures an explicit post-restart +frame. It rejects `--server-mode reuse`, because a reused server would give the +runner no verified console channel. After collection, the verdict requires the +current Windows log slice to contain `InitGame -> GameModeRestart (RPC 40) -> +InitGame` for replacement runs without the original probe. Original R5 does +not emit those replacement `rpc-in` lines. With +`--windows-probe-profile death-cleanup`, the verdict instead requires the +server restart, `SyncPilot` rejoin, `death_cleanup_r5 ... kind=gmx_reset`, and +the documented Original R5 DLL hash. The profile flag is restored to `passive` +during teardown. No Pawn command, gamemode API, or runtime bridge is added for +this path. + +The `ui_latches` driver requires the documented Original R5 DLL and the +run-scoped `ui-latches-r5` profile. It performs exactly one bounded TAB hold, +two F6 edges, and three F7 edges. The verdict requires the nine-hook install +summary, scoreboard show/hide, chat open/close, at least three chat-mode +records, and no ring overflow. The action receipts provide screenshots, but +the verdict remains trace-only and visual parity stays `TODO_VERIFY`. ESC, +pause-menu, focus-loss, and arbitrary key automation are deliberately absent. + +An explicitly selected probe profile is run-scoped: after Windows has stopped, +the runner restores the managed `passive` profile and verifies the resulting +empty flag set. Explicit DLL/ASI deployments use the remote lab's normal +hash-verified deployment and backup workflow; they are not silently rolled +back. + +Preflight requires the documented local R5 DLL and requires the local control +ASI only while the local client is the pilot. It also requires +`[game] autoPause = 0` in both local and Windows +`III.VC.SA.WindowedMode.ini`, an idle Windows lab, no active local replacement +prefix, and (for a local pilot) an idle reloop-control port. It records local +and Windows DLL/ASI hashes, verifies explicit candidate hashes again from the +Windows run manifest, and checks that local hashes remain unchanged. + +Every run creates an artifact beneath `artifacts/runs/` containing early/final +metadata, server and request-result slices, local pilot logs, per-scenario +driver state, Windows control receipts, the fetched Windows manifest and +current-run log slices, screenshot bursts, and the conservative verdict. +Cleanup verifies the Windows process inventory, removes only a pending +request ID proven to belong to the current driver artifact, stops only newly +created local-prefix processes, and stops the server only when the runner +started it. + +The strongest successful verdict is +`TRACE_CAPTURED_VISUAL_UNVERIFIED`. Screenshots are retained evidence only; +the runner always writes `visual_parity=TODO_VERIFY` and never converts server +callbacks, logs, or image existence into a visual-parity claim. + +## Original R5 death/cleanup artifact analyzer + +Reduce an existing `death_cleanup_*` probe log without starting GTA, Wine, or +the Windows lab: + +```bash +python3 tools/reloop/analyze_death_cleanup.py \ + artifacts/runs/ +python3 tools/reloop/analyze_death_cleanup.py path/to/samp_probe.log \ + --output artifacts/runs//death-cleanup-analysis.json +``` + +The JSON reports GMX, connection-loss and quit-destructor pre/post pool +counts, UI latches, and `RemoveBuilding` counts. It also checks the statically +expected nested connection-loss to GMX publication order. Event pairing uses +only event kind plus event/ring sequence; pointers and ticks are excluded. +Missing target events or snapshots remain `TODO_VERIFY` and are never promoted +to `PASS`. + +Run all three cases with coordinated prefix launch, log snapshots, physical +readback checks, and a conservative verdict: + +```bash +python3 tools/reloop/sync_edge_probe.py run \ + --server-mode replace --client-mode replace + +python3 tools/reloop/sync_edge_probe.py analyze \ + artifacts/runs/ +``` + +The runner launches and API-verifies the original pilot before starting the +replacement observer. Both prefixes must explicitly set `[game] autoPause=0`. +No ASI is installed or renamed; top-level ASI hashes are compared before and +after the run. `EDGE_SETUP` verifies the actual player vehicle, seat/state and +trailer relationship rather than merely confirming that the setup natives +returned. A successful automated result is deliberately named +`TRACE_PASS_VISUAL_UNVERIFIED`: packet decode, runtime application and GTA +readback are trace evidence, not a claim of pixel-identical motion. + The observer receives a fixed server camera for comparable screenshots. Server markers record role spawn/streaming, player state, sampled input keys and movement, and ordinary firearm bullet callbacks. Rustler driver weapons do not @@ -136,12 +402,12 @@ See `docs/traces/network_adversity_headless_20260727.md` for the isolated server-full fixture, the distinction between headless transport evidence and original/replacement GUI evidence, and a seeded impairment result. -Crash verdicts use a three-attempt policy by default. A single -`PRECONNECT_CRASH` or `RUNTIME_CRASH` is retried automatically with the same -scenario; only three consecutive crash attempts are treated as reproducible. -Every attempt keeps its own artifact and the final attempt contains -`retry-series.json`. `--crash-attempts N` may raise the limit but never lowers -it below three. +Client crash and streaming-hang verdicts use a three-attempt policy by default. +A single `PRECONNECT_CRASH`, `RUNTIME_CRASH`, `HANG_PRECONNECT_STREAMING`, or +`HANG_SPAWN_STREAMING` is retried automatically with the same scenario; only +three consecutive affected attempts are treated as reproducible. Every attempt +keeps its own artifact and the final attempt contains `retry-series.json`. +`--crash-attempts N` may raise the limit but never lowers it below three. The default profiles are: @@ -203,6 +469,11 @@ Interactive `/tbatch` and `/testcmds ...` use remain available and have - `INFRA_FAIL`: launch/connect/spawn did not reach a test marker. - `PRECONNECT_CRASH`: the client exited before `RUN_START`. - `RUNTIME_CRASH`: the client exited or logged an exception after start. +- `HANG_PRECONNECT_STREAMING`: timeout with no exception; the last + `scene_prepare_step` is an open pre-connect call and no Join/NetGame marker + was observed. +- `HANG_SPAWN_STREAMING`: timeout with no exception; Join/NetGame was observed + and the last `scene_prepare_step` is an open class/spawn call. - `PROTOCOL_MISMATCH`: a started run aborted or timed out. - `STATE_MISMATCH`: `test_cmds` produced a failing result. - `OPCODE_PARITY_UNVERIFIED`: state signatures match, but the selected diff --git a/tools/reloop/analyze_death_cleanup.py b/tools/reloop/analyze_death_cleanup.py new file mode 100644 index 0000000..ef967db --- /dev/null +++ b/tools/reloop/analyze_death_cleanup.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +"""Summarize artifact-only R5 death_cleanup_* cleanup evidence.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Sequence + + +OBSERVED = "OBSERVED" +TODO_VERIFY = "TODO_VERIFY" +MISMATCH = "MISMATCH" +UINT32_MAX = 0xFFFFFFFF + +TARGET_KINDS = ("gmx_reset", "connection_lost", "quit_destructor") + +EVENT_RE = re.compile( + r"death_cleanup_r5: seq=(?P\d+) event=(?P\d+) " + r"tick=\d+ thread=\d+ frame=\d+ kind=(?P[a-z_]+) " + r"caller_rva=(?P0x[0-9a-fA-F]+) " + r"hook_rva=(?P0x[0-9a-fA-F]+) " + r"object=0x[0-9a-fA-F]+ argument=0x[0-9a-fA-F]+ " + r"result=(?P0x[0-9a-fA-F]+) " + r"cleanup=(?P\d+)" +) + +UI_RE = re.compile( + r"death_cleanup_ui_r5: seq=(?P\d+) event=(?P\d+) " + r"phase=(?Ppre|post) " + r"scoreboard=0x[0-9a-fA-F]+ visible=(?P\d+) " + r"dialog=0x[0-9a-fA-F]+ active=(?P\d+) " + r"selector=0x[0-9a-fA-F]+ active=(?P\d+) " + r"chat=0x[0-9a-fA-F]+ active=(?P\d+) " + r"class_gui=0x[0-9a-fA-F]+ visible=(?P\d+) " + r"game=0x[0-9a-fA-F]+ " + r"input_depth=(?P\d+),(?P\d+) " + r"camera=(?P\d+),(?P\d+) " + r"frontend=(?P\d+),(?P\d+)," + r"(?P\d+)" +) + +POOLS_RE = re.compile( + r"death_cleanup_pools_r5: seq=(?P\d+) event=(?P\d+) " + r"phase=(?Ppre|post) " + r"valid=0x[0-9a-fA-F]+ netgame=0x[0-9a-fA-F]+ " + r"pools=0x[0-9a-fA-F]+ pool_ptrs=\S+ " + r"vehicle=(?P\d+)/(?P\d+) " + r"remote=(?P\d+)/(?P\d+) " + r"pickup_raw=(?P\d+)/(?P\d+)/" + r"(?P\d+) " + r"object=(?P\d+)/(?P\d+) " + r"actor=(?P\d+)/(?P\d+) " + r"gangzone=(?P\d+) " + r"textdraw=(?P\d+) " + r"label=(?P\d+) menu=(?P\d+) " + r"current=(?P\d+) " + r"remove_building_count=(?P\d+)" +) + +POOL_FIELDS = ( + "vehicle_listed", + "vehicle_wrappers", + "remote_aux", + "remote_wrappers", + "pickup_handles", + "pickup_server_ids", + "pickup_timers", + "object_listed", + "object_wrappers", + "actor_listed", + "actor_wrappers", + "gangzone_listed", + "textdraw_listed", + "label_listed", + "menu_listed", + "menu_current", +) + + +@dataclass +class CleanupEvent: + ring_seq: int + event_seq: int + kind: str + caller_rva: str + hook_rva: str + result: str + cleanup_valid: bool + ui: dict[str, dict[str, Any]] = field(default_factory=dict) + pools: dict[str, dict[str, Any]] = field(default_factory=dict) + + +@dataclass +class ParsedTrace: + records: list[CleanupEvent] + hook_installed: int | None + hook_requested: int | None + overflow_skipped: int + restore_restored: int | None + restore_requested: int | None + parse_errors: list[str] + orphan_details: int + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="replace") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def resolve_probe_log(path: Path) -> Path: + """Resolve one focused probe log without selecting by timestamp or pointer.""" + if path.is_file(): + return path + if not path.is_dir(): + raise FileNotFoundError(path) + + candidates: set[Path] = set() + for pattern in ( + "client/samp_probe.log", + "logs/samp_probe.log", + "pilot/client/samp_probe.log", + "observer/client/samp_probe.log", + "windows/*/logs/samp_probe.log", + ): + candidates.update(candidate for candidate in path.glob(pattern) if candidate.is_file()) + + focused = [ + candidate + for candidate in sorted(candidates) + if "death_cleanup_" in _read(candidate) + ] + if len(focused) == 1: + return focused[0] + if not focused and len(candidates) == 1: + return next(iter(candidates)) + if not candidates: + raise FileNotFoundError(f"no samp_probe.log beneath {path}") + raise ValueError( + "probe log selection is ambiguous; pass one samp_probe.log directly: " + + ", ".join(str(candidate) for candidate in focused or sorted(candidates)) + ) + + +def _ui_snapshot(match: re.Match[str]) -> dict[str, Any]: + return { + "scoreboard_visible": int(match["scoreboard"]), + "dialog_active": int(match["dialog"]), + "textdraw_selector_active": int(match["selector"]), + "chat_active": int(match["chat"]), + "class_gui_visible": int(match["class_gui"]), + "input_depth": [int(match["input_a"]), int(match["input_b"])], + "camera": [int(match["camera_a"]), int(match["camera_b"])], + "frontend": [ + int(match["frontend_a"]), + int(match["frontend_b"]), + int(match["frontend_c"]), + ], + } + + +def _pool_snapshot(match: re.Match[str]) -> dict[str, Any]: + snapshot: dict[str, Any] = {} + unreadable: list[str] = [] + for name in POOL_FIELDS: + value = int(match[name]) + if value == UINT32_MAX: + snapshot[name] = None + unreadable.append(name) + else: + snapshot[name] = value + snapshot["remove_building_count"] = int(match["remove_building_count"]) + snapshot["unreadable"] = unreadable + return snapshot + + +def parse_trace(text: str) -> ParsedTrace: + records_by_seq: dict[int, CleanupEvent] = {} + details: list[tuple[str, int, int, str, dict[str, Any], int]] = [] + parse_errors: list[str] = [] + + for line_number, line in enumerate(text.splitlines(), 1): + event_match = EVENT_RE.search(line) + if event_match: + ring_seq = int(event_match["seq"]) + if ring_seq in records_by_seq: + parse_errors.append(f"line {line_number}: duplicate seq={ring_seq}") + continue + records_by_seq[ring_seq] = CleanupEvent( + ring_seq=ring_seq, + event_seq=int(event_match["event"]), + kind=event_match["kind"], + caller_rva=event_match["caller"].lower(), + hook_rva=event_match["hook"].lower(), + result=event_match["result"].lower(), + cleanup_valid=event_match["cleanup"] == "1", + ) + continue + + ui_match = UI_RE.search(line) + if ui_match: + details.append( + ( + "ui", + int(ui_match["seq"]), + int(ui_match["event"]), + ui_match["phase"], + _ui_snapshot(ui_match), + line_number, + ) + ) + continue + + pool_match = POOLS_RE.search(line) + if pool_match: + details.append( + ( + "pools", + int(pool_match["seq"]), + int(pool_match["event"]), + pool_match["phase"], + _pool_snapshot(pool_match), + line_number, + ) + ) + + orphan_details = 0 + for detail_kind, ring_seq, event_seq, phase, snapshot, line_number in details: + record = records_by_seq.get(ring_seq) + if record is None: + orphan_details += 1 + continue + if record.event_seq != event_seq: + parse_errors.append( + f"line {line_number}: seq={ring_seq} event mismatch " + f"{event_seq}!={record.event_seq}" + ) + continue + target = record.ui if detail_kind == "ui" else record.pools + if phase in target: + parse_errors.append( + f"line {line_number}: duplicate {detail_kind} seq={ring_seq} phase={phase}" + ) + continue + target[phase] = snapshot + + hook_matches = re.findall( + r"death_cleanup_hook: summary installed=(\d+) requested=(\d+)", text + ) + restore_matches = re.findall( + r"death_cleanup_hook: restore restored=(\d+) requested=(\d+)", text + ) + overflow_skipped = sum( + int(value) + for value in re.findall(r"death_cleanup_r5: overflow skipped=(\d+)", text) + ) + hook_installed, hook_requested = ( + (int(hook_matches[-1][0]), int(hook_matches[-1][1])) + if hook_matches + else (None, None) + ) + restore_restored, restore_requested = ( + (int(restore_matches[-1][0]), int(restore_matches[-1][1])) + if restore_matches + else (None, None) + ) + + return ParsedTrace( + records=sorted(records_by_seq.values(), key=lambda record: record.ring_seq), + hook_installed=hook_installed, + hook_requested=hook_requested, + overflow_skipped=overflow_skipped, + restore_restored=restore_restored, + restore_requested=restore_requested, + parse_errors=parse_errors, + orphan_details=orphan_details, + ) + + +def _delta(pre: dict[str, Any], post: dict[str, Any]) -> dict[str, int | None]: + return { + name: ( + post[name] - pre[name] + if isinstance(pre.get(name), int) and isinstance(post.get(name), int) + else None + ) + for name in POOL_FIELDS + } + + +def _changes(pre: dict[str, Any], post: dict[str, Any]) -> dict[str, Any]: + return { + name: {"pre": pre[name], "post": post[name]} + for name in pre.keys() & post.keys() + if pre[name] != post[name] + } + + +def _observation(record: CleanupEvent) -> dict[str, Any]: + missing = [ + f"{kind}.{phase}" + for kind, snapshots in (("ui", record.ui), ("pools", record.pools)) + for phase in ("pre", "post") + if phase not in snapshots + ] + observation: dict[str, Any] = { + "status": OBSERVED if not missing and record.cleanup_valid else TODO_VERIFY, + "ring_seq": record.ring_seq, + "event_seq": record.event_seq, + "caller_rva": record.caller_rva, + "hook_rva": record.hook_rva, + "result": record.result, + "cleanup_valid": record.cleanup_valid, + "missing": missing, + } + if "pre" in record.ui and "post" in record.ui: + observation["ui"] = { + "pre": record.ui["pre"], + "post": record.ui["post"], + "changes": _changes(record.ui["pre"], record.ui["post"]), + } + if "pre" in record.pools and "post" in record.pools: + pre = record.pools["pre"] + post = record.pools["post"] + remove_pre = pre["remove_building_count"] + remove_post = post["remove_building_count"] + observation["pools"] = { + "pre": {name: pre[name] for name in POOL_FIELDS}, + "post": {name: post[name] for name in POOL_FIELDS}, + "delta": _delta(pre, post), + "unreadable": { + "pre": pre["unreadable"], + "post": post["unreadable"], + }, + } + observation["remove_building"] = { + "pre": remove_pre, + "post": remove_post, + "delta": remove_post - remove_pre, + "assessment": "PERSISTED" if remove_pre == remove_post else "CHANGED", + } + return observation + + +def _scenario(records: list[CleanupEvent], kind: str) -> dict[str, Any]: + matching = [record for record in records if record.kind == kind] + observations = [_observation(record) for record in matching] + return { + "status": ( + TODO_VERIFY + if not observations or any(item["status"] != OBSERVED for item in observations) + else OBSERVED + ), + "event_count": len(observations), + "observations": observations, + } + + +def _nested_connection_gmx(records: list[CleanupEvent]) -> dict[str, Any]: + connections = [record for record in records if record.kind == "connection_lost"] + gmx_events = [record for record in records if record.kind == "gmx_reset"] + if not connections or not gmx_events: + return { + "status": TODO_VERIFY, + "reason": "connection_lost or gmx_reset event missing", + "pairs": [], + } + + pairs: list[dict[str, int]] = [] + unpaired: list[int] = [] + used_gmx_ring_seqs: set[int] = set() + for connection in connections: + candidates = [ + gmx + for gmx in gmx_events + if gmx.ring_seq not in used_gmx_ring_seqs + if gmx.event_seq > connection.event_seq + and gmx.ring_seq < connection.ring_seq + ] + if not candidates: + unpaired.append(connection.event_seq) + continue + nested = min(candidates, key=lambda item: item.event_seq) + used_gmx_ring_seqs.add(nested.ring_seq) + pairs.append( + { + "connection_event_seq": connection.event_seq, + "connection_ring_seq": connection.ring_seq, + "gmx_event_seq": nested.event_seq, + "gmx_ring_seq": nested.ring_seq, + "event_seq_delta": nested.event_seq - connection.event_seq, + "publish_seq_delta": connection.ring_seq - nested.ring_seq, + } + ) + + return { + "status": OBSERVED if len(pairs) == len(connections) else MISMATCH, + "reason": ( + "connection begins first by event_seq; nested GMX publishes first by ring_seq" + if len(pairs) == len(connections) + else "both event kinds exist but nested publish ordering was not observed" + ), + "pairs": pairs, + "unpaired_connection_events": unpaired, + } + + +def analyze_log(path: Path) -> dict[str, Any]: + log_path = resolve_probe_log(path) + trace = parse_trace(_read(log_path)) + scenarios = { + kind: _scenario(trace.records, kind) + for kind in TARGET_KINDS + } + nested = _nested_connection_gmx(trace.records) + + hook_status = ( + TODO_VERIFY + if trace.hook_installed is None + else ( + OBSERVED + if trace.hook_installed == trace.hook_requested == 6 + else MISMATCH + ) + ) + restore_status = ( + TODO_VERIFY + if trace.restore_restored is None + else ( + OBSERVED + if trace.restore_restored == trace.restore_requested == 6 + else MISMATCH + ) + ) + integrity_status = ( + MISMATCH + if trace.parse_errors + or trace.overflow_skipped + or hook_status == MISMATCH + or restore_status == MISMATCH + else TODO_VERIFY + if hook_status == TODO_VERIFY + else OBSERVED + ) + + target_statuses = [scenario["status"] for scenario in scenarios.values()] + if integrity_status == MISMATCH or nested["status"] == MISMATCH: + assessment = MISMATCH + elif all(status == TODO_VERIFY for status in target_statuses): + assessment = TODO_VERIFY + elif ( + all(status == OBSERVED for status in target_statuses) + and nested["status"] == OBSERVED + and integrity_status == OBSERVED + ): + assessment = "OBSERVED_COMPLETE" + else: + assessment = "OBSERVED_PARTIAL" + + return { + "schema": 1, + "source_log": str(log_path), + "source_sha256": _sha256(log_path), + "assessment": assessment, + "evidence": ["PROBE_TRACE", "STATIC_037", "TODO_VERIFY"], + "identity_policy": { + "pointers": "excluded_from_output_and_event_pairing", + "ticks": "excluded_from_output_and_event_pairing", + "pairing": "kind+event_seq+ring_seq", + }, + "integrity": { + "status": integrity_status, + "hook_install": { + "status": hook_status, + "installed": trace.hook_installed, + "requested": trace.hook_requested, + }, + "overflow": { + "status": OBSERVED if trace.overflow_skipped == 0 else MISMATCH, + "skipped": trace.overflow_skipped, + }, + "hook_restore": { + "status": restore_status, + "restored": trace.restore_restored, + "requested": trace.restore_requested, + }, + "parse_errors": trace.parse_errors, + "orphan_details": trace.orphan_details, + }, + "record_counts": { + kind: sum(record.kind == kind for record in trace.records) + for kind in sorted({record.kind for record in trace.records}) + }, + "scenarios": scenarios, + "connection_lost_to_gmx_ordering": nested, + } + + +def _render(result: dict[str, Any], output: Path | None) -> None: + rendered = json.dumps(result, indent=2, sort_keys=True) + "\n" + if output is not None: + output.write_text(rendered, encoding="utf-8") + print(rendered, end="") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("artifact_or_log", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args(argv) + try: + result = analyze_log(args.artifact_or_log) + except (FileNotFoundError, OSError, ValueError) as error: + parser.error(str(error)) + _render(result, args.output) + return 1 if result["assessment"] == MISMATCH else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/reloop/analyze_gametext_r5.py b/tools/reloop/analyze_gametext_r5.py new file mode 100644 index 0000000..a590a9d --- /dev/null +++ b/tools/reloop/analyze_gametext_r5.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +"""Validate the focused raw-RPC73 GameText replacement sequence. + +This analyzer is intentionally strict and artifact-only. It proves that the +closed server fixture sent both raw RPC 73 payloads, that the replacement +decoded the expected styles/texts, and that its runtime globally cleared the +style-5 GameText before installing style 3. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path +from typing import Any, Sequence + + +FIRST_TEXT = "RPC73_STYLE5_FIRST" +REPLACEMENT_TEXT = "RPC73_STYLE3_SECOND" +FIRST_PAYLOAD = ( + "05 00 00 00 88 13 00 00 12 00 00 00 " + "52 50 43 37 33 5f 53 54 59 4c 45 35 5f 46 49 52 53 54" +) +REPLACEMENT_PAYLOAD = ( + "03 00 00 00 88 13 00 00 13 00 00 00 " + "52 50 43 37 33 5f 53 54 59 4c 45 33 5f 53 45 43 4f 4e 44" +) + +TOKEN_RE = re.compile( + r"(?P[A-Za-z][A-Za-z0-9_]*)=" + r"(?P'[^']*'|[^\s]+)" +) +INTEGER_RE = re.compile(r"^[+-]?\d+$") +BYTE_SEQUENCE_RE = re.compile(r"^[0-9a-fA-F]{2}(?:\s+[0-9a-fA-F]{2})*$") + +FIRST_FIXTURE = { + "phase": "first", + "rpc": 73, + "style": 5, + "time_ms": 5000, + "text_len": 18, + "payload_bits": 240, + "payload": FIRST_PAYLOAD.replace(" ", ""), + "text": FIRST_TEXT, + "dispatchEvents": 0, + "channel": 2, + "sent": 1, +} +REPLACEMENT_FIXTURE = { + "phase": "replacement", + "rpc": 73, + "style": 3, + "time_ms": 5000, + "text_len": 19, + "payload_bits": 248, + "payload": REPLACEMENT_PAYLOAD.replace(" ", ""), + "text": REPLACEMENT_TEXT, + "dispatchEvents": 0, + "channel": 2, + "sent": 1, +} + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="replace") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _parse_value(raw: str) -> str | int: + if len(raw) >= 2 and raw[0] == raw[-1] == "'": + return raw[1:-1] + if INTEGER_RE.fullmatch(raw): + return int(raw) + return raw + + +def _tokens(line: str) -> dict[str, str | int]: + return { + match.group("key"): _parse_value(match.group("value")) + for match in TOKEN_RE.finditer(line) + } + + +def _records(text: str, marker: str) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for line_number, line in enumerate(text.splitlines(), 1): + if marker not in line: + continue + record: dict[str, Any] = {"line": line_number} + record.update(_tokens(line)) + records.append(record) + return records + + +def _exact_record( + records: list[dict[str, Any]], expected: dict[str, str | int] +) -> list[dict[str, Any]]: + return [ + record + for record in records + if all(record.get(key) == value for key, value in expected.items()) + ] + + +def _resolve_one(root: Path, candidates: Sequence[str], label: str) -> Path: + for relative in candidates: + candidate = root / relative + if candidate.is_file(): + return candidate + raise FileNotFoundError( + f"missing {label}; tried: " + + ", ".join(str(root / relative) for relative in candidates) + ) + + +def _relative(path: Path, root: Path) -> str: + try: + return str(path.relative_to(root)) + except ValueError: + return str(path) + + +def _raw_rpc73_records(text: str) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + marker = "rpc-in id=73 name=ScrDisplayGameText " + for line_number, line in enumerate(text.splitlines(), 1): + if marker not in line or " first=" not in line: + continue + prefix, raw_payload = line.split(" first=", 1) + payload = " ".join(raw_payload.strip().split()).lower() + record: dict[str, Any] = { + "line": line_number, + "payload": payload if BYTE_SEQUENCE_RE.fullmatch(payload) else None, + } + record.update(_tokens(prefix)) + records.append(record) + return records + + +def _runtime_lifecycle(text: str) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for line_number, line in enumerate(text.splitlines(), 1): + if "game_text: show " in line: + kind = "show" + elif "game_text: clear_all " in line: + kind = "clear_all" + else: + continue + record: dict[str, Any] = {"line": line_number, "kind": kind} + record.update(_tokens(line)) + records.append(record) + return records + + +def _exception_filter_occurrences(root: Path) -> list[dict[str, Any]]: + occurrences: list[dict[str, Any]] = [] + for path in sorted(root.rglob("*.log")): + if not path.is_file(): + continue + for line_number, line in enumerate(_read(path).splitlines(), 1): + if "exception_filter" in line.lower(): + occurrences.append( + { + "file": _relative(path, root), + "line": line_number, + } + ) + return occurrences + + +def analyze(run: Path) -> dict[str, Any]: + root = run.resolve() + if not root.is_dir(): + raise FileNotFoundError(root) + + server_path = _resolve_one( + root, ("server.console.log", "server.log"), "server log" + ) + net_path = _resolve_one( + root, + ( + "client/samp_net_trace.log", + "pilot/client/samp_net_trace.log", + "observer/client/samp_net_trace.log", + ), + "client samp_net_trace.log", + ) + runtime_path = _resolve_one( + root, + ( + "client/samp_runtime.log", + "pilot/client/samp_runtime.log", + "observer/client/samp_runtime.log", + ), + "client samp_runtime.log", + ) + + server = _read(server_path) + net = _read(net_path) + runtime = _read(runtime_path) + + fixture_records = _records(server, "[rpc73_gametext_fixture] phase=") + fixture_first = _exact_record(fixture_records, FIRST_FIXTURE) + fixture_replacement = _exact_record( + fixture_records, REPLACEMENT_FIXTURE + ) + + raw_records = _raw_rpc73_records(net) + raw_first = [ + record + for record in raw_records + if record.get("count") == 2 + and record.get("bits") == 240 + and record.get("bytes") == 30 + and record.get("payload") == FIRST_PAYLOAD + ] + raw_replacement = [ + record + for record in raw_records + if record.get("count") == 3 + and record.get("bits") == 248 + and record.get("bytes") == 31 + and record.get("payload") == REPLACEMENT_PAYLOAD + ] + + state_records = _records(net, "rpc-state id=73 game_text_seq=") + state_first = _exact_record( + state_records, + { + "id": 73, + "game_text_seq": 2, + "action": "show", + "style": 5, + "time": 5000, + "text": FIRST_TEXT, + }, + ) + state_replacement = _exact_record( + state_records, + { + "id": 73, + "game_text_seq": 3, + "action": "show", + "style": 3, + "time": 5000, + "text": REPLACEMENT_TEXT, + }, + ) + + lifecycle = _runtime_lifecycle(runtime) + runtime_first = _exact_record( + lifecycle, + { + "kind": "show", + "seq": 2, + "style": 5, + "time": 5000, + "text": FIRST_TEXT, + }, + ) + runtime_clear = _exact_record( + lifecycle, + { + "kind": "clear_all", + "seq": 3, + "cleared": 1, + "reason": "replace_before_show", + }, + ) + runtime_replacement = _exact_record( + lifecycle, + { + "kind": "show", + "seq": 3, + "style": 3, + "time": 5000, + "text": REPLACEMENT_TEXT, + }, + ) + + fixture_order = ( + len(fixture_first) == 1 + and len(fixture_replacement) == 1 + and fixture_first[0]["line"] < fixture_replacement[0]["line"] + ) + net_order = ( + len(raw_first) == 1 + and len(state_first) == 1 + and len(raw_replacement) == 1 + and len(state_replacement) == 1 + and raw_first[0]["line"] + < state_first[0]["line"] + < raw_replacement[0]["line"] + < state_replacement[0]["line"] + ) + runtime_order = ( + len(runtime_first) == 1 + and len(runtime_clear) == 1 + and len(runtime_replacement) == 1 + and runtime_first[0]["line"] + < runtime_clear[0]["line"] + < runtime_replacement[0]["line"] + ) + + exception_filter = _exception_filter_occurrences(root) + checks = { + "fixture_first_sent": len(fixture_first) == 1, + "fixture_replacement_sent": len(fixture_replacement) == 1, + "fixture_first_before_replacement": fixture_order, + "net_raw_rpc73_first": len(raw_first) == 1, + "net_rpc73_first_style_text": len(state_first) == 1, + "net_raw_rpc73_replacement": len(raw_replacement) == 1, + "net_rpc73_replacement_style_text": len(state_replacement) == 1, + "net_rpc73_sequence": net_order, + "runtime_show_seq2_style5": len(runtime_first) == 1, + "runtime_clear_seq3_cleared1": len(runtime_clear) == 1, + "runtime_show_seq3_style3": len(runtime_replacement) == 1, + "runtime_replacement_sequence": runtime_order, + "no_exception_filter": not exception_filter, + } + verdict = "PASS" if all(checks.values()) else "MISMATCH" + + def lines(records: list[dict[str, Any]]) -> list[int]: + return [int(record["line"]) for record in records] + + return { + "schema": 1, + "run": str(root), + "verdict": verdict, + "evidence": ["STATIC_037", "PROBE_TRACE"], + "checks": checks, + "sources": { + "server": { + "path": _relative(server_path, root), + "sha256": _sha256(server_path), + }, + "net": { + "path": _relative(net_path, root), + "sha256": _sha256(net_path), + }, + "runtime": { + "path": _relative(runtime_path, root), + "sha256": _sha256(runtime_path), + }, + }, + "observations": { + "fixture": { + "first_lines": lines(fixture_first), + "replacement_lines": lines(fixture_replacement), + }, + "net": { + "raw_first_lines": lines(raw_first), + "state_first_lines": lines(state_first), + "raw_replacement_lines": lines(raw_replacement), + "state_replacement_lines": lines(state_replacement), + }, + "runtime": { + "show_seq2_style5_lines": lines(runtime_first), + "clear_seq3_cleared1_lines": lines(runtime_clear), + "show_seq3_style3_lines": lines(runtime_replacement), + }, + "exception_filter": exception_filter, + }, + "contract": { + "fixture": ( + "one fixed raw RPC73 style-5 payload followed by one fixed " + "raw RPC73 style-3 payload" + ), + "runtime": ( + "show seq2 style5 -> clear_all seq3 cleared=1 -> " + "show seq3 style3" + ), + }, + } + + +def _render(result: dict[str, Any], output: Path | None) -> None: + rendered = json.dumps(result, indent=2, sort_keys=True) + "\n" + if output is not None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(rendered, encoding="utf-8") + print(rendered, end="") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("artifact", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args(argv) + try: + result = analyze(args.artifact) + except (FileNotFoundError, OSError, ValueError) as error: + parser.error(str(error)) + _render(result, args.output) + return 0 if result["verdict"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/reloop/analyze_interaction.py b/tools/reloop/analyze_interaction.py index 1abe1ce..4ee22b3 100755 --- a/tools/reloop/analyze_interaction.py +++ b/tools/reloop/analyze_interaction.py @@ -25,7 +25,9 @@ def analyze(run: Path) -> dict[str, Any]: runtime_path = run / "client/samp_runtime.log" runtime = runtime_path.read_text(encoding="utf-8", errors="replace") if runtime_path.is_file() else "" required = ["baseline", "chat_open", "chat_input_attempt", "chat_closed", - "scoreboard_open", "scoreboard_input_attempt", "scoreboard_closed"] + "scoreboard_open", "scoreboard_plain_input_attempt", + "scoreboard_mouse_mode", "scoreboard_input_attempt", + "scoreboard_closed"] missing = [label for label in required if label not in by_label] if missing: return {"verdict": "INCOMPLETE", "missing": missing, "run": str(run)} @@ -39,11 +41,28 @@ def analyze(run: Path) -> dict[str, Any]: "chat_blocks_camera_movement": distance(baseline, chat_attempt, "aim") < 0.05, "chat_restores_original_opcode": by_label["chat_closed"]["input_call"] == ORIGINAL_CALL, "chat_rpc_reached_server": "[bare-uitest] OnPlayerText" in server, - "scoreboard_uses_input_disable_opcode": by_label["scoreboard_open"]["input_call"] == DISABLED_CALL, - "scoreboard_blocks_player_movement": distance(by_label["scoreboard_open"], score_attempt, "player") < 0.05, - "scoreboard_blocks_camera_movement": distance(by_label["scoreboard_open"], score_attempt, "aim") < 0.05, + # OBSERVED_037 + PROBE_TRACE: the key-up-latched scoreboard leaves + # gta_sa.exe+0x141DF5 intact and allows W movement both before and + # after RMB. Its raw visible flag owns the exclusive R5 overlay branch. + "scoreboard_plain_tab_keeps_original_opcode": + by_label["scoreboard_open"]["input_call"] == ORIGINAL_CALL + and by_label["scoreboard_plain_input_attempt"]["input_call"] == ORIGINAL_CALL, + "scoreboard_plain_tab_allows_player_movement": + distance(by_label["scoreboard_open"], + by_label["scoreboard_plain_input_attempt"], "player") > 0.5, + "scoreboard_cursor_mode_keeps_original_input_opcode": + by_label["scoreboard_mouse_mode"]["input_call"] == ORIGINAL_CALL, + "scoreboard_after_rmb_allows_player_movement": + distance(by_label["scoreboard_mouse_mode"], score_attempt, "player") > 0.5, + "scoreboard_hud_stays_hidden_after_plain_input": + by_label["scoreboard_plain_input_attempt"]["hud"] == 0 + and by_label["scoreboard_plain_input_attempt"]["radar_blank"] == 1, "scoreboard_hud_hide_path_observed": "scoreboard: hud hide" in runtime, - "scoreboard_mouse_mode_observed": "scoreboard: mouse mode enabled trigger=tab" in runtime, + "scoreboard_exclusive_overlay_path_observed": + "scoreboard: exclusive_overlay normal_overlay_draws=0" in runtime, + "scoreboard_right_button_observed": + "scoreboard: right_button cursor_mode=3 game_input=unchanged" in runtime + or not runtime, "scoreboard_restores_original_opcode": by_label["scoreboard_closed"]["input_call"] == ORIGINAL_CALL, "scoreboard_rpc23_reached_server": "[bare-clicktest] OnPlayerClickPlayer" in server, } @@ -56,8 +75,13 @@ def analyze(run: Path) -> dict[str, Any]: "deltas": { "chat_player": distance(baseline, chat_attempt, "player"), "chat_camera": distance(baseline, chat_attempt, "aim"), - "scoreboard_player": distance(by_label["scoreboard_open"], score_attempt, "player"), - "scoreboard_camera": distance(by_label["scoreboard_open"], score_attempt, "aim"), + "scoreboard_plain_player": + distance(by_label["scoreboard_open"], + by_label["scoreboard_plain_input_attempt"], "player"), + "scoreboard_interactive_player": + distance(by_label["scoreboard_mouse_mode"], score_attempt, "player"), + "scoreboard_interactive_camera": + distance(by_label["scoreboard_mouse_mode"], score_attempt, "aim"), }, "evidence": "PROBE_TRACE", } diff --git a/tools/reloop/analyze_pickup_r5.py b/tools/reloop/analyze_pickup_r5.py new file mode 100644 index 0000000..22a8738 --- /dev/null +++ b/tools/reloop/analyze_pickup_r5.py @@ -0,0 +1,1164 @@ +#!/usr/bin/env python3 +"""Analyze focused Original-R5 pickup probe artifacts without live access. + +The analyzer deliberately distinguishes a complete ordinary-pickup observation +from still-unexercised type-14 and dropped-pickup paths. It never reports +generic PASS: every conclusion is tagged as observed, partial, mismatched, or +still requiring an Original-R5 run. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Sequence + + +OBSERVED = "OBSERVED" +TODO_VERIFY = "TODO_VERIFY" +MISMATCH = "MISMATCH" +OBSERVED_DIFFERENT = "OBSERVED_DIFFERENT" + +ORIGINAL_R5_SHA256 = ( + "b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2" +) +GTA_10_US_SHA256 = ( + "a559aa772fd136379155efa71f00c47aad34bbfeae6196b0fe1047d0645cbd26" +) +PICKUP_FLAG = "samp_probe_pickup_hooks.flag" + +PICKED_UP_RVA = "0x00013440" +PROCESS_RVA = "0x00013520" +PROCESS_CALLER_RVA = "0x00008ca8" + +EVENT_RE = re.compile( + r"pickup_r5: seq=(?P\d+) event=(?P\d+) " + r"tick=\d+ thread=\d+ frame=\d+ " + r"kind=(?Ppicked_up|process|rpc_131|rpc_97) " + r"caller_rva=(?P0x[0-9a-fA-F]+) " + r"hook_rva=(?P0x[0-9a-fA-F]+) " + r"pool=0x[0-9a-fA-F]+ raw_argument=(?P\d+) " + r"process_ordinal=(?P\d+) " + r"process_gate=(?P\d+),(?P\d+) " + r"cadence_delta=(?P\d+)_ms/(?P\d+)_frames " + r"rpc=(?P\d+) bits=(?P\d+) " + r"payload_valid=(?P\d+) payload=(?P\d+) " + r"priority=(?P\d+) reliability=(?P\d+) " + r"channel=(?P\d+) result=(?P\d+)" +) + +POOL_RE = re.compile( + r"pickup_pool_r5: seq=(?P\d+) event=(?P\d+) " + r"phase=(?Ppre|post|rpc) valid=(?P0x[0-9a-fA-F]+) " + r"pool=0x[0-9a-fA-F]+ count=(?P\d+) " + r"active=(?P\d+) captured=(?P\d+)" +) + +SLOT_RE = re.compile( + r"pickup_slot_r5: seq=(?P\d+) event=(?P\d+) " + r"phase=(?Ppre|post|rpc) sample=(?P\d+) " + r"valid=(?P0x[0-9a-fA-F]+) slot=(?P\d+) " + r"handle=(?P0x[0-9a-fA-F]+) " + r"raw_gta_index=(?P\d+) timer=(?P\d+) " + r"dropped=(?P\d+) from_player=(?P\d+) " + r"model=(?P-?\d+) type=(?P-?\d+) " + r"pos_bits=[0-9a-fA-F]+,[0-9a-fA-F]+,[0-9a-fA-F]+" +) + + +@dataclass(frozen=True) +class PickupSlot: + sample: int + valid_mask: int + slot: int + handle: int + raw_gta_index: int + timer: int + dropped: int + from_player: int + model: int + pickup_type: int + + +@dataclass +class PoolSnapshot: + valid_mask: int + count: int + active: int + captured: int + slots: dict[int, PickupSlot] = field(default_factory=dict) + + +@dataclass +class PickupEvent: + ring_seq: int + event_seq: int + kind: str + caller_rva: str + hook_rva: str + raw_argument: int + process_ordinal: int + gate_before: int + gate_after: int + delta_ms: int + delta_frames: int + rpc_id: int + rpc_bits: int + payload_valid: int + payload: int + priority: int + reliability: int + channel: int + result: int + snapshots: dict[str, PoolSnapshot] = field(default_factory=dict) + + +@dataclass +class ParsedTrace: + records: list[PickupEvent] + hook_installed: int | None + hook_requested: int | None + restore_restored: int | None + restore_requested: int | None + overflow_skipped: int + install_failure_seen: bool + parse_errors: list[str] + orphan_details: int + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="replace") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def resolve_probe_log(path: Path) -> Path: + """Resolve the final focused Windows log in one artifact. + + ``latest_log_bytes`` is preferred over the duplicate ``logs`` snapshot. + Multiple Windows run directories remain an error rather than being chosen + by timestamp. + """ + if path.is_file(): + return path + if not path.is_dir(): + raise FileNotFoundError(path) + + priority_patterns = ( + "windows/*/latest_log_bytes/samp_probe.log", + "windows/*/logs/samp_probe.log", + "latest_log_bytes/samp_probe.log", + "logs/samp_probe.log", + "pilot/client/samp_probe.log", + "observer/client/samp_probe.log", + "client/samp_probe.log", + "samp_probe.log", + ) + fallback: list[Path] = [] + for pattern in priority_patterns: + candidates = sorted( + candidate for candidate in path.glob(pattern) if candidate.is_file() + ) + fallback.extend(candidates) + focused = [ + candidate + for candidate in candidates + if "pickup_hook:" in _read(candidate) + or "pickup_r5:" in _read(candidate) + ] + if len(focused) == 1: + return focused[0] + if len(focused) > 1: + raise ValueError( + "multiple focused pickup logs; pass one samp_probe.log directly: " + + ", ".join(str(candidate) for candidate in focused) + ) + unique = sorted(set(fallback)) + if len(unique) == 1: + return unique[0] + if not unique: + raise FileNotFoundError(f"no samp_probe.log beneath {path}") + raise ValueError( + "probe log selection is ambiguous; pass one samp_probe.log directly: " + + ", ".join(str(candidate) for candidate in unique) + ) + + +def _artifact_root(requested: Path, log_path: Path) -> Path | None: + candidates = [requested] if requested.is_dir() else [] + candidates.extend(log_path.parents) + for candidate in candidates: + if ( + (candidate / "metadata.json").is_file() + or (candidate / "driver" / "pickup.json").is_file() + or (candidate / "server.log").is_file() + ): + return candidate + return None + + +def _resolve_manifest( + requested: Path, log_path: Path, artifact_root: Path | None +) -> tuple[Path | None, dict[str, Any] | None, str | None]: + candidates: set[Path] = set() + for parent in (log_path.parent, *log_path.parents[:3]): + candidate = parent / "manifest.json" + if candidate.is_file(): + candidates.add(candidate) + if artifact_root is not None: + candidates.update( + candidate + for candidate in artifact_root.glob("windows/*/manifest.json") + if candidate.is_file() + ) + if requested.is_dir(): + direct = requested / "manifest.json" + if direct.is_file(): + candidates.add(direct) + if not candidates: + return None, None, None + if len(candidates) != 1: + return None, None, "multiple Windows manifests found" + manifest_path = next(iter(candidates)) + try: + value = json.loads(_read(manifest_path)) + except (OSError, json.JSONDecodeError) as error: + return manifest_path, None, f"invalid manifest: {error}" + if not isinstance(value, dict): + return manifest_path, None, "manifest root is not an object" + return manifest_path, value, None + + +def parse_trace(text: str) -> ParsedTrace: + records_by_ring: dict[int, PickupEvent] = {} + pool_details: list[tuple[int, int, str, PoolSnapshot, int]] = [] + slot_details: list[tuple[int, int, str, PickupSlot, int]] = [] + parse_errors: list[str] = [] + + for line_number, line in enumerate(text.splitlines(), 1): + match = EVENT_RE.search(line) + if match: + ring_seq = int(match["seq"]) + if ring_seq in records_by_ring: + parse_errors.append(f"line {line_number}: duplicate seq={ring_seq}") + continue + records_by_ring[ring_seq] = PickupEvent( + ring_seq=ring_seq, + event_seq=int(match["event"]), + kind=match["kind"], + caller_rva=match["caller"].lower(), + hook_rva=match["hook"].lower(), + raw_argument=int(match["raw"]), + process_ordinal=int(match["ordinal"]), + gate_before=int(match["gate_before"]), + gate_after=int(match["gate_after"]), + delta_ms=int(match["delta_ms"]), + delta_frames=int(match["delta_frames"]), + rpc_id=int(match["rpc"]), + rpc_bits=int(match["bits"]), + payload_valid=int(match["payload_valid"]), + payload=int(match["payload"]), + priority=int(match["priority"]), + reliability=int(match["reliability"]), + channel=int(match["channel"]), + result=int(match["result"]), + ) + continue + + match = POOL_RE.search(line) + if match: + pool_details.append( + ( + int(match["seq"]), + int(match["event"]), + match["phase"], + PoolSnapshot( + valid_mask=int(match["valid"], 16), + count=int(match["count"]), + active=int(match["active"]), + captured=int(match["captured"]), + ), + line_number, + ) + ) + continue + + match = SLOT_RE.search(line) + if match: + slot_details.append( + ( + int(match["seq"]), + int(match["event"]), + match["phase"], + PickupSlot( + sample=int(match["sample"]), + valid_mask=int(match["valid"], 16), + slot=int(match["slot"]), + handle=int(match["handle"], 16), + raw_gta_index=int(match["raw"]), + timer=int(match["timer"]), + dropped=int(match["dropped"]), + from_player=int(match["from_player"]), + model=int(match["model"]), + pickup_type=int(match["type"]), + ), + line_number, + ) + ) + continue + + if ( + ("pickup_r5:" in line and "overflow " not in line) + or "pickup_pool_r5:" in line + or "pickup_slot_r5:" in line + ): + parse_errors.append(f"line {line_number}: malformed pickup trace line") + + orphan_details = 0 + for ring_seq, event_seq, phase, snapshot, line_number in pool_details: + record = records_by_ring.get(ring_seq) + if record is None: + orphan_details += 1 + continue + if record.event_seq != event_seq: + parse_errors.append( + f"line {line_number}: seq={ring_seq} event mismatch " + f"{event_seq}!={record.event_seq}" + ) + continue + if phase in record.snapshots: + parse_errors.append( + f"line {line_number}: duplicate pool seq={ring_seq} phase={phase}" + ) + continue + record.snapshots[phase] = snapshot + + sample_keys: set[tuple[int, str, int]] = set() + for ring_seq, event_seq, phase, slot, line_number in slot_details: + record = records_by_ring.get(ring_seq) + snapshot = record.snapshots.get(phase) if record is not None else None + if record is None or snapshot is None: + orphan_details += 1 + continue + if record.event_seq != event_seq: + parse_errors.append( + f"line {line_number}: seq={ring_seq} slot event mismatch " + f"{event_seq}!={record.event_seq}" + ) + continue + sample_key = (ring_seq, phase, slot.sample) + if sample_key in sample_keys or slot.slot in snapshot.slots: + parse_errors.append( + f"line {line_number}: duplicate slot sample seq={ring_seq} " + f"phase={phase}" + ) + continue + sample_keys.add(sample_key) + snapshot.slots[slot.slot] = slot + + for record in records_by_ring.values(): + expected_phases = ( + ("rpc",) + if record.kind in ("rpc_131", "rpc_97") + else ("pre", "post") + ) + unexpected = set(record.snapshots) - set(expected_phases) + if unexpected: + parse_errors.append( + f"seq={record.ring_seq}: unexpected phases={sorted(unexpected)}" + ) + for phase, snapshot in record.snapshots.items(): + if snapshot.captured != len(snapshot.slots): + parse_errors.append( + f"seq={record.ring_seq} phase={phase}: captured=" + f"{snapshot.captured} slots={len(snapshot.slots)}" + ) + + hook_matches = re.findall( + r"pickup_hook: summary installed=(\d+) requested=(\d+)", text + ) + restore_matches = re.findall( + r"pickup_hook: restore restored=(\d+) requested=(\d+)", text + ) + overflow_skipped = sum( + int(value) + for value in re.findall(r"pickup_r5: overflow skipped=(\d+)", text) + ) + hook_installed, hook_requested = ( + (int(hook_matches[-1][0]), int(hook_matches[-1][1])) + if hook_matches + else (None, None) + ) + restore_restored, restore_requested = ( + (int(restore_matches[-1][0]), int(restore_matches[-1][1])) + if restore_matches + else (None, None) + ) + install_failure_seen = any( + marker in text + for marker in ( + "pickup_hook: skip unsupported_identity", + "pickup_hook: skip preflight_mismatch", + "pickup_hook: trampoline_allocation_failed", + "pickup_hook: incomplete_install", + ) + ) + event_seqs = [record.event_seq for record in records_by_ring.values()] + if len(event_seqs) != len(set(event_seqs)): + parse_errors.append("duplicate event sequence") + + return ParsedTrace( + records=sorted( + records_by_ring.values(), key=lambda record: record.ring_seq + ), + hook_installed=hook_installed, + hook_requested=hook_requested, + restore_restored=restore_restored, + restore_requested=restore_requested, + overflow_skipped=overflow_skipped, + install_failure_seen=install_failure_seen, + parse_errors=parse_errors, + orphan_details=orphan_details, + ) + + +def _identity( + manifest_path: Path | None, + manifest: dict[str, Any] | None, + manifest_error: str | None, +) -> dict[str, Any]: + if manifest_error: + return {"status": MISMATCH, "reason": manifest_error} + if manifest is None: + return { + "status": TODO_VERIFY, + "reason": "no Windows manifest; binary identity is uncorroborated", + } + samp_hash = manifest.get("samp_sha256") + gta_hash = manifest.get("gta_sha256") + flags = manifest.get("probe_flags") + checks = { + "original_r5_samp": samp_hash == ORIGINAL_R5_SHA256, + "gta_10_us": gta_hash == GTA_10_US_SHA256, + "pickup_profile_exact": flags == [PICKUP_FLAG], + } + return { + "status": OBSERVED if all(checks.values()) else MISMATCH, + "manifest": str(manifest_path) if manifest_path is not None else None, + "samp_sha256": samp_hash, + "gta_sha256": gta_hash, + "samp_probe_sha256": manifest.get("samp_probe_sha256"), + "probe_flags": flags, + "checks": checks, + } + + +def _rpc_errors( + record: PickupEvent, + *, + rpc_id: int, + bits: int, + reliability: int, +) -> list[str]: + expected = { + "kind": f"rpc_{rpc_id}", + "rpc_id": rpc_id, + "rpc_bits": bits, + "payload_valid": 1, + "priority": 1, + "reliability": reliability, + "channel": 0, + "hook_rva": "0x00000000", + } + actual = { + "kind": record.kind, + "rpc_id": record.rpc_id, + "rpc_bits": record.rpc_bits, + "payload_valid": record.payload_valid, + "priority": record.priority, + "reliability": record.reliability, + "channel": record.channel, + "hook_rva": record.hook_rva, + } + return [ + f"{name}={actual[name]!r}, expected={value!r}" + for name, value in expected.items() + if actual[name] != value + ] + + +def _nested_rpcs( + method: PickupEvent, records: list[PickupEvent], kinds: tuple[str, ...] +) -> list[PickupEvent]: + return sorted( + ( + record + for record in records + if record.kind in kinds + and record.event_seq > method.event_seq + and record.ring_seq < method.ring_seq + ), + key=lambda record: record.event_seq, + ) + + +def _ordinary( + records: list[PickupEvent], +) -> tuple[dict[str, Any], set[int]]: + observations: list[dict[str, Any]] = [] + used_rpc_ring_seqs: set[int] = set() + for method in (record for record in records if record.kind == "picked_up"): + errors: list[str] = [] + if method.hook_rva != PICKED_UP_RVA: + errors.append( + f"hook_rva={method.hook_rva}, expected={PICKED_UP_RVA}" + ) + pre = method.snapshots.get("pre") + post = method.snapshots.get("post") + if pre is None or post is None: + errors.append("missing pre/post pool snapshot") + observations.append( + { + "status": MISMATCH, + "event_seq": method.event_seq, + "errors": errors, + } + ) + continue + + matching = [ + slot + for slot in pre.slots.values() + if slot.raw_gta_index == method.raw_argument + ] + if len(matching) != 1: + errors.append( + f"raw_argument matched {len(matching)} pre slots, expected 1" + ) + observations.append( + { + "status": MISMATCH, + "event_seq": method.event_seq, + "errors": errors, + } + ) + continue + before = matching[0] + after = post.slots.get(before.slot) + if before.valid_mask != 0x1F: + errors.append(f"pre slot valid_mask=0x{before.valid_mask:08x}") + if after is None: + errors.append("focused slot missing from post snapshot") + else: + if after.valid_mask != 0x1F: + errors.append(f"post slot valid_mask=0x{after.valid_mask:08x}") + if before.handle == 0: + errors.append("pre handle is zero") + if before.timer != 0: + errors.append(f"pre timer={before.timer}, expected=0") + if before.dropped != 0: + errors.append(f"pre dropped={before.dropped}, expected=0") + if after.timer != 15: + errors.append(f"post timer={after.timer}, expected=15") + for name in ( + "handle", + "raw_gta_index", + "dropped", + "from_player", + "model", + "pickup_type", + ): + if getattr(before, name) != getattr(after, name): + errors.append( + f"{name} changed {getattr(before, name)}" + f"->{getattr(after, name)}" + ) + + rpc_candidates = [ + rpc + for rpc in _nested_rpcs(method, records, ("rpc_131",)) + if rpc.payload_valid == 1 and rpc.payload == before.slot + ] + rpc: PickupEvent | None = None + if len(rpc_candidates) != 1: + errors.append( + f"nested RPC131 candidates={len(rpc_candidates)}, expected=1" + ) + else: + rpc = rpc_candidates[0] + used_rpc_ring_seqs.add(rpc.ring_seq) + if rpc.event_seq != method.event_seq + 1: + errors.append( + f"nested RPC event_seq={rpc.event_seq}, expected=" + f"{method.event_seq + 1}" + ) + errors.extend( + _rpc_errors(rpc, rpc_id=131, bits=32, reliability=9) + ) + rpc_snapshot = rpc.snapshots.get("rpc") + if rpc_snapshot is None or before.slot not in rpc_snapshot.slots: + errors.append("RPC131 focus slot missing from rpc snapshot") + + observation: dict[str, Any] = { + "status": MISMATCH if errors else OBSERVED, + "event_seq": method.event_seq, + "slot": before.slot, + "raw_argument": method.raw_argument, + "model": before.model, + "pickup_type": before.pickup_type, + "dropped": before.dropped, + "handle_index": before.handle & 0xFFFF, + "handle_generation": before.handle >> 16, + "raw_equals_handle_index": ( + method.raw_argument == (before.handle & 0xFFFF) + ), + "timer": { + "pre": before.timer, + "post": after.timer if after is not None else None, + }, + "rpc": ( + { + "event_seq": rpc.event_seq, + "id": rpc.rpc_id, + "bits": rpc.rpc_bits, + "payload": rpc.payload, + "priority": rpc.priority, + "reliability": rpc.reliability, + "channel": rpc.channel, + "result": rpc.result, + } + if rpc is not None + else None + ), + "rpc_failure_timer_assignment": ( + OBSERVED + if rpc is not None + and rpc.result == 0 + and after is not None + and after.timer == 15 + else TODO_VERIFY + ), + "errors": errors, + } + observations.append(observation) + + if not observations: + status = TODO_VERIFY + elif any(item["status"] == MISMATCH for item in observations): + status = MISMATCH + else: + status = OBSERVED + return ( + { + "status": status, + "event_count": len(observations), + "observations": observations, + "static_oracle": { + "payload": "signed int32 SA-MP pickup slot", + "qos": "priority=1 reliability=9 channel=0", + "timer_transition": "0->15", + "rejects": "missing, zero-handle, nonzero-timer, dropped", + }, + }, + used_rpc_ring_seqs, + ) + + +def _process( + records: list[PickupEvent], +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], set[int]]: + process_events = [record for record in records if record.kind == "process"] + process_errors: list[str] = [] + timer_samples: list[dict[str, int]] = [] + type14_samples: list[dict[str, Any]] = [] + dropped_samples: list[dict[str, Any]] = [] + used_rpc_ring_seqs: set[int] = set() + + for method in process_events: + if method.hook_rva != PROCESS_RVA: + process_errors.append( + f"event={method.event_seq} hook_rva={method.hook_rva}, " + f"expected={PROCESS_RVA}" + ) + if method.caller_rva != PROCESS_CALLER_RVA: + process_errors.append( + f"event={method.event_seq} caller_rva={method.caller_rva}, " + f"expected={PROCESS_CALLER_RVA}" + ) + if (method.gate_before, method.gate_after) != (6, 6): + process_errors.append( + f"event={method.event_seq} process_gate=" + f"{method.gate_before},{method.gate_after}, expected=6,6" + ) + pre = method.snapshots.get("pre") + post = method.snapshots.get("post") + if pre is None or post is None: + process_errors.append( + f"event={method.event_seq} missing pre/post pool snapshot" + ) + continue + + for before in pre.slots.values(): + after = post.slots.get(before.slot) + if ( + after is not None + and before.handle != 0 + and before.dropped == 0 + and before.pickup_type != 14 + ): + expected_timer = max(0, before.timer - 1) + timer_samples.append( + { + "event_seq": method.event_seq, + "slot": before.slot, + "pre": before.timer, + "post": after.timer, + "expected_post": expected_timer, + } + ) + if after.timer != expected_timer: + process_errors.append( + f"event={method.event_seq} slot={before.slot} " + f"ordinary timer {before.timer}->{after.timer}, " + f"expected {expected_timer}" + ) + + nested = _nested_rpcs(method, records, ("rpc_131", "rpc_97")) + for rpc in nested: + used_rpc_ring_seqs.add(rpc.ring_seq) + if rpc.kind == "rpc_131": + errors = _rpc_errors( + rpc, rpc_id=131, bits=32, reliability=10 + ) + before = pre.slots.get(rpc.payload) + if before is None: + errors.append("type-14 payload slot was not captured") + status = TODO_VERIFY if len(errors) == 1 else MISMATCH + else: + if before.dropped != 0 or before.pickup_type != 14: + errors.append( + f"payload slot dropped/type=" + f"{before.dropped}/{before.pickup_type}, " + "expected=0/14" + ) + after = post.slots.get(before.slot) + if after is None or after.timer != before.timer: + errors.append("type-14 timer changed or post slot missing") + status = MISMATCH if errors else OBSERVED + type14_samples.append( + { + "status": status, + "event_seq": method.event_seq, + "rpc_event_seq": rpc.event_seq, + "slot": rpc.payload, + "qos": { + "priority": rpc.priority, + "reliability": rpc.reliability, + "channel": rpc.channel, + }, + "errors": errors, + } + ) + else: + errors = _rpc_errors(rpc, rpc_id=97, bits=16, reliability=10) + matches = [ + slot + for slot in pre.slots.values() + if slot.dropped == 1 and slot.from_player == rpc.payload + ] + if not matches: + errors.append("dropped source slot was not captured") + status = TODO_VERIFY if len(errors) == 1 else MISMATCH + slot_number = None + else: + before = matches[0] + slot_number = before.slot + after = post.slots.get(before.slot) + if after is None or after.timer != before.timer: + errors.append("dropped timer changed or post slot missing") + status = MISMATCH if errors else OBSERVED + dropped_samples.append( + { + "status": status, + "event_seq": method.event_seq, + "rpc_event_seq": rpc.event_seq, + "slot": slot_number, + "from_player": rpc.payload, + "qos": { + "priority": rpc.priority, + "reliability": rpc.reliability, + "channel": rpc.channel, + }, + "errors": errors, + } + ) + + ordinals = [event.process_ordinal for event in process_events] + ordinal_consecutive = all( + current == previous + 1 + for previous, current in zip(ordinals, ordinals[1:]) + ) + if not ordinal_consecutive: + process_errors.append("process ordinals are not consecutive") + frame_deltas = [ + event.delta_frames for event in process_events if event.delta_frames > 0 + ] + if not frame_deltas: + cadence_status = TODO_VERIFY + elif all(delta == 7 for delta in frame_deltas): + cadence_status = OBSERVED + else: + cadence_status = OBSERVED_DIFFERENT + + process_status = ( + TODO_VERIFY + if not process_events + else MISMATCH + if process_errors + else OBSERVED + ) + positive_timer_samples = [ + sample for sample in timer_samples if sample["pre"] > 0 + ] + timer_status = ( + MISMATCH + if any(sample["post"] != sample["expected_post"] for sample in timer_samples) + else OBSERVED + if positive_timer_samples + else TODO_VERIFY + ) + + def path_status(samples: list[dict[str, Any]]) -> str: + if not samples: + return TODO_VERIFY + if any(sample["status"] == MISMATCH for sample in samples): + return MISMATCH + if any(sample["status"] == TODO_VERIFY for sample in samples): + return TODO_VERIFY + return OBSERVED + + process_result = { + "status": process_status, + "event_count": len(process_events), + "errors": process_errors, + "caller_rva": PROCESS_CALLER_RVA, + "gate_oracle": "6 before and after; caller resets after return", + "ordinals_consecutive": ordinal_consecutive, + "cadence": { + "status": cadence_status, + "frame_deltas": frame_deltas, + "millisecond_deltas": [ + event.delta_ms for event in process_events if event.delta_ms > 0 + ], + "static_prediction": ( + "one Process call per seven caller invocations; frame spacing " + "remains runtime-dependent" + ), + }, + "ordinary_timer_countdown": { + "status": timer_status, + "samples": timer_samples, + "positive_timer_sample_count": len(positive_timer_samples), + }, + } + type14_result = { + "status": path_status(type14_samples), + "event_count": len(type14_samples), + "observations": type14_samples, + "static_oracle": { + "rpc": 131, + "payload": "signed int32 SA-MP pickup slot", + "qos": "priority=1 reliability=10 channel=0", + "timer": "unchanged", + }, + } + dropped_result = { + "status": path_status(dropped_samples), + "event_count": len(dropped_samples), + "observations": dropped_samples, + "static_oracle": { + "rpc": 97, + "payload": "uint16 source player", + "qos": "priority=1 reliability=10 channel=0", + "timer": "unchanged", + }, + } + return process_result, type14_result, dropped_result, used_rpc_ring_seqs + + +def _driver_request(artifact_root: Path | None) -> tuple[int | None, int | None]: + if artifact_root is None: + return None, None + driver_path = artifact_root / "driver" / "pickup.json" + if not driver_path.is_file(): + return None, None + try: + value = json.loads(_read(driver_path)) + except (OSError, json.JSONDecodeError): + return None, 1 + if not isinstance(value, dict): + return None, 1 + events = value.get("events") + request_ids = ( + { + event.get("request_id") + for event in events + if isinstance(event, dict) + and event.get("event") == "sync_pair_scenario_queued" + and isinstance(event.get("request_id"), int) + } + if isinstance(events, list) + else set() + ) + request_id = next(iter(request_ids)) if len(request_ids) == 1 else None + returncode = value.get("returncode") + return request_id, returncode if isinstance(returncode, int) else None + + +def _request_block(text: str, request_id: int) -> str: + selected: list[str] = [] + active = False + for line in text.splitlines(): + accepted = re.search(r"marker=REQUEST_ACCEPTED\b.*\brequest=(\d+)\b", line) + if accepted: + active = int(accepted.group(1)) == request_id + if active: + selected.append(line) + return "\n".join(selected) + + +def _fixture(artifact_root: Path | None) -> dict[str, Any]: + request_id, returncode = _driver_request(artifact_root) + if artifact_root is None or request_id is None: + return { + "status": MISMATCH if returncode is not None else TODO_VERIFY, + "request_id": request_id, + "reason": ( + "pickup driver receipt is invalid or has no unique request" + if returncode is not None + else "artifact driver request context is unavailable" + ), + } + blocks = [ + _request_block(_read(path), request_id) + for path in ( + artifact_root / "server.log", + artifact_root / "server.console.log", + artifact_root / "sync-pair-results.log", + ) + if path.is_file() + ] + checks = { + "driver_completed": returncode == 0, + "request_done": any( + re.search( + rf"marker=REQUEST_DONE\b.*\brequest={request_id}\b.*" + r"\bstatus=PASS\b.*\bscenario=pickup\b", + block, + ) + is not None + for block in blocks + ), + "pickup_created": any("marker=PICKUP_CREATED" in block for block in blocks), + "pickup_collected": any( + "marker=PICKUP_COLLECTED" in block for block in blocks + ), + } + return { + "status": OBSERVED if all(checks.values()) else MISMATCH, + "request_id": request_id, + "checks": checks, + "scope": "ordinary type-1 pickup only", + } + + +def analyze_log(path: Path) -> dict[str, Any]: + requested = path.resolve() + log_path = resolve_probe_log(requested) + trace = parse_trace(_read(log_path)) + artifact_root = _artifact_root(requested, log_path) + manifest_path, manifest, manifest_error = _resolve_manifest( + requested, log_path, artifact_root + ) + identity = _identity(manifest_path, manifest, manifest_error) + + hook_status = ( + MISMATCH + if trace.install_failure_seen + else TODO_VERIFY + if trace.hook_installed is None + else OBSERVED + if trace.hook_installed == trace.hook_requested == 2 + else MISMATCH + ) + restore_status = ( + TODO_VERIFY + if trace.restore_restored is None + else OBSERVED + if trace.restore_restored == trace.restore_requested == 2 + else MISMATCH + ) + integrity_status = ( + MISMATCH + if ( + hook_status == MISMATCH + or restore_status == MISMATCH + or trace.overflow_skipped + or trace.parse_errors + or trace.orphan_details + ) + else TODO_VERIFY + if hook_status == TODO_VERIFY + else OBSERVED + ) + + ordinary, ordinary_rpc = _ordinary(trace.records) + process, type14, dropped, process_rpc = _process(trace.records) + used_rpc = ordinary_rpc | process_rpc + all_rpc = { + record.ring_seq + for record in trace.records + if record.kind in ("rpc_131", "rpc_97") + } + unpaired_rpc = sorted(all_rpc - used_rpc) + rpc_pairing_status = OBSERVED if not unpaired_rpc else MISMATCH + fixture = _fixture(artifact_root) + + mismatch_statuses = ( + integrity_status, + identity["status"], + fixture["status"], + ordinary["status"], + process["status"], + type14["status"], + dropped["status"], + rpc_pairing_status, + ) + if MISMATCH in mismatch_statuses: + assessment = MISMATCH + elif ( + identity["status"] == OBSERVED + and integrity_status == OBSERVED + and fixture["status"] == OBSERVED + and ordinary["status"] == OBSERVED + and process["status"] == OBSERVED + ): + if ( + type14["status"] == OBSERVED + and dropped["status"] == OBSERVED + and process["ordinary_timer_countdown"]["status"] == OBSERVED + ): + assessment = "OBSERVED_COMPLETE" + else: + assessment = "OBSERVED_ORDINARY" + elif any( + status == OBSERVED + for status in ( + ordinary["status"], + process["status"], + type14["status"], + dropped["status"], + ) + ): + assessment = "OBSERVED_PARTIAL" + else: + assessment = TODO_VERIFY + + return { + "schema": 1, + "source_log": str(log_path), + "source_sha256": _sha256(log_path), + "assessment": assessment, + "evidence": ["STATIC_037", "PROBE_TRACE", "TODO_VERIFY"], + "identity": identity, + "identity_policy": { + "pointers": "excluded_from_output_and_pairing", + "ticks_and_absolute_frames": "excluded_from_output_and_pairing", + "event_pairing": ( + "event_seq establishes nesting; ring_seq establishes " + "publication order" + ), + }, + "integrity": { + "status": integrity_status, + "hook_install": { + "status": hook_status, + "installed": trace.hook_installed, + "requested": trace.hook_requested, + }, + "overflow": { + "status": OBSERVED if trace.overflow_skipped == 0 else MISMATCH, + "skipped": trace.overflow_skipped, + }, + "hook_restore": { + "status": restore_status, + "restored": trace.restore_restored, + "requested": trace.restore_requested, + "required_for_observation": False, + }, + "parse_errors": trace.parse_errors, + "orphan_details": trace.orphan_details, + "rpc_pairing": { + "status": rpc_pairing_status, + "unpaired_ring_sequences": unpaired_rpc, + }, + }, + "fixture": fixture, + "record_counts": { + kind: sum(record.kind == kind for record in trace.records) + for kind in ("picked_up", "process", "rpc_131", "rpc_97") + }, + "ordinary_picked_up": ordinary, + "process": process, + "type14_process": type14, + "dropped_process": dropped, + "coverage_boundary": { + "ordinary_fixture": ( + "the current sync_pair pickup scenario creates type 1 and " + "destroys it after the server callback" + ), + "not_claimed_from_ordinary_fixture": [ + "15-tick repeated-notification cadence", + "type-14 RPC131 path", + "dropped RPC97 path", + "pause/unpause behavior", + "destroy/recreate handle generations", + "visual disappearance independent of the SA-MP slot", + ], + }, + } + + +def _render(result: dict[str, Any], output: Path | None) -> None: + rendered = json.dumps(result, indent=2, sort_keys=True) + "\n" + if output is not None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(rendered, encoding="utf-8") + print(rendered, end="") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("artifact_or_log", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args(argv) + try: + result = analyze_log(args.artifact_or_log) + except (FileNotFoundError, OSError, ValueError) as error: + parser.error(str(error)) + _render(result, args.output) + return 1 if result["assessment"] == MISMATCH else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/reloop/analyze_trailer_physics.py b/tools/reloop/analyze_trailer_physics.py new file mode 100644 index 0000000..389c616 --- /dev/null +++ b/tools/reloop/analyze_trailer_physics.py @@ -0,0 +1,1193 @@ +#!/usr/bin/env python3 +"""Summarize and compare focused trailer-physics ASI probe traces. + +The probe emits one ``trailer_physics`` record followed by pre/post +``trailer_physics_state`` and ``trailer_physics_detail`` records. Raw GTA +addresses are deliberately not compared: object relationships are reduced to +``trailer.tow == tractor`` and ``tractor.reverse == trailer`` first. + +This analyzer reports measurements only. It has no tolerance-based parity +verdict because acceptable residuals still need original 0.3.7 golden runs. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import json +import math +import re +import statistics +from collections import Counter +from pathlib import Path +from typing import Any, Iterable, Sequence + + +FRAME_LIMIT = 64 +EVIDENCE = "PROBE_TRACE" +TOKEN_RE = re.compile( + r"(?P[A-Za-z][A-Za-z0-9_]*)=" + r"(?P\([^)]*\)|[^\s]+)" +) +INTEGER_RE = re.compile(r"^[+-]?\d+$") +HEX_RE = re.compile(r"^0x[0-9a-fA-F]+$") + + +class TrailerPhysicsAnalysisError(RuntimeError): + """An input cannot be used as a trailer-physics trace.""" + + +@dataclasses.dataclass +class PhysicsRecord: + line_number: int + seq: int + event: int + generation: int + frame: int + kind: str + values: dict[str, Any] + states: dict[str, dict[str, Any]] = dataclasses.field(default_factory=dict) + details: dict[str, dict[str, Any]] = dataclasses.field(default_factory=dict) + + +@dataclasses.dataclass +class PhysicsTrace: + source: str + records: list[PhysicsRecord] = dataclasses.field(default_factory=list) + malformed_lines: list[int] = dataclasses.field(default_factory=list) + orphan_auxiliary_lines: list[int] = dataclasses.field(default_factory=list) + duplicate_auxiliary_lines: list[int] = dataclasses.field(default_factory=list) + overflow_skipped: int = 0 + + +def _parse_value(raw: str) -> Any: + if raw.startswith("(") and raw.endswith(")"): + values = raw[1:-1].split(",") if len(raw) > 2 else [] + parsed: list[Any] = [] + for value in values: + parsed.append(_parse_value(value)) + return tuple(parsed) + if HEX_RE.fullmatch(raw): + return int(raw, 16) + if INTEGER_RE.fullmatch(raw): + return int(raw, 10) + try: + value = float(raw) + except ValueError: + return raw + return value + + +def _tokens(text: str) -> dict[str, Any]: + return { + match.group("key"): _parse_value(match.group("value")) + for match in TOKEN_RE.finditer(text) + } + + +def _required_int(values: dict[str, Any], key: str) -> int: + value = values.get(key) + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{key} is not an integer") + return value + + +def _record_identity(values: dict[str, Any]) -> tuple[int, int, int, int]: + return ( + _required_int(values, "seq"), + _required_int(values, "event"), + _required_int(values, "generation"), + _required_int(values, "frame"), + ) + + +def _find_record( + records: Sequence[PhysicsRecord], + identity: tuple[int, int, int, int], +) -> PhysicsRecord | None: + for record in reversed(records): + if (record.seq, record.event, record.generation, record.frame) == identity: + return record + return None + + +def _parse_state_payload(payload: str) -> tuple[dict[str, Any], str]: + trailer_at = payload.find("trailer=") + if trailer_at < 0: + raise ValueError("missing trailer state") + tractor_at = payload.find(" tractor=", trailer_at) + if tractor_at < 0: + raise ValueError("missing tractor state") + + prefix = _tokens(payload[:trailer_at]) + trailer = _tokens(payload[trailer_at:tractor_at]) + tractor = _tokens(payload[tractor_at + 1 :]) + phase = prefix.get("phase") + if phase not in {"pre", "post"}: + raise ValueError("invalid phase") + trailer["object"] = trailer.pop("trailer") + tractor["object"] = tractor.pop("tractor") + return { + "trailer": trailer, + "tractor": tractor, + }, phase + + +def parse_trace(text: str, source: str = "") -> PhysicsTrace: + """Parse complete or partial probe output without assuming a log prefix.""" + trace = PhysicsTrace(source=source) + + for line_number, line in enumerate(text.splitlines(), start=1): + if "trailer_physics: overflow " in line: + values = _tokens(line.split("trailer_physics: overflow ", 1)[1]) + skipped = values.get("skipped") + if isinstance(skipped, int) and skipped > 0: + trace.overflow_skipped += skipped + else: + trace.malformed_lines.append(line_number) + continue + + marker = "trailer_physics: " + if marker in line: + payload = line.split(marker, 1)[1] + values = _tokens(payload) + try: + seq, event, generation, frame = _record_identity(values) + kind = values["kind"] + if kind not in {"set_tow_link", "process_control"}: + raise ValueError("invalid kind") + except (KeyError, ValueError): + trace.malformed_lines.append(line_number) + continue + trace.records.append( + PhysicsRecord( + line_number=line_number, + seq=seq, + event=event, + generation=generation, + frame=frame, + kind=kind, + values=values, + ) + ) + continue + + marker = "trailer_physics_state: " + if marker in line: + payload = line.split(marker, 1)[1] + try: + values = _tokens(payload) + identity = _record_identity(values) + state, phase = _parse_state_payload(payload) + except (KeyError, ValueError): + trace.malformed_lines.append(line_number) + continue + record = _find_record(trace.records, identity) + if record is None: + trace.orphan_auxiliary_lines.append(line_number) + elif phase in record.states: + trace.duplicate_auxiliary_lines.append(line_number) + else: + record.states[phase] = state + continue + + marker = "trailer_physics_detail: " + if marker in line: + payload = line.split(marker, 1)[1] + values = _tokens(payload) + try: + identity = _record_identity(values) + phase = values["phase"] + if phase not in {"pre", "post"}: + raise ValueError("invalid phase") + except (KeyError, ValueError): + trace.malformed_lines.append(line_number) + continue + record = _find_record(trace.records, identity) + if record is None: + trace.orphan_auxiliary_lines.append(line_number) + elif phase in record.details: + trace.duplicate_auxiliary_lines.append(line_number) + else: + record.details[phase] = values + + return trace + + +def load_trace(path: Path) -> PhysicsTrace: + resolved = resolve_log_path(path) + return parse_trace( + resolved.read_text(encoding="utf-8", errors="replace"), + source=str(resolved), + ) + + +def resolve_log_path(path: Path) -> Path: + """Resolve a direct log or an artifact without selecting ``*.root.log``. + + Native-Windows collection stores the active Documents/SA-MP Logs file at + ``windows//latest_log_bytes/samp_probe.log``. The adjacent + ``samp_probe.root.log`` can legitimately be empty, so it is never a + candidate. + """ + resolved = path.resolve() + if resolved.is_file(): + return resolved + if not resolved.is_dir(): + raise TrailerPhysicsAnalysisError(f"not a file or directory: {resolved}") + + patterns = ( + "windows/*/latest_log_bytes/samp_probe.log", + "windows/*/logs/samp_probe.log", + "latest_log_bytes/samp_probe.log", + "logs/samp_probe.log", + "client/samp_probe.log", + "pilot/client/samp_probe.log", + "observer/client/samp_probe.log", + ) + for pattern in patterns: + candidates = sorted( + candidate + for candidate in resolved.glob(pattern) + if candidate.is_file() and candidate.stat().st_size > 0 + ) + if len(candidates) == 1: + return candidates[0].resolve() + if len(candidates) > 1: + rendered = ", ".join(str(candidate) for candidate in candidates) + raise TrailerPhysicsAnalysisError( + f"ambiguous samp_probe.log beneath {resolved}: {rendered}" + ) + raise TrailerPhysicsAnalysisError( + f"no non-empty non-root samp_probe.log beneath {resolved}" + ) + + +def _finite_float(value: Any) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + result = float(value) + return result if math.isfinite(result) else None + + +def _vector(value: Any, length: int | None = None) -> tuple[float, ...] | None: + if not isinstance(value, tuple): + return None + converted = tuple(_finite_float(component) for component in value) + if any(component is None for component in converted): + return None + result = tuple(component for component in converted if component is not None) + if length is not None and len(result) != length: + return None + return result + + +def _norm(value: Sequence[float]) -> float: + return math.sqrt(sum(component * component for component in value)) + + +def _distance(left: Sequence[float], right: Sequence[float]) -> float: + if len(left) != len(right): + raise ValueError("vector dimensions differ") + return _norm(tuple(a - b for a, b in zip(left, right))) + + +def _vector_distance(left: Any, right: Any, length: int | None = None) -> float | None: + left_vector = _vector(left, length) + right_vector = _vector(right, length) + if left_vector is None or right_vector is None: + return None + return _distance(left_vector, right_vector) + + +def _stats(values: Iterable[float | None]) -> dict[str, Any]: + finite = [ + number + for value in values + if value is not None and (number := _finite_float(value)) is not None + ] + if not finite: + return {"count": 0} + ordered = sorted(finite) + p95_index = max(0, math.ceil(len(ordered) * 0.95) - 1) + return { + "count": len(ordered), + "min": ordered[0], + "mean": statistics.fmean(ordered), + "rms": math.sqrt(statistics.fmean(value * value for value in ordered)), + "p95": ordered[p95_index], + "max": ordered[-1], + } + + +def _value_norm(value: Any, length: int | None = None) -> float | None: + vector = _vector(value, length) + return _norm(vector) if vector is not None else None + + +def _state_vehicle( + record: PhysicsRecord, phase: str, vehicle: str +) -> dict[str, Any] | None: + state = record.states.get(phase) + if state is None: + return None + result = state.get(vehicle) + return result if isinstance(result, dict) else None + + +def _link_state(record: PhysicsRecord, phase: str) -> dict[str, bool] | None: + trailer = _state_vehicle(record, phase, "trailer") + tractor = _state_vehicle(record, phase, "tractor") + if trailer is None or tractor is None: + return None + trailer_object = trailer.get("object") + tractor_object = tractor.get("object") + trailer_tow = trailer.get("tow") + tractor_reverse = tractor.get("reverse") + if not all( + isinstance(value, int) + for value in ( + trailer_object, + tractor_object, + trailer_tow, + tractor_reverse, + ) + ): + return None + tow_matches = tractor_object != 0 and trailer_tow == tractor_object + reverse_matches = trailer_object != 0 and tractor_reverse == trailer_object + return { + "trailer_tow_matches_tractor": tow_matches, + "tractor_reverse_matches_trailer": reverse_matches, + "bidirectional": tow_matches and reverse_matches, + } + + +def _record_snapshot(record: PhysicsRecord) -> dict[str, Any]: + result: dict[str, Any] = { + "frame": record.frame, + "trailer_dpos": record.values.get("trailer_dpos"), + "tractor_dpos": record.values.get("tractor_dpos"), + } + for phase in ("pre", "post"): + phase_result: dict[str, Any] = {} + for vehicle_name in ("trailer", "tractor"): + vehicle = _state_vehicle(record, phase, vehicle_name) + if vehicle is None: + continue + phase_result[vehicle_name] = { + key: vehicle.get(key) + for key in ( + "valid", + "basis_r", + "basis_f", + "basis_u", + "pos", + "move", + "turn", + "flags", + "status", + "fake", + ) + } + link = _link_state(record, phase) + if link is not None: + phase_result["link"] = link + detail = record.details.get(phase) + if detail is not None: + phase_result["trailer_detail"] = { + key: detail.get(key) + for key in ( + "trailer_valid", + "support", + "wheel", + "wheel_prev", + "spring", + "line", + "ride", + ) + } + result[phase] = phase_result + return result + + +def _unique_frames(records: Iterable[PhysicsRecord]) -> tuple[dict[int, PhysicsRecord], list[int]]: + by_frame: dict[int, PhysicsRecord] = {} + counts: Counter[int] = Counter() + for record in records: + if record.kind != "process_control" or not 0 <= record.frame < FRAME_LIMIT: + continue + counts[record.frame] += 1 + by_frame.setdefault(record.frame, record) + duplicates = sorted(frame for frame, count in counts.items() if count > 1) + return by_frame, duplicates + + +def _generation_order(trace: PhysicsTrace) -> list[int]: + seen: set[int] = set() + ordered: list[int] = [] + for record in trace.records: + if record.generation <= 0 or record.generation in seen: + continue + seen.add(record.generation) + ordered.append(record.generation) + return ordered + + +def _elapsed_u32(current: Any, first: Any) -> int | None: + if not isinstance(current, int) or not isinstance(first, int): + return None + return (current - first) & 0xFFFFFFFF + + +def _generation_summary( + trace: PhysicsTrace, generation: int, ordinal: int +) -> dict[str, Any]: + records = [record for record in trace.records if record.generation == generation] + attach = [record for record in records if record.kind == "set_tow_link"] + frame_map, duplicate_frames = _unique_frames(records) + frames = [frame_map[index] for index in sorted(frame_map)] + expected = set(range(FRAME_LIMIT)) + observed = set(frame_map) + contiguous = 0 + while contiguous in observed: + contiguous += 1 + + first_game_ms = frames[0].values.get("pre_game_ms") if frames else None + link_pre = [_link_state(frame, "pre") for frame in frames] + link_post = [_link_state(frame, "post") for frame in frames] + link_loss_frames: list[int] = [] + link_recovery_frames: list[int] = [] + previous_linked = ( + _linked(attach[0], "post") if attach else None + ) + sequence_status_changes: list[dict[str, int]] = [] + previous_status: int | None = None + if attach: + attach_post = _state_vehicle(attach[0], "post", "trailer") + if attach_post is not None and isinstance(attach_post.get("status"), int): + previous_status = attach_post["status"] + for frame in frames: + pre_linked = _linked(frame, "pre") + if previous_linked is True and pre_linked is False: + link_loss_frames.append(frame.frame) + elif previous_linked is False and pre_linked is True: + link_recovery_frames.append(frame.frame) + if pre_linked is not None: + previous_linked = pre_linked + + trailer_pre = _state_vehicle(frame, "pre", "trailer") + trailer_post = _state_vehicle(frame, "post", "trailer") + pre_status = trailer_pre.get("status") if trailer_pre is not None else None + post_status = trailer_post.get("status") if trailer_post is not None else None + if ( + isinstance(previous_status, int) + and isinstance(pre_status, int) + and previous_status != pre_status + ): + sequence_status_changes.append( + { + "frame": frame.frame, + "from": previous_status, + "to": pre_status, + } + ) + if isinstance(post_status, int): + previous_status = post_status + elif isinstance(pre_status, int): + previous_status = pre_status + complete_aux = sum( + set(record.states) == {"pre", "post"} + and set(record.details) == {"pre", "post"} + for record in records + ) + + return { + "ordinal": ordinal, + "generation": generation, + "set_tow_link_events": len(attach), + "set_tow_link": ( + { + "result": attach[0].values.get("result"), + "set_my_pos_raw": attach[0].values.get("set_my_pos_raw"), + "trailer_jump_m": _value_norm( + attach[0].values.get("trailer_dpos"), 3 + ), + "tractor_jump_m": _value_norm( + attach[0].values.get("tractor_dpos"), 3 + ), + "link_pre": _link_state(attach[0], "pre"), + "link_post": _link_state(attach[0], "post"), + "state": _record_snapshot(attach[0]), + } + if attach + else None + ), + "process_control": { + "frames_observed": len(frames), + "frame_indices": sorted(observed), + "contiguous_prefix_frames": contiguous, + "missing_first_64": sorted(expected - observed), + "duplicate_frames": duplicate_frames, + "trailer_step_m": _stats( + _value_norm(record.values.get("trailer_dpos"), 3) + for record in frames + ), + "tractor_step_m": _stats( + _value_norm(record.values.get("tractor_dpos"), 3) + for record in frames + ), + "pre_timestep": _stats( + _finite_float(record.values.get("pre_timestep")) + for record in frames + ), + "elapsed_game_ms": _stats( + _elapsed_u32(record.values.get("pre_game_ms"), first_game_ms) + for record in frames + ), + "pre_linked_frames": sum( + bool(link and link["bidirectional"]) for link in link_pre + ), + "post_linked_frames": sum( + bool(link and link["bidirectional"]) for link in link_post + ), + "link_loss_frames": link_loss_frames, + "link_recovery_frames": link_recovery_frames, + "trailer_status_changes_between_frames": sequence_status_changes, + "trailer_move_speed_effect": _stats( + _value_norm(_state_effect(record, "trailer", "move", 3), 3) + for record in frames + ), + "trailer_turn_speed_effect": _stats( + _value_norm(_state_effect(record, "trailer", "turn", 3), 3) + for record in frames + ), + "tractor_move_speed_effect": _stats( + _value_norm(_state_effect(record, "tractor", "move", 3), 3) + for record in frames + ), + "tractor_turn_speed_effect": _stats( + _value_norm(_state_effect(record, "tractor", "turn", 3), 3) + for record in frames + ), + "support_effect": _stats( + _value_norm(_detail_effect(record, "support", 5), 5) + for record in frames + ), + "wheel_compression_effect": _stats( + _value_norm(_detail_effect(record, "wheel", 4), 4) + for record in frames + ), + "complete_pre_post_state_and_detail_records": complete_aux, + "first_frame": _record_snapshot(frames[0]) if frames else None, + "last_frame": _record_snapshot(frames[-1]) if frames else None, + }, + } + + +def summarize_trace(trace: PhysicsTrace) -> dict[str, Any]: + set_tow_link = [ + record for record in trace.records if record.kind == "set_tow_link" + ] + process_control = [ + record for record in trace.records if record.kind == "process_control" + ] + generations = _generation_order(trace) + attach_intervals = [ + _elapsed_u32( + set_tow_link[index].values.get("pre_game_ms"), + set_tow_link[index - 1].values.get("pre_game_ms"), + ) + for index in range(1, len(set_tow_link)) + ] + return { + "schema_version": 1, + "source": trace.source, + "evidence": EVIDENCE, + "assessment": "MEASURED_NO_PARITY_THRESHOLD", + "parse": { + "physics_records": len(trace.records), + "set_tow_link_records": len(set_tow_link), + "process_control_records": len(process_control), + "malformed_lines": trace.malformed_lines, + "orphan_auxiliary_lines": trace.orphan_auxiliary_lines, + "duplicate_auxiliary_lines": trace.duplicate_auxiliary_lines, + "overflow_skipped": trace.overflow_skipped, + "untracked_generation_zero_records": sum( + record.generation == 0 for record in trace.records + ), + }, + "set_tow_link": { + "events": len(set_tow_link), + "successful_results": sum( + record.values.get("result") == 1 for record in set_tow_link + ), + "trailer_jump_m": _stats( + _value_norm(record.values.get("trailer_dpos"), 3) + for record in set_tow_link + ), + "tractor_jump_m": _stats( + _value_norm(record.values.get("tractor_dpos"), 3) + for record in set_tow_link + ), + "inter_event_game_ms": _stats(attach_intervals), + "intervals_le_100ms": sum( + interval is not None and interval <= 100 + for interval in attach_intervals + ), + "intervals_le_250ms": sum( + interval is not None and interval <= 250 + for interval in attach_intervals + ), + "bidirectional_link_pre": sum( + bool(link and link["bidirectional"]) + for record in set_tow_link + if (link := _link_state(record, "pre")) is not None + ), + "bidirectional_link_post": sum( + bool(link and link["bidirectional"]) + for record in set_tow_link + if (link := _link_state(record, "post")) is not None + ), + }, + "generations": [ + _generation_summary(trace, generation, ordinal) + for ordinal, generation in enumerate(generations) + ], + } + + +def _metric( + records: Iterable[tuple[PhysicsRecord, PhysicsRecord]], + getter, +) -> dict[str, Any]: + return _stats(getter(original, replacement) for original, replacement in records) + + +def _vehicle_vector_metric( + original: PhysicsRecord, + replacement: PhysicsRecord, + phase: str, + vehicle: str, + key: str, + length: int | None = None, +) -> float | None: + original_vehicle = _state_vehicle(original, phase, vehicle) + replacement_vehicle = _state_vehicle(replacement, phase, vehicle) + if original_vehicle is None or replacement_vehicle is None: + return None + return _vector_distance( + original_vehicle.get(key), replacement_vehicle.get(key), length + ) + + +def _state_vector( + record: PhysicsRecord, + phase: str, + vehicle: str, + key: str, + length: int | None = None, +) -> tuple[float, ...] | None: + state = _state_vehicle(record, phase, vehicle) + if state is None: + return None + return _vector(state.get(key), length) + + +def _detail_vector( + record: PhysicsRecord, + phase: str, + key: str, + length: int | None = None, +) -> tuple[float, ...] | None: + detail = record.details.get(phase) + if detail is None: + return None + return _vector(detail.get(key), length) + + +def _vector_effect( + before: Sequence[float] | None, + after: Sequence[float] | None, +) -> tuple[float, ...] | None: + if before is None or after is None or len(before) != len(after): + return None + return tuple(right - left for left, right in zip(before, after)) + + +def _state_effect( + record: PhysicsRecord, + vehicle: str, + key: str, + length: int | None = None, +) -> tuple[float, ...] | None: + return _vector_effect( + _state_vector(record, "pre", vehicle, key, length), + _state_vector(record, "post", vehicle, key, length), + ) + + +def _detail_effect( + record: PhysicsRecord, + key: str, + length: int | None = None, +) -> tuple[float, ...] | None: + return _vector_effect( + _detail_vector(record, "pre", key, length), + _detail_vector(record, "post", key, length), + ) + + +def _effect_delta( + original_effect: Sequence[float] | None, + replacement_effect: Sequence[float] | None, +) -> float | None: + if original_effect is None or replacement_effect is None: + return None + return _distance(original_effect, replacement_effect) + + +def _detail_vector_metric( + original: PhysicsRecord, + replacement: PhysicsRecord, + phase: str, + key: str, + length: int | None = None, +) -> float | None: + original_detail = original.details.get(phase) + replacement_detail = replacement.details.get(phase) + if original_detail is None or replacement_detail is None: + return None + return _vector_distance( + original_detail.get(key), replacement_detail.get(key), length + ) + + +def _bool_mismatch(left: Any, right: Any) -> bool | None: + if left is None or right is None: + return None + return left != right + + +def _vehicle_scalar( + record: PhysicsRecord, phase: str, vehicle: str, key: str +) -> Any: + state = _state_vehicle(record, phase, vehicle) + return state.get(key) if state is not None else None + + +def _integer_xor(left: Any, right: Any) -> int | None: + if not isinstance(left, int) or not isinstance(right, int): + return None + return left ^ right + + +def _linked(record: PhysicsRecord, phase: str) -> bool | None: + link = _link_state(record, phase) + return link["bidirectional"] if link is not None else None + + +def _generation_diff( + original_trace: PhysicsTrace, + replacement_trace: PhysicsTrace, + original_generation: int, + replacement_generation: int, + ordinal: int, +) -> dict[str, Any]: + original_records = [ + record + for record in original_trace.records + if record.generation == original_generation + ] + replacement_records = [ + record + for record in replacement_trace.records + if record.generation == replacement_generation + ] + original_frames, original_duplicates = _unique_frames(original_records) + replacement_frames, replacement_duplicates = _unique_frames(replacement_records) + common_indices = sorted(set(original_frames) & set(replacement_frames)) + pairs = [ + (original_frames[index], replacement_frames[index]) + for index in common_indices + ] + + original_attach = next( + (record for record in original_records if record.kind == "set_tow_link"), + None, + ) + replacement_attach = next( + (record for record in replacement_records if record.kind == "set_tow_link"), + None, + ) + attach_delta = None + if original_attach is not None and replacement_attach is not None: + attach_delta = { + "trailer_jump_vector_delta_m": _vector_distance( + original_attach.values.get("trailer_dpos"), + replacement_attach.values.get("trailer_dpos"), + 3, + ), + "tractor_jump_vector_delta_m": _vector_distance( + original_attach.values.get("tractor_dpos"), + replacement_attach.values.get("tractor_dpos"), + 3, + ), + "pre_link_mismatch": _bool_mismatch( + _linked(original_attach, "pre"), + _linked(replacement_attach, "pre"), + ), + "post_link_mismatch": _bool_mismatch( + _linked(original_attach, "post"), + _linked(replacement_attach, "post"), + ), + "trailer_pre_position_residual_m": _vehicle_vector_metric( + original_attach, + replacement_attach, + "pre", + "trailer", + "pos", + 3, + ), + "trailer_post_position_residual_m": _vehicle_vector_metric( + original_attach, + replacement_attach, + "post", + "trailer", + "pos", + 3, + ), + "tractor_pre_position_residual_m": _vehicle_vector_metric( + original_attach, + replacement_attach, + "pre", + "tractor", + "pos", + 3, + ), + "trailer_pre_move_speed_delta": _vehicle_vector_metric( + original_attach, + replacement_attach, + "pre", + "trailer", + "move", + 3, + ), + "tractor_pre_move_speed_delta": _vehicle_vector_metric( + original_attach, + replacement_attach, + "pre", + "tractor", + "move", + 3, + ), + "trailer_pre_turn_speed_delta": _vehicle_vector_metric( + original_attach, + replacement_attach, + "pre", + "trailer", + "turn", + 3, + ), + "tractor_pre_turn_speed_delta": _vehicle_vector_metric( + original_attach, + replacement_attach, + "pre", + "tractor", + "turn", + 3, + ), + "trailer_pre_flags_xor": _integer_xor( + _vehicle_scalar(original_attach, "pre", "trailer", "flags"), + _vehicle_scalar( + replacement_attach, "pre", "trailer", "flags" + ), + ), + "tractor_pre_flags_xor": _integer_xor( + _vehicle_scalar(original_attach, "pre", "tractor", "flags"), + _vehicle_scalar( + replacement_attach, "pre", "tractor", "flags" + ), + ), + "trailer_pre_status_mismatch": _bool_mismatch( + _vehicle_scalar(original_attach, "pre", "trailer", "status"), + _vehicle_scalar( + replacement_attach, "pre", "trailer", "status" + ), + ), + "tractor_pre_status_mismatch": _bool_mismatch( + _vehicle_scalar(original_attach, "pre", "tractor", "status"), + _vehicle_scalar( + replacement_attach, "pre", "tractor", "status" + ), + ), + } + + original_first_ms = ( + original_frames[common_indices[0]].values.get("pre_game_ms") + if common_indices + else None + ) + replacement_first_ms = ( + replacement_frames[common_indices[0]].values.get("pre_game_ms") + if common_indices + else None + ) + + def elapsed_delta( + original: PhysicsRecord, replacement: PhysicsRecord + ) -> float | None: + original_elapsed = _elapsed_u32( + original.values.get("pre_game_ms"), original_first_ms + ) + replacement_elapsed = _elapsed_u32( + replacement.values.get("pre_game_ms"), replacement_first_ms + ) + if original_elapsed is None or replacement_elapsed is None: + return None + return abs(float(original_elapsed - replacement_elapsed)) + + pre_link_mismatches = [ + _bool_mismatch(_linked(original, "pre"), _linked(replacement, "pre")) + for original, replacement in pairs + ] + post_link_mismatches = [ + _bool_mismatch(_linked(original, "post"), _linked(replacement, "post")) + for original, replacement in pairs + ] + + return { + "ordinal": ordinal, + "original_generation": original_generation, + "replacement_generation": replacement_generation, + "attach": attach_delta, + "frames": { + "original_observed": len(original_frames), + "replacement_observed": len(replacement_frames), + "common": len(common_indices), + "common_indices": common_indices, + "missing_in_replacement": sorted( + set(original_frames) - set(replacement_frames) + ), + "extra_in_replacement": sorted( + set(replacement_frames) - set(original_frames) + ), + "original_duplicate_frames": original_duplicates, + "replacement_duplicate_frames": replacement_duplicates, + }, + "metrics": { + "trailer_step_vector_delta_m": _metric( + pairs, + lambda original, replacement: _vector_distance( + original.values.get("trailer_dpos"), + replacement.values.get("trailer_dpos"), + 3, + ), + ), + "tractor_step_vector_delta_m": _metric( + pairs, + lambda original, replacement: _vector_distance( + original.values.get("tractor_dpos"), + replacement.values.get("tractor_dpos"), + 3, + ), + ), + "trailer_pre_position_residual_m": _metric( + pairs, + lambda original, replacement: _vehicle_vector_metric( + original, replacement, "pre", "trailer", "pos", 3 + ), + ), + "trailer_post_position_residual_m": _metric( + pairs, + lambda original, replacement: _vehicle_vector_metric( + original, replacement, "post", "trailer", "pos", 3 + ), + ), + "tractor_pre_position_residual_m": _metric( + pairs, + lambda original, replacement: _vehicle_vector_metric( + original, replacement, "pre", "tractor", "pos", 3 + ), + ), + "trailer_pre_move_speed_delta": _metric( + pairs, + lambda original, replacement: _vehicle_vector_metric( + original, replacement, "pre", "trailer", "move", 3 + ), + ), + "trailer_pre_turn_speed_delta": _metric( + pairs, + lambda original, replacement: _vehicle_vector_metric( + original, replacement, "pre", "trailer", "turn", 3 + ), + ), + "trailer_move_speed_effect_delta": _metric( + pairs, + lambda original, replacement: _effect_delta( + _state_effect(original, "trailer", "move", 3), + _state_effect(replacement, "trailer", "move", 3), + ), + ), + "trailer_turn_speed_effect_delta": _metric( + pairs, + lambda original, replacement: _effect_delta( + _state_effect(original, "trailer", "turn", 3), + _state_effect(replacement, "trailer", "turn", 3), + ), + ), + "tractor_move_speed_effect_delta": _metric( + pairs, + lambda original, replacement: _effect_delta( + _state_effect(original, "tractor", "move", 3), + _state_effect(replacement, "tractor", "move", 3), + ), + ), + "support_pre_delta": _metric( + pairs, + lambda original, replacement: _detail_vector_metric( + original, replacement, "pre", "support", 5 + ), + ), + "support_effect_delta": _metric( + pairs, + lambda original, replacement: _effect_delta( + _detail_effect(original, "support", 5), + _detail_effect(replacement, "support", 5), + ), + ), + "wheel_compression_pre_delta": _metric( + pairs, + lambda original, replacement: _detail_vector_metric( + original, replacement, "pre", "wheel", 4 + ), + ), + "wheel_compression_effect_delta": _metric( + pairs, + lambda original, replacement: _effect_delta( + _detail_effect(original, "wheel", 4), + _detail_effect(replacement, "wheel", 4), + ), + ), + "pre_timestep_abs_delta": _metric( + pairs, + lambda original, replacement: ( + abs(original_value - replacement_value) + if ( + original_value := _finite_float( + original.values.get("pre_timestep") + ) + ) + is not None + and ( + replacement_value := _finite_float( + replacement.values.get("pre_timestep") + ) + ) + is not None + else None + ), + ), + "relative_game_time_abs_delta_ms": _metric(pairs, elapsed_delta), + }, + "state_mismatches": { + "pre_link": sum(value is True for value in pre_link_mismatches), + "post_link": sum(value is True for value in post_link_mismatches), + "pre_link_comparable": sum(value is not None for value in pre_link_mismatches), + "post_link_comparable": sum( + value is not None for value in post_link_mismatches + ), + }, + } + + +def compare_traces( + original_trace: PhysicsTrace, replacement_trace: PhysicsTrace +) -> dict[str, Any]: + """Diff generations by attach encounter order, never by raw pointer.""" + original_generations = _generation_order(original_trace) + replacement_generations = _generation_order(replacement_trace) + original_attach_count = sum( + record.kind == "set_tow_link" for record in original_trace.records + ) + replacement_attach_count = sum( + record.kind == "set_tow_link" for record in replacement_trace.records + ) + paired = min(len(original_generations), len(replacement_generations)) + return { + "schema_version": 1, + "comparison": "original_vs_replacement", + "evidence": EVIDENCE, + "assessment": "MEASURED_NO_PARITY_THRESHOLD", + "pointer_policy": "raw_addresses_ignored_relationships_compared", + "generation_pairing_policy": "successful_attach_encounter_order", + "set_tow_link_comparison": { + "original_events": original_attach_count, + "replacement_events": replacement_attach_count, + "replacement_minus_original": ( + replacement_attach_count - original_attach_count + ), + }, + "original": summarize_trace(original_trace), + "replacement": summarize_trace(replacement_trace), + "generation_pairs": [ + _generation_diff( + original_trace, + replacement_trace, + original_generations[index], + replacement_generations[index], + index, + ) + for index in range(paired) + ], + "unpaired_generations": { + "original": original_generations[paired:], + "replacement": replacement_generations[paired:], + }, + } + + +def _render(result: dict[str, Any], output: Path | None) -> None: + rendered = json.dumps(result, indent=2, sort_keys=True) + "\n" + if output is not None: + output.write_text(rendered, encoding="utf-8") + print(rendered, end="") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + summary_parser = subparsers.add_parser( + "summary", help="summarize one samp_probe.log" + ) + summary_parser.add_argument("log", type=Path) + summary_parser.add_argument("--output", type=Path) + + diff_parser = subparsers.add_parser( + "diff", help="compare original and replacement samp_probe.log files" + ) + diff_parser.add_argument("original", type=Path) + diff_parser.add_argument("replacement", type=Path) + diff_parser.add_argument("--output", type=Path) + + args = parser.parse_args(argv) + try: + if args.command == "summary": + trace = load_trace(args.log) + result = summarize_trace(trace) + _render(result, args.output) + return 0 if trace.records else 1 + + original = load_trace(args.original) + replacement = load_trace(args.replacement) + result = compare_traces(original, replacement) + _render(result, args.output) + return 0 if original.records and replacement.records else 1 + except (OSError, TrailerPhysicsAnalysisError) as error: + parser.error(str(error)) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/reloop/control_client.py b/tools/reloop/control_client.py index 49f889a..29a823c 100755 --- a/tools/reloop/control_client.py +++ b/tools/reloop/control_client.py @@ -5,6 +5,7 @@ import argparse import json +import os import socket import time from pathlib import Path @@ -76,6 +77,14 @@ def chat_command(client: ControlClient, command: str) -> None: client.command("window_key", vk=VK_RETURN) +def request_samp_screenshot(client: ControlClient) -> dict[str, Any]: + """Use the guarded R5 request flag, with ordinary F8 for the replacement.""" + try: + return client.command("samp_screenshot") + except RuntimeError: + return client.key(VK_F8, "tap") + + def run_scenario(output_path: Path, settle: float) -> None: states: list[dict[str, Any]] = [] client = wait_for_api() @@ -86,6 +95,12 @@ def run_scenario(output_path: Path, settle: float) -> None: time.sleep(max(3.0, settle)) baseline = sample(client, "baseline", states) + if os.environ.get("SAMP_RELOOP_RPC73_REPLACE") == "1": + chat_command(client, "/rpc73replace") + time.sleep(max(1.0, settle)) + request_samp_screenshot(client) + time.sleep(0.4) + # Chat: opening T must own the mouse and stop GTA control/camera input. client.command("char", code=ord("t")) time.sleep(settle) @@ -107,7 +122,7 @@ def run_scenario(output_path: Path, settle: float) -> None: chat_command(client, "/menutest") time.sleep(max(1.0, settle)) sample(client, "legacy_menu_open", states) - client.key(VK_F8, "tap") + request_samp_screenshot(client) time.sleep(0.4) client.key(VK_DOWN, "tap") time.sleep(settle) @@ -119,15 +134,30 @@ def run_scenario(output_path: Path, settle: float) -> None: time.sleep(max(1.0, settle)) sample(client, "legacy_menu_closed", states) - # Scoreboard: TAB owns mouse/input; first player row is centered below header. - client.key(9, "down") + # OBSERVED_037 + PROBE_TRACE + STATIC_037: + # R5 WndProc toggles CScoreboard+0x0 on WM_KEYUP/VK_TAB + # (samp.dll+0x61785..+0x617B6). One complete pulse opens it and a + # second complete pulse closes it; visibility is not a physical-key + # hold. + client.key(9, "tap") time.sleep(settle) scoreboard = sample(client, "scoreboard_open", states) row_x = scoreboard["client_w"] // 2 row_y = scoreboard["client_h"] // 2 - # OBSERVED_037 calibration: R5 accepts RMB as the explicit scoreboard - # cursor trigger. The replacement already enables mouse mode on TAB; - # this event is harmless there and lets us compare the interactive path. + # OBSERVED_037 + PROBE_TRACE: + # Show already acquires R5 cursor mode 3, but that mode leaves GTA's + # gameplay-input call intact. Capture the exclusive scoreboard frame, + # then verify that ordinary movement remains available. + request_samp_screenshot(client) + time.sleep(0.4) + client.key(ord("W"), "down") + client.command("mouse", action="move", x=row_x + 45, y=row_y) + time.sleep(settle) + sample(client, "scoreboard_plain_input_attempt", states) + client.key(ord("W"), "up") + # OBSERVED_037 + PROBE_TRACE: Show already selects cursor mode 3. + # Exercise RMB as a forwarded GUI edge, but require GTA gameplay input + # to remain enabled just as it does before the edge. client.command("mouse", action="right_click", x=row_x, y=row_y) time.sleep(settle) sample(client, "scoreboard_mouse_mode", states) @@ -146,7 +176,7 @@ def run_scenario(output_path: Path, settle: float) -> None: time.sleep(0.5) time.sleep(settle) sample(client, "scoreboard_clicked", states) - client.key(9, "up") + client.key(9, "tap") time.sleep(settle) sample(client, "scoreboard_closed", states) finally: diff --git a/tools/reloop/distributed_sync_runner.py b/tools/reloop/distributed_sync_runner.py new file mode 100644 index 0000000..b4b8ea0 --- /dev/null +++ b/tools/reloop/distributed_sync_runner.py @@ -0,0 +1,1996 @@ +#!/usr/bin/env python3 +"""Coordinate focused sync_pair scenarios across host Original R5 and Windows. + +The default topology is: + + local Original R5 SyncPilot + native-Windows SyncObserver + +For death and GMX state-machine probes, ``--windows-role=pilot`` reverses the +roles so the native-Windows client receives the transition. Windows deployment +and probe-profile changes are opt-in only. Successful automation produces +trace and screenshot artifacts; it never asserts visual parity. +""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import contextlib +import json +import re +import shlex +import shutil +import subprocess +import sys +import time +import traceback +from pathlib import Path +from typing import Any + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[1] +sys.path.insert(0, str(SCRIPT_DIR)) + +import reloop # noqa: E402 +import lifecycle_probe # noqa: E402 +import sync_edge_probe # noqa: E402 +import sync_pair_client # noqa: E402 +import windows_sync_edge_probe as windows_edge # noqa: E402 +from control_client import wait_for_api # noqa: E402 + + +SCENARIOS = ("pistol", "m4", "sniper", "angles", "jetpack", "death", "pickup") +GMX_SCENARIO = "gmx" +UI_LATCH_SCENARIO = "ui_latches" +WINDOWS_ROLES = ("observer", "pilot") +WEAPON_IDS = {"pistol": 22, "m4": 31, "sniper": 34} +UI_LATCH_ACTIONS = ( + # STATIC_037: WndProc toggles the scoreboard on WM_KEYUP at + # samp.dll+0x61785..+0x617B6. Two complete key pulses are therefore + # required to observe one show edge followed by one hide edge. + ("tab_show_keyup", "TAB"), + ("tab_hide_keyup", "TAB"), + ("chat_open_edge", "F6"), + ("chat_close_edge", "F6"), + ("chat_mode_edge_1", "F7"), + ("chat_mode_edge_2", "F7"), + ("chat_mode_edge_3", "F7"), +) +ORIGINAL_R5_SHA256 = windows_edge.ORIGINAL_R5_SHA256 +AUTOPAUSE_INI = sync_edge_probe.AUTOPAUSE_INI +CONTROL_PORT = sync_edge_probe.CONTROL_PORT +LAB_WRAPPER = windows_edge.LAB_WRAPPER +SAFE_HOST = windows_edge.SAFE_HOST +CRASH_MARKERS = sync_edge_probe.CRASH_MARKERS +PROBE_PROFILE_FLAGS: dict[str, tuple[str, ...]] = { + "passive": (), + "no-hooks": ("samp_probe_no_hooks.flag",), + "asset-paths": ("samp_probe_asset_paths.flag",), + "custom-object-heavy": ("samp_probe_custom_object_heavy.flag",), + "textdraw": ("samp_probe_textdraw_hooks.flag",), + "textdraw-verbose": ( + "samp_probe_textdraw_hooks.flag", + "samp_probe_textdraw_verbose.flag", + ), + "textdraw-render": ("samp_probe_textdraw_render.flag",), + "font5": ("samp_probe_font5_hooks.flag",), + "actor": ("samp_probe_actor_hooks.flag",), + "actor-heavy": ("samp_probe_actor_heavy.flag",), + "rpc-gap": ("samp_probe_rpc_gap_hooks.flag",), + "dialog-menu": ("samp_probe_dialog_menu_rpc_hooks.flag",), + "trailer-r5": ( + "samp_probe_trailer_sync_hooks.flag", + "samp_probe_trailer_physics_hooks.flag", + ), + "vehicle-lifecycle": ("samp_probe_vehicle_lifecycle_hooks.flag",), + "aim-bullet-jetpack": ("samp_probe_aim_bullet_jetpack_hooks.flag",), + "death-cleanup": ("samp_probe_death_cleanup_hooks.flag",), + "ui-latches-r5": ("samp_probe_ui_latches_hooks.flag",), +} + + +class DistributedSyncError(RuntimeError): + """Expected operator-facing distributed runner failure.""" + + +def validate_role_scenario(windows_role: str, scenario: str) -> None: + """Keep the reversed topology limited to Windows state-machine probes.""" + if windows_role not in WINDOWS_ROLES: + raise DistributedSyncError(f"unsupported Windows role: {windows_role}") + if windows_role == "pilot" and scenario not in ( + "death", + GMX_SCENARIO, + UI_LATCH_SCENARIO, + ): + raise DistributedSyncError( + "--windows-role=pilot is only supported with " + "--scenario=death, --scenario=gmx, or --scenario=ui_latches" + ) + if ( + scenario in (GMX_SCENARIO, UI_LATCH_SCENARIO) + and windows_role != "pilot" + ): + raise DistributedSyncError( + f"--scenario={scenario} requires --windows-role=pilot" + ) + + +def validate_death_f4( + death_f4: bool, + windows_role: str, + scenario: str, +) -> None: + if death_f4 and not (windows_role == "pilot" and scenario == "death"): + raise DistributedSyncError( + "--death-f4 requires --scenario=death and --windows-role=pilot" + ) + + +def validate_ui_latch_profile(scenario: str, probe_profile: str | None) -> None: + if scenario == UI_LATCH_SCENARIO and probe_profile != "ui-latches-r5": + raise DistributedSyncError( + "--scenario=ui_latches requires " + "--windows-probe-profile=ui-latches-r5" + ) + + +def topology_for_role(windows_role: str) -> str: + if windows_role == "pilot": + return "native_windows_pilot+local_original_r5_observer" + return "local_original_r5_pilot+native_windows_observer" + + +def optional_sha256(path: Path) -> str | None: + return reloop.sha256(path) if path.is_file() else None + + +def expand_scenarios(value: str) -> list[str]: + if value == "all": + return list(SCENARIOS) + if value in (GMX_SCENARIO, UI_LATCH_SCENARIO): + return [value] + if value not in SCENARIOS: + raise DistributedSyncError(f"unsupported distributed scenario: {value}") + return [value] + + +def _find(value: Any, key: str) -> Any | None: + return windows_edge._find_key(value, key) + + +def parse_windows_preflight(text: str) -> dict[str, Any]: + """Extract the read-only lab state returned by the interactive agent.""" + value = windows_edge._json_value(text) + if value is None: + raise DistributedSyncError("Windows ping returned no JSON state") + processes = _find(value, "processes") + autopause = _find(value, "autopause") + probe_flags = _find(value, "probe_flags") + state = { + "processes": processes, + "game_dir": _find(value, "game_dir"), + "gta_sha256": _find(value, "gta_sha256"), + "samp_sha256": _find(value, "samp_sha256"), + "samp_probe_sha256": _find(value, "samp_probe_sha256"), + "reloop_control_sha256": _find(value, "reloop_control_sha256"), + "autopause": autopause, + "probe_flags": probe_flags, + } + if not isinstance(processes, list): + raise DistributedSyncError( + "Windows ping did not expose an authoritative process inventory" + ) + if not isinstance(autopause, dict) or autopause.get("disabled") is not True: + path = autopause.get("path") if isinstance(autopause, dict) else None + observed = autopause.get("value") if isinstance(autopause, dict) else None + raise DistributedSyncError( + "Windows requires [game] autoPause = 0 in " + f"{path or AUTOPAUSE_INI}; observed value={observed!r}" + ) + if not isinstance(probe_flags, list) or not all( + isinstance(item, str) for item in probe_flags + ): + raise DistributedSyncError("Windows ping did not expose probe flag state") + for key in ("gta_sha256", "samp_sha256"): + if not isinstance(state[key], str) or len(state[key]) != 64: + raise DistributedSyncError(f"Windows ping returned no valid {key}") + return state + + +def validate_windows_idle(state: dict[str, Any]) -> None: + processes = state.get("processes") + if not isinstance(processes, list) or processes: + raise DistributedSyncError( + f"Windows GTA/SA-MP is not idle: processes={processes!r}" + ) + + +def expected_profile_flags(profile: str | None) -> tuple[str, ...] | None: + if profile is None: + return None + try: + return PROBE_PROFILE_FLAGS[profile] + except KeyError as error: + raise DistributedSyncError( + f"unsupported managed probe profile: {profile}" + ) from error + + +def validate_probe_state( + state: dict[str, Any], + requested_profile: str | None, +) -> None: + active = tuple(sorted(state.get("probe_flags", []))) + expected = expected_profile_flags(requested_profile) + if expected is None: + if active: + raise DistributedSyncError( + "Windows has unmanaged probe flags active; either clear them " + "manually or explicitly request --windows-probe-profile passive: " + f"{list(active)}" + ) + return + if active != tuple(sorted(expected)): + raise DistributedSyncError( + f"Windows probe profile mismatch: expected={sorted(expected)} " + f"active={list(active)}" + ) + if expected and not state.get("samp_probe_sha256"): + raise DistributedSyncError( + f"probe profile {requested_profile!r} requires samp_probe.asi" + ) + + +def verify_profile_receipt(text: str, requested_profile: str) -> dict[str, Any]: + value = windows_edge._json_value(text) + if value is None: + raise DistributedSyncError("probe-profile command returned no JSON") + profile = _find(value, "profile") + enabled = _find(value, "enabled") + expected = sorted(expected_profile_flags(requested_profile) or ()) + if profile != requested_profile or not isinstance(enabled, list): + raise DistributedSyncError( + f"probe-profile receipt is incomplete for {requested_profile!r}" + ) + if sorted(enabled) != expected: + raise DistributedSyncError( + f"probe-profile receipt mismatch: expected={expected} enabled={enabled}" + ) + return { + "profile": profile, + "enabled": enabled, + "probe_sha256": _find(value, "probe_sha256"), + } + + +def verify_deploy_receipt( + text: str, + candidate: dict[str, Any], + *, + kind: str, +) -> dict[str, Any]: + value = windows_edge._json_value(text) + if value is None: + raise DistributedSyncError(f"Windows {kind} deployment returned no JSON") + installed_hash = _find(value, "target_sha256_after") + if installed_hash != candidate["sha256"]: + raise DistributedSyncError( + f"Windows {kind} deployment hash mismatch: " + f"candidate={candidate['sha256']} installed={installed_hash}" + ) + return { + "candidate_sha256": candidate["sha256"], + "installed_sha256": installed_hash, + "backup": _find(value, "backup"), + } + + +def windows_mutation_plan( + dll_candidate: dict[str, Any] | None, + probe_candidate: dict[str, Any] | None, + probe_profile: str | None, + label: str, +) -> list[tuple[str, list[str]]]: + """Return only explicitly requested Windows mutations.""" + actions: list[tuple[str, list[str]]] = [] + if dll_candidate is not None: + path = str(dll_candidate["path"]) + actions.extend( + ( + ("dll-validate", ["validate", path]), + ("dll-deploy", ["deploy", path, label]), + ) + ) + if probe_candidate is not None: + actions.append( + ( + "probe-deploy", + ["deploy-probe", str(probe_candidate["path"]), label], + ) + ) + if probe_profile is not None: + actions.append( + ("probe-profile", ["probe-profile", probe_profile]) + ) + return actions + + +def validate_local_layout( + settings: reloop.Settings, + local_client: reloop.ClientProfile, + windows_role: str = "observer", +) -> dict[str, Any]: + layout = windows_edge.validate_layout(settings, local_client, windows_role) + if reloop.sha256(local_client.samp_dll) != ORIGINAL_R5_SHA256: + raise DistributedSyncError( + "local Original client is not the documented Original R5" + ) + installed_control = local_client.gta_root / reloop.CONTROL_ASI.name + if windows_role == "observer" and not installed_control.is_file(): + raise DistributedSyncError( + f"local Original pilot control ASI is missing: {installed_control}" + ) + return { + **layout, + "topology": topology_for_role(windows_role), + "local_control_path": str(installed_control), + "local_control_sha256": ( + reloop.sha256(installed_control) if installed_control.is_file() else None + ), + "local_control_required": windows_role == "observer", + } + + +def run_driver( + scenario: str, + artifact_dir: Path, + *, + fixture_timeout: float, + action_seconds: float, + screenshot_count: int, + screenshot_interval: float, +) -> int: + """Reuse sync_pair_client without importing any scenario implementation.""" + output_path = artifact_dir / "driver" / f"{scenario}.json" + console_path = artifact_dir / "driver" / f"{scenario}.console.log" + output_path.parent.mkdir(parents=True, exist_ok=True) + label = f"dist-sync-{scenario}-{int(time.time())}" + command = [ + sys.executable, + str(SCRIPT_DIR / "sync_pair_client.py"), + scenario, + "--output", + str(output_path), + "--action-seconds", + str(action_seconds), + "--observer-screenshot-label", + label, + "--observer-screenshot-count", + str(screenshot_count), + "--observer-screenshot-interval", + str(screenshot_interval), + "--sync-pair-request-timeout", + str(fixture_timeout), + ] + capture_seconds = screenshot_count * max(0.025, screenshot_interval) + with console_path.open("wb") as handle: + completed = subprocess.run( + command, + cwd=REPO_ROOT, + stdout=handle, + stderr=subprocess.STDOUT, + timeout=fixture_timeout + action_seconds + capture_seconds + 60.0, + check=False, + ) + return completed.returncode + + +def wait_for_death_event( + server_log: Path, + start_offset: int, + request_id: int, + timeout_seconds: float, + output: list[dict[str, Any]], +) -> str: + """Wait for the client-originated death callback in this request block.""" + deadline = time.monotonic() + timeout_seconds + request_token = f"request={request_id} " + while time.monotonic() < deadline: + if server_log.exists(): + data = server_log.read_bytes() + if len(data) < start_offset: + start_offset = 0 + appended = data[start_offset:].decode("utf-8", errors="replace") + request_seen = ( + "marker=SCENARIO_START" in appended + and request_token in appended + and "scenario=death " in appended + ) + death_line = next( + ( + line + for line in appended.splitlines() + if "marker=PLAYER_DEATH " in line + ), + None, + ) + if request_seen and death_line is not None: + event = { + "event": "windows_pilot_death_observed", + "scenario": "death", + "request_id": request_id, + "result": death_line, + "host_time": time.time(), + } + output.append(event) + print(json.dumps(event, sort_keys=True)) + return death_line + time.sleep(0.05) + raise TimeoutError( + f"sync_pair emitted no PLAYER_DEATH for request {request_id} " + f"within {timeout_seconds:.1f}s" + ) + + +def run_windows_death_driver( + artifact_dir: Path, + *, + request_path: Path, + results_path: Path, + server_log: Path, + fixture_timeout: float, + screenshot_count: int, + screenshot_interval: float, + death_f4: bool = False, + lab_timeout: float = 45.0, +) -> int: + """Capture the Windows SyncPilot while the host queues its death.""" + scenario = "death" + output_path = artifact_dir / "driver" / f"{scenario}.json" + console_path = artifact_dir / "driver" / f"{scenario}.console.log" + output_path.parent.mkdir(parents=True, exist_ok=True) + output: list[dict[str, Any]] = [] + returncode = 0 + screenshot_label = f"dist-sync-death-pilot-{int(time.time())}" + start_offset = server_log.stat().st_size if server_log.exists() else 0 + + with console_path.open("w", encoding="utf-8") as handle: + with contextlib.redirect_stdout(handle), contextlib.redirect_stderr(handle): + try: + if death_f4: + f4_started = time.time() + requested = { + "event": "windows_pilot_death_f4_requested", + "scenario": scenario, + "input_action": "CLASS", + "phase": "before_death_request", + "host_time": f4_started, + } + output.append(requested) + print(json.dumps(requested, sort_keys=True)) + windows_edge.run_lab( + artifact_dir, + "pilot-death-f4", + [ + "key", + "CLASS", + f"dist-sync-death-f4-{int(f4_started)}", + ], + timeout=lab_timeout, + ) + # Preserve a visible, timestamped edge between the F4 + # latch and the server-side SetPlayerHealth(0) request. + f4_settle_seconds = 0.75 + time.sleep(f4_settle_seconds) + completed = { + "event": "windows_pilot_death_f4_completed", + "scenario": scenario, + "input_action": "CLASS", + "phase": "before_death_request", + "started_host_time": f4_started, + "completed_host_time": time.time(), + "settle_seconds": f4_settle_seconds, + } + output.append(completed) + print(json.dumps(completed, sort_keys=True)) + sync_pair_client.capture_observer( + f"{screenshot_label}-before", + 1, + 0.0, + ) + # Begin the burst before the one-shot host request. The server + # request is independent of the Windows command queue, so the + # captured frames span SetPlayerHealth(0) and OnPlayerDeath. + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + burst = pool.submit( + sync_pair_client.capture_observer, + screenshot_label, + screenshot_count, + screenshot_interval, + ) + time.sleep(min(1.0, max(0.5, screenshot_interval * 4.0))) + request_id = sync_pair_client.queue_sync_pair_scenario( + scenario, + request_path, + results_path, + fixture_timeout, + output, + ) + wait_for_death_event( + server_log, + start_offset, + request_id, + fixture_timeout, + output, + ) + burst.result( + timeout=fixture_timeout + + screenshot_count * max(0.025, screenshot_interval) + + 30.0 + ) + output.append( + { + "event": "windows_pilot_death_capture_completed", + "scenario": scenario, + "request_id": request_id, + "screenshot_label": screenshot_label, + "screenshot_count": screenshot_count, + "screenshot_interval": screenshot_interval, + "host_time": time.time(), + } + ) + except Exception as error: + returncode = 1 + output.append( + { + "event": "windows_pilot_death_driver_error", + "scenario": scenario, + "error": f"{type(error).__name__}: {error}", + "host_time": time.time(), + } + ) + traceback.print_exc() + reloop.write_json( + output_path, + { + "scenario": scenario, + "windows_role": "pilot", + "death_f4": death_f4, + "returncode": returncode, + "events": output, + }, + ) + return returncode + + +def run_windows_ui_latches_driver( + artifact_dir: Path, + *, + lab_timeout: float = 45.0, +) -> int: + """Exercise only the fixed TAB/F6/F7 actions on Windows Original R5.""" + scenario = UI_LATCH_SCENARIO + output_path = artifact_dir / "driver" / f"{scenario}.json" + console_path = artifact_dir / "driver" / f"{scenario}.console.log" + output_path.parent.mkdir(parents=True, exist_ok=True) + output: list[dict[str, Any]] = [] + returncode = 0 + + with console_path.open("w", encoding="utf-8") as handle: + with contextlib.redirect_stdout(handle), contextlib.redirect_stderr(handle): + try: + for step, key in UI_LATCH_ACTIONS: + started = time.time() + requested = { + "event": "windows_pilot_ui_latch_key_requested", + "scenario": scenario, + "step": step, + "input_action": key, + "host_time": started, + } + output.append(requested) + print(json.dumps(requested, sort_keys=True)) + windows_edge.run_lab( + artifact_dir, + f"pilot-ui-latches-{step}", + [ + "key", + key, + f"dist-sync-ui-latches-{step}", + ], + timeout=lab_timeout, + ) + completed = { + "event": "windows_pilot_ui_latch_key_completed", + "scenario": scenario, + "step": step, + "input_action": key, + "started_host_time": started, + "completed_host_time": time.time(), + } + output.append(completed) + print(json.dumps(completed, sort_keys=True)) + except Exception as error: + returncode = 1 + output.append( + { + "event": "windows_pilot_ui_latch_driver_error", + "scenario": scenario, + "error": f"{type(error).__name__}: {error}", + "host_time": time.time(), + } + ) + traceback.print_exc() + + reloop.write_json( + output_path, + { + "scenario": scenario, + "windows_role": "pilot", + "input_contract": { + "bounded_actions": [key for _step, key in UI_LATCH_ACTIONS], + "tab_keyup_pulses": 2, + "tab_hold_ms": 750, + "f6_f7_hold_ms": 100, + "pause_or_escape_automation": False, + }, + "returncode": returncode, + "events": output, + }, + ) + return returncode + + +def wait_for_gmx_server_restart( + server_console: Path, + start_offset: int, + nickname: str, + timeout_seconds: float, + output: list[dict[str, Any]], + process: subprocess.Popen[Any] | None = None, +) -> str: + """Wait for the new gamemode and this run's Windows pilot to rejoin.""" + deadline = time.monotonic() + timeout_seconds + banner = "Bare open.mp Vehicle/Object Test Script" + join_pattern = re.compile( + rf"\[bare-rpctest\] RPC137 ServerJoin player=\d+ " + rf"name={re.escape(nickname)}\b" + ) + while time.monotonic() < deadline: + if server_console.exists(): + data = server_console.read_bytes() + if len(data) < start_offset: + start_offset = 0 + appended = data[start_offset:].decode("utf-8", errors="replace") + banner_index = appended.find(banner) + if banner_index >= 0: + joined = join_pattern.search(appended, banner_index) + if joined is not None: + event = { + "event": "windows_pilot_gmx_server_restart_observed", + "scenario": GMX_SCENARIO, + "server_banner": banner, + "server_join": joined.group(0), + "host_time": time.time(), + } + output.append(event) + print(json.dumps(event, sort_keys=True)) + return joined.group(0) + if process is not None and process.poll() is not None: + raise DistributedSyncError( + "open.mp exited while waiting for the post-GMX restart" + ) + time.sleep(0.05) + raise TimeoutError( + f"open.mp emitted no post-GMX restart and {nickname} rejoin " + f"within {timeout_seconds:.1f}s" + ) + + +def run_windows_gmx_driver( + artifact_dir: Path, + *, + server: lifecycle_probe.StdinProcess, + server_console: Path, + fixture_timeout: float, + screenshot_count: int, + screenshot_interval: float, + nickname: str = "SyncPilot", +) -> int: + """Send GMX through open.mp stdin and capture the Windows pilot transition.""" + output_path = artifact_dir / "driver" / f"{GMX_SCENARIO}.json" + console_path = artifact_dir / "driver" / f"{GMX_SCENARIO}.console.log" + output_path.parent.mkdir(parents=True, exist_ok=True) + output: list[dict[str, Any]] = [] + returncode = 0 + screenshot_label = f"dist-sync-gmx-pilot-{int(time.time())}" + start_offset = server_console.stat().st_size if server_console.exists() else 0 + + with console_path.open("w", encoding="utf-8") as handle: + with contextlib.redirect_stdout(handle), contextlib.redirect_stderr(handle): + try: + sync_pair_client.capture_observer( + f"{screenshot_label}-before", + 1, + 0.0, + ) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + burst = pool.submit( + sync_pair_client.capture_observer, + screenshot_label, + screenshot_count, + screenshot_interval, + ) + time.sleep(min(1.0, max(0.5, screenshot_interval * 4.0))) + server.send("gmx") + sent = { + "event": "windows_pilot_gmx_sent", + "scenario": GMX_SCENARIO, + "source": "open.mp_console", + "host_time": time.time(), + } + output.append(sent) + print(json.dumps(sent, sort_keys=True)) + wait_for_gmx_server_restart( + server_console, + start_offset, + nickname, + fixture_timeout, + output, + server.process, + ) + burst.result( + timeout=fixture_timeout + + screenshot_count * max(0.025, screenshot_interval) + + 30.0 + ) + sync_pair_client.capture_observer( + f"{screenshot_label}-after", + 1, + 0.0, + ) + output.append( + { + "event": "windows_pilot_gmx_capture_completed", + "scenario": GMX_SCENARIO, + "screenshot_label": screenshot_label, + "screenshot_count": screenshot_count, + "screenshot_interval": screenshot_interval, + "post_restart_screenshot": ( + f"{screenshot_label}-after" + ), + "host_time": time.time(), + } + ) + except Exception as error: + returncode = 1 + output.append( + { + "event": "windows_pilot_gmx_driver_error", + "scenario": GMX_SCENARIO, + "error": f"{type(error).__name__}: {error}", + "host_time": time.time(), + } + ) + traceback.print_exc() + reloop.write_json( + output_path, + { + "scenario": GMX_SCENARIO, + "windows_role": "pilot", + "command_source": "open.mp_console", + "returncode": returncode, + "events": output, + }, + ) + return returncode + + +def collect_local_artifacts( + artifact_dir: Path, + server_snapshot: reloop.FileSnapshot, + result_snapshot: reloop.FileSnapshot, + local_snapshots: dict[str, reloop.FileSnapshot], + local_role: str = "pilot", +) -> str: + server_snapshot.capture_append(artifact_dir / "server.log") + result_snapshot.capture_append(artifact_dir / "sync-pair-results.log") + return sync_edge_probe.collect_logs( + artifact_dir / local_role / "client", + local_snapshots, + ) + + +def driver_request_ids(artifact_dir: Path) -> dict[str, int]: + request_ids: dict[str, int] = {} + for path in sorted((artifact_dir / "driver").glob("*.json")): + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + events = value if isinstance(value, list) else value.get("events", []) + if not isinstance(events, list): + continue + for event in events: + if not isinstance(event, dict): + continue + request_id = event.get("request_id") + if ( + event.get("event") == "sync_pair_scenario_queued" + and isinstance(request_id, int) + and request_id > 0 + ): + request_ids[path.stem] = request_id + break + return request_ids + + +def owned_request_ids(artifact_dir: Path) -> set[int]: + return set(driver_request_ids(artifact_dir).values()) + + +def cleanup_owned_request( + request_path: Path, + artifact_dir: Path, +) -> tuple[bool, str | None]: + """Remove only a request ID proven to have been emitted by this run.""" + if not request_path.exists(): + return True, None + try: + raw = request_path.read_text(encoding="utf-8", errors="replace") + except OSError as error: + return False, f"read_failed:{type(error).__name__}:{error}" + match = re.fullmatch(r"\s*(\d+)\s+([A-Za-z0-9_]+)\s*", raw) + ids = owned_request_ids(artifact_dir) + if not match or int(match.group(1)) not in ids: + return False, "pending_request_not_owned" + saved = artifact_dir / "cleanup" / "pending-sync-pair-request.txt" + saved.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(request_path, saved) + try: + request_path.unlink() + except OSError as error: + return False, f"unlink_failed:{type(error).__name__}:{error}" + return not request_path.exists(), None + + +def _contains_crash(text: str) -> bool: + lowered = text.lower() + return any(marker in lowered for marker in CRASH_MARKERS) + + +def request_trace(text: str, request_id: int | None) -> str: + """Return only fixture blocks opened by this driver's request ID.""" + if request_id is None: + return "" + selected: list[str] = [] + active = False + for line in text.splitlines(): + if "marker=ARTIFACT_BOUNDARY" in line: + active = False + continue + accepted = re.search( + r"marker=REQUEST_ACCEPTED\b[^\r\n]*\brequest=(\d+)\b", + line, + ) + if accepted: + active = int(accepted.group(1)) == request_id + if active: + selected.append(line) + return "\n".join(selected) + + +def _request_done(text: str, scenario: str, request_id: int | None) -> bool: + if request_id is None: + return False + return bool( + re.search( + rf"marker=REQUEST_DONE\b[^\r\n]*\brequest={request_id}\b" + rf"[^\r\n]*\bstatus=PASS\b" + rf"[^\r\n]*\bscenario={re.escape(scenario)}\b", + text, + ) + ) + + +def gmx_server_cycle_checks(server_text: str, nickname: str) -> tuple[bool, bool]: + """Require both initial and post-GMX markers inside one artifact slice.""" + banner = "Bare open.mp Vehicle/Object Test Script" + join_pattern = re.compile( + rf"\[bare-rpctest\] RPC137 ServerJoin player=\d+ " + rf"name={re.escape(nickname)}\b" + ) + sections = server_text.split( + "[distributed_sync] marker=ARTIFACT_BOUNDARY" + ) + restart_seen = any(section.count(banner) >= 2 for section in sections) + rejoin_seen = any(len(join_pattern.findall(section)) >= 2 for section in sections) + return restart_seen, rejoin_seen + + +def scenario_checks( + scenario: str, + server_text: str, + windows_logs: str, + driver_returncodes: dict[str, int], + driver_request_ids_by_scenario: dict[str, int], + requested_probe_profile: str | None = None, +) -> dict[str, bool]: + if scenario == UI_LATCH_SCENARIO: + return { + "driver_completed": driver_returncodes.get(scenario) == 0, + "ui_latch_hooks_installed": bool( + re.search( + r"ui_latches_hook: summary installed=9 requested=9\b", + windows_logs, + ) + ), + "scoreboard_show_seen": "kind=scoreboard_show " in windows_logs, + "scoreboard_hide_seen": "kind=scoreboard_hide " in windows_logs, + "chat_open_seen": "kind=chat_open " in windows_logs, + "chat_close_seen": "kind=chat_close " in windows_logs, + "three_chat_mode_edges_seen": windows_logs.count( + "kind=chat_mode_toggle " + ) + >= 3, + "ui_latch_ring_not_overflowed": ( + "ui_latches_r5: overflow " not in windows_logs + ), + } + + if scenario == GMX_SCENARIO: + server_restart_seen, windows_pilot_rejoined = gmx_server_cycle_checks( + server_text, + "SyncPilot", + ) + checks = { + "driver_completed": driver_returncodes.get(scenario) == 0, + "server_restart_seen": server_restart_seen, + "windows_pilot_rejoined": windows_pilot_rejoined, + } + if requested_probe_profile == "death-cleanup": + checks["death_cleanup_gmx_seen"] = bool( + re.search( + r"death_cleanup_r5:[^\r\n]*\bkind=gmx_reset\b", + windows_logs, + ) + ) + else: + initial_init = windows_logs.find("rpc-in id=139") + restart = windows_logs.find("rpc-in id=40", initial_init + 1) + post_restart_init = windows_logs.find("rpc-in id=139", restart + 1) + checks.update( + { + "initial_init_seen": initial_init >= 0, + "gmx_rpc40_seen": restart > initial_init, + "post_gmx_init_seen": post_restart_init > restart, + } + ) + return checks + + fixture_scenario = "m4" if scenario == "angles" else scenario + request_id = driver_request_ids_by_scenario.get(scenario) + trace = request_trace(server_text, request_id) + checks = { + "driver_completed": driver_returncodes.get(scenario) == 0, + "request_id_recorded": request_id is not None, + "request_acknowledged": _request_done( + trace, + fixture_scenario, + request_id, + ), + } + if scenario in WEAPON_IDS: + weapon = WEAPON_IDS[scenario] + checks["onfoot_setup_seen"] = bool( + re.search( + rf"marker=SCENARIO_START\b[^\r\n]*\bscenario=onfoot\b" + rf"[^\r\n]*\bweapon={weapon}\b", + trace, + ) + ) + checks["matching_weapon_shot_seen"] = bool( + re.search( + rf"marker=WEAPON_SHOT\b[^\r\n]*\bweapon={weapon}\b" + rf"[^\r\n]*\bexpected_weapon={weapon}\b", + trace, + ) + ) + elif scenario == "angles": + checks["m4_setup_seen"] = bool( + re.search( + r"marker=SCENARIO_START\b[^\r\n]*\bscenario=onfoot\b" + r"[^\r\n]*\bweapon=31\b", + trace, + ) + ) + facing_values = { + match.group(1) + for match in re.finditer( + r"marker=PILOT_SYNC\b[^\r\n]*\bscenario=onfoot\b" + r"[^\r\n]*\bweapon=31\b[^\r\n]*\bfacing=(-?\d+(?:\.\d+)?)", + trace, + ) + } + checks["multiple_facing_samples_seen"] = len(facing_values) >= 2 + elif scenario == "jetpack": + checks["jetpack_setup_seen"] = ( + "marker=SCENARIO_START" in trace + and "scenario=jetpack" in trace + ) + checks["jetpack_special_action_seen"] = bool( + re.search( + r"marker=PILOT_SYNC\b[^\r\n]*\bscenario=jetpack\b" + r"[^\r\n]*\bspecial=2\b", + trace, + ) + ) + elif scenario == "pickup": + checks["pickup_created_seen"] = "marker=PICKUP_CREATED" in trace + checks["pickup_collected_seen"] = "marker=PICKUP_COLLECTED" in trace + elif scenario == "death": + checks["death_trigger_seen"] = "marker=DEATH_TRIGGER" in trace + checks["player_death_seen"] = "marker=PLAYER_DEATH" in trace + return checks + + +def build_verdict( + *, + windows_role: str = "observer", + scenarios: list[str], + server_text: str, + windows_logs: str, + local_logs: str, + windows_manifest: dict[str, Any], + driver_returncodes: dict[str, int], + driver_request_ids_by_scenario: dict[str, int], + dll_candidate: dict[str, Any] | None, + probe_candidate: dict[str, Any] | None, + requested_probe_profile: str | None, + runner_error: str | None, + cleanup_errors: list[str], + local_hashes_unchanged: bool, + windows_artifact_fetched: bool, + windows_logs_fetched: bool, + windows_screenshots_fetched: bool, +) -> dict[str, Any]: + scenario_results: dict[str, Any] = {} + scenarios_pass = True + for scenario in scenarios: + checks = scenario_checks( + scenario, + server_text, + windows_logs, + driver_returncodes, + driver_request_ids_by_scenario, + requested_probe_profile, + ) + passed = all(checks.values()) + scenario_results[scenario] = { + "checks": checks, + "verdict": "SERVER_TRACE_PASS" if passed else "FAIL", + "visual_parity": "TODO_VERIFY", + } + scenarios_pass = scenarios_pass and passed + + manifest_autopause = windows_manifest.get("autopause") + manifest_flags = windows_manifest.get("probe_flags") + expected_flags = expected_profile_flags(requested_probe_profile) + if expected_flags is None: + expected_flags = () + windows_hash = windows_manifest.get("samp_sha256") + probe_hash = windows_manifest.get("samp_probe_sha256") + dll_hash_matches = ( + dll_candidate is None or windows_hash == dll_candidate.get("sha256") + ) + probe_hash_matches = ( + probe_candidate is None or probe_hash == probe_candidate.get("sha256") + ) + profile_matches = ( + isinstance(manifest_flags, list) + and sorted(manifest_flags) == sorted(expected_flags) + ) + scenario_profile_valid = ( + UI_LATCH_SCENARIO not in scenarios + or requested_probe_profile == "ui-latches-r5" + ) + autopause_verified = bool( + isinstance(manifest_autopause, dict) + and manifest_autopause.get("disabled") is True + ) + original_probe_identity_required = ( + GMX_SCENARIO in scenarios + and requested_probe_profile == "death-cleanup" + ) or UI_LATCH_SCENARIO in scenarios + original_probe_identity_matches = ( + not original_probe_identity_required + or windows_hash == ORIGINAL_R5_SHA256 + ) + windows_crash = _contains_crash(windows_logs) + local_crash = _contains_crash(local_logs) + hard_failure = any( + ( + not scenarios_pass, + bool(runner_error), + bool(cleanup_errors), + not local_hashes_unchanged, + not windows_artifact_fetched, + not windows_logs_fetched, + not windows_screenshots_fetched, + not windows_hash, + not dll_hash_matches, + not probe_hash_matches, + not profile_matches, + not scenario_profile_valid, + not autopause_verified, + not original_probe_identity_matches, + windows_crash, + local_crash, + ) + ) + if windows_hash == ORIGINAL_R5_SHA256: + windows_identity = "original_r5" + elif dll_candidate and windows_hash == dll_candidate.get("sha256"): + windows_identity = "explicit_candidate" + elif windows_hash: + windows_identity = "installed_non_original" + else: + windows_identity = "unknown" + return { + "verdict": "FAIL" if hard_failure else "TRACE_CAPTURED_VISUAL_UNVERIFIED", + "visual_parity": "TODO_VERIFY", + "visual_evidence": ( + "Screenshot artifacts were retained, but require manual review or " + "a separately defined image-diff oracle" + ), + "topology": topology_for_role(windows_role), + "windows_role": windows_role, + "local_role": "observer" if windows_role == "pilot" else "pilot", + "scenarios": scenario_results, + "windows_identity": windows_identity, + "windows_samp_sha256": windows_hash, + "windows_probe_sha256": probe_hash, + "requested_probe_profile": requested_probe_profile, + "scenario_probe_profile_valid": scenario_profile_valid, + "windows_probe_flags": manifest_flags, + "windows_autopause_verified": autopause_verified, + "original_probe_identity_required": original_probe_identity_required, + "original_probe_identity_matches": original_probe_identity_matches, + "explicit_dll_hash_matches_manifest": dll_hash_matches, + "explicit_probe_hash_matches_manifest": probe_hash_matches, + "probe_profile_matches_manifest": profile_matches, + "windows_artifact_fetched": windows_artifact_fetched, + "windows_logs_fetched": windows_logs_fetched, + "windows_screenshots_fetched": windows_screenshots_fetched, + "local_hashes_unchanged": local_hashes_unchanged, + "windows_crash_marker": windows_crash, + "local_crash_marker": local_crash, + "driver_returncodes": driver_returncodes, + "runner_error": runner_error, + "cleanup_errors": cleanup_errors, + } + + +def execute(args: argparse.Namespace) -> tuple[Path, dict[str, Any]]: + validate_role_scenario(args.windows_role, args.scenario) + validate_death_f4(args.death_f4, args.windows_role, args.scenario) + validate_ui_latch_profile(args.scenario, args.windows_probe_profile) + if args.scenario == GMX_SCENARIO and args.server_mode == "reuse": + raise DistributedSyncError( + "--scenario=gmx cannot use --server-mode=reuse because the " + "runner must own open.mp console stdin" + ) + settings = reloop.load_settings(args.config.resolve()) + local_client = settings.clients["original"] + windows_role = args.windows_role + local_role = "observer" if windows_role == "pilot" else "pilot" + windows_nickname = "SyncPilot" if windows_role == "pilot" else "SyncObserver" + local_nickname = "SyncObserver" if windows_role == "pilot" else "SyncPilot" + windows_metadata_key = f"windows_{windows_role}" + local_metadata_key = f"local_{local_role}" + scenarios = expand_scenarios(args.scenario) + artifact_dir = settings.artifacts_root / reloop.run_id( + "distributed-sync", args.scenario + ) + artifact_dir.mkdir(parents=True, exist_ok=False) + print(f"artifact: {artifact_dir}", flush=True) + + dll_candidate = ( + windows_edge.validate_x86_pe(args.deploy_windows_dll.expanduser().resolve()) + if args.deploy_windows_dll is not None + else None + ) + probe_candidate = ( + windows_edge.validate_x86_pe( + args.deploy_windows_probe.expanduser().resolve() + ) + if args.deploy_windows_probe is not None + else None + ) + expected_profile_flags(args.windows_probe_profile) + layout = validate_local_layout(settings, local_client, windows_role) + if not LAB_WRAPPER.is_file(): + raise DistributedSyncError(f"Windows lab wrapper is missing: {LAB_WRAPPER}") + if not SAFE_HOST.fullmatch(args.windows_server_host): + raise DistributedSyncError( + f"invalid Windows-visible server host: {args.windows_server_host}" + ) + fixture = settings.server_root / "filterscripts/sync_pair.amx" + request_path = settings.server_root / "scriptfiles/sync_pair_request.txt" + if not fixture.is_file(): + raise DistributedSyncError(f"sync-pair fixture is not compiled: {fixture}") + if request_path.exists(): + raise DistributedSyncError( + f"sync-pair request is already pending: {request_path}" + ) + + local_hashes_before = { + "gta_exe": reloop.sha256(local_client.gta_exe), + "samp_dll": reloop.sha256(local_client.samp_dll), + "control_asi": optional_sha256( + local_client.gta_root / reloop.CONTROL_ASI.name + ), + "asi": sync_edge_probe.file_hashes(local_client.gta_root), + } + metadata: dict[str, Any] = { + "run_id": artifact_dir.name, + "started_at": reloop.utc_timestamp(), + "windows_role": windows_role, + "local_role": local_role, + "topology": topology_for_role(windows_role), + "scenarios": scenarios, + "death_f4": { + "requested": args.death_f4, + "input_action": "CLASS" if args.death_f4 else None, + "phase": "before_death_request" if args.death_f4 else None, + "settle_seconds": 0.75 if args.death_f4 else None, + }, + "gmx": { + "requested": args.scenario == GMX_SCENARIO, + "source": ( + "open.mp_console" + if args.scenario == GMX_SCENARIO + else None + ), + "post_restart_screenshot": args.scenario == GMX_SCENARIO, + }, + "ui_latches": { + "requested": args.scenario == UI_LATCH_SCENARIO, + "input_actions": ( + [key for _step, key in UI_LATCH_ACTIONS] + if args.scenario == UI_LATCH_SCENARIO + else [] + ), + "pause_or_escape_automation": False, + }, + "layout": layout, + "server": { + "local_host": settings.host, + "windows_visible_host": args.windows_server_host, + "port": settings.port, + "mode": args.server_mode, + }, + local_metadata_key: { + "profile": "original", + "nickname": local_nickname, + "dll_sha256": reloop.sha256(local_client.samp_dll), + }, + windows_metadata_key: { + "nickname": windows_nickname, + "favorite_index": args.windows_favorite_index, + "dll_deploy_requested": dll_candidate is not None, + "dll_candidate": dll_candidate, + "probe_deploy_requested": probe_candidate is not None, + "probe_candidate": probe_candidate, + "probe_profile_requested": args.windows_probe_profile, + }, + "local_hashes_before": local_hashes_before, + "visual_parity": "TODO_VERIFY", + } + reloop.write_json(artifact_dir / "metadata.json", metadata) + + server_console = artifact_dir / "server.console.log" + server_log = settings.server_root / "log.txt" + result_file = settings.server_root / "scriptfiles/sync_pair_results.log" + server_snapshot = reloop.FileSnapshot.take(server_log) + result_snapshot = reloop.FileSnapshot.take(result_file) + local_snapshots = { + name: reloop.FileSnapshot.take(local_client.gta_root / name) + for name in reloop.CLIENT_LOG_NAMES + } + local_pre_pids = reloop.prefix_pids(local_client.prefix) + server: reloop.ManagedProcess | lifecycle_probe.StdinProcess | None = None + local_process: reloop.ManagedProcess | None = None + windows_start_attempted = False + windows_run_id: str | None = None + profile_changed = False + driver_returncodes: dict[str, int] = {} + runner_exception: BaseException | None = None + runner_error: str | None = None + cleanup_errors: list[str] = [] + windows_fetch_root = artifact_dir / "windows" + windows_receipts: dict[str, Any] = {} + windows_preflight: dict[str, Any] | None = None + windows_post_setup: dict[str, Any] | None = None + + try: + reloop.replace_existing_client( + local_client, args.local_client_mode, settings.shutdown_timeout_s + ) + if ( + local_role == "pilot" + and not sync_edge_probe.tcp_port_available("127.0.0.1", CONTROL_PORT) + ): + raise DistributedSyncError( + f"localhost control port {CONTROL_PORT} is occupied" + ) + reused_server = reloop.replace_existing_server(settings, args.server_mode) + if not reused_server: + if args.scenario == GMX_SCENARIO: + server = lifecycle_probe._start_server(settings, server_console) + else: + server = reloop.start_process( + [str(settings.server_executable)], + settings.server_root, + server_console, + "open.mp", + ) + if not reloop.wait_for_text( + server_console, + reloop.SERVER_READY_PATTERN, + settings.server_ready_timeout_s, + server.process, + ): + raise DistributedSyncError( + "open.mp did not become ready; see server.console.log" + ) + + first_ping = windows_edge.run_lab( + artifact_dir, + "preflight-ping", + ["ping"], + timeout=args.lab_timeout, + ) + if args.windows_client_mode == "replace": + windows_edge.run_lab( + artifact_dir, + "preflight-stop", + ["stop"], + timeout=args.lab_timeout, + ) + idle_ping = windows_edge.run_lab( + artifact_dir, + "preflight-idle-ping", + ["ping"], + timeout=args.lab_timeout, + ) + else: + idle_ping = first_ping + windows_preflight = parse_windows_preflight(idle_ping.stdout) + validate_windows_idle(windows_preflight) + if args.windows_probe_profile is None: + validate_probe_state(windows_preflight, None) + + label = f"dist-sync-{artifact_dir.name[-24:]}" + for action_label, action in windows_mutation_plan( + dll_candidate, + probe_candidate, + args.windows_probe_profile, + label, + ): + if action_label == "probe-profile": + # The remote command can time out after applying the flag + # change but before returning its receipt. Mark it dirty + # before execution so finally always attempts passive reset. + profile_changed = True + receipt = windows_edge.run_lab( + artifact_dir, + action_label, + action, + timeout=args.lab_timeout, + ) + if action_label == "dll-deploy": + windows_receipts[action_label] = verify_deploy_receipt( + receipt.stdout, dll_candidate, kind="DLL" # type: ignore[arg-type] + ) + elif action_label == "probe-deploy": + windows_receipts[action_label] = verify_deploy_receipt( + receipt.stdout, probe_candidate, kind="probe" # type: ignore[arg-type] + ) + elif action_label == "probe-profile": + windows_receipts[action_label] = verify_profile_receipt( + receipt.stdout, args.windows_probe_profile # type: ignore[arg-type] + ) + + post_setup_ping = windows_edge.run_lab( + artifact_dir, + "post-setup-ping", + ["ping"], + timeout=args.lab_timeout, + ) + windows_post_setup = parse_windows_preflight(post_setup_ping.stdout) + validate_windows_idle(windows_post_setup) + validate_probe_state( + windows_post_setup, + args.windows_probe_profile, + ) + if ( + args.scenario == GMX_SCENARIO + and args.windows_probe_profile == "death-cleanup" + and windows_post_setup["samp_sha256"] != ORIGINAL_R5_SHA256 + ): + raise DistributedSyncError( + "GMX with the R5 death-cleanup probe requires the documented " + "Original R5 samp.dll on Windows" + ) + if ( + args.scenario == UI_LATCH_SCENARIO + and windows_post_setup["samp_sha256"] != ORIGINAL_R5_SHA256 + ): + raise DistributedSyncError( + "ui_latches with the R5 UI-latch probe requires the " + "documented Original R5 samp.dll on Windows" + ) + if ( + dll_candidate is not None + and windows_post_setup["samp_sha256"] != dll_candidate["sha256"] + ): + raise DistributedSyncError( + "Windows installed samp.dll hash differs from explicit candidate" + ) + if ( + probe_candidate is not None + and windows_post_setup["samp_probe_sha256"] + != probe_candidate["sha256"] + ): + raise DistributedSyncError( + "Windows installed samp_probe.asi hash differs from explicit candidate" + ) + + local_command: list[str] | None = None + local_env: dict[str, str] | None = None + local_api_verified = False + if local_role == "pilot": + local_command, local_env = reloop.direct_client_launch( + settings, local_client, artifact_dir / f"{local_role}-launch" + ) + local_command[-1] = f"-n{local_nickname}" + local_process = reloop.start_process( + local_command, + local_client.gta_root, + artifact_dir / f"{local_role}-launcher.log", + local_nickname, + env=local_env, + ) + local_api = wait_for_api(timeout=args.client_ready_timeout) + local_api.close() + local_api_verified = True + + windows_start_attempted = True + start_result = windows_edge.run_lab( + artifact_dir, + f"{windows_role}-start", + [ + "start", + f"dist_sync_{args.scenario}_{windows_role}", + "samp", + args.windows_server_host, + str(settings.port), + windows_nickname, + str(args.windows_favorite_index), + ], + timeout=args.windows_start_timeout, + ) + windows_run_id = windows_edge.extract_run_id(start_result.stdout) + if windows_run_id is None: + discovery = windows_edge.run_lab( + artifact_dir, + f"{windows_role}-run-id-discovery", + ["collect"], + timeout=args.windows_start_timeout, + ) + windows_run_id = windows_edge.extract_run_id(discovery.stdout) + if windows_run_id is None: + raise DistributedSyncError( + f"Windows {windows_role} started without a recoverable run_id" + ) + + if local_role == "observer": + local_command, local_env = reloop.direct_client_launch( + settings, local_client, artifact_dir / f"{local_role}-launch" + ) + local_command[-1] = f"-n{local_nickname}" + local_process = reloop.start_process( + local_command, + local_client.gta_root, + artifact_dir / f"{local_role}-launcher.log", + local_nickname, + env=local_env, + ) + assert local_command is not None + reloop.write_json( + artifact_dir / "launch-order.json", + { + "windows_started_first": windows_role == "pilot", + f"windows_{windows_role}_started_first": windows_role == "pilot", + f"local_{local_role}_started_first": windows_role == "observer", + "windows_run_id": windows_run_id, + f"{local_metadata_key}_command": shlex.join(local_command), + f"{local_metadata_key}_api_verified": local_api_verified, + f"{local_metadata_key}_api_required": local_role == "pilot", + "local_replacement_launched": False, + }, + ) + sync_edge_probe.wait_for_pair( + server_console, + server_log, + server_snapshot, + args.pair_ready_timeout, + ) + + for index, scenario in enumerate(scenarios): + if index: + time.sleep(args.between) + if windows_role == "pilot": + if scenario == GMX_SCENARIO: + if not isinstance(server, lifecycle_probe.StdinProcess): + raise DistributedSyncError( + "GMX requires a runner-owned open.mp stdin process" + ) + returncode = run_windows_gmx_driver( + artifact_dir, + server=server, + server_console=server_console, + fixture_timeout=args.fixture_timeout, + screenshot_count=args.screenshot_count, + screenshot_interval=args.screenshot_interval, + nickname=windows_nickname, + ) + elif scenario == UI_LATCH_SCENARIO: + returncode = run_windows_ui_latches_driver( + artifact_dir, + lab_timeout=args.lab_timeout, + ) + else: + returncode = run_windows_death_driver( + artifact_dir, + request_path=request_path, + results_path=result_file, + server_log=server_log, + fixture_timeout=args.fixture_timeout, + screenshot_count=args.screenshot_count, + screenshot_interval=args.screenshot_interval, + death_f4=args.death_f4, + lab_timeout=args.lab_timeout, + ) + else: + returncode = run_driver( + scenario, + artifact_dir, + fixture_timeout=args.fixture_timeout, + action_seconds=args.action_seconds, + screenshot_count=args.screenshot_count, + screenshot_interval=args.screenshot_interval, + ) + driver_returncodes[scenario] = returncode + if returncode: + raise DistributedSyncError( + f"{scenario} pilot driver failed with exit code {returncode}" + ) + except BaseException as error: + runner_exception = error + runner_error = f"{type(error).__name__}: {error}" + finally: + try: + collect_local_artifacts( + artifact_dir, + server_snapshot, + result_snapshot, + local_snapshots, + local_role, + ) + except Exception as error: + cleanup_errors.append( + f"pre_teardown_local_capture:{type(error).__name__}:{error}" + ) + + if windows_start_attempted: + try: + collection = windows_edge.run_lab( + artifact_dir, + "final-collect", + ["collect"], + timeout=args.lab_timeout, + check=False, + ) + if collection.returncode: + cleanup_errors.append( + f"windows_collect:exit={collection.returncode}" + ) + windows_run_id = windows_run_id or windows_edge.extract_run_id( + collection.stdout + ) + except Exception as error: + cleanup_errors.append( + f"windows_collect:{type(error).__name__}:{error}" + ) + try: + stopped = windows_edge.run_lab( + artifact_dir, + "final-stop", + ["stop"], + timeout=args.lab_timeout, + check=False, + ) + if stopped.returncode: + cleanup_errors.append( + f"windows_stop:exit={stopped.returncode}" + ) + except Exception as error: + cleanup_errors.append( + f"windows_stop:{type(error).__name__}:{error}" + ) + try: + final_ping = windows_edge.run_lab( + artifact_dir, + "final-idle-ping", + ["ping"], + timeout=args.lab_timeout, + check=False, + ) + final_state = parse_windows_preflight(final_ping.stdout) + validate_windows_idle(final_state) + except Exception as error: + cleanup_errors.append( + f"windows_idle:{type(error).__name__}:{error}" + ) + + if profile_changed: + try: + reset = windows_edge.run_lab( + artifact_dir, + "probe-profile-reset", + ["probe-profile", "passive"], + timeout=args.lab_timeout, + check=False, + ) + if reset.returncode: + raise DistributedSyncError( + f"passive profile reset exited {reset.returncode}" + ) + verify_profile_receipt(reset.stdout, "passive") + reset_ping = windows_edge.run_lab( + artifact_dir, + "probe-profile-reset-ping", + ["ping"], + timeout=args.lab_timeout, + check=False, + ) + if reset_ping.returncode: + raise DistributedSyncError( + f"post-reset ping exited {reset_ping.returncode}" + ) + reset_state = parse_windows_preflight(reset_ping.stdout) + validate_windows_idle(reset_state) + validate_probe_state(reset_state, "passive") + profile_changed = False + except Exception as error: + cleanup_errors.append( + f"probe_profile_reset:{type(error).__name__}:{error}" + ) + + if local_process is not None: + try: + local_process.stop(settings.shutdown_timeout_s) + except Exception as error: + cleanup_errors.append( + f"local_{local_role}_stop:{type(error).__name__}:{error}" + ) + try: + survivors = reloop.terminate_pids( + reloop.prefix_pids(local_client.prefix) - local_pre_pids, + settings.shutdown_timeout_s, + ) + if survivors: + cleanup_errors.append( + f"local_{local_role}_prefix_stop:" + f"surviving_pids={sorted(survivors)}" + ) + except Exception as error: + cleanup_errors.append( + f"local_{local_role}_prefix_stop:{type(error).__name__}:{error}" + ) + if server is not None: + try: + server.stop(settings.shutdown_timeout_s) + except Exception as error: + cleanup_errors.append( + f"server_stop:{type(error).__name__}:{error}" + ) + + request_clean, request_error = cleanup_owned_request( + request_path, artifact_dir + ) + if not request_clean: + cleanup_errors.append(f"sync_pair_request_cleanup:{request_error}") + + windows_artifact_fetched = False + if windows_run_id is not None: + try: + fetched = windows_edge.run_lab( + artifact_dir, + "final-fetch", + ["fetch-run", windows_run_id, str(windows_fetch_root)], + timeout=args.fetch_timeout, + check=False, + ) + windows_artifact_fetched = ( + fetched.returncode == 0 + and any(windows_fetch_root.rglob("manifest.json")) + ) + if not windows_artifact_fetched: + cleanup_errors.append( + f"windows_fetch:exit={fetched.returncode}:manifest=missing" + ) + except Exception as error: + cleanup_errors.append( + f"windows_fetch:{type(error).__name__}:{error}" + ) + elif windows_start_attempted: + cleanup_errors.append("windows_fetch:run_id_unavailable") + + try: + local_logs = collect_local_artifacts( + artifact_dir, + server_snapshot, + result_snapshot, + local_snapshots, + local_role, + ) + except Exception as error: + local_logs = "" + cleanup_errors.append( + f"post_teardown_local_capture:{type(error).__name__}:{error}" + ) + + local_hashes_after = { + "gta_exe": reloop.sha256(local_client.gta_exe), + "samp_dll": reloop.sha256(local_client.samp_dll), + "control_asi": optional_sha256( + local_client.gta_root / reloop.CONTROL_ASI.name + ), + "asi": sync_edge_probe.file_hashes(local_client.gta_root), + } + local_hashes_unchanged = local_hashes_after == local_hashes_before + manifest_path, windows_manifest = windows_edge.find_windows_manifest( + windows_fetch_root, windows_run_id + ) + windows_logs = windows_edge.collect_windows_logs(windows_fetch_root) + windows_logs_fetched = bool( + windows_fetch_root.is_dir() + and any(windows_fetch_root.rglob("*.log")) + ) + windows_screenshots_fetched = bool( + windows_fetch_root.is_dir() + and any(windows_fetch_root.rglob("*.png")) + ) + server_text = "" + for path in ( + server_console, + artifact_dir / "server.log", + artifact_dir / "sync-pair-results.log", + ): + if path.is_file(): + server_text += ( + f"[distributed_sync] marker=ARTIFACT_BOUNDARY file={path.name}\n" + ) + server_text += path.read_text( + encoding="utf-8", errors="replace" + ) + "\n" + verdict = build_verdict( + windows_role=windows_role, + scenarios=scenarios, + server_text=server_text, + windows_logs=windows_logs, + local_logs=local_logs, + windows_manifest=windows_manifest, + driver_returncodes=driver_returncodes, + driver_request_ids_by_scenario=driver_request_ids(artifact_dir), + dll_candidate=dll_candidate, + probe_candidate=probe_candidate, + requested_probe_profile=args.windows_probe_profile, + runner_error=runner_error, + cleanup_errors=cleanup_errors, + local_hashes_unchanged=local_hashes_unchanged, + windows_artifact_fetched=windows_artifact_fetched, + windows_logs_fetched=windows_logs_fetched, + windows_screenshots_fetched=windows_screenshots_fetched, + ) + metadata.update( + { + "finished_at": reloop.utc_timestamp(), + "windows_preflight": windows_preflight, + "windows_post_setup": windows_post_setup, + "windows_receipts": windows_receipts, + windows_metadata_key: { + **metadata[windows_metadata_key], + "run_id": windows_run_id, + "manifest_path": ( + str(manifest_path) if manifest_path else None + ), + "installed_samp_sha256": windows_manifest.get("samp_sha256"), + "installed_probe_sha256": windows_manifest.get( + "samp_probe_sha256" + ), + }, + "local_hashes_after": local_hashes_after, + "runner_error": runner_error, + "cleanup_errors": cleanup_errors, + } + ) + reloop.write_json(artifact_dir / "metadata.json", metadata) + reloop.write_json(artifact_dir / "verdict.json", verdict) + + print(f"verdict: {verdict['verdict']}", flush=True) + if runner_exception is not None: + raise runner_exception.with_traceback(runner_exception.__traceback__) + if cleanup_errors: + raise DistributedSyncError("; ".join(cleanup_errors)) + return artifact_dir, verdict + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--config", + type=Path, + default=reloop.DEFAULT_CONFIG, + ) + parser.add_argument( + "--scenario", + choices=(*SCENARIOS, GMX_SCENARIO, UI_LATCH_SCENARIO, "all"), + default="all", + ) + parser.add_argument( + "--server-mode", + choices=("fail", "replace", "reuse"), + default="replace", + ) + parser.add_argument( + "--local-client-mode", + choices=("fail", "replace"), + default="replace", + ) + parser.add_argument( + "--windows-client-mode", + choices=("fail", "replace"), + default="replace", + ) + parser.add_argument( + "--windows-role", + choices=WINDOWS_ROLES, + default="observer", + help=( + "role assigned by nickname to Windows; pilot is restricted to " + "--scenario=death, --scenario=gmx, or --scenario=ui_latches so " + "Windows receives the state-machine/UI transition" + ), + ) + parser.add_argument( + "--death-f4", + action="store_true", + help=( + "send the allowlisted Windows CLASS/F4 action before the death " + "request; valid only with --scenario=death --windows-role=pilot" + ), + ) + parser.add_argument("--windows-server-host", default="192.168.3.181") + parser.add_argument("--windows-favorite-index", type=int, default=3) + parser.add_argument( + "--deploy-windows-dll", + type=Path, + help=( + "explicitly validate and deploy this x86 samp.dll; omitted means " + "the runner never changes the Windows DLL" + ), + ) + parser.add_argument( + "--deploy-windows-probe", + type=Path, + help=( + "explicitly deploy this x86 samp_probe.asi; omitted means the " + "runner never changes the Windows probe" + ), + ) + parser.add_argument( + "--windows-probe-profile", + choices=tuple(PROBE_PROFILE_FLAGS), + help=( + "explicit managed profile for this run; omitted leaves profile " + "files untouched and requires no pre-existing probe flags" + ), + ) + parser.add_argument("--client-ready-timeout", type=float, default=60.0) + parser.add_argument("--pair-ready-timeout", type=float, default=90.0) + parser.add_argument("--fixture-timeout", type=float, default=60.0) + parser.add_argument("--action-seconds", type=float, default=4.0) + parser.add_argument("--between", type=float, default=1.0) + parser.add_argument("--screenshot-count", type=int, default=40) + parser.add_argument("--screenshot-interval", type=float, default=0.05) + parser.add_argument("--windows-start-timeout", type=float, default=45.0) + parser.add_argument("--lab-timeout", type=float, default=45.0) + parser.add_argument("--fetch-timeout", type=float, default=120.0) + args = parser.parse_args() + + for field in ( + "client_ready_timeout", + "pair_ready_timeout", + "fixture_timeout", + "action_seconds", + "windows_start_timeout", + "lab_timeout", + "fetch_timeout", + ): + if getattr(args, field) <= 0: + parser.error(f"--{field.replace('_', '-')} must be positive") + if args.between < 0: + parser.error("--between must not be negative") + if not 1 <= args.screenshot_count <= 120: + parser.error("--screenshot-count must be between 1 and 120") + if not 0.025 <= args.screenshot_interval <= 10.0: + parser.error("--screenshot-interval must be between 0.025 and 10 seconds") + if not 0 <= args.windows_favorite_index <= 100: + parser.error("--windows-favorite-index must be between 0 and 100") + if args.windows_role == "pilot" and args.scenario not in ( + "death", + GMX_SCENARIO, + UI_LATCH_SCENARIO, + ): + parser.error( + "--windows-role=pilot requires --scenario=death, " + "--scenario=gmx, or --scenario=ui_latches" + ) + if ( + args.scenario in (GMX_SCENARIO, UI_LATCH_SCENARIO) + and args.windows_role != "pilot" + ): + parser.error( + f"--scenario={args.scenario} requires --windows-role=pilot" + ) + if args.scenario == GMX_SCENARIO and args.server_mode == "reuse": + parser.error("--scenario=gmx cannot use --server-mode=reuse") + if args.death_f4 and not ( + args.scenario == "death" and args.windows_role == "pilot" + ): + parser.error( + "--death-f4 requires --scenario=death and --windows-role=pilot" + ) + if ( + args.scenario == UI_LATCH_SCENARIO + and args.windows_probe_profile != "ui-latches-r5" + ): + parser.error( + "--scenario=ui_latches requires " + "--windows-probe-profile=ui-latches-r5" + ) + + try: + _artifact, verdict = execute(args) + except ( + DistributedSyncError, + windows_edge.WindowsSyncEdgeError, + sync_edge_probe.SyncEdgeError, + reloop.ReLoopError, + subprocess.TimeoutExpired, + ) as error: + print(f"Distributed sync runner failed: {error}", file=sys.stderr) + return 2 + except Exception: + traceback.print_exc() + return 2 + return 1 if verdict["verdict"] == "FAIL" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/reloop/lifecycle_probe.py b/tools/reloop/lifecycle_probe.py index f5ab655..b5457c7 100644 --- a/tools/reloop/lifecycle_probe.py +++ b/tools/reloop/lifecycle_probe.py @@ -466,26 +466,23 @@ def _capture_logs( def _click_replacement_spawn_button( control: ControlClient, state: dict[str, Any] ) -> None: - """Click the replacement class-selection overlay's resolution-scaled Spawn.""" + """Click the replacement class-selection overlay's fixed R5 Spawn control.""" width = int(state["client_w"]) height = int(state["client_h"]) - button_width = 86 - button_height = 36 - spawn_width = 100 - gap = 10 - if width < 640: - button_width = 72 - button_height = 32 - spawn_width = 86 - group_width = (button_width * 2) + spawn_width + (gap * 2) - start_x = (width - group_width) // 2 - spawn_x = start_x + (button_width * 2) + (gap * 2) - bottom_y = height - button_height - 36 + dialog_width = 310 + dialog_height = 40 + bottom_margin = 50 + spawn_offset_x = 210 + button_offset_y = 5 + button_width = 90 + button_height = 30 + dialog_x = (width - dialog_width) // 2 + dialog_y = height - dialog_height - bottom_margin control.command( "mouse", action="click", - x=spawn_x + (spawn_width // 2), - y=bottom_y + (button_height // 2), + x=dialog_x + spawn_offset_x + (button_width // 2), + y=dialog_y + button_offset_y + (button_height // 2), ) diff --git a/tools/reloop/pickup_r5_runner.py b/tools/reloop/pickup_r5_runner.py new file mode 100644 index 0000000..3c51af8 --- /dev/null +++ b/tools/reloop/pickup_r5_runner.py @@ -0,0 +1,409 @@ +#!/usr/bin/env python3 +"""Run the focused Original-R5 ordinary-pickup probe on Windows. + +This is a pickup-only adapter around ``distributed_sync_runner``. It keeps the +shared runner untouched, reverses the topology so Windows Original R5 is the +pilot, enables only the pickup probe profile, waits for the server-side pickup +callback, and emits ``pickup-r5-analysis.json`` beside the captured artifact. +""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import contextlib +import json +import re +import subprocess +import sys +import time +import traceback +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator, Sequence + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[1] +sys.path.insert(0, str(SCRIPT_DIR)) + +import distributed_sync_runner as distributed # noqa: E402 +import reloop # noqa: E402 +import sync_edge_probe # noqa: E402 +import sync_pair_client # noqa: E402 +import windows_sync_edge_probe as windows_edge # noqa: E402 +from analyze_pickup_r5 import ( # noqa: E402 + MISMATCH, + ORIGINAL_R5_SHA256, + PICKUP_FLAG, + analyze_log, +) + + +SCENARIO = "pickup" +PROFILE = "pickup-r5" +PROFILE_FLAGS = (PICKUP_FLAG,) +ACCEPTABLE_ASSESSMENTS = ("OBSERVED_ORDINARY", "OBSERVED_COMPLETE") + + +def pickup_request_state(text: str, request_id: int) -> dict[str, Any]: + """Return request-scoped server markers, ignoring older fixture runs.""" + active = False + checks = { + "request_accepted": False, + "scenario_started": False, + "pickup_created": False, + "request_done": False, + "pickup_collected": False, + } + collection_line: str | None = None + for line in text.splitlines(): + accepted = re.search(r"marker=REQUEST_ACCEPTED\b.*\brequest=(\d+)\b", line) + if accepted: + active = int(accepted.group(1)) == request_id + if active: + checks["request_accepted"] = True + if not active: + continue + if ( + "marker=SCENARIO_START" in line + and f"request={request_id} " in line + and "scenario=pickup " in line + ): + checks["scenario_started"] = True + elif "marker=PICKUP_CREATED" in line: + checks["pickup_created"] = True + elif ( + "marker=REQUEST_DONE" in line + and f"request={request_id} " in line + and "status=PASS " in line + and "scenario=pickup " in line + ): + checks["request_done"] = True + elif "marker=PICKUP_COLLECTED" in line: + checks["pickup_collected"] = True + collection_line = line + return { + "complete": all(checks.values()), + "checks": checks, + "collection_line": collection_line, + } + + +def wait_for_pickup_collection( + server_log: Path, + start_offset: int, + request_id: int, + timeout_seconds: float, + output: list[dict[str, Any]], +) -> str: + """Wait until the Windows pilot's RPC131 reaches the fixture callback.""" + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if server_log.exists(): + data = server_log.read_bytes() + if len(data) < start_offset: + start_offset = 0 + state = pickup_request_state( + data[start_offset:].decode("utf-8", errors="replace"), + request_id, + ) + if state["complete"]: + event = { + "event": "windows_pilot_pickup_observed", + "scenario": SCENARIO, + "request_id": request_id, + "result": state["collection_line"], + "checks": state["checks"], + "host_time": time.time(), + } + output.append(event) + print(json.dumps(event, sort_keys=True)) + return str(state["collection_line"]) + time.sleep(0.05) + raise TimeoutError( + f"sync_pair emitted no complete pickup collection for request " + f"{request_id} within {timeout_seconds:.1f}s" + ) + + +def run_windows_pickup_driver( + artifact_dir: Path, + *, + request_path: Path, + results_path: Path, + server_log: Path, + fixture_timeout: float, + screenshot_count: int, + screenshot_interval: float, + death_f4: bool = False, + lab_timeout: float = 45.0, +) -> int: + """Queue one ordinary pickup while Original R5 is the Windows pilot.""" + del lab_timeout + output_path = artifact_dir / "driver" / f"{SCENARIO}.json" + console_path = artifact_dir / "driver" / f"{SCENARIO}.console.log" + output_path.parent.mkdir(parents=True, exist_ok=True) + output: list[dict[str, Any]] = [] + returncode = 0 + screenshot_label = f"dist-sync-pickup-r5-pilot-{int(time.time())}" + start_offset = server_log.stat().st_size if server_log.exists() else 0 + + with console_path.open("w", encoding="utf-8") as handle: + with contextlib.redirect_stdout(handle), contextlib.redirect_stderr(handle): + try: + if death_f4: + raise ValueError("pickup driver does not accept death_f4") + sync_pair_client.capture_observer( + f"{screenshot_label}-before", 1, 0.0 + ) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + burst = pool.submit( + sync_pair_client.capture_observer, + screenshot_label, + screenshot_count, + screenshot_interval, + ) + time.sleep(min(0.5, max(0.1, screenshot_interval * 2.0))) + request_id = sync_pair_client.queue_sync_pair_scenario( + SCENARIO, + request_path, + results_path, + fixture_timeout, + output, + ) + wait_for_pickup_collection( + server_log, + start_offset, + request_id, + fixture_timeout, + output, + ) + # Give the probe worker a bounded opportunity to flush the + # nested RPC event and enclosing PickedUp snapshot. + time.sleep(0.75) + burst.result( + timeout=fixture_timeout + + screenshot_count * max(0.025, screenshot_interval) + + 30.0 + ) + sync_pair_client.capture_observer( + f"{screenshot_label}-after", 1, 0.0 + ) + output.append( + { + "event": "windows_pilot_pickup_capture_completed", + "scenario": SCENARIO, + "request_id": request_id, + "screenshot_label": screenshot_label, + "screenshot_count": screenshot_count, + "screenshot_interval": screenshot_interval, + "probe_flush_settle_seconds": 0.75, + "host_time": time.time(), + } + ) + except Exception as error: + returncode = 1 + output.append( + { + "event": "windows_pilot_pickup_driver_error", + "scenario": SCENARIO, + "error": f"{type(error).__name__}: {error}", + "host_time": time.time(), + } + ) + traceback.print_exc() + reloop.write_json( + output_path, + { + "scenario": SCENARIO, + "windows_role": "pilot", + "fixture_scope": "ordinary type-1 pickup", + "returncode": returncode, + "events": output, + }, + ) + return returncode + + +@contextmanager +def pickup_runner_patch() -> Iterator[None]: + """Temporarily add only the pickup topology/profile adapter in memory.""" + missing = object() + previous_profile = distributed.PROBE_PROFILE_FLAGS.get(PROFILE, missing) + previous_validate = distributed.validate_role_scenario + previous_death_driver = distributed.run_windows_death_driver + previous_pickup_driver = getattr( + distributed, "run_windows_pickup_driver", missing + ) + if previous_profile is not missing and previous_profile != PROFILE_FLAGS: + raise distributed.DistributedSyncError( + f"shared runner defines incompatible {PROFILE!r} flags: " + f"{previous_profile!r}" + ) + + def validate_role_scenario(windows_role: str, scenario: str) -> None: + if windows_role == "pilot" and scenario == SCENARIO: + return + previous_validate(windows_role, scenario) + + distributed.PROBE_PROFILE_FLAGS[PROFILE] = PROFILE_FLAGS + distributed.validate_role_scenario = validate_role_scenario + # Current shared-runner fallback dispatches all non-UI/GMX Windows-pilot + # scenarios through this symbol. Also expose the explicit pickup symbol + # so the adapter remains compatible when that dispatch grows upstream. + distributed.run_windows_death_driver = run_windows_pickup_driver + distributed.run_windows_pickup_driver = run_windows_pickup_driver + try: + yield + finally: + distributed.validate_role_scenario = previous_validate + distributed.run_windows_death_driver = previous_death_driver + if previous_pickup_driver is missing: + delattr(distributed, "run_windows_pickup_driver") + else: + distributed.run_windows_pickup_driver = previous_pickup_driver + if previous_profile is missing: + distributed.PROBE_PROFILE_FLAGS.pop(PROFILE, None) + else: + distributed.PROBE_PROFILE_FLAGS[PROFILE] = previous_profile + + +def build_runner_args(args: argparse.Namespace) -> argparse.Namespace: + return argparse.Namespace( + config=args.config, + scenario=SCENARIO, + server_mode=args.server_mode, + local_client_mode=args.local_client_mode, + windows_client_mode=args.windows_client_mode, + windows_role="pilot", + death_f4=False, + windows_server_host=args.windows_server_host, + windows_favorite_index=args.windows_favorite_index, + deploy_windows_dll=args.original_dll, + deploy_windows_probe=args.probe, + windows_probe_profile=PROFILE, + client_ready_timeout=args.client_ready_timeout, + pair_ready_timeout=args.pair_ready_timeout, + fixture_timeout=args.fixture_timeout, + action_seconds=1.0, + between=0.0, + screenshot_count=args.screenshot_count, + screenshot_interval=args.screenshot_interval, + windows_start_timeout=args.windows_start_timeout, + lab_timeout=args.lab_timeout, + fetch_timeout=args.fetch_timeout, + ) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--config", type=Path, default=reloop.DEFAULT_CONFIG + ) + parser.add_argument( + "--original-dll", + type=Path, + default=REPO_ROOT / "samp.dll", + help="documented Original 0.3.7-R5 samp.dll to deploy on Windows", + ) + parser.add_argument( + "--probe", + type=Path, + default=REPO_ROOT / "build-asi-probe" / "samp_probe.asi", + help="focused probe ASI to deploy on Windows", + ) + parser.add_argument( + "--server-mode", choices=("fail", "replace", "reuse"), default="replace" + ) + parser.add_argument( + "--local-client-mode", choices=("fail", "replace"), default="replace" + ) + parser.add_argument( + "--windows-client-mode", choices=("fail", "replace"), default="replace" + ) + parser.add_argument("--windows-server-host", default="192.168.3.181") + parser.add_argument("--windows-favorite-index", type=int, default=3) + parser.add_argument("--client-ready-timeout", type=float, default=60.0) + parser.add_argument("--pair-ready-timeout", type=float, default=90.0) + parser.add_argument("--fixture-timeout", type=float, default=45.0) + parser.add_argument("--screenshot-count", type=int, default=12) + parser.add_argument("--screenshot-interval", type=float, default=0.05) + parser.add_argument("--windows-start-timeout", type=float, default=45.0) + parser.add_argument("--lab-timeout", type=float, default=45.0) + parser.add_argument("--fetch-timeout", type=float, default=120.0) + return parser + + +def validate_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None: + args.original_dll = args.original_dll.expanduser().resolve() + args.probe = args.probe.expanduser().resolve() + args.config = args.config.expanduser().resolve() + if not args.original_dll.is_file(): + parser.error(f"Original R5 DLL is missing: {args.original_dll}") + if reloop.sha256(args.original_dll) != ORIGINAL_R5_SHA256: + parser.error( + "pickup-r5 requires the documented Original R5 samp.dll " + f"SHA256={ORIGINAL_R5_SHA256}" + ) + if not args.probe.is_file(): + parser.error(f"probe ASI is missing: {args.probe}") + for field in ( + "client_ready_timeout", + "pair_ready_timeout", + "fixture_timeout", + "windows_start_timeout", + "lab_timeout", + "fetch_timeout", + ): + if getattr(args, field) <= 0: + parser.error(f"--{field.replace('_', '-')} must be positive") + if not 1 <= args.screenshot_count <= 120: + parser.error("--screenshot-count must be between 1 and 120") + if not 0.025 <= args.screenshot_interval <= 10.0: + parser.error("--screenshot-interval must be between 0.025 and 10 seconds") + if not 0 <= args.windows_favorite_index <= 100: + parser.error("--windows-favorite-index must be between 0 and 100") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _parser() + args = parser.parse_args(argv) + validate_args(parser, args) + try: + with pickup_runner_patch(): + artifact, distributed_verdict = distributed.execute( + build_runner_args(args) + ) + analysis = analyze_log(artifact) + reloop.write_json(artifact / "pickup-r5-analysis.json", analysis) + print(f"pickup analysis: {analysis['assessment']}", flush=True) + print( + f"pickup analysis artifact: {artifact / 'pickup-r5-analysis.json'}", + flush=True, + ) + except ( + distributed.DistributedSyncError, + windows_edge.WindowsSyncEdgeError, + sync_edge_probe.SyncEdgeError, + reloop.ReLoopError, + subprocess.TimeoutExpired, + FileNotFoundError, + OSError, + ValueError, + ) as error: + print(f"Pickup R5 runner failed: {error}", file=sys.stderr) + return 2 + except Exception: + traceback.print_exc() + return 2 + + if distributed_verdict.get("verdict") == "FAIL": + return 1 + if analysis["assessment"] == MISMATCH: + return 1 + return 0 if analysis["assessment"] in ACCEPTABLE_ASSESSMENTS else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/reloop/reloop.py b/tools/reloop/reloop.py index bb16495..cbb9664 100755 --- a/tools/reloop/reloop.py +++ b/tools/reloop/reloop.py @@ -39,10 +39,25 @@ CONTROL_ASI = REPO_ROOT / "build-reloop-control/reloop_control.asi" CONTROL_CLIENT = SCRIPT_DIR / "control_client.py" PASS_VERDICTS = {"PASS", "PASS_WITH_WARNINGS"} -CRASH_VERDICTS = {"PRECONNECT_CRASH", "RUNTIME_CRASH"} +CRASH_VERDICTS = { + "PRECONNECT_CRASH", + "RUNTIME_CRASH", + "HANG_PRECONNECT_STREAMING", + "HANG_SPAWN_STREAMING", +} SERVER_READY_PATTERN = re.compile(r"Legacy Network started on port\s+(\d+)") RESULT_PATTERN = re.compile(r"\[test_cmds\]\s+(.*)") KEY_VALUE_PATTERN = re.compile(r"([a-z_]+)=([^\s]+)") +SCENE_PREPARE_STEP_PATTERN = re.compile( + r"scene_prepare_step:\s+reason=(?P[^\s]+)\s+" + r"step=(?P[^\s]+)\s+phase=(?Pbegin|end)\b" +) +SPAWN_SCENE_REASONS = {"class_select_player_pos", "server_player_pos", "spawn"} +NETGAME_JOIN_MARKERS = ( + "rpc-auto-out id=25 name=ClientJoin", + "rpc-in id=139 name=ScrInitGame", + "rpc-state id=139 init_game", +) class ReLoopError(RuntimeError): @@ -232,6 +247,20 @@ def write_json(path: Path, value: Any) -> None: path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") +def write_run_metadata( + artifact_dir: Path, + metadata: dict[str, Any], + settings: Settings, + profile: ClientProfile, +) -> None: + metadata.update({ + "dll_sha256": sha256(profile.samp_dll), + "built_dll_sha256": sha256(settings.built_dll), + "pawn_sha256": sha256(settings.pawn_output), + }) + write_json(artifact_dir / "metadata.json", metadata) + + def append_event(path: Path, state: str, **details: Any) -> None: payload = {"time": utc_timestamp(), "state": state, **details} with path.open("a", encoding="utf-8") as handle: @@ -658,6 +687,31 @@ def collect_request_lines(path: Path, offset: int, current_request: int) -> list return [line.rstrip("\r") for line in text.splitlines() if needle in line] +def classify_streaming_hang(client_logs: str) -> tuple[str, str] | None: + """Return a conservative phase-specific verdict for an open GTA stream call.""" + steps = list(SCENE_PREPARE_STEP_PATTERN.finditer(client_logs)) + if not steps or steps[-1].group("phase") != "begin": + return None + + last = steps[-1] + reason = last.group("reason") + step = last.group("step") + joined_netgame = any(marker in client_logs for marker in NETGAME_JOIN_MARKERS) + if reason == "preconnect" and not joined_netgame: + verdict = "HANG_PRECONNECT_STREAMING" + elif reason in SPAWN_SCENE_REASONS and joined_netgame: + verdict = "HANG_SPAWN_STREAMING" + else: + return None + + join_state = "present" if joined_netgame else "absent" + evidence = ( + "PROBE_TRACE: timeout left scene_prepare_step open at " + f"reason={reason} step={step} phase=begin; Join/NetGame evidence {join_state}" + ) + return verdict, evidence + + def classify_run(lines: list[str], client_returncode: int | None, client_logs: str, timed_out: bool) -> tuple[str, list[str]]: warnings: list[str] = [] run_start = any("marker=RUN_START" in line for line in lines) @@ -675,6 +729,12 @@ def classify_run(lines: list[str], client_returncode: int | None, client_logs: s if observations: warnings.append(f"{observations} visual observations require screenshot/golden review") return ("PASS_WITH_WARNINGS" if warnings else "PASS"), warnings + if timed_out and not run_start and not run_abort and not crash: + streaming_hang = classify_streaming_hang(client_logs) + if streaming_hang is not None: + verdict, evidence = streaming_hang + warnings.append(evidence) + return verdict, warnings if client_returncode is not None or crash: return ("RUNTIME_CRASH" if run_start else "PRECONNECT_CRASH"), warnings if run_abort: @@ -771,11 +831,8 @@ def execute_run( "server_mode": server_mode, "client_mode": client_mode, "device_helper_path": str(profile.gta_root / settings.device_helper_filename), - "dll_sha256": sha256(profile.samp_dll), - "built_dll_sha256": sha256(settings.built_dll), - "pawn_sha256": sha256(settings.pawn_output), } - write_json(artifact_dir / "metadata.json", metadata) + write_run_metadata(artifact_dir, metadata, settings, profile) append_event(events, "PREPARE", artifact=str(artifact_dir), request=current_request) replace_existing_client(profile, client_mode, settings.shutdown_timeout_s) @@ -785,6 +842,9 @@ def execute_run( if deploy and client_name == "replacement": append_event(events, "DEPLOY") deploy_replacement(settings, artifact_dir / "build") + # Build and deploy may have replaced every hashed artifact above. Persist + # the identities that will actually participate in this run. + write_run_metadata(artifact_dir, metadata, settings, profile) append_event(events, "DEVICE_HELPER_INSTALL") install_device_helper(settings, profile, artifact_dir / "build") if interaction: diff --git a/tools/reloop/sync_edge_probe.py b/tools/reloop/sync_edge_probe.py new file mode 100755 index 0000000..0caa80b --- /dev/null +++ b/tools/reloop/sync_edge_probe.py @@ -0,0 +1,758 @@ +#!/usr/bin/env python3 +"""Run the Packet 209/210/211 two-prefix sync fixture and collect evidence. + +The pilot is launched first and must own reloop_control's fixed localhost +endpoint before the observer starts. The runner deliberately does not install, +disable, rename, or replace ASIs in either prefix; their hashes are recorded +before and after the run so an interrupted probe cannot leave a prefix altered. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import json +import re +import shlex +import socket +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[1] +sys.path.insert(0, str(SCRIPT_DIR)) + +import reloop # noqa: E402 +from control_client import wait_for_api # noqa: E402 + + +EDGE_SCENARIOS = ("passenger", "unoccupied", "trailer") +PASSENGER_G_SCENARIO = "passenger_g" +SUPPORTED_EDGE_SCENARIOS = (*EDGE_SCENARIOS, PASSENGER_G_SCENARIO) +CONTROL_PORT = 18737 +AUTOPAUSE_INI = "III.VC.SA.WindowedMode.ini" +CRASH_MARKERS = ("exception_filter", "unhandled page fault") + + +@dataclasses.dataclass(frozen=True) +class EdgeEvidence: + packet_id: int + runtime_pattern: str + server_marker: str + + +EVIDENCE = { + "passenger": EdgeEvidence( + 211, + r"remote_passenger: apply[^\r\n]*\bseated=1\b[^\r\n]*\bseat_read=1\b", + "scenario=passenger", + ), + "passenger_g": EdgeEvidence( + 211, + r"remote_passenger: apply[^\r\n]*\bseated=1\b[^\r\n]*\bseat_read=1\b", + "marker=PASSENGER_ENTRY_RESULT", + ), + "unoccupied": EdgeEvidence( + 209, + r"remote_unoccupied: apply[^\r\n]*\breadback=1\b", + "marker=UNOCCUPIED_UPDATE", + ), + "trailer": EdgeEvidence( + 210, + r"remote_trailer: apply[^\r\n]*\battached=1\b[^\r\n]*\breadback=1\b", + "marker=TRAILER_UPDATE", + ), +} + + +class SyncEdgeError(RuntimeError): + """Expected operator-facing edge-probe failure.""" + + +def write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def file_hashes(root: Path) -> dict[str, str | None]: + """Snapshot every top-level ASI by relative name without changing it.""" + return { + path.name: reloop.sha256(path) + for path in sorted(root.iterdir(), key=lambda item: item.name.lower()) + if path.is_file() and path.suffix.lower() == ".asi" + } + + +def autopause_disabled(path: Path) -> bool: + """Return whether [game] explicitly contains autoPause=0.""" + if not path.is_file(): + return False + section = "" + for raw_line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = raw_line.strip() + if not line or line.startswith((";", "#")): + continue + if line.startswith("[") and line.endswith("]"): + section = line[1:-1].strip().lower() + continue + if section != "game" or "=" not in line: + continue + key, value = (part.strip().lower() for part in line.split("=", 1)) + value = value.split(";", 1)[0].split("#", 1)[0].strip() + if key == "autopause": + return value == "0" + return False + + +def tcp_port_available(host: str, port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + probe.bind((host, port)) + except OSError: + return False + return True + + +def appended_text(path: Path, snapshot: reloop.FileSnapshot) -> str: + if not path.is_file(): + return "" + current = path.stat() + offset = ( + snapshot.size + if current.st_ino == snapshot.inode and current.st_size >= snapshot.size + else 0 + ) + with path.open("rb") as handle: + handle.seek(offset) + return handle.read().decode("utf-8", errors="replace") + + +def wait_for_pair( + console_path: Path, + server_log: Path, + server_snapshot: reloop.FileSnapshot, + timeout_seconds: float, +) -> None: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + console = ( + console_path.read_text(encoding="utf-8", errors="replace") + if console_path.is_file() + else "" + ) + text = console + "\n" + appended_text(server_log, server_snapshot) + if "marker=PAIR_READY" in text: + return + # GE-Proton's wine launcher can exit successfully after handing the + # game to the prefix wineserver. Its Popen lifetime is therefore not + # the GTA process lifetime; prefix cleanup and client/server traces are + # the authoritative liveness signals. + time.sleep(0.25) + raise SyncEdgeError( + f"SyncPilot and SyncObserver did not reach PAIR_READY in {timeout_seconds:.1f}s" + ) + + +def _contains_crash(text: str) -> bool: + lowered = text.lower() + return any(marker in lowered for marker in CRASH_MARKERS) + + +def evaluate_evidence( + scenarios: list[str], + server_text: str, + observer_logs: str, + observer_is_replacement: bool, + driver_returncodes: dict[str, int], +) -> dict[str, Any]: + """Reduce trace artifacts conservatively; this never claims visual parity.""" + results: dict[str, Any] = {} + hard_failure = _contains_crash(observer_logs) + for scenario in scenarios: + contract = EVIDENCE[scenario] + checks = { + "driver_completed": driver_returncodes.get(scenario) == 0, + "server_scenario_seen": contract.server_marker in server_text, + } + if scenario in {"passenger", PASSENGER_G_SCENARIO}: + checks["server_passenger_state_seen"] = bool( + re.search( + rf"marker=PILOT_SYNC scenario={scenario}\b.*\bstate=3\b", + server_text, + ) + ) + if scenario == PASSENGER_G_SCENARIO: + request_match = re.search( + r"marker=PASSENGER_ENTER_REQUEST\b[^\r\n]*" + r"\bstatus=ACTION\b[^\r\n]*\bscenario=passenger_g\b" + r"[^\r\n]*\brpc=26\b[^\r\n]*\bvehicle=(\d+)\b" + r"[^\r\n]*\bexpected_vehicle=(\d+)\b" + r"[^\r\n]*\bis_passenger=1\b", + server_text, + ) + result_match = re.search( + r"marker=PASSENGER_ENTRY_RESULT\b[^\r\n]*" + r"\bstatus=PASS\b[^\r\n]*\bscenario=passenger_g\b" + r"[^\r\n]*\brpc=26\b[^\r\n]*\benter_seen=1\b" + r"[^\r\n]*\benter_vehicle=(\d+)\b" + r"[^\r\n]*\bis_passenger=1\b[^\r\n]*\bvehicle=(\d+)\b" + r"[^\r\n]*\bseat=1\b[^\r\n]*\bstate=3\b", + server_text, + ) + checks["server_rpc26_passenger_request_seen"] = bool(request_match) + checks["server_passenger_entry_verified"] = bool(result_match) + checks["server_passenger_vehicle_verified"] = bool( + request_match + and result_match + and request_match.group(1) == request_match.group(2) + and request_match.group(1) == result_match.group(1) + and request_match.group(1) == result_match.group(2) + ) + if observer_is_replacement: + checks["observer_packet_decoded"] = ( + f"packet-state id={contract.packet_id} " in observer_logs + ) + checks["observer_runtime_apply_seen"] = bool( + re.search(contract.runtime_pattern, observer_logs) + ) + checks["observer_edge_consumed"] = bool( + re.search( + rf"remote_edge: consume[^\r\n]*\bpacket={contract.packet_id}\b" + rf"[^\r\n]*\bresult=applied\b", + observer_logs, + ) + ) + results[scenario] = { + "packet_id": contract.packet_id, + "checks": checks, + "verdict": "TRACE_PASS" if all(checks.values()) else "FAIL", + } + hard_failure = hard_failure or not all(checks.values()) + + if hard_failure: + verdict = "FAIL" + elif observer_is_replacement: + verdict = "TRACE_PASS_VISUAL_UNVERIFIED" + else: + verdict = "SERVER_TRACE_PASS_OBSERVER_INTERNALS_UNAVAILABLE" + return { + "verdict": verdict, + "visual_parity": "TODO_VERIFY", + "scenarios": results, + "observer_crash_marker": _contains_crash(observer_logs), + } + + +def collect_logs( + destination: Path, + snapshots: dict[str, reloop.FileSnapshot], + capture_errors: list[str] | None = None, +) -> str: + combined = "" + for name, snapshot in snapshots.items(): + target = destination / name + try: + if snapshot.capture_append(target): + combined += target.read_text(encoding="utf-8", errors="replace") + "\n" + except Exception as error: + if capture_errors is None: + raise + capture_errors.append(f"{name}: {type(error).__name__}: {error}") + return combined + + +def finalize_artifact( + *, + artifact_dir: Path, + metadata: dict[str, Any], + scenarios: list[str], + server_console: Path, + server_snapshot: reloop.FileSnapshot, + result_snapshot: reloop.FileSnapshot, + pilot_snapshots: dict[str, reloop.FileSnapshot], + observer_snapshots: dict[str, reloop.FileSnapshot], + pilot: reloop.ClientProfile, + observer: reloop.ClientProfile, + asi_before: dict[str, dict[str, str | None]], + driver_returncodes: dict[str, int], + runner_error: str | None, + teardown_errors: list[str], + capture_complete: bool, +) -> dict[str, Any]: + """Write a self-contained checkpoint before and after process teardown.""" + capture_errors: list[str] = [] + + for label, snapshot, destination in ( + ("server.log", server_snapshot, artifact_dir / "server.log"), + ( + "sync-pair-results.log", + result_snapshot, + artifact_dir / "sync-pair-results.log", + ), + ): + try: + snapshot.capture_append(destination) + except Exception as error: + capture_errors.append(f"{label}: {type(error).__name__}: {error}") + + pilot_logs = collect_logs( + artifact_dir / "pilot" / "client", + pilot_snapshots, + capture_errors, + ) + observer_logs = collect_logs( + artifact_dir / "observer" / "client", + observer_snapshots, + capture_errors, + ) + console_text = ( + server_console.read_text(encoding="utf-8", errors="replace") + if server_console.is_file() + else "" + ) + captured_server = artifact_dir / "server.log" + server_append = ( + captured_server.read_text(encoding="utf-8", errors="replace") + if captured_server.is_file() + else "" + ) + verdict = evaluate_evidence( + scenarios, + console_text + "\n" + server_append, + observer_logs, + observer.name == "replacement", + driver_returncodes, + ) + verdict.update( + { + "finished_at": reloop.utc_timestamp(), + "artifact_complete": capture_complete, + "runner_error": runner_error, + "teardown_errors": list(teardown_errors), + "capture_errors": capture_errors, + "pilot_crash_marker": _contains_crash(pilot_logs), + "driver_returncodes": driver_returncodes, + } + ) + if runner_error or teardown_errors or capture_errors or verdict["pilot_crash_marker"]: + verdict["verdict"] = "FAIL" + elif not capture_complete: + verdict["verdict"] = "INCOMPLETE_TEARDOWN" + + asi_after = { + "pilot": file_hashes(pilot.gta_root), + "observer": file_hashes(observer.gta_root), + } + verdict["asi_hashes_unchanged"] = asi_after == asi_before + if not verdict["asi_hashes_unchanged"]: + verdict["verdict"] = "FAIL" + metadata["asi_hashes_after"] = asi_after + metadata["artifact_complete"] = capture_complete + write_json(artifact_dir / "metadata.json", metadata) + write_json(artifact_dir / "verdict.json", verdict) + return verdict + + +def validate_layout( + settings: reloop.Settings, + pilot: reloop.ClientProfile, + observer: reloop.ClientProfile, +) -> dict[str, Any]: + if pilot.name == observer.name or pilot.prefix.resolve() == observer.prefix.resolve(): + raise SyncEdgeError("pilot and observer must use distinct prefixes") + if not reloop.CONTROL_ASI.is_file(): + raise SyncEdgeError(f"control helper is not built: {reloop.CONTROL_ASI}") + installed_control = pilot.gta_root / reloop.CONTROL_ASI.name + expected_hash = reloop.sha256(reloop.CONTROL_ASI) + actual_hash = reloop.sha256(installed_control) + if actual_hash is None: + raise SyncEdgeError( + f"pilot control ASI is missing; install it before the probe: {installed_control}" + ) + for profile in (pilot, observer): + if not profile.samp_exe.is_file() or not profile.samp_dll.is_file(): + raise SyncEdgeError(f"{profile.name} GTA/SA-MP layout is incomplete") + ini = profile.gta_root / AUTOPAUSE_INI + if not autopause_disabled(ini): + raise SyncEdgeError(f"{profile.name} requires [game] autoPause = 0 in {ini}") + return { + "pilot_control_path": str(installed_control), + "pilot_control_sha256": actual_hash, + "built_control_sha256": expected_hash, + "pilot_control_matches_current_build": actual_hash == expected_hash, + "pilot_autopause": True, + "observer_autopause": True, + "control_strategy": ( + "pilot launched and API-verified before observer; no ASI file is modified" + ), + } + + +def run_driver( + scenario: str, + artifact_dir: Path, + timeout_seconds: float, + action_seconds: float, +) -> tuple[int, Path]: + output_path = artifact_dir / "driver" / f"{scenario}.json" + console_path = artifact_dir / "driver" / f"{scenario}.console.log" + output_path.parent.mkdir(parents=True, exist_ok=True) + command = [ + sys.executable, + str(SCRIPT_DIR / "sync_pair_client.py"), + scenario, + "--output", + str(output_path), + "--action-seconds", + str(action_seconds), + "--sync-pair-request-timeout", + str(timeout_seconds), + ] + with console_path.open("wb") as handle: + completed = subprocess.run( + command, + cwd=REPO_ROOT, + stdout=handle, + stderr=subprocess.STDOUT, + timeout=timeout_seconds + action_seconds + 20.0, + check=False, + ) + return completed.returncode, console_path + + +def execute(args: argparse.Namespace) -> tuple[Path, dict[str, Any]]: + settings = reloop.load_settings(args.config.resolve()) + pilot = settings.clients[args.pilot] + observer = settings.clients[args.observer] + scenarios = list(EDGE_SCENARIOS if args.scenario == "all" else (args.scenario,)) + artifact_dir = settings.artifacts_root / reloop.run_id("sync-edge", args.scenario) + artifact_dir.mkdir(parents=True, exist_ok=False) + # Automation normally block-buffers stdout. Publish and flush the path + # before client startup/teardown so a long Wine shutdown is not mistaken + # for a runner that failed to create an artifact. + print(f"artifact: {artifact_dir}", flush=True) + + layout = validate_layout(settings, pilot, observer) + asi_before = { + "pilot": file_hashes(pilot.gta_root), + "observer": file_hashes(observer.gta_root), + } + metadata = { + "run_id": artifact_dir.name, + "started_at": reloop.utc_timestamp(), + "pilot": pilot.name, + "pilot_prefix": str(pilot.prefix), + "pilot_nickname": "SyncPilot", + "pilot_dll_sha256": reloop.sha256(pilot.samp_dll), + "observer": observer.name, + "observer_prefix": str(observer.prefix), + "observer_nickname": "SyncObserver", + "observer_dll_sha256": reloop.sha256(observer.samp_dll), + "scenarios": scenarios, + "host": settings.host, + "port": settings.port, + "server_mode": args.server_mode, + "client_mode": args.client_mode, + "layout": layout, + "asi_hashes_before": asi_before, + } + write_json(artifact_dir / "metadata.json", metadata) + + request_path = settings.server_root / "scriptfiles/sync_pair_request.txt" + if request_path.exists(): + raise SyncEdgeError(f"sync-pair request is already pending: {request_path}") + fixture = settings.server_root / "filterscripts/sync_pair.amx" + if not fixture.is_file(): + raise SyncEdgeError(f"sync-pair fixture is not compiled: {fixture}") + + reloop.replace_existing_client(pilot, args.client_mode, settings.shutdown_timeout_s) + reloop.replace_existing_client(observer, args.client_mode, settings.shutdown_timeout_s) + if not tcp_port_available("127.0.0.1", CONTROL_PORT): + raise SyncEdgeError( + f"localhost control port {CONTROL_PORT} is already occupied after " + "client preflight; refusing to risk driving the wrong client" + ) + reused_server = reloop.replace_existing_server(settings, args.server_mode) + + server_log = settings.server_root / "log.txt" + result_file = settings.server_root / "scriptfiles/sync_pair_results.log" + server_snapshot = reloop.FileSnapshot.take(server_log) + result_snapshot = reloop.FileSnapshot.take(result_file) + pilot_snapshots = { + name: reloop.FileSnapshot.take(pilot.gta_root / name) + for name in reloop.CLIENT_LOG_NAMES + } + observer_snapshots = { + name: reloop.FileSnapshot.take(observer.gta_root / name) + for name in reloop.CLIENT_LOG_NAMES + } + pilot_pre_pids = reloop.prefix_pids(pilot.prefix) + observer_pre_pids = reloop.prefix_pids(observer.prefix) + + server: reloop.ManagedProcess | None = None + pilot_process: reloop.ManagedProcess | None = None + observer_process: reloop.ManagedProcess | None = None + driver_returncodes: dict[str, int] = {} + server_console = artifact_dir / "server.console.log" + runner_exception: BaseException | None = None + runner_error: str | None = None + teardown_errors: list[str] = [] + verdict: dict[str, Any] + try: + if not reused_server: + server = reloop.start_process( + [str(settings.server_executable)], + settings.server_root, + server_console, + "open.mp", + ) + ready = reloop.wait_for_text( + server_console, + reloop.SERVER_READY_PATTERN, + settings.server_ready_timeout_s, + server.process, + ) + if not ready: + raise SyncEdgeError("open.mp did not become ready; see server.console.log") + + pilot_command, pilot_env = reloop.direct_client_launch( + settings, pilot, artifact_dir / "pilot-launch" + ) + pilot_command[-1] = "-nSyncPilot" + pilot_process = reloop.start_process( + pilot_command, + pilot.gta_root, + artifact_dir / "pilot-launcher.log", + "SyncPilot", + env=pilot_env, + ) + pilot_api = wait_for_api(timeout=args.client_ready_timeout) + pilot_api.close() + + observer_command, observer_env = reloop.direct_client_launch( + settings, observer, artifact_dir / "observer-launch" + ) + observer_command[-1] = "-nSyncObserver" + observer_process = reloop.start_process( + observer_command, + observer.gta_root, + artifact_dir / "observer-launcher.log", + "SyncObserver", + env=observer_env, + ) + write_json( + artifact_dir / "launch-order.json", + { + "pilot": shlex.join(pilot_command), + "pilot_api_verified_before_observer": True, + "observer": shlex.join(observer_command), + }, + ) + wait_for_pair( + server_console, + server_log, + server_snapshot, + args.pair_ready_timeout, + ) + + for scenario in scenarios: + returncode, _console = run_driver( + scenario, + artifact_dir, + args.fixture_timeout, + args.action_seconds, + ) + driver_returncodes[scenario] = returncode + if returncode: + break + time.sleep(args.between) + except BaseException as error: + runner_exception = error + runner_error = f"{type(error).__name__}: {error}" + finally: + # Capture before teardown as an interruption-safe checkpoint. A + # successful post-teardown pass below overwrites it with the complete + # append-only slices. + try: + finalize_artifact( + artifact_dir=artifact_dir, + metadata=metadata, + scenarios=scenarios, + server_console=server_console, + server_snapshot=server_snapshot, + result_snapshot=result_snapshot, + pilot_snapshots=pilot_snapshots, + observer_snapshots=observer_snapshots, + pilot=pilot, + observer=observer, + asi_before=asi_before, + driver_returncodes=driver_returncodes, + runner_error=runner_error, + teardown_errors=teardown_errors, + capture_complete=False, + ) + except Exception as error: + teardown_errors.append( + f"pre_teardown_capture: {type(error).__name__}: {error}" + ) + + if observer_process is not None: + try: + observer_process.stop(settings.shutdown_timeout_s) + except Exception as error: + teardown_errors.append( + f"observer_stop: {type(error).__name__}: {error}" + ) + try: + reloop.terminate_pids( + reloop.prefix_pids(observer.prefix) - observer_pre_pids, + settings.shutdown_timeout_s, + ) + except Exception as error: + teardown_errors.append( + f"observer_prefix_stop: {type(error).__name__}: {error}" + ) + if pilot_process is not None: + try: + pilot_process.stop(settings.shutdown_timeout_s) + except Exception as error: + teardown_errors.append( + f"pilot_stop: {type(error).__name__}: {error}" + ) + try: + reloop.terminate_pids( + reloop.prefix_pids(pilot.prefix) - pilot_pre_pids, + settings.shutdown_timeout_s, + ) + except Exception as error: + teardown_errors.append( + f"pilot_prefix_stop: {type(error).__name__}: {error}" + ) + if server is not None: + try: + server.stop(settings.shutdown_timeout_s) + except Exception as error: + teardown_errors.append( + f"server_stop: {type(error).__name__}: {error}" + ) + + verdict = finalize_artifact( + artifact_dir=artifact_dir, + metadata=metadata, + scenarios=scenarios, + server_console=server_console, + server_snapshot=server_snapshot, + result_snapshot=result_snapshot, + pilot_snapshots=pilot_snapshots, + observer_snapshots=observer_snapshots, + pilot=pilot, + observer=observer, + asi_before=asi_before, + driver_returncodes=driver_returncodes, + runner_error=runner_error, + teardown_errors=teardown_errors, + capture_complete=True, + ) + + print(f"verdict: {verdict['verdict']}", flush=True) + if runner_exception is not None: + raise runner_exception.with_traceback(runner_exception.__traceback__) + if teardown_errors: + raise SyncEdgeError("; ".join(teardown_errors)) + return artifact_dir, verdict + + +def analyze(path: Path) -> dict[str, Any]: + metadata = json.loads((path / "metadata.json").read_text(encoding="utf-8")) + scenarios = list(metadata["scenarios"]) + server_text = "" + for name in ("server.console.log", "server.log"): + candidate = path / name + if candidate.is_file(): + server_text += candidate.read_text(encoding="utf-8", errors="replace") + "\n" + observer_logs = "" + observer_dir = path / "observer" / "client" + for name in reloop.CLIENT_LOG_NAMES: + candidate = observer_dir / name + if candidate.is_file(): + observer_logs += candidate.read_text(encoding="utf-8", errors="replace") + "\n" + returncodes: dict[str, int] = {} + old_verdict = path / "verdict.json" + if old_verdict.is_file(): + raw = json.loads(old_verdict.read_text(encoding="utf-8")) + returncodes = { + str(key): int(value) + for key, value in raw.get("driver_returncodes", {}).items() + } + verdict = evaluate_evidence( + scenarios, + server_text, + observer_logs, + metadata["observer"] == "replacement", + returncodes, + ) + write_json(path / "verdict.reanalyzed.json", verdict) + print(json.dumps(verdict, indent=2, sort_keys=True)) + return verdict + + +def main() -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + run_parser = subparsers.add_parser("run") + run_parser.add_argument( + "--config", type=Path, default=reloop.DEFAULT_CONFIG + ) + run_parser.add_argument("--pilot", choices=("original", "replacement"), default="original") + run_parser.add_argument("--observer", choices=("original", "replacement"), default="replacement") + run_parser.add_argument( + "--scenario", choices=(*SUPPORTED_EDGE_SCENARIOS, "all"), default="all" + ) + run_parser.add_argument( + "--server-mode", choices=("fail", "replace", "reuse"), default="fail" + ) + run_parser.add_argument( + "--client-mode", choices=("fail", "replace"), default="fail" + ) + run_parser.add_argument("--client-ready-timeout", type=float, default=45.0) + run_parser.add_argument("--pair-ready-timeout", type=float, default=60.0) + run_parser.add_argument("--fixture-timeout", type=float, default=20.0) + run_parser.add_argument("--action-seconds", type=float, default=4.0) + run_parser.add_argument("--between", type=float, default=1.0) + + analyze_parser = subparsers.add_parser("analyze") + analyze_parser.add_argument("artifact", type=Path) + + args = parser.parse_args() + if args.command == "analyze": + verdict = analyze(args.artifact.resolve()) + return 1 if verdict["verdict"] == "FAIL" else 0 + for field in ( + "client_ready_timeout", + "pair_ready_timeout", + "fixture_timeout", + "action_seconds", + ): + if getattr(args, field) <= 0.0: + parser.error(f"--{field.replace('_', '-')} must be positive") + if args.between < 0.0: + parser.error("--between must not be negative") + try: + _artifact, verdict = execute(args) + except (SyncEdgeError, reloop.ReLoopError, subprocess.TimeoutExpired) as exc: + print(f"sync-edge probe failed: {exc}", file=sys.stderr) + return 2 + return 1 if verdict["verdict"] == "FAIL" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/reloop/sync_pair_client.py b/tools/reloop/sync_pair_client.py index 895308f..ff03453 100644 --- a/tools/reloop/sync_pair_client.py +++ b/tools/reloop/sync_pair_client.py @@ -5,6 +5,7 @@ import argparse import json +import math import subprocess import time from pathlib import Path @@ -17,9 +18,16 @@ VK_SPACE = 0x20 VK_A = ord("A") VK_D = ord("D") +VK_G = ord("G") VK_H = ord("H") VK_S = ord("S") VK_W = ord("W") +ANGLE_SWEEP_TARGET_DEGREES = 35.0 +ANGLE_SWEEP_TOLERANCE_DEGREES = 2.0 +ANGLE_SWEEP_MOUSE_PULSE_X = 6 +ANGLE_SWEEP_PULSE_SECONDS = 0.08 +ANGLE_SWEEP_NETWORK_SETTLE_SECONDS = 0.6 +ANGLE_SWEEP_MAX_PULSES = 120 DEFAULT_TEST_CMDS_REQUEST = ( Path(__file__).resolve().parents[2] / "omp-server-bare" @@ -106,6 +114,12 @@ def queue_sync_pair_scenario( deadline = time.monotonic() + timeout_seconds request_token = f" request={request_id} " + edge_setup_required = scenario in { + "passenger", + "passenger_g", + "unoccupied", + "trailer", + } while time.monotonic() < deadline: if results_path.exists(): result_bytes = results_path.read_bytes() @@ -119,11 +133,33 @@ def queue_sync_pair_scenario( raise RuntimeError( f"sync_pair rejected scenario {scenario!r}: {line}" ) + if ( + edge_setup_required + and "marker=EDGE_SETUP " in line + and f" scenario={scenario} " in line + ): + if " status=FAIL " in line: + raise RuntimeError( + f"sync_pair failed edge setup {scenario!r}: {line}" + ) + if " status=PASS " in line: + acknowledged = { + "event": "sync_pair_edge_setup_acknowledged", + "scenario": scenario, + "request_id": request_id, + "result": line, + "host_time": time.time(), + } + output.append(acknowledged) + print(json.dumps(acknowledged, sort_keys=True)) + return request_id if ( "marker=REQUEST_DONE " in line and " status=PASS " in line and f" scenario={scenario} " in line ): + if edge_setup_required: + continue acknowledged = { "event": "sync_pair_scenario_acknowledged", "scenario": scenario, @@ -142,6 +178,52 @@ def queue_sync_pair_scenario( ) +def wait_for_sync_pair_result( + marker: str, + scenario: str, + request_id: int, + results_path: Path, + timeout_seconds: float, + output: list[dict[str, Any]], +) -> str: + """Wait for one request-scoped PASS/FAIL result emitted after host input.""" + deadline = time.monotonic() + timeout_seconds + request_token = f" request={request_id} " + marker_token = f"marker={marker} " + while time.monotonic() < deadline: + if results_path.exists(): + text = results_path.read_text(encoding="utf-8", errors="replace") + for line in text.splitlines(): + if ( + request_token not in line + or marker_token not in line + or f" scenario={scenario} " not in line + ): + continue + if " status=FAIL " in line: + raise RuntimeError( + f"sync_pair failed {scenario!r} after host input: {line}" + ) + if " status=PASS " in line: + completed = { + "event": "sync_pair_input_result_acknowledged", + "marker": marker, + "scenario": scenario, + "request_id": request_id, + "result": line, + "host_time": time.time(), + } + output.append(completed) + print(json.dumps(completed, sort_keys=True)) + return line + time.sleep(0.05) + + raise TimeoutError( + f"sync_pair did not emit {marker} for request {request_id} " + f"and scenario {scenario!r} within {timeout_seconds:.1f}s" + ) + + def drive_control_matrix( client: ControlClient, output: list[dict[str, Any]], @@ -281,13 +363,25 @@ def drive_onfoot_weapon( sync_pair_request_timeout: float, ) -> None: client.command("focus") - queue_sync_pair_scenario( - scenario, - sync_pair_request_path, - sync_pair_results_path, - sync_pair_request_timeout, - output, - ) + edge_stream_wakeup = scenario in {"passenger", "unoccupied", "trailer"} + if edge_stream_wakeup: + # PROBE_TRACE: + # In repeated two-prefix runs the unfocused original pilot could be + # mutually player-streamed while sending no OnPlayerUpdate during a + # newly created vehicle's stream-in window. Keep one ordinary movement + # key active until the server verifies the physical edge setup. + client.key(VK_W, "down") + try: + queue_sync_pair_scenario( + scenario, + sync_pair_request_path, + sync_pair_results_path, + sync_pair_request_timeout, + output, + ) + finally: + if edge_stream_wakeup: + client.key(VK_W, "up") time.sleep(1.0) ready = sample(client, f"{scenario}_ready", output) time.sleep(pre_action_seconds) @@ -344,6 +438,157 @@ def drive_onfoot_weapon( sample(client, f"{scenario}_after_fire", output) +def vector_heading_degrees(x: float, y: float, label: str) -> float: + if not math.isfinite(x) or not math.isfinite(y) or math.hypot(x, y) < 0.05: + raise RuntimeError(f"{label} is unavailable or degenerate: ({x!r}, {y!r})") + return math.degrees(math.atan2(y, x)) % 360.0 + + +def signed_angle_delta_degrees(angle: float, baseline: float) -> float: + return (angle - baseline + 180.0) % 360.0 - 180.0 + + +def aim_heading_degrees(state: dict[str, Any]) -> float: + try: + front_x = float(state["aim_front_x"]) + front_y = float(state["aim_front_y"]) + except (KeyError, TypeError, ValueError) as exc: + raise RuntimeError( + "reloop_control state lacks CCamera::InternalAim front-vector fields; " + "deploy the current control ASI before running angles" + ) from exc + return vector_heading_degrees(front_x, front_y, "aim front") + + +def annotate_angle_state( + state: dict[str, Any], + baseline_heading: float, + mouse_pulses: int, +) -> None: + heading = aim_heading_degrees(state) + state["aim_heading_degrees"] = heading + state["aim_delta_from_baseline_degrees"] = signed_angle_delta_degrees( + heading, + baseline_heading, + ) + state["relative_mouse_pulses"] = mouse_pulses + try: + state["player_heading_degrees"] = vector_heading_degrees( + float(state["player_forward_x"]), + float(state["player_forward_y"]), + "player forward", + ) + except (KeyError, TypeError, ValueError, RuntimeError): + state["player_heading_degrees"] = None + + +def calibrate_left_aim_sign( + client: ControlClient, + baseline_heading: float, + output: list[dict[str, Any]], +) -> tuple[int, int]: + """Return the world-heading sign produced by a physical left mouse pulse.""" + for pulse_count in range(1, 13): + client.command( + "mouse", + action="move_delta", + x=-ANGLE_SWEEP_MOUSE_PULSE_X, + y=0, + ) + time.sleep(ANGLE_SWEEP_PULSE_SECONDS) + state = client.command("state") + delta = signed_angle_delta_degrees( + aim_heading_degrees(state), + baseline_heading, + ) + if abs(delta) >= 0.5: + event = { + "event": "angles_relative_input_calibrated", + "mouse_delta_x": -ANGLE_SWEEP_MOUSE_PULSE_X, + "pulses": pulse_count, + "observed_heading_delta_degrees": delta, + "left_heading_sign": 1 if delta > 0.0 else -1, + "host_time": time.time(), + } + output.append(event) + print(json.dumps(event, sort_keys=True)) + return event["left_heading_sign"], pulse_count + raise RuntimeError( + "relative mouse input did not change CCamera::InternalAim after 12 " + "bounded pulses; angles trace would not be valid" + ) + + +def drive_aim_to_relative_target( + client: ControlClient, + output: list[dict[str, Any]], + label: str, + baseline_heading: float, + target_sign: int, + mouse_delta_x: int, + initial_pulses: int = 0, +) -> dict[str, Any]: + """Drive to a measured relative heading and reject stalled input.""" + best_progress = -181.0 + stale_pulses = 0 + pulse_count = initial_pulses + for _ in range(ANGLE_SWEEP_MAX_PULSES + 1): + state = client.command("state") + heading = aim_heading_degrees(state) + delta = signed_angle_delta_degrees(heading, baseline_heading) + progress = delta * target_sign + if progress >= ( + ANGLE_SWEEP_TARGET_DEGREES - ANGLE_SWEEP_TOLERANCE_DEGREES + ): + # Let at least several normal aim-sync intervals reach the observer + # before its screenshot is requested, then prove the local heading + # did not fall back during that window. + time.sleep(ANGLE_SWEEP_NETWORK_SETTLE_SECONDS) + state = client.command("state") + heading = aim_heading_degrees(state) + delta = signed_angle_delta_degrees(heading, baseline_heading) + if delta * target_sign < ( + ANGLE_SWEEP_TARGET_DEGREES + - ANGLE_SWEEP_TOLERANCE_DEGREES + ): + raise RuntimeError( + f"aim heading fell back before observer capture for {label}: " + f"delta={delta:.3f}" + ) + state["label"] = label + state["host_time"] = time.time() + annotate_angle_state(state, baseline_heading, pulse_count) + output.append(state) + print(json.dumps(state, sort_keys=True)) + return state + + if progress > best_progress + 0.1: + best_progress = progress + stale_pulses = 0 + else: + stale_pulses += 1 + if stale_pulses >= 16: + raise RuntimeError( + f"relative mouse input stalled before {label}: " + f"best_progress={best_progress:.3f} " + f"target={ANGLE_SWEEP_TARGET_DEGREES:.3f}" + ) + + client.command( + "mouse", + action="move_delta", + x=mouse_delta_x, + y=0, + ) + pulse_count += 1 + time.sleep(ANGLE_SWEEP_PULSE_SECONDS) + + raise RuntimeError( + f"relative mouse input did not reach {label} within " + f"{ANGLE_SWEEP_MAX_PULSES} pulses" + ) + + def drive_angle_sweep( client: ControlClient, output: list[dict[str, Any]], @@ -369,7 +614,9 @@ def drive_angle_sweep( client.command("mouse", action="right_down", x=center_x, y=center_y) try: time.sleep(1.0) - sample(client, "angles_aim_baseline", output) + baseline = sample(client, "angles_aim_baseline", output) + baseline_heading = aim_heading_degrees(baseline) + annotate_angle_state(baseline, baseline_heading, 0) capture_observer( f"{observer_screenshot_label}_baseline" if observer_screenshot_label @@ -379,20 +626,40 @@ def drive_angle_sweep( ) # PROBE_TRACE: - # GTA recentres the cursor while mouse-look owns input. Repeated moves - # to one side therefore provide deterministic yaw deltas without - # depending on the host's physical mouse. - for direction, offset in (("left", -140), ("right", 140)): - for _ in range(20): - client.command( - "mouse", - action="move", - x=center_x + offset, - y=center_y, - ) - time.sleep(0.08) - time.sleep(1.2) - sample(client, f"angles_after_{direction}", output) + # Runs 20260728-aim-{original,replacement}-observer-manual proved that + # absolute SetCursorPos/WM_MOUSEMOVE changed cursor_x but left the + # sampled CCamera::InternalAim camera position unchanged. Use bounded + # relative SendInput pulses and stop on the measured front vector, so + # frame-rate/input coalescing cannot silently turn the angle comparison + # into two identical frames. + left_heading_sign, calibration_pulses = calibrate_left_aim_sign( + client, + baseline_heading, + output, + ) + for direction, target_sign, mouse_delta_x, initial_pulses in ( + ( + "left", + left_heading_sign, + -ANGLE_SWEEP_MOUSE_PULSE_X, + calibration_pulses, + ), + ( + "right", + -left_heading_sign, + ANGLE_SWEEP_MOUSE_PULSE_X, + 0, + ), + ): + drive_aim_to_relative_target( + client, + output, + f"angles_after_{direction}", + baseline_heading, + target_sign, + mouse_delta_x, + initial_pulses, + ) capture_observer( f"{observer_screenshot_label}_{direction}" if observer_screenshot_label @@ -416,6 +683,7 @@ def drive_scenario( observer_screenshot_label: str | None, observer_screenshot_count: int, observer_screenshot_interval: float, + steer_during_capture: str | None, active_transition: str | None, test_cmds_request_path: Path, test_cmds_results_path: Path, @@ -471,7 +739,7 @@ def drive_scenario( return client.command("focus") - queue_sync_pair_scenario( + request_id = queue_sync_pair_scenario( scenario, sync_pair_request_path, sync_pair_results_path, @@ -482,8 +750,14 @@ def drive_scenario( sample(client, f"{scenario}_ready", output) time.sleep(pre_action_seconds) - if scenario == "car": + if scenario in {"car", "trailer"}: + steering_key = { + "left": VK_A, + "right": VK_D, + }.get(steer_during_capture) client.key(VK_W, "down") + if steering_key is not None: + client.key(steering_key, "down") try: capture_observer( observer_screenshot_label, @@ -492,11 +766,42 @@ def drive_scenario( ) time.sleep(action_seconds) finally: + if steering_key is not None: + client.key(steering_key, "up") client.key(VK_W, "up") hold_key(client, VK_A, 1.0) hold_key(client, VK_S, 0.8) time.sleep(1.0) - sample(client, "car_after_drive", output) + sample(client, f"{scenario}_after_drive", output) + elif scenario in {"passenger", "unoccupied"}: + capture_observer( + observer_screenshot_label, + observer_screenshot_count, + observer_screenshot_interval, + ) + time.sleep(max(action_seconds, 1.0)) + sample(client, f"{scenario}_after_sync", output) + elif scenario == "passenger_g": + # STATIC_037: + # R5 consumes the passenger control on its first pressed frame and + # immediately sends RPC 26. Keep VK_G down across several render/input + # frames without turning this into a long held-key scenario. + hold_key(client, VK_G, 0.15) + wait_for_sync_pair_result( + "PASSENGER_ENTRY_RESULT", + scenario, + request_id, + sync_pair_results_path, + sync_pair_request_timeout, + output, + ) + capture_observer( + observer_screenshot_label, + observer_screenshot_count, + observer_screenshot_interval, + ) + time.sleep(max(action_seconds, 1.0)) + sample(client, "passenger_g_after_sync", output) elif scenario == "rustler": client.key(VK_CONTROL, "down") try: @@ -557,6 +862,10 @@ def main() -> int: "combat", "car", "rustler", + "passenger", + "passenger_g", + "unoccupied", + "trailer", "jetpack", "pickup", "death", @@ -573,6 +882,14 @@ def main() -> int: parser.add_argument("--observer-screenshot-label") parser.add_argument("--observer-screenshot-count", type=int, default=1) parser.add_argument("--observer-screenshot-interval", type=float, default=0.15) + parser.add_argument( + "--steer-during-capture", + choices=["left", "right"], + help=( + "hold steering together with throttle during car/trailer screenshot " + "capture so articulated motion remains in the recorded window" + ), + ) parser.add_argument( "--active-transition", choices=["vehicle", "streamout"], @@ -626,7 +943,17 @@ def main() -> int: ) if args.scenario == "all": - scenarios = ["onfoot", "car", "rustler", "jetpack", "pickup", "death"] + scenarios = [ + "onfoot", + "car", + "rustler", + "passenger", + "unoccupied", + "trailer", + "jetpack", + "pickup", + "death", + ] elif args.scenario == "combat": scenarios = ["pistol", "m4", "sniper"] else: @@ -656,6 +983,7 @@ def main() -> int: screenshot_label, args.observer_screenshot_count, args.observer_screenshot_interval, + args.steer_during_capture, args.active_transition, args.test_cmds_request_path, args.test_cmds_results_path, @@ -667,15 +995,8 @@ def main() -> int: args.sync_pair_request_timeout, ) run_index += 1 - queue_sync_pair_scenario( - "stop", - args.sync_pair_request_path, - args.sync_pair_results_path, - args.sync_pair_request_timeout, - output, - ) finally: - for vk in (VK_CONTROL, VK_SPACE, VK_W, VK_A, VK_D, VK_S, VK_H): + for vk in (VK_CONTROL, VK_SPACE, VK_W, VK_A, VK_D, VK_G, VK_S, VK_H): try: client.key(vk, "up") except (OSError, RuntimeError): @@ -685,10 +1006,33 @@ def main() -> int: client.command("mouse", action="right_up", x=0, y=0) except (OSError, RuntimeError): pass + try: + queue_sync_pair_scenario( + "stop", + args.sync_pair_request_path, + args.sync_pair_results_path, + args.sync_pair_request_timeout, + output, + ) + except (OSError, RuntimeError, TimeoutError) as exc: + cleanup = { + "event": "sync_pair_stop_request_failed", + "error": str(exc), + "fallback": "chat_command", + "host_time": time.time(), + } + output.append(cleanup) + print(json.dumps(cleanup, sort_keys=True)) + try: + chat_command(client, "/syncpair stop") + except (OSError, RuntimeError) as fallback_exc: + cleanup["fallback_error"] = str(fallback_exc) client.close() - - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(output, indent=2, sort_keys=True) + "\n", encoding="utf-8") + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(output, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) return 0 diff --git a/tools/reloop/test_analyze_death_cleanup.py b/tools/reloop/test_analyze_death_cleanup.py new file mode 100644 index 0000000..73d5880 --- /dev/null +++ b/tools/reloop/test_analyze_death_cleanup.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Unit tests for the artifact-only R5 death/cleanup analyzer.""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) +from analyze_death_cleanup import analyze_log + + +HOOK_SUMMARY = "death_cleanup_hook: summary installed=6 requested=6\n" + + +def event_line(ring_seq: int, event_seq: int, kind: str, cleanup: int = 1) -> str: + return ( + f"death_cleanup_r5: seq={ring_seq} event={event_seq} tick=987654 " + f"thread=7 frame=99 kind={kind} caller_rva=0x00000011 " + "hook_rva=0x00000022 object=0xdeadbeef argument=0xcafebabe " + f"result=0x00000000 cleanup={cleanup} evidence=PROBE_TRACE\n" + ) + + +def ui_line( + ring_seq: int, + event_seq: int, + phase: str, + *, + dialog: int = 0, + scoreboard: int = 0, +) -> str: + return ( + f"death_cleanup_ui_r5: seq={ring_seq} event={event_seq} phase={phase} " + f"scoreboard=0xdead0001 visible={scoreboard} " + f"dialog=0xdead0002 active={dialog} " + "selector=0xdead0003 active=0 chat=0xdead0004 active=0 " + "class_gui=0xdead0005 visible=0 game=0xdead0006 " + "input_depth=1,2 camera=3,4 frontend=5,6,7\n" + ) + + +def pools_line( + ring_seq: int, + event_seq: int, + phase: str, + *, + vehicle: int = 2, + remote: int = 1, + objects: int = 3, + remove_building: int = 4, +) -> str: + return ( + f"death_cleanup_pools_r5: seq={ring_seq} event={event_seq} phase={phase} " + "valid=0x000000ff netgame=0xdead1000 pools=0xdead1001 " + "pool_ptrs=0xdead1002,0xdead1003 " + f"vehicle={vehicle}/{vehicle} remote={remote}/{remote} " + "pickup_raw=2/2/2 " + f"object={objects}/{objects} actor=1/1 gangzone=1 " + "textdraw=2 label=3 menu=1 current=1 " + f"remove_building_count={remove_building}\n" + ) + + +def complete_event( + ring_seq: int, + event_seq: int, + kind: str, + *, + post_vehicle: int = 0, + post_remote: int = 0, + post_objects: int = 0, + remove_pre: int = 4, + remove_post: int = 4, +) -> str: + return "".join( + ( + event_line(ring_seq, event_seq, kind), + ui_line(ring_seq, event_seq, "pre", dialog=1, scoreboard=1), + ui_line(ring_seq, event_seq, "post"), + pools_line( + ring_seq, + event_seq, + "pre", + remove_building=remove_pre, + ), + pools_line( + ring_seq, + event_seq, + "post", + vehicle=post_vehicle, + remote=post_remote, + objects=post_objects, + remove_building=remove_post, + ), + ) + ) + + +class DeathCleanupAnalyzerTests(unittest.TestCase): + def analyze(self, root: Path, text: str, nested: bool = False) -> dict: + if nested: + log = root / "windows" / "run-1" / "logs" / "samp_probe.log" + else: + log = root / "samp_probe.log" + log.parent.mkdir(parents=True, exist_ok=True) + log.write_text(text, encoding="utf-8") + return analyze_log(root if nested else log) + + def test_missing_events_remain_todo_verify_and_never_pass(self) -> None: + with tempfile.TemporaryDirectory() as directory: + result = self.analyze(Path(directory), HOOK_SUMMARY) + + self.assertEqual("TODO_VERIFY", result["assessment"]) + self.assertEqual( + {"TODO_VERIFY"}, + {scenario["status"] for scenario in result["scenarios"].values()}, + ) + self.assertEqual( + "TODO_VERIFY", result["connection_lost_to_gmx_ordering"]["status"] + ) + self.assertNotIn("PASS", json.dumps(result)) + + def test_gmx_reports_pool_ui_and_persistent_remove_building(self) -> None: + trace = HOOK_SUMMARY + complete_event( + 10, + 20, + "gmx_reset", + post_vehicle=0, + post_remote=0, + post_objects=1, + ) + with tempfile.TemporaryDirectory() as directory: + result = self.analyze(Path(directory), trace) + + scenario = result["scenarios"]["gmx_reset"] + self.assertEqual("OBSERVED", scenario["status"]) + observation = scenario["observations"][0] + self.assertEqual(-2, observation["pools"]["delta"]["vehicle_listed"]) + self.assertEqual(-2, observation["pools"]["delta"]["object_listed"]) + self.assertEqual( + {"pre": 1, "post": 0}, + observation["ui"]["changes"]["dialog_active"], + ) + self.assertEqual("PERSISTED", observation["remove_building"]["assessment"]) + rendered = json.dumps(result) + self.assertNotIn("0xdeadbeef", rendered) + self.assertNotIn("0xcafebabe", rendered) + self.assertNotIn("0xdead1002", rendered) + self.assertNotIn("987654", rendered) + + def test_connection_lost_pairs_with_nested_gmx_publication_order(self) -> None: + trace = ( + HOOK_SUMMARY + + complete_event(4, 21, "gmx_reset") + + complete_event(5, 20, "connection_lost") + ) + with tempfile.TemporaryDirectory() as directory: + result = self.analyze(Path(directory), trace, nested=True) + + ordering = result["connection_lost_to_gmx_ordering"] + self.assertEqual("OBSERVED", ordering["status"]) + self.assertEqual(1, ordering["pairs"][0]["event_seq_delta"]) + self.assertEqual(1, ordering["pairs"][0]["publish_seq_delta"]) + + def test_non_nested_connection_gmx_order_is_mismatch(self) -> None: + trace = ( + HOOK_SUMMARY + + complete_event(4, 20, "connection_lost") + + complete_event(5, 21, "gmx_reset") + ) + with tempfile.TemporaryDirectory() as directory: + result = self.analyze(Path(directory), trace) + + self.assertEqual( + "MISMATCH", result["connection_lost_to_gmx_ordering"]["status"] + ) + self.assertEqual("MISMATCH", result["assessment"]) + + def test_destructor_pre_post_and_remove_building_change(self) -> None: + trace = HOOK_SUMMARY + complete_event( + 30, + 40, + "quit_destructor", + remove_pre=5, + remove_post=0, + ) + with tempfile.TemporaryDirectory() as directory: + result = self.analyze(Path(directory), trace) + + observation = result["scenarios"]["quit_destructor"]["observations"][0] + self.assertEqual("OBSERVED", observation["status"]) + self.assertEqual(-5, observation["remove_building"]["delta"]) + self.assertEqual("CHANGED", observation["remove_building"]["assessment"]) + + def test_present_event_with_missing_snapshots_is_todo_verify(self) -> None: + trace = HOOK_SUMMARY + event_line(50, 60, "gmx_reset") + with tempfile.TemporaryDirectory() as directory: + result = self.analyze(Path(directory), trace) + + observation = result["scenarios"]["gmx_reset"]["observations"][0] + self.assertEqual("TODO_VERIFY", observation["status"]) + self.assertEqual( + ["ui.pre", "ui.post", "pools.pre", "pools.post"], + observation["missing"], + ) + self.assertNotIn("PASS", json.dumps(result)) + + def test_overflow_is_integrity_mismatch(self) -> None: + trace = ( + HOOK_SUMMARY + + "death_cleanup_r5: overflow skipped=2 total_skipped=2 ring=256\n" + ) + with tempfile.TemporaryDirectory() as directory: + result = self.analyze(Path(directory), trace) + + self.assertEqual("MISMATCH", result["integrity"]["status"]) + self.assertEqual("MISMATCH", result["assessment"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_analyze_gametext_r5.py b/tools/reloop/test_analyze_gametext_r5.py new file mode 100644 index 0000000..fbe11cb --- /dev/null +++ b/tools/reloop/test_analyze_gametext_r5.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Unit tests for the focused raw-RPC73 GameText analyzer.""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPOSITORY_ROOT = SCRIPT_DIR.parents[1] +sys.path.insert(0, str(SCRIPT_DIR)) + +from analyze_gametext_r5 import ( # noqa: E402 + FIRST_PAYLOAD, + FIRST_TEXT, + REPLACEMENT_PAYLOAD, + REPLACEMENT_TEXT, + analyze, + main, +) + + +GOLDEN_ARTIFACT = ( + REPOSITORY_ROOT + / "artifacts/runs/20260728-155306-replacement-pvars-1747209" +) + + +def valid_server() -> str: + return "\n".join( + ( + "[rpc73_gametext_fixture] phase=first player=0 rpc=73 " + "style=5 time_ms=5000 text_len=18 payload_bits=240 " + "payload=05000000881300001200000052504337335f5354594c45355f4649525354 " + "text=RPC73_STYLE5_FIRST dispatchEvents=0 channel=2 sent=1", + "[rpc73_gametext_fixture] scheduled player=0 " + "replacement_delay_ms=350 generation=1", + "[rpc73_gametext_fixture] phase=replacement player=0 rpc=73 " + "style=3 time_ms=5000 text_len=19 payload_bits=248 " + "payload=03000000881300001300000052504337335f5354594c45335f5345434f4e44 " + "text=RPC73_STYLE3_SECOND dispatchEvents=0 channel=2 sent=1", + ) + ) + "\n" + + +def valid_net() -> str: + return "\n".join( + ( + "rpc-in id=73 name=ScrDisplayGameText local=implemented " + f"count=2 bits=240 bytes=30 first={FIRST_PAYLOAD}", + "rpc-state id=73 game_text_seq=2 action=show style=5 time=5000 " + f"text='{FIRST_TEXT}' evidence=PROBE_TRACE", + "rpc-in id=73 name=ScrDisplayGameText local=implemented " + f"count=3 bits=248 bytes=31 first={REPLACEMENT_PAYLOAD}", + "rpc-state id=73 game_text_seq=3 action=show style=3 time=5000 " + f"text='{REPLACEMENT_TEXT}' evidence=PROBE_TRACE", + ) + ) + "\n" + + +def valid_runtime() -> str: + return "\n".join( + ( + "[sampdll-runtime] game_text: show seq=2 style=5 time=5000 " + f"text='{FIRST_TEXT}' evidence=STATIC_037", + "[sampdll-runtime] unrelated render marker", + "[sampdll-runtime] game_text: clear_all seq=3 cleared=1 " + "reason=replace_before_show evidence=STATIC_037", + "[sampdll-runtime] game_text: show seq=3 style=3 time=5000 " + f"text='{REPLACEMENT_TEXT}' evidence=STATIC_037", + ) + ) + "\n" + + +def make_artifact( + root: Path, + *, + server: str | None = None, + net: str | None = None, + runtime: str | None = None, + extra_client_log: str = "", +) -> Path: + artifact = root / "artifact" + client = artifact / "client" + client.mkdir(parents=True) + (artifact / "server.console.log").write_text( + valid_server() if server is None else server, encoding="utf-8" + ) + (client / "samp_net_trace.log").write_text( + valid_net() if net is None else net, encoding="utf-8" + ) + (client / "samp_runtime.log").write_text( + valid_runtime() if runtime is None else runtime, encoding="utf-8" + ) + (client / "samp_hook_trace.log").write_text( + extra_client_log, encoding="utf-8" + ) + return artifact + + +class GameTextR5AnalyzerTests(unittest.TestCase): + def test_complete_fixed_sequence_passes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + result = analyze(make_artifact(Path(directory))) + + self.assertEqual("PASS", result["verdict"]) + self.assertTrue(all(result["checks"].values())) + self.assertEqual( + [1], + result["observations"]["runtime"]["show_seq2_style5_lines"], + ) + self.assertEqual( + [3], + result["observations"]["runtime"]["clear_seq3_cleared1_lines"], + ) + self.assertEqual( + [4], + result["observations"]["runtime"]["show_seq3_style3_lines"], + ) + + @unittest.skipUnless( + GOLDEN_ARTIFACT.is_dir(), + "repository-local successful GameText artifact is unavailable", + ) + def test_successful_repository_artifact_is_pass(self) -> None: + result = analyze(GOLDEN_ARTIFACT) + + self.assertEqual("PASS", result["verdict"]) + self.assertTrue(all(result["checks"].values())) + self.assertEqual( + [825], + result["observations"]["runtime"]["show_seq2_style5_lines"], + ) + self.assertEqual( + [830], + result["observations"]["runtime"]["clear_seq3_cleared1_lines"], + ) + self.assertEqual( + [831], + result["observations"]["runtime"]["show_seq3_style3_lines"], + ) + + def test_fixture_transport_failure_is_mismatch(self) -> None: + server = valid_server().replace("channel=2 sent=1", "channel=2 sent=0", 1) + with tempfile.TemporaryDirectory() as directory: + result = analyze(make_artifact(Path(directory), server=server)) + + self.assertEqual("MISMATCH", result["verdict"]) + self.assertFalse(result["checks"]["fixture_first_sent"]) + self.assertTrue(result["checks"]["fixture_replacement_sent"]) + + def test_wrong_net_style_or_text_is_mismatch(self) -> None: + net = valid_net().replace( + "style=3 time=5000 text='RPC73_STYLE3_SECOND'", + "style=4 time=5000 text='WRONG'", + ) + with tempfile.TemporaryDirectory() as directory: + result = analyze(make_artifact(Path(directory), net=net)) + + self.assertEqual("MISMATCH", result["verdict"]) + self.assertFalse(result["checks"]["net_rpc73_replacement_style_text"]) + self.assertFalse(result["checks"]["net_rpc73_sequence"]) + + def test_wrong_clear_count_is_mismatch(self) -> None: + runtime = valid_runtime().replace("cleared=1", "cleared=0") + with tempfile.TemporaryDirectory() as directory: + result = analyze( + make_artifact(Path(directory), runtime=runtime) + ) + + self.assertEqual("MISMATCH", result["verdict"]) + self.assertFalse(result["checks"]["runtime_clear_seq3_cleared1"]) + self.assertFalse(result["checks"]["runtime_replacement_sequence"]) + + def test_reversed_runtime_order_is_mismatch(self) -> None: + runtime_lines = valid_runtime().splitlines() + runtime = "\n".join( + (runtime_lines[0], runtime_lines[1], runtime_lines[3], runtime_lines[2]) + ) + "\n" + with tempfile.TemporaryDirectory() as directory: + result = analyze( + make_artifact(Path(directory), runtime=runtime) + ) + + self.assertEqual("MISMATCH", result["verdict"]) + self.assertTrue(result["checks"]["runtime_clear_seq3_cleared1"]) + self.assertTrue(result["checks"]["runtime_show_seq3_style3"]) + self.assertFalse(result["checks"]["runtime_replacement_sequence"]) + + def test_exception_filter_in_any_artifact_log_is_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + result = analyze( + make_artifact( + Path(directory), + extra_client_log="exception_filter: code=0xc0000005\n", + ) + ) + + self.assertEqual("MISMATCH", result["verdict"]) + self.assertFalse(result["checks"]["no_exception_filter"]) + self.assertEqual( + [{"file": "client/samp_hook_trace.log", "line": 1}], + result["observations"]["exception_filter"], + ) + + def test_missing_required_trace_is_an_error(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact = make_artifact(Path(directory)) + (artifact / "client/samp_runtime.log").unlink() + with self.assertRaises(FileNotFoundError): + analyze(artifact) + + def test_cli_writes_pass_json_and_returns_zero(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + artifact = make_artifact(root) + output = root / "gametext-analysis.json" + with mock.patch("builtins.print"): + result = main([str(artifact), "--output", str(output)]) + + self.assertEqual(0, result) + written = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual("PASS", written["verdict"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_analyze_pickup_r5.py b/tools/reloop/test_analyze_pickup_r5.py new file mode 100644 index 0000000..c18ca1c --- /dev/null +++ b/tools/reloop/test_analyze_pickup_r5.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +"""Unit tests for the artifact-only Original-R5 pickup analyzer.""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) +from analyze_pickup_r5 import ( # noqa: E402 + GTA_10_US_SHA256, + ORIGINAL_R5_SHA256, + PICKUP_FLAG, + analyze_log, +) + + +HOOK_SUMMARY = ( + "pickup_hook: summary installed=2 requested=2 " + "rvas=0x13440,0x13520 evidence=STATIC_037,TODO_VERIFY\n" +) + + +def event_line( + ring_seq: int, + event_seq: int, + kind: str, + *, + hook: str, + caller: str = "0xffffffff", + raw: int = 0xFFFFFFFF, + ordinal: int = 0, + gate: tuple[int, int] = (0, 0), + delta_ms: int = 0, + delta_frames: int = 0, + rpc: int = 0, + bits: int = 0, + payload_valid: int = 0, + payload: int = 0, + priority: int = 0, + reliability: int = 0, + channel: int = 0, + result: int = 0, +) -> str: + return ( + f"pickup_r5: seq={ring_seq} event={event_seq} tick=987654 " + f"thread=77 frame=1234 kind={kind} caller_rva={caller} " + f"hook_rva={hook} pool=0xdeadbeef raw_argument={raw} " + f"process_ordinal={ordinal} process_gate={gate[0]},{gate[1]} " + f"cadence_delta={delta_ms}_ms/{delta_frames}_frames rpc={rpc} " + f"bits={bits} payload_valid={payload_valid} payload={payload} " + f"priority={priority} reliability={reliability} channel={channel} " + f"result={result} evidence=STATIC_037,TODO_VERIFY\n" + ) + + +def pool_line( + ring_seq: int, + event_seq: int, + phase: str, + slots: list[dict[str, int]], +) -> str: + lines = [ + f"pickup_pool_r5: seq={ring_seq} event={event_seq} phase={phase} " + f"valid=0x00000001 pool=0xdeadbeef count={len(slots)} " + f"active={len(slots)} captured={len(slots)}\n" + ] + for sample, slot in enumerate(slots): + lines.append( + f"pickup_slot_r5: seq={ring_seq} event={event_seq} phase={phase} " + f"sample={sample} valid=0x0000001f slot={slot['slot']} " + f"handle=0x{slot['handle']:08x} " + f"raw_gta_index={slot['raw']} timer={slot['timer']} " + f"dropped={slot.get('dropped', 0)} " + f"from_player={slot.get('from_player', 65535)} " + f"model={slot.get('model', 1240)} type={slot.get('type', 1)} " + "pos_bits=3f800000,40000000,40400000\n" + ) + return "".join(lines) + + +def process_event( + ring_seq: int, + event_seq: int, + ordinal: int, + *, + delta_frames: int, + slots_pre: list[dict[str, int]] | None = None, + slots_post: list[dict[str, int]] | None = None, +) -> str: + before = slots_pre or [] + after = slots_post if slots_post is not None else before + return "".join( + ( + event_line( + ring_seq, + event_seq, + "process", + hook="0x00013520", + caller="0x00008ca8", + ordinal=ordinal, + gate=(6, 6), + delta_ms=116 if delta_frames else 0, + delta_frames=delta_frames, + ), + pool_line(ring_seq, event_seq, "pre", before), + pool_line(ring_seq, event_seq, "post", after), + ) + ) + + +def rpc_event( + ring_seq: int, + event_seq: int, + rpc_id: int, + payload: int, + reliability: int, + slots: list[dict[str, int]], + *, + result: int = 1, +) -> str: + return "".join( + ( + event_line( + ring_seq, + event_seq, + f"rpc_{rpc_id}", + hook="0x00000000", + rpc=rpc_id, + bits=32 if rpc_id == 131 else 16, + payload_valid=1, + payload=payload, + priority=1, + reliability=reliability, + channel=0, + result=result, + ), + pool_line(ring_seq, event_seq, "rpc", slots), + ) + ) + + +def ordinary_trace( + *, + reliability: int = 9, + post_timer: int = 15, + handle: int = 0x0002002A, + raw: int = 42, +) -> str: + before = { + "slot": 5, + "handle": handle, + "raw": raw, + "timer": 0, + "type": 1, + } + after = {**before, "timer": post_timer} + return "".join( + ( + HOOK_SUMMARY, + process_event(1, 1, 1, delta_frames=0), + process_event(2, 2, 2, delta_frames=7), + rpc_event(3, 4, 131, 5, reliability, [before]), + event_line( + 4, + 3, + "picked_up", + hook="0x00013440", + raw=raw, + ordinal=2, + gate=(0, 0), + ), + pool_line(4, 3, "pre", [before]), + pool_line(4, 3, "post", [after]), + ) + ) + + +def add_type14_and_dropped(trace: str) -> str: + type14 = { + "slot": 6, + "handle": 0x00010033, + "raw": 51, + "timer": 0, + "type": 14, + } + dropped = { + "slot": 7, + "handle": 0x00010034, + "raw": 52, + "timer": 0, + "type": 4, + "dropped": 1, + "from_player": 9, + } + return "".join( + ( + trace, + rpc_event(5, 6, 131, 6, 10, [type14, dropped]), + rpc_event(6, 7, 97, 9, 10, [type14, dropped]), + process_event( + 7, + 5, + 3, + delta_frames=7, + slots_pre=[type14, dropped], + slots_post=[type14, dropped], + ), + ) + ) + + +class PickupAnalyzerTests(unittest.TestCase): + def make_artifact( + self, + root: Path, + trace: str, + *, + samp_hash: str = ORIGINAL_R5_SHA256, + duplicate_log: bool = False, + ) -> Path: + artifact = root / "distributed-sync-pickup" + run = artifact / "windows" / "run-1" + latest = run / "latest_log_bytes" / "samp_probe.log" + latest.parent.mkdir(parents=True) + latest.write_text(trace, encoding="utf-8") + if duplicate_log: + copied = run / "logs" / "samp_probe.log" + copied.parent.mkdir(parents=True) + copied.write_text(trace, encoding="utf-8") + (run / "manifest.json").write_text( + json.dumps( + { + "samp_sha256": samp_hash, + "gta_sha256": GTA_10_US_SHA256, + "probe_flags": [PICKUP_FLAG], + } + ), + encoding="utf-8", + ) + driver = artifact / "driver" / "pickup.json" + driver.parent.mkdir(parents=True) + driver.write_text( + json.dumps( + { + "scenario": "pickup", + "returncode": 0, + "events": [ + { + "event": "sync_pair_scenario_queued", + "scenario": "pickup", + "request_id": 123, + } + ], + } + ), + encoding="utf-8", + ) + (artifact / "server.log").write_text( + "\n".join( + ( + "[sync_pair] marker=REQUEST_ACCEPTED request=123 " + "status=ACTION scenario=pickup detail=host_request", + "[sync_pair] marker=PICKUP_CREATED pickup=5 player=0", + "[sync_pair] marker=SCENARIO_START request=123 " + "scenario=pickup pilot=0 observer=1", + "[sync_pair] marker=REQUEST_DONE request=123 " + "status=PASS scenario=pickup detail=scenario_started", + "[sync_pair] marker=PICKUP_COLLECTED player=0 pickup=5 " + "scenario=11", + ) + ) + + "\n", + encoding="utf-8", + ) + return artifact + + def test_complete_ordinary_artifact_has_bounded_claim(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact = self.make_artifact( + Path(directory), ordinary_trace(), duplicate_log=True + ) + result = analyze_log(artifact) + + self.assertEqual("OBSERVED_ORDINARY", result["assessment"]) + self.assertEqual("OBSERVED", result["identity"]["status"]) + self.assertEqual("OBSERVED", result["fixture"]["status"]) + ordinary = result["ordinary_picked_up"] + self.assertEqual("OBSERVED", ordinary["status"]) + self.assertEqual(9, ordinary["observations"][0]["rpc"]["reliability"]) + self.assertEqual({"pre": 0, "post": 15}, ordinary["observations"][0]["timer"]) + self.assertEqual("TODO_VERIFY", result["type14_process"]["status"]) + self.assertEqual("TODO_VERIFY", result["dropped_process"]["status"]) + rendered = json.dumps(result) + self.assertNotIn("PASS", rendered) + self.assertNotIn("0xdeadbeef", rendered) + self.assertNotIn("987654", rendered) + + def test_handle_low_word_relationship_is_observed_not_assumed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact = self.make_artifact( + Path(directory), + ordinary_trace(handle=0x00020007, raw=42), + ) + result = analyze_log(artifact) + + observation = result["ordinary_picked_up"]["observations"][0] + self.assertFalse(observation["raw_equals_handle_index"]) + self.assertEqual("OBSERVED_ORDINARY", result["assessment"]) + + def test_wrong_ordinary_rpc_reliability_is_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact = self.make_artifact( + Path(directory), ordinary_trace(reliability=10) + ) + result = analyze_log(artifact) + + self.assertEqual("MISMATCH", result["assessment"]) + errors = result["ordinary_picked_up"]["observations"][0]["errors"] + self.assertTrue(any("reliability" in error for error in errors)) + self.assertEqual("MISMATCH", result["ordinary_picked_up"]["status"]) + + def test_wrong_timer_transition_is_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact = self.make_artifact( + Path(directory), ordinary_trace(post_timer=0) + ) + result = analyze_log(artifact) + + self.assertEqual("MISMATCH", result["assessment"]) + self.assertTrue( + any( + "post timer=0" in error + for error in result["ordinary_picked_up"]["observations"][0][ + "errors" + ] + ) + ) + + def test_type14_and_dropped_process_qos_are_separate_oracles(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact = self.make_artifact( + Path(directory), add_type14_and_dropped(ordinary_trace()) + ) + result = analyze_log(artifact) + + self.assertEqual("OBSERVED", result["type14_process"]["status"]) + self.assertEqual(10, result["type14_process"]["observations"][0]["qos"]["reliability"]) + self.assertEqual("OBSERVED", result["dropped_process"]["status"]) + self.assertEqual(97, result["dropped_process"]["static_oracle"]["rpc"]) + + def test_positive_process_timer_sample_confirms_tick_countdown(self) -> None: + before = { + "slot": 5, + "handle": 0x0002002A, + "raw": 42, + "timer": 15, + "type": 1, + } + after = {**before, "timer": 14} + trace = ordinary_trace() + process_event( + 5, + 5, + 3, + delta_frames=7, + slots_pre=[before], + slots_post=[after], + ) + with tempfile.TemporaryDirectory() as directory: + artifact = self.make_artifact(Path(directory), trace) + result = analyze_log(artifact) + + timer = result["process"]["ordinary_timer_countdown"] + self.assertEqual("OBSERVED", timer["status"]) + self.assertEqual(1, timer["positive_timer_sample_count"]) + + def test_wrong_binary_identity_is_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact = self.make_artifact( + Path(directory), ordinary_trace(), samp_hash="0" * 64 + ) + result = analyze_log(artifact) + + self.assertEqual("MISMATCH", result["identity"]["status"]) + self.assertEqual("MISMATCH", result["assessment"]) + + def test_overflow_is_integrity_mismatch(self) -> None: + trace = ( + ordinary_trace() + + "pickup_r5: overflow skipped=3 total_skipped=3 ring=256\n" + ) + with tempfile.TemporaryDirectory() as directory: + artifact = self.make_artifact(Path(directory), trace) + result = analyze_log(artifact) + + self.assertEqual("MISMATCH", result["integrity"]["status"]) + self.assertEqual(3, result["integrity"]["overflow"]["skipped"]) + self.assertEqual("MISMATCH", result["assessment"]) + + def test_direct_hook_only_log_keeps_claims_todo_verify(self) -> None: + with tempfile.TemporaryDirectory() as directory: + log = Path(directory) / "samp_probe.log" + log.write_text(HOOK_SUMMARY, encoding="utf-8") + result = analyze_log(log) + + self.assertEqual("TODO_VERIFY", result["assessment"]) + self.assertEqual("TODO_VERIFY", result["identity"]["status"]) + self.assertEqual("TODO_VERIFY", result["ordinary_picked_up"]["status"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_analyze_trailer_physics.py b/tools/reloop/test_analyze_trailer_physics.py new file mode 100644 index 0000000..09e5578 --- /dev/null +++ b/tools/reloop/test_analyze_trailer_physics.py @@ -0,0 +1,399 @@ +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +MODULE_PATH = Path(__file__).with_name("analyze_trailer_physics.py") +SPEC = importlib.util.spec_from_file_location("analyze_trailer_physics", MODULE_PATH) +assert SPEC and SPEC.loader +analyzer = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = analyzer +SPEC.loader.exec_module(analyzer) + + +def main_line( + *, + seq: int, + event: int, + kind: str, + generation: int, + frame: int, + trailer_dpos: str, + tractor_dpos: str = "(0.000000,0.000000,0.000000)", + game_ms: int = 1000, +) -> str: + return ( + f"[ {game_ms:9d}] trailer_physics: seq={seq} event={event} kind={kind} " + f"generation={generation} frame={frame} trailer=0x10000000 " + "tractor=0x20000000 set_my_pos_raw=0x00000001 result=1 " + f"pre_tick={game_ms} pre_thread=7 pre_valid=0x07 pre_gta_frame={frame + 20} " + f"pre_game_ms={game_ms} pre_timestep=1.000000 " + f"post_tick={game_ms} post_thread=7 post_valid=0x07 " + f"post_gta_frame={frame + 20} post_game_ms={game_ms} " + f"post_timestep=1.000000 trailer_dpos={trailer_dpos} " + f"tractor_dpos={tractor_dpos} evidence=PROBE_TRACE" + ) + + +def state_line( + *, + seq: int, + event: int, + generation: int, + frame: int, + phase: str, + trailer_object: str, + tractor_object: str, + trailer_tow: str, + tractor_reverse: str, + trailer_x: float, +) -> str: + return ( + f"[ 1000] trailer_physics_state: seq={seq} event={event} " + f"generation={generation} frame={frame} phase={phase} " + f"trailer={trailer_object} valid=0x07 vtable=0x11111111 matrix=0x11112222 " + "basis_r=(1.000000,0.000000,0.000000) " + "basis_f=(0.000000,1.000000,0.000000) " + "basis_u=(0.000000,0.000000,1.000000) " + f"pos=({trailer_x:.6f},2.000000,3.000000) " + "move=(0.100000,0.000000,0.000000) " + "turn=(0.000000,0.000000,0.010000) flags=0x00000001 " + f"status=0x02 fake=0x00 tow={trailer_tow} reverse=0x00000000 " + f"tractor={tractor_object} valid=0x03 vtable=0x22222222 matrix=0x22223333 " + "basis_r=(1.000000,0.000000,0.000000) " + "basis_f=(0.000000,1.000000,0.000000) " + "basis_u=(0.000000,0.000000,1.000000) " + "pos=(1.000000,2.000000,3.000000) " + "move=(0.100000,0.000000,0.000000) " + "turn=(0.000000,0.000000,0.010000) flags=0x00000001 " + f"status=0x02 fake=0x00 tow=0x00000000 reverse={tractor_reverse}" + ) + + +def detail_line( + *, + seq: int, + event: int, + generation: int, + frame: int, + phase: str, + support_x: float = 1.0, +) -> str: + return ( + f"[ 1000] trailer_physics_detail: seq={seq} event={event} " + f"generation={generation} frame={frame} phase={phase} trailer_valid=0x07 " + f"support=({support_x:.6f},2.000000,3.000000,4.000000,5.000000) " + "wheel=(0.100000,0.200000,0.300000,0.400000) " + "wheel_prev=(0.100000,0.200000,0.300000,0.400000) " + "spring=(1.000000,1.000000,1.000000,1.000000) " + "line=(1.000000,1.000000,1.000000,1.000000) " + "ride=(0.500000,0.600000) evidence=PROBE_TRACE" + ) + + +def record_lines( + *, + seq: int, + event: int, + kind: str, + generation: int, + frame: int, + trailer_dpos: str, + pointer_bias: int = 0, + trailer_x: float = 1.0, + game_ms: int = 1000, + linked: bool = True, +) -> list[str]: + trailer = f"0x{0x10000000 + pointer_bias:08x}" + tractor = f"0x{0x20000000 + pointer_bias:08x}" + no_pointer = "0x00000000" + lines = [ + main_line( + seq=seq, + event=event, + kind=kind, + generation=generation, + frame=frame, + trailer_dpos=trailer_dpos, + game_ms=game_ms, + ) + ] + for phase in ("pre", "post"): + relation_active = linked and (phase == "post" or kind == "process_control") + lines.append( + state_line( + seq=seq, + event=event, + generation=generation, + frame=frame, + phase=phase, + trailer_object=trailer, + tractor_object=tractor, + trailer_tow=tractor if relation_active else no_pointer, + tractor_reverse=trailer if relation_active else no_pointer, + trailer_x=trailer_x, + ) + ) + lines.append( + detail_line( + seq=seq, + event=event, + generation=generation, + frame=frame, + phase=phase, + ) + ) + return lines + + +class ParseTests(unittest.TestCase): + def test_correlates_main_state_and_detail_and_normalizes_links(self): + text = "\n".join( + record_lines( + seq=1, + event=9, + kind="set_tow_link", + generation=3, + frame=0, + trailer_dpos="(1.000000,0.000000,0.000000)", + pointer_bias=0x1234, + ) + ) + + trace = analyzer.parse_trace(text) + + self.assertEqual(len(trace.records), 1) + record = trace.records[0] + self.assertEqual(set(record.states), {"pre", "post"}) + self.assertEqual(set(record.details), {"pre", "post"}) + self.assertFalse(analyzer._link_state(record, "pre")["bidirectional"]) + self.assertTrue(analyzer._link_state(record, "post")["bidirectional"]) + summary = analyzer.summarize_trace(trace) + self.assertEqual(summary["set_tow_link"]["events"], 1) + self.assertEqual(summary["set_tow_link"]["trailer_jump_m"]["max"], 1.0) + self.assertEqual(summary["set_tow_link"]["bidirectional_link_post"], 1) + + def test_reports_partial_frame_coverage_overflow_and_orphan(self): + lines = ["trailer_physics: overflow skipped=3 total_skipped=3 write_seq=515"] + lines.extend( + record_lines( + seq=1, + event=1, + kind="set_tow_link", + generation=4, + frame=0, + trailer_dpos="(0.000000,0.000000,0.000000)", + ) + ) + lines.extend( + record_lines( + seq=2, + event=2, + kind="process_control", + generation=4, + frame=0, + trailer_dpos="(0.100000,0.000000,0.000000)", + ) + ) + lines.extend( + record_lines( + seq=3, + event=3, + kind="process_control", + generation=4, + frame=1, + trailer_dpos="(0.200000,0.000000,0.000000)", + game_ms=1033, + ) + ) + lines.append( + detail_line( + seq=99, + event=99, + generation=99, + frame=0, + phase="pre", + ) + ) + + trace = analyzer.parse_trace("\n".join(lines)) + summary = analyzer.summarize_trace(trace) + process = summary["generations"][0]["process_control"] + + self.assertEqual(summary["parse"]["overflow_skipped"], 3) + self.assertEqual(len(summary["parse"]["orphan_auxiliary_lines"]), 1) + self.assertEqual(process["frames_observed"], 2) + self.assertEqual(process["contiguous_prefix_frames"], 2) + self.assertEqual(process["missing_first_64"][:2], [2, 3]) + self.assertAlmostEqual(process["trailer_step_m"]["mean"], 0.15) + self.assertEqual(process["elapsed_game_ms"]["max"], 33.0) + + def test_malformed_physics_record_is_nonfatal(self): + trace = analyzer.parse_trace( + "prefix trailer_physics: seq=no event=1 kind=process_control " + "generation=1 frame=0\n" + ) + self.assertEqual(trace.records, []) + self.assertEqual(trace.malformed_lines, [1]) + + +class DiffTests(unittest.TestCase): + def _trace( + self, + *, + pointer_bias: int, + attach_delta: float, + frame_delta: float, + trailer_x: float, + frame_one: bool = False, + ): + lines = record_lines( + seq=1, + event=1, + kind="set_tow_link", + generation=10 + pointer_bias, + frame=0, + trailer_dpos=f"({attach_delta:.6f},0.000000,0.000000)", + pointer_bias=pointer_bias, + trailer_x=trailer_x, + ) + lines.extend( + record_lines( + seq=2, + event=2, + kind="process_control", + generation=10 + pointer_bias, + frame=0, + trailer_dpos=f"({frame_delta:.6f},0.000000,0.000000)", + pointer_bias=pointer_bias, + trailer_x=trailer_x, + ) + ) + if frame_one: + lines.extend( + record_lines( + seq=3, + event=3, + kind="process_control", + generation=10 + pointer_bias, + frame=1, + trailer_dpos=f"({frame_delta:.6f},0.000000,0.000000)", + pointer_bias=pointer_bias, + trailer_x=trailer_x, + game_ms=1033, + ) + ) + return analyzer.parse_trace("\n".join(lines)) + + def test_diff_pairs_by_encounter_order_and_ignores_raw_pointers(self): + original = self._trace( + pointer_bias=0, + attach_delta=0.1, + frame_delta=0.1, + trailer_x=1.0, + frame_one=True, + ) + replacement = self._trace( + pointer_bias=100, + attach_delta=1.1, + frame_delta=0.4, + trailer_x=3.0, + ) + + result = analyzer.compare_traces(original, replacement) + pair = result["generation_pairs"][0] + + self.assertEqual( + result["generation_pairing_policy"], + "successful_attach_encounter_order", + ) + self.assertEqual(result["set_tow_link_comparison"]["original_events"], 1) + self.assertEqual( + result["set_tow_link_comparison"]["replacement_events"], 1 + ) + self.assertEqual( + result["pointer_policy"], + "raw_addresses_ignored_relationships_compared", + ) + self.assertAlmostEqual( + pair["attach"]["trailer_jump_vector_delta_m"], 1.0 + ) + self.assertEqual(pair["frames"]["common_indices"], [0]) + self.assertEqual(pair["frames"]["missing_in_replacement"], [1]) + self.assertAlmostEqual( + pair["metrics"]["trailer_step_vector_delta_m"]["max"], 0.3 + ) + self.assertEqual( + pair["metrics"]["trailer_pre_position_residual_m"]["max"], 2.0 + ) + self.assertEqual(pair["state_mismatches"]["pre_link"], 0) + self.assertEqual(pair["state_mismatches"]["post_link"], 0) + + +class CliTests(unittest.TestCase): + def test_artifact_discovery_prefers_windows_non_root_documents_log(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + collected = root / "windows/run/latest_log_bytes" + collected.mkdir(parents=True) + active = collected / "samp_probe.log" + active.write_text( + "\n".join( + record_lines( + seq=1, + event=1, + kind="set_tow_link", + generation=1, + frame=0xFFFFFFFF, + trailer_dpos="(0.000000,0.000000,0.000000)", + ) + ), + encoding="utf-8", + ) + (collected / "samp_probe.root.log").write_text("", encoding="utf-8") + (root / "pilot/client").mkdir(parents=True) + (root / "pilot/client/samp_probe.log").write_text( + "unrelated host-side log\n", encoding="utf-8" + ) + + trace = analyzer.load_trace(root) + + self.assertEqual(trace.source, str(active.resolve())) + self.assertEqual(len(trace.records), 1) + + def test_summary_writes_json(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "samp_probe.log" + output = root / "summary.json" + source.write_text( + "\n".join( + record_lines( + seq=1, + event=1, + kind="set_tow_link", + generation=1, + frame=0, + trailer_dpos="(0.000000,0.000000,0.000000)", + ) + ), + encoding="utf-8", + ) + + with mock.patch("builtins.print"): + return_code = analyzer.main( + ["summary", str(source), "--output", str(output)] + ) + + self.assertEqual(return_code, 0) + result = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(result["evidence"], "PROBE_TRACE") + self.assertEqual(result["assessment"], "MEASURED_NO_PARITY_THRESHOLD") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_dialog_cursor_input_lock_source_contract.py b/tools/reloop/test_dialog_cursor_input_lock_source_contract.py new file mode 100644 index 0000000..16bf1e0 --- /dev/null +++ b/tools/reloop/test_dialog_cursor_input_lock_source_contract.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +"""Source contracts for modal-dialog input ownership and cursor rendering.""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + + +RUNTIME_SOURCE = ( + Path(__file__).resolve().parents[2] / "reimpl" / "src" / "runtime_bridge.c" +) + + +def definition_body(source: str, signature: str) -> str: + """Return the function definition beginning with *signature*.""" + + cursor = 0 + while True: + start = source.index(signature, cursor) + brace = source.find("{", start + len(signature)) + semicolon = source.find(";", start + len(signature)) + if brace >= 0 and (semicolon < 0 or brace < semicolon): + depth = 0 + for index in range(brace, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[start : index + 1] + raise AssertionError(f"unterminated function definition: {signature}") + cursor = start + len(signature) + + +class DialogCursorInputLockSourceContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.source = RUNTIME_SOURCE.read_text(encoding="utf-8") + + def test_network_and_f1_dialogs_apply_keyboard_and_mouse_input_patches( + self, + ) -> None: + network_dialog = definition_body( + self.source, + "static void dialog_compat_update_from_snapshot(", + ) + help_dialog = definition_body( + self.source, + "static void dialog_compat_show_help(", + ) + + self.assertIn( + 'chat_input_game_controls_apply_compat(\n' + ' new_dialog ? "dialog_open" : "dialog_active")', + network_dialog, + ) + self.assertIn( + 'chat_input_game_controls_apply_compat("help_dialog_open")', + help_dialog, + ) + self.assertIn("dialog_game_mouse_flush_pending", network_dialog) + self.assertIn("dialog_game_mouse_flush_pending", help_dialog) + self.assertIn( + "dialog_game_mouse_controls_activate_compat(", + network_dialog, + ) + self.assertIn( + 'dialog_game_mouse_controls_activate_compat("help_dialog_open")', + help_dialog, + ) + + def test_dialog_close_uses_r5_ten_tick_delayed_restore(self) -> None: + close = definition_body( + self.source, + "static void dialog_compat_close(", + ) + + self.assertIsNotNone( + re.search( + r"^#define\s+SAMP_DIALOG_INPUT_RELEASE_FRAMES\s+10\s*$", + self.source, + re.MULTILINE, + ), + ) + self.assertIn("LONG was_active", close) + self.assertIn( + "InterlockedExchange(&g_runtime.chat_game_input_release_frames,", + close, + ) + self.assertIn( + "InterlockedExchange(&g_runtime.dialog_game_mouse_release_frames,", + close, + ) + self.assertEqual(close.count("SAMP_DIALOG_INPUT_RELEASE_FRAMES"), 2) + self.assertIn("chat_input_active", close) + + def test_update_holds_and_flushes_dialog_patches_but_not_scoreboard( + self, + ) -> None: + update = definition_body( + self.source, + "static void chat_input_game_controls_update_compat(", + ) + + self.assertIn("g_runtime.chat_input_active", update) + self.assertIn("dialog_compat_active()", update) + self.assertIn( + 'chat_input_game_controls_apply_compat("dialog_active_tick")', + update, + ) + self.assertIn( + 'dialog_game_mouse_controls_activate_compat("dialog_active_tick")', + update, + ) + self.assertIn( + "InterlockedExchange(&g_runtime.chat_game_input_release_frames, 0)", + update, + ) + self.assertIn("chat_input_game_controls_restore_compat(", update) + self.assertIn("dialog_game_mouse_controls_restore_compat(", update) + self.assertNotIn("scoreboard_visible", update) + self.assertNotIn("scoreboard_compat_cursor_owned", update) + self.assertNotIn("g_runtime.dialog_mouse_mode", update) + + def test_dialog_restore_edge_survives_chat_open_and_retries(self) -> None: + update = definition_body( + self.source, + "static void chat_input_game_controls_update_compat(", + ) + restore_due = update.index("if (dialog_restore_due)") + chat_return = update.index("if (chat_active || dialog_active)") + restore_block = update[restore_due:chat_return] + + self.assertLess(restore_due, chat_return) + self.assertIn("if (!chat_active)", restore_block) + self.assertIn( + "chat_input_game_controls_restore_compat(" + '"dialog_close_release")', + restore_block, + ) + self.assertIn( + "dialog_game_mouse_controls_restore_compat(", + restore_block, + ) + self.assertIn("dialog_game_mouse_patch_applied", update) + self.assertIn('"input_release_retry"', update) + + def test_r5_directinput_mouse_gate_has_exact_validated_bytes(self) -> None: + apply = definition_body( + self.source, + "static int dialog_game_mouse_controls_apply_compat(", + ) + flush = definition_body( + self.source, + "static int dialog_game_mouse_controls_flush_compat(", + ) + state_flush = definition_body( + self.source, + "static int dialog_game_mouse_state_flush_compat(", + ) + restore = definition_body( + self.source, + "static int dialog_game_mouse_controls_restore_code_compat(", + ) + + for address in ( + "SAMP_ADDR_GAME_MOUSE_POLL_CALL", + "SAMP_ADDR_GAME_MOUSE_RESULT_BRANCH", + ): + with self.subTest(address=address): + self.assertIn(address, apply) + self.assertIn(address, restore) + for expected in ( + "0xE8u, 0xB4u, 0x7Au, 0x20u, 0x00u", + "0x90u, 0x90u, 0x90u, 0x90u, 0x90u", + "0x85u, 0xC0u, 0x0Fu, 0x8Cu", + "0x33u, 0xC0u, 0x0Fu, 0x84u", + ): + with self.subTest(expected=expected): + self.assertIn(expected, apply) + self.assertIn("target_bytes_mismatch=1", apply) + self.assertIn("dialog_game_mouse_patch_owned", apply) + self.assertIn("dialog_game_mouse_patch_owned", restore) + self.assertIn("SAMP_ADDR_GAME_MOUSE_MOVE_X", state_flush) + self.assertIn("SAMP_ADDR_GAME_MOUSE_MOVE_Y", state_flush) + self.assertIn("SAMP_GTA_FUNC_CLEAR_MOUSE_HISTORY", state_flush) + self.assertIn("SAMP_GTA_FUNC_UPDATE_PADS", state_flush) + self.assertLess( + state_flush.index("clear_mouse_history();"), + state_flush.index("update_pads();"), + ) + self.assertIn( + 'dialog_game_mouse_state_flush_compat(\n' + ' "dialog_restore_after_patch", 1)', + restore, + ) + self.assertIn( + 'dialog_game_mouse_state_flush_compat(\n' + ' "dialog_restore_second_clear", 0)', + restore, + ) + self.assertIn( + "visible_cursor_recenter_patch_restore_compat(", + restore, + ) + + def test_pad_calls_are_game_thread_only_and_shutdown_is_code_only( + self, + ) -> None: + apply = definition_body( + self.source, + "static int dialog_game_mouse_controls_apply_compat(", + ) + state_flush = definition_body( + self.source, + "static int dialog_game_mouse_state_flush_compat(", + ) + restore = definition_body( + self.source, + "static int dialog_game_mouse_controls_restore_compat(", + ) + shutdown = definition_body( + self.source, + "static void dialog_game_mouse_controls_restore_shutdown_compat(", + ) + modules_shutdown = definition_body( + self.source, + "static void phase_runtime_modules_shutdown(", + ) + + for body in (apply, state_flush, restore): + with self.subTest(signature=body.splitlines()[0]): + self.assertIn( + "dialog_game_mouse_on_game_thread_compat(", + body, + ) + self.assertIn( + "dialog_game_mouse_controls_restore_code_compat(reason, 0)", + shutdown, + ) + self.assertNotIn("dialog_game_mouse_state_flush_compat(", shutdown) + self.assertNotIn("d3d9_cursor_show_compat(", shutdown) + self.assertIn( + 'dialog_game_mouse_controls_restore_shutdown_compat(' + '"module_shutdown")', + modules_shutdown, + ) + self.assertNotIn( + 'dialog_game_mouse_controls_restore_compat("module_shutdown")', + modules_shutdown, + ) + + def test_restore_ownership_is_kept_until_no_owned_patch_remains( + self, + ) -> None: + restore = definition_body( + self.source, + "static int dialog_game_mouse_controls_restore_code_compat(", + ) + + ownership_clear = restore.index( + "g_runtime.dialog_game_mouse_patch_owned = 0u" + ) + remaining_check = restore.index("owned_patch_remaining=1") + self.assertLess(remaining_check, ownership_clear) + self.assertIn("poll_rollback", restore) + self.assertGreaterEqual(restore.count("retry=1"), 4) + self.assertEqual( + restore.count( + "g_runtime.dialog_game_mouse_patch_owned = 0u" + ), + 1, + ) + + def test_other_cursor_owner_does_not_hold_dialog_input_patches(self) -> None: + update = definition_body( + self.source, + "static void chat_input_game_controls_update_compat(", + ) + restore = definition_body( + self.source, + "static int dialog_game_mouse_controls_restore_code_compat(", + ) + + self.assertNotIn("dialog_mouse_mode", update) + self.assertNotIn("scoreboard_compat_", update) + self.assertNotIn("g_runtime.scoreboard_", update) + self.assertIn("g_runtime.dialog_mouse_mode", restore) + ownership_clear = restore.index( + "g_runtime.dialog_game_mouse_patch_owned = 0u" + ) + cursor_owner_check = restore.index("g_runtime.dialog_mouse_mode") + self.assertLess(ownership_clear, cursor_owner_check) + + def test_recenter_restore_is_deferred_with_the_r5_dialog_gate(self) -> None: + mouse_mode = definition_body( + self.source, + "static void dialog_compat_set_mouse_mode(", + ) + + self.assertIn( + "g_runtime.dialog_game_mouse_patch_applied", + mouse_mode, + ) + self.assertIn( + 'visible_cursor_recenter_patch_restore_compat("dialog_mouse_disable")', + mouse_mode, + ) + self.assertIn("recenter restore deferred", mouse_mode) + + def test_bullet_sync_is_suppressed_during_dialog_mouse_ownership( + self, + ) -> None: + callback = definition_body( + self.source, + "static void __cdecl bullet_impact_hook_callback_compat(", + ) + + precondition = callback[: callback.index("memcpy(&start_value")] + self.assertIn("dialog_compat_active()", precondition) + self.assertIn( + "g_runtime.dialog_game_mouse_patch_applied", + precondition, + ) + + def test_game_window_key_poll_is_gated_by_chat_and_dialog_only(self) -> None: + key_down = definition_body( + self.source, + "static int game_window_key_down_compat(", + ) + + self.assertIn("g_runtime.chat_input_active", key_down) + self.assertIn("g_runtime.dialog_overlay_active", key_down) + self.assertIn("return 0;", key_down) + self.assertNotIn("scoreboard_visible", key_down) + self.assertNotIn("scoreboard_compat_cursor_owned", key_down) + + def test_modal_wndproc_consumes_non_primary_mouse_input(self) -> None: + wndproc = definition_body( + self.source, + "static LRESULT CALLBACK chat_input_wndproc_compat(", + ) + dialog_branch = wndproc[wndproc.index("if (dialog_active)") :] + consumed_branch = dialog_branch[ + dialog_branch.index("if (msg == WM_RBUTTONDOWN") : + ] + consumed_branch = consumed_branch[: consumed_branch.index("return 0;") + 9] + + for message in ( + "WM_RBUTTONDOWN", + "WM_RBUTTONUP", + "WM_RBUTTONDBLCLK", + "WM_MBUTTONDOWN", + "WM_MBUTTONUP", + "WM_MBUTTONDBLCLK", + "WM_XBUTTONDOWN", + "WM_XBUTTONUP", + "WM_XBUTTONDBLCLK", + "WM_MOUSEWHEEL", + "WM_MOUSEHWHEEL", + ): + with self.subTest(message=message): + self.assertIn(message, consumed_branch) + self.assertTrue(consumed_branch.rstrip().endswith("return 0;")) + + def test_legacy_hand_drawn_cursor_is_absent(self) -> None: + for legacy_raster in ("outline_widths", "fill_offsets", "fill_widths"): + with self.subTest(legacy_raster=legacy_raster): + self.assertNotIn(legacy_raster, self.source) + + def test_external_mouse_texture_drives_one_backbuffer_quad(self) -> None: + prepare = definition_body( + self.source, + "static int ui_cursor_compat_prepare_d3d(", + ) + draw = definition_body( + self.source, + "static int ui_compat_draw_cursor(", + ) + + self.assertIn('snprintf(path, sizeof(path), "%smouse.png"', prepare) + self.assertIn("g_runtime.d3dx_create_texture_from_file_a(", prepare) + self.assertIn("ui_cursor_texture_ready", prepare) + self.assertIn("g_runtime.ui_cursor_texture", draw) + self.assertIn("SAMP_UI_CURSOR_WIDTH", draw) + self.assertIn("SAMP_UI_CURSOR_HEIGHT", draw) + self.assertIn("SAMP_D3DFVF_XYZRHW_DIFFUSE_TEX1", draw) + self.assertIn("SAMP_D3D9_DRAW_PRIMITIVE_UP_INDEX", draw) + self.assertIn("SAMP_D3DTOP_DISABLE", draw) + self.assertIn( + "draw_primitive_up(device, SAMP_D3DPT_TRIANGLESTRIP, 2u, vertices,", + draw, + ) + + def test_cursor_is_drawn_once_after_each_mouse_ui_render_branch(self) -> None: + overlay = definition_body( + self.source, + "static int chat_compat_draw_d3dx_overlay(", + ) + + self.assertEqual(overlay.count("ui_compat_draw_cursor(device)"), 2) + scoreboard_draw = overlay.index("scoreboard_compat_draw_d3dx_overlay(device)") + scoreboard_cursor = overlay.index("ui_compat_draw_cursor(device)") + scoreboard_restore = overlay.index( + "chat_compat_end_d3dx_overlay_state(state_block, apply_state_block)", + scoreboard_cursor, + ) + self.assertLess(scoreboard_draw, scoreboard_cursor) + self.assertLess(scoreboard_cursor, scoreboard_restore) + + dialog_draw = overlay.rindex("dialog_compat_draw_d3dx_overlay(") + normal_cursor = overlay.rindex("ui_compat_draw_cursor(device)") + normal_restore = overlay.rindex( + "chat_compat_end_d3dx_overlay_state(state_block, apply_state_block)" + ) + self.assertLess(dialog_draw, normal_cursor) + self.assertLess(normal_cursor, normal_restore) + + def test_win32_cursor_is_an_explicit_fallback_only(self) -> None: + prepare = definition_body( + self.source, + "static int ui_cursor_compat_prepare_d3d(", + ) + mouse_mode = definition_body( + self.source, + "static void dialog_compat_set_mouse_mode(", + ) + + self.assertIn("fallback=win32", prepare) + self.assertIn( + 'd3d9_cursor_show_compat(0, "dialog_mouse_enable_backbuffer")', + mouse_mode, + ) + self.assertIn( + "ui_cursor_compat_set_win32_visible(!texture_cursor_ready)", + mouse_mode, + ) + self.assertNotIn( + "d3d9_cursor_show_compat(texture_cursor_ready", mouse_mode + ) + self.assertIn( + 'texture_cursor_ready ? "backbuffer_mouse.png"\n' + ' : "win32_fallback"', + mouse_mode, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_distributed_sync_runner.py b/tools/reloop/test_distributed_sync_runner.py new file mode 100644 index 0000000..4e7b612 --- /dev/null +++ b/tools/reloop/test_distributed_sync_runner.py @@ -0,0 +1,959 @@ +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + + +MODULE_PATH = Path(__file__).with_name("distributed_sync_runner.py") +SPEC = importlib.util.spec_from_file_location("distributed_sync_runner", MODULE_PATH) +assert SPEC and SPEC.loader +runner = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = runner +SPEC.loader.exec_module(runner) + + +def valid_ping(**overrides): + result = { + "processes": [], + "game_dir": r"C:\Games\GTA", + "gta_sha256": "1" * 64, + "samp_sha256": "2" * 64, + "samp_probe_sha256": "3" * 64, + "reloop_control_sha256": None, + "autopause": { + "path": r"C:\Games\GTA\III.VC.SA.WindowedMode.ini", + "exists": True, + "value": "0", + "disabled": True, + }, + "probe_flags": [], + } + result.update(overrides) + return json.dumps({"status": "ok", "result": result}) + + +class ScenarioTests(unittest.TestCase): + def test_all_is_focused_and_excludes_gmx_and_trailer(self): + self.assertEqual(runner.expand_scenarios("all"), list(runner.SCENARIOS)) + self.assertNotIn("gmx", runner.SCENARIOS) + self.assertNotIn("trailer", runner.SCENARIOS) + self.assertEqual( + runner.SCENARIOS, + ("pistol", "m4", "sniper", "angles", "jetpack", "death", "pickup"), + ) + + def test_gmx_is_an_explicit_standalone_scenario(self): + self.assertEqual(runner.expand_scenarios("gmx"), ["gmx"]) + + def test_ui_latches_is_explicit_and_not_part_of_all(self): + self.assertEqual( + runner.expand_scenarios("ui_latches"), + ["ui_latches"], + ) + self.assertNotIn("ui_latches", runner.SCENARIOS) + + def test_rejects_unknown_scenario(self): + with self.assertRaisesRegex(runner.DistributedSyncError, "unsupported"): + runner.expand_scenarios("trailer") + + def test_windows_pilot_is_limited_to_death_and_gmx(self): + runner.validate_role_scenario("observer", "m4") + runner.validate_role_scenario("pilot", "death") + runner.validate_role_scenario("pilot", "gmx") + runner.validate_role_scenario("pilot", "ui_latches") + with self.assertRaisesRegex( + runner.DistributedSyncError, + "only supported with --scenario=death", + ): + runner.validate_role_scenario("pilot", "m4") + with self.assertRaisesRegex( + runner.DistributedSyncError, + "requires --windows-role=pilot", + ): + runner.validate_role_scenario("observer", "gmx") + with self.assertRaisesRegex( + runner.DistributedSyncError, + "requires --windows-role=pilot", + ): + runner.validate_role_scenario("observer", "ui_latches") + + def test_role_topologies_are_explicit(self): + self.assertEqual( + runner.topology_for_role("observer"), + "local_original_r5_pilot+native_windows_observer", + ) + self.assertEqual( + runner.topology_for_role("pilot"), + "native_windows_pilot+local_original_r5_observer", + ) + + def test_death_f4_is_reversed_death_only(self): + runner.validate_death_f4(False, "observer", "m4") + runner.validate_death_f4(True, "pilot", "death") + for role, scenario in (("observer", "death"), ("pilot", "m4")): + with self.assertRaisesRegex( + runner.DistributedSyncError, + "requires --scenario=death and --windows-role=pilot", + ): + runner.validate_death_f4(True, role, scenario) + + def test_ui_latches_requires_the_focused_probe_profile(self): + runner.validate_ui_latch_profile( + "ui_latches", + "ui-latches-r5", + ) + with self.assertRaisesRegex( + runner.DistributedSyncError, + "requires --windows-probe-profile=ui-latches-r5", + ): + runner.validate_ui_latch_profile("ui_latches", None) + runner.validate_ui_latch_profile("death", None) + + +class WindowsPreflightTests(unittest.TestCase): + def test_parses_idle_hash_and_autopause_state(self): + state = runner.parse_windows_preflight(valid_ping()) + runner.validate_windows_idle(state) + runner.validate_probe_state(state, None) + self.assertEqual(state["samp_sha256"], "2" * 64) + self.assertTrue(state["autopause"]["disabled"]) + + def test_rejects_missing_autopause_zero(self): + text = valid_ping( + autopause={ + "path": r"C:\Games\GTA\III.VC.SA.WindowedMode.ini", + "exists": True, + "value": "1", + "disabled": False, + } + ) + with self.assertRaisesRegex(runner.DistributedSyncError, "autoPause"): + runner.parse_windows_preflight(text) + + def test_rejects_non_idle_windows(self): + state = runner.parse_windows_preflight( + valid_ping(processes=[{"name": "gta_sa", "id": 42}]) + ) + with self.assertRaisesRegex(runner.DistributedSyncError, "not idle"): + runner.validate_windows_idle(state) + + def test_requires_explicit_profile_for_preexisting_flags(self): + state = runner.parse_windows_preflight( + valid_ping(probe_flags=["samp_probe_death_cleanup_hooks.flag"]) + ) + with self.assertRaisesRegex(runner.DistributedSyncError, "unmanaged"): + runner.validate_probe_state(state, None) + runner.validate_probe_state(state, "death-cleanup") + + def test_exact_profile_flag_set_is_required(self): + state = runner.parse_windows_preflight( + valid_ping( + probe_flags=[ + "samp_probe_textdraw_hooks.flag", + "samp_probe_textdraw_verbose.flag", + ] + ) + ) + runner.validate_probe_state(state, "textdraw-verbose") + with self.assertRaisesRegex(runner.DistributedSyncError, "mismatch"): + runner.validate_probe_state(state, "textdraw") + + +class ExplicitMutationTests(unittest.TestCase): + def test_default_plan_has_no_windows_mutations(self): + self.assertEqual( + runner.windows_mutation_plan(None, None, None, "unit"), + [], + ) + + def test_explicit_options_are_the_only_mutations(self): + dll = {"path": "/tmp/samp.dll", "sha256": "a" * 64} + probe = {"path": "/tmp/samp_probe.asi", "sha256": "b" * 64} + self.assertEqual( + runner.windows_mutation_plan( + dll, + probe, + "aim-bullet-jetpack", + "unit", + ), + [ + ("dll-validate", ["validate", "/tmp/samp.dll"]), + ("dll-deploy", ["deploy", "/tmp/samp.dll", "unit"]), + ( + "probe-deploy", + ["deploy-probe", "/tmp/samp_probe.asi", "unit"], + ), + ( + "probe-profile", + ["probe-profile", "aim-bullet-jetpack"], + ), + ], + ) + + def test_verifies_deployment_and_profile_receipts(self): + candidate = {"sha256": "a" * 64} + receipt = json.dumps( + { + "target_sha256_after": "a" * 64, + "backup": r"C:\samp-test\backups\samp.dll", + } + ) + self.assertEqual( + runner.verify_deploy_receipt( + receipt, + candidate, + kind="DLL", + )["installed_sha256"], + "a" * 64, + ) + profile = runner.verify_profile_receipt( + json.dumps( + { + "profile": "death-cleanup", + "enabled": ["samp_probe_death_cleanup_hooks.flag"], + "probe_sha256": "b" * 64, + } + ), + "death-cleanup", + ) + self.assertEqual(profile["profile"], "death-cleanup") + + +class DriverTests(unittest.TestCase): + def test_reuses_sync_pair_client_and_requests_windows_screenshots(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with mock.patch.object( + runner.subprocess, + "run", + return_value=SimpleNamespace(returncode=0), + ) as run: + self.assertEqual( + runner.run_driver( + "jetpack", + root, + fixture_timeout=1.0, + action_seconds=0.1, + screenshot_count=3, + screenshot_interval=0.05, + ), + 0, + ) + command = run.call_args.args[0] + self.assertIn("sync_pair_client.py", command[1]) + self.assertIn("jetpack", command) + self.assertEqual( + command[command.index("--observer-screenshot-count") + 1], + "3", + ) + + def test_windows_death_driver_spans_request_with_screenshot_burst(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + request = root / "sync_pair_request.txt" + results = root / "sync_pair_results.log" + server = root / "server.log" + server.write_text("", encoding="utf-8") + calls = [] + + def capture(label, count, interval): + calls.append(("capture", label, count, interval)) + + def queue(scenario, request_path, results_path, timeout, output): + calls.append(("queue", scenario)) + output.append( + { + "event": "sync_pair_scenario_queued", + "request_id": 23, + } + ) + return 23 + + def wait(server_log, start_offset, request_id, timeout, output): + calls.append(("wait", request_id)) + return "marker=PLAYER_DEATH player=0" + + with ( + mock.patch.object( + runner.sync_pair_client, + "capture_observer", + side_effect=capture, + ), + mock.patch.object( + runner.sync_pair_client, + "queue_sync_pair_scenario", + side_effect=queue, + ), + mock.patch.object( + runner, + "wait_for_death_event", + side_effect=wait, + ), + mock.patch.object(runner.time, "sleep"), + ): + returncode = runner.run_windows_death_driver( + root / "artifact", + request_path=request, + results_path=results, + server_log=server, + fixture_timeout=1.0, + screenshot_count=5, + screenshot_interval=0.05, + ) + + self.assertEqual(returncode, 0) + self.assertEqual(calls[0][0], "capture") + self.assertIn(("queue", "death"), calls) + self.assertIn(("wait", 23), calls) + self.assertTrue( + any( + call[0] == "capture" + and call[2:] == (5, 0.05) + for call in calls + ) + ) + payload = json.loads( + ( + root / "artifact/driver/death.json" + ).read_text(encoding="utf-8") + ) + self.assertEqual(payload["windows_role"], "pilot") + self.assertFalse(payload["death_f4"]) + self.assertEqual(payload["returncode"], 0) + + def test_windows_death_driver_can_latch_f4_before_request(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + server = root / "server.log" + server.write_text("", encoding="utf-8") + order = [] + + def lab(_artifact, label, arguments, *, timeout): + order.append(("lab", label, tuple(arguments), timeout)) + return SimpleNamespace(returncode=0, stdout="{}") + + def capture(label, count, interval): + order.append(("capture", label, count, interval)) + + def queue(_scenario, _request, _results, _timeout, output): + order.append(("queue",)) + output.append( + { + "event": "sync_pair_scenario_queued", + "request_id": 29, + } + ) + return 29 + + def wait(*_args): + order.append(("wait",)) + return "marker=PLAYER_DEATH player=0" + + with ( + mock.patch.object(runner.windows_edge, "run_lab", side_effect=lab), + mock.patch.object( + runner.sync_pair_client, + "capture_observer", + side_effect=capture, + ), + mock.patch.object( + runner.sync_pair_client, + "queue_sync_pair_scenario", + side_effect=queue, + ), + mock.patch.object( + runner, + "wait_for_death_event", + side_effect=wait, + ), + mock.patch.object(runner.time, "sleep"), + ): + returncode = runner.run_windows_death_driver( + root / "artifact", + request_path=root / "request.txt", + results_path=root / "results.log", + server_log=server, + fixture_timeout=1.0, + screenshot_count=3, + screenshot_interval=0.05, + death_f4=True, + lab_timeout=7.0, + ) + + self.assertEqual(returncode, 0) + self.assertEqual(order[0][0:2], ("lab", "pilot-death-f4")) + self.assertEqual(order[0][2][0:2], ("key", "CLASS")) + self.assertLess( + next(index for index, item in enumerate(order) if item[0] == "lab"), + next(index for index, item in enumerate(order) if item[0] == "queue"), + ) + payload = json.loads( + ( + root / "artifact/driver/death.json" + ).read_text(encoding="utf-8") + ) + self.assertTrue(payload["death_f4"]) + events = [event["event"] for event in payload["events"]] + self.assertIn("windows_pilot_death_f4_requested", events) + self.assertIn("windows_pilot_death_f4_completed", events) + + def test_windows_gmx_driver_uses_console_and_captures_before_burst_after(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + server_console = root / "server.console.log" + server_console.write_text("initial server\n", encoding="utf-8") + order = [] + + class FakeProcess: + def poll(self): + return None + + class FakeServer: + process = FakeProcess() + + def send(self, command): + order.append(("send", command)) + + def capture(label, count, interval): + order.append(("capture", label, count, interval)) + + def wait( + _server_console, + _start_offset, + nickname, + _timeout, + output, + _process, + ): + order.append(("wait", nickname)) + output.append( + { + "event": "windows_pilot_gmx_server_restart_observed", + "scenario": "gmx", + } + ) + return "RPC137 ServerJoin player=0 name=SyncPilot" + + with ( + mock.patch.object( + runner.sync_pair_client, + "capture_observer", + side_effect=capture, + ), + mock.patch.object( + runner, + "wait_for_gmx_server_restart", + side_effect=wait, + ), + mock.patch.object(runner.time, "sleep"), + ): + returncode = runner.run_windows_gmx_driver( + root / "artifact", + server=FakeServer(), + server_console=server_console, + fixture_timeout=1.0, + screenshot_count=4, + screenshot_interval=0.05, + ) + + self.assertEqual(returncode, 0) + self.assertEqual(order[0][0], "capture") + self.assertIn(("send", "gmx"), order) + self.assertIn(("wait", "SyncPilot"), order) + self.assertEqual(order[-1][0], "capture") + self.assertTrue(order[-1][1].endswith("-after")) + payload = json.loads( + ( + root / "artifact/driver/gmx.json" + ).read_text(encoding="utf-8") + ) + self.assertEqual(payload["command_source"], "open.mp_console") + self.assertEqual(payload["returncode"], 0) + events = [event["event"] for event in payload["events"]] + self.assertIn("windows_pilot_gmx_sent", events) + self.assertIn("windows_pilot_gmx_capture_completed", events) + + def test_windows_ui_latch_driver_uses_only_bounded_key_plan(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + calls = [] + + def lab(_artifact, label, arguments, *, timeout): + calls.append((label, tuple(arguments), timeout)) + return SimpleNamespace(returncode=0, stdout="{}") + + with mock.patch.object( + runner.windows_edge, + "run_lab", + side_effect=lab, + ): + returncode = runner.run_windows_ui_latches_driver( + root / "artifact", + lab_timeout=7.0, + ) + + self.assertEqual(returncode, 0) + self.assertEqual( + [call[1][1] for call in calls], + ["TAB", "TAB", "F6", "F6", "F7", "F7", "F7"], + ) + self.assertTrue(all(call[1][0] == "key" for call in calls)) + self.assertFalse( + any( + key in {"ESCAPE", "PAUSE"} + for key in [call[1][1] for call in calls] + ) + ) + payload = json.loads( + ( + root / "artifact/driver/ui_latches.json" + ).read_text(encoding="utf-8") + ) + self.assertFalse( + payload["input_contract"]["pause_or_escape_automation"] + ) + self.assertEqual( + payload["input_contract"]["tab_keyup_pulses"], + 2, + ) + self.assertEqual(payload["returncode"], 0) + + +class DeathEventTests(unittest.TestCase): + def test_waits_for_request_scoped_death_event(self): + with tempfile.TemporaryDirectory() as directory: + server = Path(directory) / "server.log" + server.write_text( + "\n".join( + [ + "marker=SCENARIO_START request=31 scenario=death pilot=0 observer=1", + "marker=PLAYER_DEATH player=0 killer=65535 reason=255 scenario=9", + ] + ), + encoding="utf-8", + ) + output = [] + result = runner.wait_for_death_event( + server, + 0, + 31, + 0.1, + output, + ) + self.assertIn("marker=PLAYER_DEATH", result) + self.assertEqual( + output[0]["event"], + "windows_pilot_death_observed", + ) + + +class GmxEventTests(unittest.TestCase): + def test_waits_for_post_gmx_banner_then_windows_pilot_rejoin(self): + with tempfile.TemporaryDirectory() as directory: + server = Path(directory) / "server.console.log" + server.write_text( + "\n".join( + [ + "pre-existing output", + "Bare open.mp Vehicle/Object Test Script", + "[bare-rpctest] RPC137 ServerJoin player=0 name=SyncPilot npc=0", + ] + ), + encoding="utf-8", + ) + output = [] + result = runner.wait_for_gmx_server_restart( + server, + len("pre-existing output\n"), + "SyncPilot", + 0.1, + output, + ) + self.assertIn("name=SyncPilot", result) + self.assertEqual( + output[0]["event"], + "windows_pilot_gmx_server_restart_observed", + ) + + def test_server_cycle_requires_two_markers_in_the_same_slice(self): + text = "\n".join( + [ + "Bare open.mp Vehicle/Object Test Script", + "[bare-rpctest] RPC137 ServerJoin player=0 name=SyncPilot npc=0", + "Bare open.mp Vehicle/Object Test Script", + "[bare-rpctest] RPC137 ServerJoin player=0 name=SyncPilot npc=0", + ] + ) + self.assertEqual( + runner.gmx_server_cycle_checks(text, "SyncPilot"), + (True, True), + ) + + +class RequestCleanupTests(unittest.TestCase): + def test_removes_only_request_owned_by_driver_artifact(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + driver = root / "artifact/driver" + driver.mkdir(parents=True) + (driver / "death.json").write_text( + json.dumps( + [ + { + "event": "sync_pair_scenario_queued", + "request_id": 17, + } + ] + ), + encoding="utf-8", + ) + request = root / "sync_pair_request.txt" + request.write_text("17 death\n", encoding="utf-8") + cleaned, error = runner.cleanup_owned_request( + request, + root / "artifact", + ) + self.assertTrue(cleaned) + self.assertIsNone(error) + self.assertFalse(request.exists()) + self.assertEqual( + ( + root + / "artifact/cleanup/pending-sync-pair-request.txt" + ).read_text(encoding="utf-8"), + "17 death\n", + ) + + def test_leaves_foreign_request_untouched(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "artifact/driver").mkdir(parents=True) + request = root / "sync_pair_request.txt" + request.write_text("99 death\n", encoding="utf-8") + cleaned, error = runner.cleanup_owned_request( + request, + root / "artifact", + ) + self.assertFalse(cleaned) + self.assertEqual(error, "pending_request_not_owned") + self.assertTrue(request.exists()) + + +class VerdictTests(unittest.TestCase): + SERVER_TEXT = "\n".join( + [ + "marker=REQUEST_ACCEPTED request=1 status=ACTION scenario=pistol detail=host_request", + "marker=REQUEST_DONE request=1 status=PASS scenario=pistol detail=scenario_started", + "marker=SCENARIO_START request=1 scenario=onfoot pilot=0 observer=1 weapon=22", + "marker=WEAPON_SHOT shot=1 weapon=22 expected_weapon=22 hittype=2", + "marker=REQUEST_ACCEPTED request=2 status=ACTION scenario=m4 detail=host_request", + "marker=REQUEST_DONE request=2 status=PASS scenario=m4 detail=scenario_started", + "marker=SCENARIO_START request=2 scenario=onfoot pilot=0 observer=1 weapon=31", + "marker=WEAPON_SHOT shot=1 weapon=31 expected_weapon=31 hittype=2", + "marker=REQUEST_ACCEPTED request=3 status=ACTION scenario=sniper detail=host_request", + "marker=REQUEST_DONE request=3 status=PASS scenario=sniper detail=scenario_started", + "marker=SCENARIO_START request=3 scenario=onfoot pilot=0 observer=1 weapon=34", + "marker=WEAPON_SHOT shot=1 weapon=34 expected_weapon=34 hittype=2", + "marker=REQUEST_ACCEPTED request=4 status=ACTION scenario=m4 detail=host_request", + "marker=REQUEST_DONE request=4 status=PASS scenario=m4 detail=scenario_started", + "marker=SCENARIO_START request=4 scenario=onfoot pilot=0 observer=1 weapon=31", + "marker=PILOT_SYNC scenario=onfoot sample=1 weapon=31 facing=90.0000", + "marker=PILOT_SYNC scenario=onfoot sample=2 weapon=31 facing=270.0000", + "marker=REQUEST_ACCEPTED request=5 status=ACTION scenario=jetpack detail=host_request", + "marker=REQUEST_DONE request=5 status=PASS scenario=jetpack detail=scenario_started", + "marker=SCENARIO_START request=5 scenario=jetpack pilot=0 observer=1 weapon=0", + "marker=PILOT_SYNC scenario=jetpack sample=1 special=2", + "marker=REQUEST_ACCEPTED request=6 status=ACTION scenario=pickup detail=host_request", + "marker=REQUEST_DONE request=6 status=PASS scenario=pickup detail=scenario_started", + "marker=PICKUP_CREATED pickup=3 player=0", + "marker=PICKUP_COLLECTED player=0 pickup=3 scenario=8", + "marker=REQUEST_ACCEPTED request=7 status=ACTION scenario=death detail=host_request", + "marker=REQUEST_DONE request=7 status=PASS scenario=death detail=scenario_started", + "marker=DEATH_TRIGGER player=0 method=health_zero", + "marker=PLAYER_DEATH player=0 killer=65535 reason=255 scenario=9", + ] + ) + REQUEST_IDS = { + "pistol": 1, + "m4": 2, + "sniper": 3, + "angles": 4, + "jetpack": 5, + "pickup": 6, + "death": 7, + } + + def test_request_trace_does_not_borrow_adjacent_m4_evidence(self): + m4 = runner.request_trace(self.SERVER_TEXT, 2) + angles = runner.request_trace(self.SERVER_TEXT, 4) + self.assertIn("marker=WEAPON_SHOT", m4) + self.assertNotIn("facing=270.0000", m4) + self.assertNotIn("marker=WEAPON_SHOT", angles) + self.assertIn("facing=270.0000", angles) + + def test_ui_latch_checks_require_all_bounded_probe_edges(self): + windows_logs = "\n".join( + [ + "ui_latches_hook: summary installed=9 requested=9", + "ui_latches_r5: seq=1 kind=scoreboard_show reason=0x01", + "ui_latches_r5: seq=2 kind=scoreboard_hide reason=0x01", + "ui_latches_r5: seq=3 kind=chat_open reason=0x01", + "ui_latches_r5: seq=4 kind=chat_close reason=0x01", + "ui_latches_r5: seq=5 kind=chat_mode_toggle reason=0x01", + "ui_latches_r5: seq=6 kind=chat_mode_toggle reason=0x01", + "ui_latches_r5: seq=7 kind=chat_mode_toggle reason=0x01", + ] + ) + checks = runner.scenario_checks( + "ui_latches", + "", + windows_logs, + {"ui_latches": 0}, + {}, + "ui-latches-r5", + ) + self.assertTrue(all(checks.values())) + self.assertNotIn("request_id_recorded", checks) + + missing_edge = runner.scenario_checks( + "ui_latches", + "", + windows_logs.rsplit("\n", 1)[0], + {"ui_latches": 0}, + {}, + "ui-latches-r5", + ) + self.assertFalse(missing_edge["three_chat_mode_edges_seen"]) + + def test_success_is_trace_capture_never_visual_parity(self): + verdict = runner.build_verdict( + scenarios=list(runner.SCENARIOS), + server_text=self.SERVER_TEXT, + windows_logs="process_attach: current", + local_logs="reloop_control: ready", + windows_manifest={ + "samp_sha256": "f" * 64, + "samp_probe_sha256": None, + "autopause": {"disabled": True}, + "probe_flags": [], + }, + driver_returncodes={scenario: 0 for scenario in runner.SCENARIOS}, + driver_request_ids_by_scenario=self.REQUEST_IDS, + dll_candidate=None, + probe_candidate=None, + requested_probe_profile=None, + runner_error=None, + cleanup_errors=[], + local_hashes_unchanged=True, + windows_artifact_fetched=True, + windows_logs_fetched=True, + windows_screenshots_fetched=True, + ) + self.assertEqual( + verdict["verdict"], + "TRACE_CAPTURED_VISUAL_UNVERIFIED", + ) + self.assertEqual(verdict["visual_parity"], "TODO_VERIFY") + self.assertNotIn("PASS", verdict["verdict"].replace("UNVERIFIED", "")) + + def test_missing_screenshot_or_hash_mismatch_fails(self): + verdict = runner.build_verdict( + scenarios=["death"], + server_text=self.SERVER_TEXT, + windows_logs="process_attach: current", + local_logs="reloop_control: ready", + windows_manifest={ + "samp_sha256": "f" * 64, + "samp_probe_sha256": None, + "autopause": {"disabled": True}, + "probe_flags": [], + }, + driver_returncodes={"death": 0}, + driver_request_ids_by_scenario={"death": 7}, + dll_candidate={"sha256": "e" * 64}, + probe_candidate=None, + requested_probe_profile=None, + runner_error=None, + cleanup_errors=[], + local_hashes_unchanged=True, + windows_artifact_fetched=True, + windows_logs_fetched=True, + windows_screenshots_fetched=False, + ) + self.assertEqual(verdict["verdict"], "FAIL") + self.assertFalse(verdict["explicit_dll_hash_matches_manifest"]) + self.assertFalse(verdict["windows_screenshots_fetched"]) + + def test_reversed_death_verdict_records_actual_roles(self): + verdict = runner.build_verdict( + windows_role="pilot", + scenarios=["death"], + server_text=self.SERVER_TEXT, + windows_logs="process_attach: original r5", + local_logs="observer connected", + windows_manifest={ + "samp_sha256": runner.ORIGINAL_R5_SHA256, + "samp_probe_sha256": None, + "autopause": {"disabled": True}, + "probe_flags": [], + }, + driver_returncodes={"death": 0}, + driver_request_ids_by_scenario={"death": 7}, + dll_candidate=None, + probe_candidate=None, + requested_probe_profile=None, + runner_error=None, + cleanup_errors=[], + local_hashes_unchanged=True, + windows_artifact_fetched=True, + windows_logs_fetched=True, + windows_screenshots_fetched=True, + ) + self.assertEqual(verdict["windows_role"], "pilot") + self.assertEqual(verdict["local_role"], "observer") + self.assertEqual( + verdict["topology"], + "native_windows_pilot+local_original_r5_observer", + ) + self.assertEqual(verdict["windows_identity"], "original_r5") + + def test_original_r5_gmx_requires_rpc_cycle_and_cleanup_probe_event(self): + gmx_server = "\n".join( + [ + "Bare open.mp Vehicle/Object Test Script", + "[bare-rpctest] RPC137 ServerJoin player=0 name=SyncPilot npc=0", + "Bare open.mp Vehicle/Object Test Script", + "[bare-rpctest] RPC137 ServerJoin player=0 name=SyncPilot npc=0", + ] + ) + windows_logs = "\n".join( + [ + "rpc-in id=139 name=ScrInitGame", + "rpc-in id=40 name=ScrGameModeRestart", + "death_cleanup_r5: seq=1 kind=gmx_reset cleanup=1", + "rpc-in id=139 name=ScrInitGame", + ] + ) + verdict = runner.build_verdict( + windows_role="pilot", + scenarios=["gmx"], + server_text=gmx_server, + windows_logs=windows_logs, + local_logs="observer connected", + windows_manifest={ + "samp_sha256": runner.ORIGINAL_R5_SHA256, + "samp_probe_sha256": "a" * 64, + "autopause": {"disabled": True}, + "probe_flags": ["samp_probe_death_cleanup_hooks.flag"], + }, + driver_returncodes={"gmx": 0}, + driver_request_ids_by_scenario={}, + dll_candidate=None, + probe_candidate=None, + requested_probe_profile="death-cleanup", + runner_error=None, + cleanup_errors=[], + local_hashes_unchanged=True, + windows_artifact_fetched=True, + windows_logs_fetched=True, + windows_screenshots_fetched=True, + ) + self.assertEqual( + verdict["verdict"], + "TRACE_CAPTURED_VISUAL_UNVERIFIED", + ) + self.assertTrue( + all(verdict["scenarios"]["gmx"]["checks"].values()) + ) + self.assertEqual(verdict["windows_identity"], "original_r5") + + def test_original_r5_cleanup_gmx_does_not_require_replacement_net_trace(self): + gmx_server = "\n".join( + [ + "Bare open.mp Vehicle/Object Test Script", + "[bare-rpctest] RPC137 ServerJoin player=0 name=SyncPilot npc=0", + "Bare open.mp Vehicle/Object Test Script", + "[bare-rpctest] RPC137 ServerJoin player=0 name=SyncPilot npc=0", + ] + ) + verdict = runner.build_verdict( + windows_role="pilot", + scenarios=["gmx"], + server_text=gmx_server, + windows_logs=( + "ui-neutral original trace\n" + "death_cleanup_r5: seq=1 event=1 kind=gmx_reset cleanup=1\n" + ), + local_logs="observer connected", + windows_manifest={ + "samp_sha256": runner.ORIGINAL_R5_SHA256, + "samp_probe_sha256": "a" * 64, + "autopause": {"disabled": True}, + "probe_flags": ["samp_probe_death_cleanup_hooks.flag"], + }, + driver_returncodes={"gmx": 0}, + driver_request_ids_by_scenario={}, + dll_candidate=None, + probe_candidate=None, + requested_probe_profile="death-cleanup", + runner_error=None, + cleanup_errors=[], + local_hashes_unchanged=True, + windows_artifact_fetched=True, + windows_logs_fetched=True, + windows_screenshots_fetched=True, + ) + checks = verdict["scenarios"]["gmx"]["checks"] + self.assertNotIn("initial_init_seen", checks) + self.assertNotIn("gmx_rpc40_seen", checks) + self.assertNotIn("post_gmx_init_seen", checks) + self.assertTrue(all(checks.values())) + self.assertEqual( + "TRACE_CAPTURED_VISUAL_UNVERIFIED", + verdict["verdict"], + ) + + def test_original_probe_gmx_rejects_non_r5_manifest_hash(self): + gmx_server = "\n".join( + [ + "Bare open.mp Vehicle/Object Test Script", + "[bare-rpctest] RPC137 ServerJoin player=0 name=SyncPilot npc=0", + "Bare open.mp Vehicle/Object Test Script", + "[bare-rpctest] RPC137 ServerJoin player=0 name=SyncPilot npc=0", + ] + ) + verdict = runner.build_verdict( + windows_role="pilot", + scenarios=["gmx"], + server_text=gmx_server, + windows_logs="death_cleanup_r5: kind=gmx_reset cleanup=1", + local_logs="observer connected", + windows_manifest={ + "samp_sha256": "f" * 64, + "samp_probe_sha256": "a" * 64, + "autopause": {"disabled": True}, + "probe_flags": ["samp_probe_death_cleanup_hooks.flag"], + }, + driver_returncodes={"gmx": 0}, + driver_request_ids_by_scenario={}, + dll_candidate=None, + probe_candidate=None, + requested_probe_profile="death-cleanup", + runner_error=None, + cleanup_errors=[], + local_hashes_unchanged=True, + windows_artifact_fetched=True, + windows_logs_fetched=True, + windows_screenshots_fetched=True, + ) + self.assertEqual("FAIL", verdict["verdict"]) + self.assertTrue(verdict["original_probe_identity_required"]) + self.assertFalse(verdict["original_probe_identity_matches"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_gametext_r5_source_contract.py b/tools/reloop/test_gametext_r5_source_contract.py new file mode 100644 index 0000000..0524775 --- /dev/null +++ b/tools/reloop/test_gametext_r5_source_contract.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Source contracts for Original-R5's globally exclusive GameText lifetime.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +RUNTIME_SOURCE = ( + Path(__file__).resolve().parents[2] / "reimpl" / "src" / "runtime_bridge.c" +) + + +def definition_body(source: str, signature: str) -> str: + cursor = 0 + while True: + start = source.index(signature, cursor) + brace = source.find("{", start + len(signature)) + semicolon = source.find(";", start + len(signature)) + if brace >= 0 and (semicolon < 0 or brace < semicolon): + end = source.find("\nstatic ", start + len(signature)) + return source[start:] if end < 0 else source[start:end] + cursor = start + len(signature) + + +class GameTextR5SourceContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.source = RUNTIME_SOURCE.read_text(encoding="utf-8") + cls.clear_all = definition_body( + cls.source, + "static void game_text_compat_clear_all(" + "uint32_t seq, const char *reason)", + ) + cls.show = definition_body( + cls.source, + "static void game_text_compat_show_slot(" + "int style, uint32_t seq, int32_t time_ms, const char *text)", + ) + cls.apply_event = definition_body( + cls.source, + "static void game_text_compat_apply_event(" + "const samp_raknet_game_text_event *event)", + ) + cls.snapshot = definition_body( + cls.source, + "static void game_text_compat_update_from_snapshot(" + "const samp_raknet_rpc_probe_snapshot *snapshot)", + ) + + def test_clear_all_invalidates_every_style_and_global_count(self) -> None: + self.assertIn( + "style < (int)SAMP_RAKNET_GAMETEXT_MAX_STYLES", + self.clear_all, + ) + self.assertIn("InterlockedExchange(&slot->active, 0)", self.clear_all) + self.assertIn("memset(slot, 0, sizeof(*slot));", self.clear_all) + self.assertIn( + "InterlockedExchange(&g_runtime.game_text_active_count, 0)", + self.clear_all, + ) + self.assertIn( + "InterlockedExchange(&g_runtime.game_text_active, 0)", + self.clear_all, + ) + + def test_new_show_clears_all_before_installing_only_one_slot(self) -> None: + clear = self.show.index( + 'game_text_compat_clear_all(seq, "replace_before_show")' + ) + install = self.show.index("slot = &g_runtime.game_text_slots[style]") + publish = self.show.index( + "InterlockedExchange(&g_runtime.game_text_active_count, 1)" + ) + + self.assertLess(clear, install) + self.assertLess(install, publish) + self.assertNotIn( + "InterlockedIncrement(&g_runtime.game_text_active_count)", + self.show, + ) + self.assertIn( + "samp.dll+0xA0CE0,+0xA0CEC,+0xEC724,+0xA0D3E", + self.show, + ) + + def test_empty_zero_and_hide_paths_do_not_leave_old_style_visible(self) -> None: + self.assertIn( + 'game_text_compat_clear_all(seq, "empty_text")', + self.show, + ) + self.assertIn( + 'game_text_compat_clear_all(seq, "zero_time")', + self.show, + ) + self.assertIn( + 'game_text_compat_clear_all(event->seq, "event_hide")', + self.apply_event, + ) + self.assertIn( + "game_text_compat_clear_all(snapshot->game_text_seq, " + '"legacy_snapshot_hide")', + self.snapshot, + ) + + def test_static_evidence_names_text_clear_all_descriptor(self) -> None: + self.assertIn("GTA opcode 0x00BE", self.clear_all) + self.assertIn("text_clear_all", self.clear_all) + self.assertIn("samp.dll+0xEC724", self.clear_all) + self.assertIn("samp.dll+0xA0D3E", self.clear_all) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_lifecycle_probe.py b/tools/reloop/test_lifecycle_probe.py index f454f10..0fb090b 100644 --- a/tools/reloop/test_lifecycle_probe.py +++ b/tools/reloop/test_lifecycle_probe.py @@ -131,7 +131,7 @@ def command(self, command: str, **fields: object) -> dict[str, object]: control, {"client_w": 640, "client_h": 448} ) self.assertEqual( - [("mouse", {"action": "click", "x": 416, "y": 394})], + [("mouse", {"action": "click", "x": 420, "y": 378})], control.commands, ) diff --git a/tools/reloop/test_local_respawn_source_contract.py b/tools/reloop/test_local_respawn_source_contract.py new file mode 100644 index 0000000..e0a5d6f --- /dev/null +++ b/tools/reloop/test_local_respawn_source_contract.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Source-contract tests for the observed R5 local respawn ordering.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +RUNTIME_SOURCE = ( + Path(__file__).resolve().parents[2] / "reimpl" / "src" / "runtime_bridge.c" +) + + +def function_body(source: str, name: str, next_name: str) -> str: + declaration = f"static int {name}(" + first = source.index(declaration) + start = source.index(declaration, first + len(declaration)) + end = source.index(f"static int {next_name}(", start) + return source[start:end] + + +class LocalRespawnSourceContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.source = RUNTIME_SOURCE.read_text(encoding="utf-8") + + def test_wasted_latch_precedes_death_rpc_result(self) -> None: + body = function_body( + self.source, + "local_death_compat_process_report", + "class_selection_compat_process_after_death_latch", + ) + + latch = body.index( + "InterlockedExchange(&g_runtime.local_death_reported, 1);" + ) + send = body.index("samp_raknet_client_send_death_notification(") + self.assertLess(latch, send) + self.assertNotIn( + "if (result == 0) {\n" + " InterlockedExchange(&g_runtime.local_death_reported, 1);", + body, + ) + + def test_health_recovery_reopens_cached_spawn_without_new_spawn_info(self) -> None: + body = function_body( + self.source, + "local_death_compat_process_report", + "class_selection_compat_process_after_death_latch", + ) + + reopen = body.index( + "InterlockedExchange(&g_runtime.mp_session_finalized_spawn_seq, 0);" + ) + pending = body.index( + "InterlockedExchange(&g_runtime.local_respawn_pending, " + "SAMP_LOCAL_RESPAWN_PENDING_APPLY);" + ) + self.assertLess(reopen, pending) + self.assertNotIn("raknet_spawn_info_seq, 1", body) + self.assertNotIn("send_respawn_notification", body) + + def test_full_local_spawn_completes_before_unguarded_respawn_rpc(self) -> None: + apply_start = self.source.index( + "static void apply_multiplayer_session_bridge_compat(" + ) + apply_body = self.source[apply_start:] + + restart = apply_body.index("gta_restart_if_wasted_at_compat(") + teleport = apply_body.index('runtime_tracef("mp_session_bridge: spawn_teleport_end') + complete = apply_body.index( + 'runtime_tracef("local_death: respawn_apply_complete' + ) + send = apply_body.index("samp_raknet_client_send_respawn_notification(") + self.assertLess(restart, teleport) + self.assertLess(teleport, complete) + self.assertLess(complete, send) + + initial_notify = apply_body.index( + "samp_raknet_client_send_spawn_notification_for_seq(" + ) + pending_guard = apply_body.rfind( + "respawn_pending != SAMP_LOCAL_RESPAWN_PENDING_APPLY", + 0, + initial_notify, + ) + self.assertNotEqual(-1, pending_guard) + + def test_sync_waits_until_respawn_rpc_is_queued(self) -> None: + apply_start = self.source.index( + "static void apply_multiplayer_session_bridge_compat(" + ) + apply_body = self.source[apply_start:] + sync_gate = ( + "InterlockedCompareExchange(&g_runtime.local_death_reported, 0, 0) == 0 &&\n" + " InterlockedCompareExchange(&g_runtime.local_respawn_pending, 0, 0) == 0" + ) + self.assertIn(sync_gate, apply_body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_pickup_probe_source_contract.py b/tools/reloop/test_pickup_probe_source_contract.py new file mode 100644 index 0000000..bb1d22f --- /dev/null +++ b/tools/reloop/test_pickup_probe_source_contract.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Source contracts for the focused original-R5 pickup memory probe.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +PICKUP_SOURCE = REPO_ROOT / "tools/asi_probe/src/samp_probe_pickup.c" +ASI_SOURCE = REPO_ROOT / "tools/asi_probe/src/samp_probe_asi.c" +ASI_CMAKE = REPO_ROOT / "tools/asi_probe/CMakeLists.txt" +PROFILE_SOURCE = ( + REPO_ROOT / "tools/windows/remote_lab/Set-SampProbeProfile.ps1" +) +LAB_WRAPPER = REPO_ROOT / "tools/windows/remote_lab/samp_lab.sh" +PROBE_README = REPO_ROOT / "tools/asi_probe/README.md" +EVIDENCE_DOC = REPO_ROOT / "docs/re/pickup_memory_probe_r5_20260728.md" + + +def body_between(source: str, start: str, end: str) -> str: + start_offset = source.rindex(start) + end_offset = source.index(end, start_offset + len(start)) + return source[start_offset:end_offset] + + +class PickupProbeSourceContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.pickup = PICKUP_SOURCE.read_text(encoding="utf-8") + cls.asi = ASI_SOURCE.read_text(encoding="utf-8") + cls.profile = PROFILE_SOURCE.read_text(encoding="utf-8") + cls.readme = PROBE_README.read_text(encoding="utf-8") + cls.evidence = EVIDENCE_DOC.read_text(encoding="utf-8") + + def test_exact_r5_identity_hooks_and_layout_are_guarded(self) -> None: + required = ( + "b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2", + "PROBE_PICKUP_PICKED_UP_RVA 0x00013440u", + "PROBE_PICKUP_PROCESS_RVA 0x00013520u", + "PROBE_PICKUP_PROCESS_GATE_RVA 0x00118a10u", + "PROBE_PICKUP_HANDLE_OFFSET 0x00000004u", + "PROBE_PICKUP_RAW_GTA_INDEX_OFFSET 0x00004004u", + "PROBE_PICKUP_TIMER_OFFSET 0x00008004u", + "PROBE_PICKUP_DROPPED_OFFSET 0x0000c004u", + "PROBE_PICKUP_DATA_OFFSET 0x0000f004u", + "PROBE_PICKUP_DATA_STRIDE 0x14u", + "PROBE_PICKUP_CAPACITY 4096u", + ) + for token in required: + self.assertIn(token, self.pickup) + + self.assertIn( + "return pickup_bytes_match(0x00013500u, picked_up_tail,", + self.pickup, + ) + self.assertIn( + "pickup_bytes_match(0x00013655u, process_tail,", + self.pickup, + ) + self.assertIn("pickup_preflight()", self.pickup) + self.assertIn("pickup_process_gate_bytes_match()", self.pickup) + self.assertIn("memcpy(expected + 2u, &relocated_gate", self.pickup) + self.assertIn("incomplete_install installed=0", self.pickup) + self.assertIn("pickup_patch_is_owned", self.pickup) + + def test_hook_threads_only_publish_to_the_bounded_ring(self) -> None: + picked_up = body_between( + self.pickup, + "static void PROBE_PICKUP_THISCALL hook_pickup_picked_up(", + "static void PROBE_PICKUP_THISCALL hook_pickup_process(", + ) + process = body_between( + self.pickup, + "static void PROBE_PICKUP_THISCALL hook_pickup_process(", + "void probe_pickup_observe_rpc(", + ) + observer = body_between( + self.pickup, + "void probe_pickup_observe_rpc(", + "static int pickup_bytes_match(", + ) + for hook_body in (picked_up, process, observer): + self.assertIn("pickup_publish_trace", hook_body) + self.assertNotIn("log_fn(", hook_body) + self.assertNotIn("CreateFile", hook_body) + self.assertNotIn("fopen", hook_body) + + self.assertIn("#define PROBE_PICKUP_TRACE_RING_SIZE 256u", self.pickup) + self.assertIn("probe_pickup_flush(probe_log);", self.asi) + + def test_outgoing_pickup_rpcs_share_the_existing_vtable_hook(self) -> None: + self.assertIn( + "#define PROBE_RAKCLIENT_RPC_BITSTREAM_VTBL_INDEX 25u", + self.asi, + ) + self.assertIn( + "#define PROBE_SAMP_R5_RAKCLIENT_RPC_BITSTREAM_RVA 0x00034620u", + self.asi, + ) + rpc_hook = body_between( + self.asi, + "static BYTE PROBE_THISCALL hook_rakclient_rpc_bitstream(", + "static int install_dialog_menu_rpc_hook(", + ) + original_call = rpc_hook.index( + "g_orig_rakclient_rpc_bitstream)(" + ) + observation = rpc_hook.rindex("probe_pickup_observe_rpc(") + self.assertLess(original_call, observation) + self.assertIn("rpc_id == PROBE_PICKUP_RPC", rpc_hook) + self.assertIn("rpc_id == PROBE_PICKUP_WEAPON_RPC", rpc_hook) + self.assertIn("probe_pickup_is_active()", rpc_hook) + self.assertNotIn("pickup_hooks_enabled()", rpc_hook) + self.assertNotIn("network_id", rpc_hook) + self.assertNotIn("reply_from_target", rpc_hook) + self.assertIn("overload=short", rpc_hook) + + installer = body_between( + self.asi, + "static int install_dialog_menu_rpc_hook(", + "static int preflight_samp_font5_code_hooks(", + ) + self.assertIn("PROBE_SAMP_R5_RAKCLIENT_RPC_BITSTREAM_RVA", installer) + self.assertIn("unexpected_short_bitstream_target", installer) + self.assertIn("Index 26/+0x68", installer) + + observer = body_between( + self.pickup, + "void probe_pickup_observe_rpc(", + "static int pickup_bytes_match(", + ) + self.assertIn("bits >= 32", observer) + self.assertIn("bits >= 16", observer) + self.assertIn("g_pickup_install_state", observer) + + def test_profile_build_and_documentation_wiring_is_complete(self) -> None: + self.assertIn("src/samp_probe_pickup.c", ASI_CMAKE.read_text()) + self.assertIn('"pickup-r5"', self.profile) + self.assertIn('"samp_probe_pickup_hooks.flag"', self.profile) + self.assertIn( + "death-cleanup|pickup-r5|ui-latches-r5", + LAB_WRAPPER.read_text(encoding="utf-8"), + ) + self.assertIn("SAMP_PROBE_PICKUP_HOOKS=1", self.readme) + self.assertIn("pickup_r5", self.readme) + self.assertIn("STATIC_037", self.evidence) + self.assertIn("TODO_VERIFY", self.evidence) + self.assertIn("samp.dll+0x00013440", self.evidence) + self.assertIn("samp.dll+0x00013520", self.evidence) + self.assertIn("samp.dll+0x00118A10", self.evidence) + self.assertIn("20260802-112802-distributed-sync-pickup-57189", self.evidence) + self.assertIn("`OBSERVED_ORDINARY`", self.evidence) + self.assertIn("vtable slot 25", self.evidence) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_pickup_r5_runner.py b/tools/reloop/test_pickup_r5_runner.py new file mode 100644 index 0000000..7afd957 --- /dev/null +++ b/tools/reloop/test_pickup_r5_runner.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Contract tests for the pickup-only Original-R5 runner adapter.""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) +import distributed_sync_runner as distributed # noqa: E402 +import pickup_r5_runner as pickup_runner # noqa: E402 + + +class PickupR5RunnerTests(unittest.TestCase): + def test_server_state_is_scoped_to_requested_fixture(self) -> None: + text = "\n".join( + ( + "[sync_pair] marker=REQUEST_ACCEPTED request=1 " + "status=ACTION scenario=pickup", + "[sync_pair] marker=PICKUP_COLLECTED player=0 pickup=99", + "[sync_pair] marker=REQUEST_ACCEPTED request=2 " + "status=ACTION scenario=pickup", + "[sync_pair] marker=PICKUP_CREATED pickup=5 player=0", + "[sync_pair] marker=SCENARIO_START request=2 " + "scenario=pickup pilot=0 observer=1", + "[sync_pair] marker=REQUEST_DONE request=2 " + "status=PASS scenario=pickup detail=scenario_started", + "[sync_pair] marker=PICKUP_COLLECTED player=0 pickup=5", + ) + ) + + state = pickup_runner.pickup_request_state(text, 2) + + self.assertTrue(state["complete"]) + self.assertIn("pickup=5", state["collection_line"]) + + def test_in_memory_patch_restores_shared_runner(self) -> None: + prior_profile = distributed.PROBE_PROFILE_FLAGS.get( + pickup_runner.PROFILE + ) + prior_validate = distributed.validate_role_scenario + prior_death_driver = distributed.run_windows_death_driver + had_pickup_driver = hasattr(distributed, "run_windows_pickup_driver") + prior_pickup_driver = getattr( + distributed, "run_windows_pickup_driver", None + ) + + with pickup_runner.pickup_runner_patch(): + self.assertEqual( + pickup_runner.PROFILE_FLAGS, + distributed.PROBE_PROFILE_FLAGS[pickup_runner.PROFILE], + ) + distributed.validate_role_scenario("pilot", "pickup") + self.assertIs( + pickup_runner.run_windows_pickup_driver, + distributed.run_windows_death_driver, + ) + + self.assertIs(prior_validate, distributed.validate_role_scenario) + self.assertIs(prior_death_driver, distributed.run_windows_death_driver) + self.assertEqual( + prior_profile, + distributed.PROBE_PROFILE_FLAGS.get(pickup_runner.PROFILE), + ) + self.assertEqual( + had_pickup_driver, + hasattr(distributed, "run_windows_pickup_driver"), + ) + if had_pickup_driver: + self.assertIs( + prior_pickup_driver, distributed.run_windows_pickup_driver + ) + + def test_driver_waits_for_collection_and_writes_request_receipt(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + artifact = root / "artifact" + server_log = root / "server.log" + request_path = root / "request.txt" + results_path = root / "results.log" + server_log.write_text("", encoding="utf-8") + + def queue( + scenario: str, + _request_path: Path, + _results_path: Path, + _timeout: float, + output: list[dict], + ) -> int: + self.assertEqual("pickup", scenario) + output.append( + { + "event": "sync_pair_scenario_queued", + "scenario": "pickup", + "request_id": 77, + } + ) + server_log.write_text( + "\n".join( + ( + "[sync_pair] marker=REQUEST_ACCEPTED request=77 " + "status=ACTION scenario=pickup", + "[sync_pair] marker=PICKUP_CREATED pickup=5 player=0", + "[sync_pair] marker=SCENARIO_START request=77 " + "scenario=pickup pilot=0 observer=1", + "[sync_pair] marker=REQUEST_DONE request=77 " + "status=PASS scenario=pickup detail=scenario_started", + "[sync_pair] marker=PICKUP_COLLECTED " + "player=0 pickup=5", + ) + ) + + "\n", + encoding="utf-8", + ) + return 77 + + with ( + mock.patch.object( + pickup_runner.sync_pair_client, + "capture_observer", + return_value=None, + ), + mock.patch.object( + pickup_runner.sync_pair_client, + "queue_sync_pair_scenario", + side_effect=queue, + ), + mock.patch.object(pickup_runner.time, "sleep", return_value=None), + ): + returncode = pickup_runner.run_windows_pickup_driver( + artifact, + request_path=request_path, + results_path=results_path, + server_log=server_log, + fixture_timeout=1.0, + screenshot_count=2, + screenshot_interval=0.025, + ) + + receipt = json.loads( + (artifact / "driver" / "pickup.json").read_text( + encoding="utf-8" + ) + ) + + self.assertEqual(0, returncode) + self.assertEqual("ordinary type-1 pickup", receipt["fixture_scope"]) + self.assertEqual(77, receipt["events"][-1]["request_id"]) + self.assertTrue( + any( + event["event"] == "windows_pilot_pickup_observed" + for event in receipt["events"] + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_reloop.py b/tools/reloop/test_reloop.py index b75fa40..c6d73ac 100644 --- a/tools/reloop/test_reloop.py +++ b/tools/reloop/test_reloop.py @@ -38,6 +38,54 @@ def test_preconnect_exit(self): verdict, _warnings = reloop.classify_run([], 1, "", False) self.assertEqual(verdict, "PRECONNECT_CRASH") + def test_preconnect_streaming_timeout_uses_last_open_step(self): + client_logs = "\n".join([ + "[sampdll-runtime] scene_prepare_step: reason=preconnect " + "step=LoadSceneCollision phase=begin evidence=PROBE_TRACE", + "[sampdll-runtime] scene_prepare_step: reason=preconnect " + "step=LoadSceneCollision phase=end evidence=PROBE_TRACE", + "[sampdll-runtime] scene_prepare_step: reason=preconnect " + "step=LoadScene phase=begin evidence=PROBE_TRACE", + ]) + verdict, warnings = reloop.classify_run([], 0, client_logs, True) + self.assertEqual(verdict, "HANG_PRECONNECT_STREAMING") + self.assertIn("reason=preconnect step=LoadScene phase=begin", warnings[0]) + self.assertIn("Join/NetGame evidence absent", warnings[0]) + + def test_spawn_streaming_timeout_requires_join_evidence(self): + client_logs = "\n".join([ + "rpc-auto-out id=25 name=ClientJoin nickname=ReLoop sent=1", + "rpc-in id=139 name=ScrInitGame local=implemented count=1", + "[sampdll-runtime] scene_prepare_step: reason=spawn " + "step=LoadSceneCollision phase=begin evidence=PROBE_TRACE", + "[sampdll-runtime] scene_prepare_step: reason=spawn " + "step=LoadSceneCollision phase=end evidence=PROBE_TRACE", + "[sampdll-runtime] scene_prepare_step: reason=spawn " + "step=LoadScene phase=begin evidence=PROBE_TRACE", + ]) + verdict, warnings = reloop.classify_run([], 0, client_logs, True) + self.assertEqual(verdict, "HANG_SPAWN_STREAMING") + self.assertIn("Join/NetGame evidence present", warnings[0]) + + def test_closed_streaming_step_is_not_classified_as_hang(self): + client_logs = "\n".join([ + "[sampdll-runtime] scene_prepare_step: reason=preconnect " + "step=LoadScene phase=begin evidence=PROBE_TRACE", + "[sampdll-runtime] scene_prepare_step: reason=preconnect " + "step=LoadScene phase=end evidence=PROBE_TRACE", + ]) + verdict, _warnings = reloop.classify_run([], 0, client_logs, True) + self.assertEqual(verdict, "PRECONNECT_CRASH") + + def test_exception_marker_takes_precedence_over_open_streaming_step(self): + client_logs = "\n".join([ + "[sampdll-runtime] scene_prepare_step: reason=preconnect " + "step=LoadScene phase=begin evidence=PROBE_TRACE", + "[sampdll-runtime] exception_filter: code=0xc0000005", + ]) + verdict, _warnings = reloop.classify_run([], 1, client_logs, True) + self.assertEqual(verdict, "PRECONNECT_CRASH") + def test_failure_is_state_mismatch(self): lines = [ "[test_cmds] request=1 marker=RUN_START status=ACTION", @@ -50,7 +98,10 @@ def test_failure_is_state_mismatch(self): def test_crash_retry_requires_remaining_attempt(self): self.assertTrue(reloop.should_retry_crash("PRECONNECT_CRASH", 1, 3)) self.assertTrue(reloop.should_retry_crash("RUNTIME_CRASH", 2, 3)) + self.assertTrue(reloop.should_retry_crash("HANG_PRECONNECT_STREAMING", 1, 3)) + self.assertTrue(reloop.should_retry_crash("HANG_SPAWN_STREAMING", 2, 3)) self.assertFalse(reloop.should_retry_crash("RUNTIME_CRASH", 3, 3)) + self.assertFalse(reloop.should_retry_crash("HANG_SPAWN_STREAMING", 3, 3)) self.assertFalse(reloop.should_retry_crash("STATE_MISMATCH", 1, 3)) def test_retry_wrapper_enforces_three_attempt_minimum(self): @@ -101,6 +152,45 @@ def test_capture_only_appended_bytes(self): self.assertEqual(destination.read_text(encoding="utf-8"), "new\n") +class MetadataTests(unittest.TestCase): + def test_metadata_hashes_match_deployed_artifacts(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prefix = root / "prefix" + profile = reloop.ClientProfile("replacement", prefix, None, None) + profile.gta_root.mkdir(parents=True) + profile.samp_dll.write_bytes(b"previous dll") + previous_hash = reloop.sha256(profile.samp_dll) + + built_dll = root / "build" / "samp.dll" + built_dll.parent.mkdir() + built_dll.write_bytes(b"current dll") + pawn_output = root / "test_cmds.amx" + pawn_output.write_bytes(b"current pawn") + settings = type("SettingsStub", (), { + "built_dll": built_dll, + "pawn_output": pawn_output, + "clients": {"replacement": profile}, + })() + artifact_dir = root / "artifact" + metadata = { + "dll_sha256": previous_hash, + "built_dll_sha256": "stale-build-hash", + "pawn_sha256": "stale-pawn-hash", + } + + deploy = reloop.deploy_replacement(settings, artifact_dir / "build") + reloop.write_run_metadata(artifact_dir, metadata, settings, profile) + + persisted = __import__("json").loads( + (artifact_dir / "metadata.json").read_text(encoding="utf-8") + ) + self.assertEqual(persisted["dll_sha256"], deploy["installed_sha256"]) + self.assertEqual(persisted["dll_sha256"], persisted["built_dll_sha256"]) + self.assertEqual(persisted["pawn_sha256"], reloop.sha256(pawn_output)) + self.assertNotEqual(persisted["dll_sha256"], previous_hash) + + class ComparisonTests(unittest.TestCase): def test_matching_state_is_not_claimed_as_opcode_parity(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tools/reloop/test_remove_building_source_contract.py b/tools/reloop/test_remove_building_source_contract.py new file mode 100644 index 0000000..bd912bf --- /dev/null +++ b/tools/reloop/test_remove_building_source_contract.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Source-contract tests for the observed R5 RemoveBuilding rule lifecycle.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +RUNTIME_SOURCE = ( + Path(__file__).resolve().parents[2] / "reimpl" / "src" / "runtime_bridge.c" +) + + +class RemoveBuildingSourceContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.source = RUNTIME_SOURCE.read_text(encoding="utf-8") + start = cls.source.index( + "static void remove_building_store_event_compat(" + ) + end = cls.source.index( + "static void remove_building_compat_update_from_snapshot(", start + ) + cls.body = cls.source[start:end] + + def test_duplicate_rules_are_appended(self) -> None: + self.assertNotIn("remove_building_event_equal_compat", self.source) + self.assertNotIn("memcmp(", self.body) + self.assertIn( + "g_runtime.remove_building_records[count] = *event;", self.body + ) + self.assertIn( + "InterlockedExchange(&g_runtime.remove_building_record_count, count + 1);", + self.body, + ) + + def test_observed_r5_evidence_is_recorded(self) -> None: + self.assertIn("OBSERVED_037 + PROBE_TRACE + STATIC_037", self.body) + self.assertIn("samp.dll+0x9D3D0", self.body) + self.assertIn( + "20260728-144428-distributed-sync-gmx-1646159", self.body + ) + + def test_conservative_capacity_guard_remains(self) -> None: + guard = self.body.index( + "if (count >= (LONG)SAMP_REMOVE_BUILDING_COMPAT_MAX)" + ) + write = self.body.index( + "g_runtime.remove_building_records[count] = *event;" + ) + self.assertLess(guard, write) + self.assertIn("remove_building: record_overflow", self.body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_scoreboard_latch_source_contract.py b/tools/reloop/test_scoreboard_latch_source_contract.py new file mode 100644 index 0000000..a169f4c --- /dev/null +++ b/tools/reloop/test_scoreboard_latch_source_contract.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Source contracts for the observed Original-R5 TAB key-up latch.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +RUNTIME_SOURCE = ( + Path(__file__).resolve().parents[2] / "reimpl" / "src" / "runtime_bridge.c" +) + + +def function_body(source: str, name: str, next_name: str) -> str: + start = source.index(f"static int {name}(") + start = source.index(f"static int {name}(", start + len(name)) + end = source.index(f"static int {next_name}(", start) + return source[start:end] + + +def definition_body(source: str, signature: str) -> str: + cursor = 0 + while True: + start = source.index(signature, cursor) + brace = source.find("{", start + len(signature)) + semicolon = source.find(";", start + len(signature)) + if brace >= 0 and (semicolon < 0 or brace < semicolon): + end = source.find("\nstatic ", start + len(signature)) + return source[start:] if end < 0 else source[start:end] + cursor = start + len(signature) + + +class ScoreboardLatchSourceContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.source = RUNTIME_SOURCE.read_text(encoding="utf-8") + cls.visible = definition_body( + cls.source, + "static int scoreboard_compat_visible_latched(void)", + ) + cls.active = function_body( + cls.source, + "scoreboard_compat_active", + "scoreboard_compat_handle_key", + ) + cls.handle = function_body( + cls.source, + "scoreboard_compat_handle_key", + "scoreboard_compat_ensure_font", + ) + + def test_visibility_is_latched_instead_of_polled_from_tab(self) -> None: + self.assertIn("LONG scoreboard_visible;", self.source) + self.assertIn( + "return InterlockedCompareExchange(" + "&g_runtime.scoreboard_visible, 0, 0) != 0;", + self.visible, + ) + self.assertIn("return scoreboard_compat_visible_latched();", self.active) + self.assertNotIn( + "return game_window_key_down_compat(VK_TAB);", + self.active, + ) + + def test_plain_tab_toggles_only_on_keyup(self) -> None: + self.assertIn( + "if (msg == WM_KEYUP && wparam == VK_TAB)", + self.handle, + ) + self.assertIn( + "InterlockedExchange(&g_runtime.scoreboard_visible, 0);", + self.handle, + ) + self.assertIn( + "InterlockedExchange(&g_runtime.scoreboard_visible, 1);", + self.handle, + ) + self.assertIn("samp.dll+0x61785..+0x61790", self.handle) + self.assertIn("samp.dll+0x617A0", self.handle) + self.assertIn("samp.dll+0x617B6", self.handle) + self.assertIn( + "OBSERVED_037 + PROBE_TRACE + STATIC_037", + self.handle, + ) + + def test_paging_uses_visible_latch(self) -> None: + self.assertIn("if (scoreboard_compat_active())", self.handle) + self.assertNotIn( + "if (game_window_key_down_compat(VK_TAB))", + self.handle, + ) + + def test_show_owns_cursor_without_disabling_gameplay_input(self) -> None: + self.assertIn( + "InterlockedExchange(&g_runtime.scoreboard_mouse_mode, 1);", + self.handle, + ) + self.assertIn("dialog_compat_set_mouse_mode(1);", self.handle) + self.assertIn("cursor_mode=3", self.handle) + + mouse_start = self.source.index( + "static int scoreboard_compat_handle_mouse(" + ) + mouse_end = self.source.index( + "static int scoreboard_compat_slot_name_conflicts_with_local(", + mouse_start, + ) + mouse = self.source[mouse_start:mouse_end] + self.assertIn( + "scoreboard: right_button cursor_mode=3 game_input=unchanged", + mouse, + ) + self.assertNotIn( + 'chat_input_game_controls_apply_compat("scoreboard_right_button")', + mouse, + ) + + def test_hud_is_enforced_on_scoreboard_render_cadence(self) -> None: + draw_start = self.source.index( + "static int scoreboard_compat_draw_d3dx_overlay(" + ) + draw_start = self.source.index( + "static int scoreboard_compat_draw_d3dx_overlay(", + draw_start + 1, + ) + draw_end = self.source.index( + "static const samp_scoreboard_player_compat " + "*death_window_compat_scoreboard_slot(", + draw_start, + ) + draw = self.source[draw_start:draw_end] + active = draw.index("if (!scoreboard_compat_visible_latched())") + enforce = draw.index("scoreboard_compat_update_hud();") + font = draw.index("if (!scoreboard_compat_ensure_font(device))") + self.assertLess(active, enforce) + self.assertLess(enforce, font) + self.assertIn("Original R5 keeps HUD=0/radar_blank=1", draw) + + def test_shared_cursor_release_paths_preserve_scoreboard_owner(self) -> None: + required_guards = ( + "static void dialog_compat_close(void)", + "static void edit_state_compat_set_mouse_mode(int enabled)", + "static void textdraw_compat_update_from_snapshot(", + "static void textdraw_compat_clear_select_mode(const char *reason)", + "static void class_selection_compat_update_mouse_mode(void)", + ) + for index, marker in enumerate(required_guards): + with self.subTest(marker=marker): + self.assertIn( + "scoreboard_compat_cursor_owned()", + definition_body(self.source, marker), + ) + cursor_owner = definition_body( + self.source, + "static int scoreboard_compat_cursor_owned(void)", + ) + self.assertIn("&g_runtime.scoreboard_visible", cursor_owner) + self.assertIn("&g_runtime.scoreboard_mouse_mode", cursor_owner) + + def test_session_hud_writers_defer_to_visible_scoreboard(self) -> None: + maintain_start = self.source.index( + "static void maintain_online_session_state(void)" + ) + maintain_end = self.source.index( + "static int gta_code_ptr_compat(", + maintain_start, + ) + maintain = self.source[maintain_start:maintain_end] + self.assertIn( + "scoreboard_compat_write_hud_if_unowned(1u, 0u)", + maintain, + ) + self.assertNotIn( + "write_game_u8(SAMP_ADDR_ENABLE_HUD, 1u)", + maintain, + ) + + session = definition_body( + self.source, + "static void apply_multiplayer_session_bridge_compat(void)", + ) + self.assertIn( + "scoreboard_compat_write_hud_if_unowned(spawn_ready ? 1u : 0u", + session, + ) + self.assertIn( + "scoreboard_compat_write_hud_if_unowned(1u, 0u)", + session, + ) + hud_owner = definition_body( + self.source, + "static int scoreboard_compat_hud_owned(void)", + ) + self.assertIn("&g_runtime.scoreboard_visible", hud_owner) + self.assertIn("&g_runtime.scoreboard_hud_hidden", hud_owner) + guarded_writer = definition_body( + self.source, + "static int scoreboard_compat_write_hud_if_unowned(" + "uint8_t hud, uint8_t radar_blank)", + ) + self.assertEqual( + guarded_writer.count("if (scoreboard_compat_hud_owned())"), + 2, + ) + + def test_visible_scoreboard_is_exclusive_overlay_branch(self) -> None: + draw = definition_body( + self.source, + "static int chat_compat_draw_d3dx_overlay(void *device)", + ) + raw_latch = draw.index( + "scoreboard_active = scoreboard_compat_visible_latched();" + ) + branch = draw.index("if (scoreboard_active)", raw_latch) + netstats = draw.index("if (netstats_active)", branch) + chat_history = draw.index( + "for (i = 0; i < display_count; ++i)", + branch, + ) + branch_end = draw.index( + "if (!chat_compat_ensure_d3dx_font(device))", + branch, + ) + exclusive = draw[branch:branch_end] + + self.assertLess(branch, netstats) + self.assertLess(branch, chat_history) + self.assertIn("scoreboard_compat_draw_d3dx_overlay(device)", exclusive) + self.assertIn( + "chat_compat_end_d3dx_overlay_state(state_block, apply_state_block)", + exclusive, + ) + self.assertIn("return 1;", exclusive) + self.assertIn( + "scoreboard: exclusive_overlay normal_overlay_draws=0", + exclusive, + ) + self.assertIn("samp.dll+0x7593A..+0x7593F", draw) + self.assertIn("Chat::Draw (+0x75A81)", draw) + self.assertIn("ChatInput::Draw (+0x75A90)", draw) + + def test_focus_and_session_resets_clear_visibility(self) -> None: + focus = self.source.index( + "InterlockedExchange(&g_runtime.scoreboard_focus_release_latched, 1);" + ) + focus_clear = self.source.index( + "InterlockedExchange(&g_runtime.scoreboard_visible, 0);", + focus, + ) + focus_restore = self.source.index( + 'scoreboard_compat_restore_hud("wm_killfocus");', + focus, + ) + self.assertLess(focus_clear, focus_restore) + + reset = self.source.index( + "static void game_session_reset_to_preconnect_compat(" + ) + reset_clear = self.source.index( + "InterlockedExchange(&g_runtime.scoreboard_visible, 0);", + reset, + ) + self.assertLess(reset_clear, self.source.index("textdraw_compat_clear_select_mode", reset)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_sync_edge_probe.py b/tools/reloop/test_sync_edge_probe.py new file mode 100644 index 0000000..a7f5d66 --- /dev/null +++ b/tools/reloop/test_sync_edge_probe.py @@ -0,0 +1,409 @@ +import contextlib +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + + +MODULE_PATH = Path(__file__).with_name("sync_edge_probe.py") +SPEC = importlib.util.spec_from_file_location("sync_edge_probe", MODULE_PATH) +assert SPEC and SPEC.loader +sync_edge_probe = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = sync_edge_probe +SPEC.loader.exec_module(sync_edge_probe) + + +class AutoPauseTests(unittest.TestCase): + def test_requires_game_section_and_zero(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "windowed.ini" + path.write_text( + "[other]\nautoPause=1\n[game]\nautoPause = 0 ; keep running\n", + encoding="utf-8", + ) + self.assertTrue(sync_edge_probe.autopause_disabled(path)) + + def test_missing_or_enabled_is_false(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "windowed.ini" + self.assertFalse(sync_edge_probe.autopause_disabled(path)) + path.write_text("[game]\nautoPause=1\n", encoding="utf-8") + self.assertFalse(sync_edge_probe.autopause_disabled(path)) + + +class EvidenceTests(unittest.TestCase): + def test_replacement_observer_requires_decode_and_apply(self): + server = "\n".join( + [ + "marker=SCENARIO_START request=1 scenario=passenger", + "marker=PILOT_SYNC scenario=passenger sample=1 state=3 vehicle=4", + "marker=UNOCCUPIED_UPDATE count=1", + "marker=TRAILER_UPDATE count=1", + ] + ) + observer = "\n".join( + [ + "packet-state id=211 remote_passenger seq=1", + "remote_passenger: apply seq=1 mode=already_seated seated=1 seat_read=1", + "remote_edge: consume movement_seq=1 packet=211 packet_seq=1 type=3 result=applied", + "packet-state id=209 remote_unoccupied seq=1", + "remote_unoccupied: apply seq=1 mode=noop readback=1", + "remote_edge: consume movement_seq=2 packet=209 packet_seq=1 type=2 result=applied", + "packet-state id=210 remote_trailer seq=1", + "remote_trailer: apply seq=1 attached=1 readback=1", + "remote_edge: consume movement_seq=3 packet=210 packet_seq=1 type=4 result=applied", + ] + ) + verdict = sync_edge_probe.evaluate_evidence( + list(sync_edge_probe.EDGE_SCENARIOS), + server, + observer, + True, + {name: 0 for name in sync_edge_probe.EDGE_SCENARIOS}, + ) + self.assertEqual(verdict["verdict"], "TRACE_PASS_VISUAL_UNVERIFIED") + self.assertEqual(verdict["visual_parity"], "TODO_VERIFY") + + def test_missing_runtime_apply_fails(self): + verdict = sync_edge_probe.evaluate_evidence( + ["trailer"], + "marker=TRAILER_UPDATE count=1", + "\n".join( + [ + "packet-state id=210 remote_trailer seq=1", + "remote_edge: consume movement_seq=1 packet=210 packet_seq=1 type=4 result=applied", + ] + ), + True, + {"trailer": 0}, + ) + self.assertEqual(verdict["verdict"], "FAIL") + self.assertFalse( + verdict["scenarios"]["trailer"]["checks"]["observer_runtime_apply_seen"] + ) + + def test_passenger_g_requires_rpc26_and_verified_passenger_state(self): + server = "\n".join( + [ + "marker=PASSENGER_ENTER_REQUEST request=7 status=ACTION " + "scenario=passenger_g detail=rpc=26 vehicle=4 " + "expected_vehicle=4 is_passenger=1 state_before=1 " + "current_vehicle=0 seat_before=-1", + "marker=PASSENGER_ENTRY_RESULT request=7 status=PASS " + "scenario=passenger_g detail=rpc=26 enter_seen=1 " + "enter_vehicle=4 is_passenger=1 vehicle=4 seat=1 state=3 " + "verify_attempt=4", + "marker=PILOT_SYNC scenario=passenger_g sample=2 state=3 vehicle=4", + ] + ) + observer = "\n".join( + [ + "packet-state id=211 remote_passenger seq=1", + "remote_passenger: apply seq=1 mode=already_seated seated=1 seat_read=1", + "remote_edge: consume movement_seq=1 packet=211 " + "packet_seq=1 type=3 result=applied", + ] + ) + + verdict = sync_edge_probe.evaluate_evidence( + ["passenger_g"], + server, + observer, + True, + {"passenger_g": 0}, + ) + + self.assertEqual(verdict["verdict"], "TRACE_PASS_VISUAL_UNVERIFIED") + checks = verdict["scenarios"]["passenger_g"]["checks"] + self.assertTrue(checks["server_rpc26_passenger_request_seen"]) + self.assertTrue(checks["server_passenger_entry_verified"]) + self.assertTrue(checks["server_passenger_vehicle_verified"]) + self.assertTrue(checks["server_passenger_state_seen"]) + + def test_passenger_g_without_rpc26_callback_fails(self): + server = "\n".join( + [ + "marker=PASSENGER_ENTRY_RESULT request=7 status=PASS " + "scenario=passenger_g detail=rpc=26 enter_seen=1 " + "enter_vehicle=4 is_passenger=1 vehicle=4 seat=1 state=3", + "marker=PILOT_SYNC scenario=passenger_g sample=2 state=3 vehicle=4", + ] + ) + verdict = sync_edge_probe.evaluate_evidence( + ["passenger_g"], + server, + "", + False, + {"passenger_g": 0}, + ) + + self.assertEqual(verdict["verdict"], "FAIL") + self.assertFalse( + verdict["scenarios"]["passenger_g"]["checks"][ + "server_rpc26_passenger_request_seen" + ] + ) + + def test_passenger_g_rejects_mismatched_vehicle_ids(self): + server = "\n".join( + [ + "marker=PASSENGER_ENTER_REQUEST request=7 status=ACTION " + "scenario=passenger_g detail=rpc=26 vehicle=4 " + "expected_vehicle=4 is_passenger=1", + "marker=PASSENGER_ENTRY_RESULT request=7 status=PASS " + "scenario=passenger_g detail=rpc=26 enter_seen=1 " + "enter_vehicle=5 is_passenger=1 vehicle=5 seat=1 state=3", + "marker=PILOT_SYNC scenario=passenger_g sample=2 state=3 vehicle=5", + ] + ) + verdict = sync_edge_probe.evaluate_evidence( + ["passenger_g"], + server, + "", + False, + {"passenger_g": 0}, + ) + + self.assertEqual(verdict["verdict"], "FAIL") + self.assertFalse( + verdict["scenarios"]["passenger_g"]["checks"][ + "server_passenger_vehicle_verified" + ] + ) + + def test_original_observer_does_not_claim_internal_evidence(self): + verdict = sync_edge_probe.evaluate_evidence( + ["unoccupied"], + "marker=UNOCCUPIED_UPDATE count=1", + "", + False, + {"unoccupied": 0}, + ) + self.assertEqual( + verdict["verdict"], + "SERVER_TRACE_PASS_OBSERVER_INTERNALS_UNAVAILABLE", + ) + self.assertNotIn( + "observer_packet_decoded", + verdict["scenarios"]["unoccupied"]["checks"], + ) + + +class AnalyzeTests(unittest.TestCase): + def test_analyze_writes_separate_reanalysis(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "metadata.json").write_text( + json.dumps( + { + "scenarios": ["trailer"], + "observer": "replacement", + } + ), + encoding="utf-8", + ) + (root / "server.log").write_text( + "marker=TRAILER_UPDATE count=1\n", encoding="utf-8" + ) + client = root / "observer/client" + client.mkdir(parents=True) + (client / "samp_runtime.log").write_text( + "\n".join( + [ + "remote_trailer: apply seq=1 attached=1 readback=1", + "remote_edge: consume movement_seq=1 packet=210 packet_seq=1 type=4 result=applied", + "", + ] + ), + encoding="utf-8", + ) + (client / "samp_net_trace.log").write_text( + "packet-state id=210 remote_trailer seq=1\n", encoding="utf-8" + ) + (root / "verdict.json").write_text( + '{"driver_returncodes":{"trailer":0}}\n', encoding="utf-8" + ) + + verdict = sync_edge_probe.analyze(root) + + self.assertEqual(verdict["verdict"], "TRACE_PASS_VISUAL_UNVERIFIED") + self.assertTrue((root / "verdict.reanalyzed.json").is_file()) + + +class ArtifactFinalizationTests(unittest.TestCase): + @staticmethod + def _profile(root: Path, name: str) -> object: + prefix = root / name + gta_root = prefix / sync_edge_probe.reloop.GTA_RELATIVE_ROOT + gta_root.mkdir(parents=True) + return sync_edge_probe.reloop.ClientProfile(name, prefix, None, None) + + def test_pre_teardown_checkpoint_is_explicitly_incomplete(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + artifact = root / "artifact" + artifact.mkdir() + pilot = self._profile(root, "original") + observer = self._profile(root, "replacement") + server_source = root / "server-source.log" + result_source = root / "results-source.log" + observer_source = observer.gta_root / "samp_runtime.log" + server_snapshot = sync_edge_probe.reloop.FileSnapshot.take(server_source) + result_snapshot = sync_edge_probe.reloop.FileSnapshot.take(result_source) + observer_snapshot = sync_edge_probe.reloop.FileSnapshot.take(observer_source) + server_source.write_text( + "marker=SCENARIO_START request=1 scenario=passenger\n" + "marker=PILOT_SYNC scenario=passenger sample=1 state=3 vehicle=4\n", + encoding="utf-8", + ) + observer_source.write_text( + "packet-state id=211 remote_passenger seq=1\n" + "remote_passenger: apply seq=1 mode=already_seated seated=1 seat_read=1\n" + "remote_edge: consume movement_seq=1 packet=211 packet_seq=1 " + "type=3 result=applied\n", + encoding="utf-8", + ) + + verdict = sync_edge_probe.finalize_artifact( + artifact_dir=artifact, + metadata={"observer": "replacement"}, + scenarios=["passenger"], + server_console=artifact / "server.console.log", + server_snapshot=server_snapshot, + result_snapshot=result_snapshot, + pilot_snapshots={}, + observer_snapshots={"samp_runtime.log": observer_snapshot}, + pilot=pilot, + observer=observer, + asi_before={"pilot": {}, "observer": {}}, + driver_returncodes={"passenger": 0}, + runner_error=None, + teardown_errors=[], + capture_complete=False, + ) + + self.assertEqual(verdict["verdict"], "INCOMPLETE_TEARDOWN") + self.assertFalse(verdict["artifact_complete"]) + self.assertTrue((artifact / "server.log").is_file()) + self.assertTrue((artifact / "observer/client/samp_runtime.log").is_file()) + on_disk = json.loads((artifact / "verdict.json").read_text(encoding="utf-8")) + self.assertEqual(on_disk["verdict"], "INCOMPLETE_TEARDOWN") + + def _execute_fixture( + self, + root: Path, + *, + run_driver_side_effect: Exception | None = None, + observer_stop_side_effect: Exception | None = None, + ) -> tuple[Path, SimpleNamespace, list[object]]: + pilot = self._profile(root, "original") + observer = self._profile(root, "replacement") + server_root = root / "server" + (server_root / "filterscripts").mkdir(parents=True) + (server_root / "filterscripts/sync_pair.amx").write_bytes(b"fixture") + settings = SimpleNamespace( + clients={"original": pilot, "replacement": observer}, + artifacts_root=root / "artifacts", + server_root=server_root, + server_executable=server_root / "omp-server", + server_ready_timeout_s=1, + shutdown_timeout_s=1, + host="127.0.0.1", + port=7777, + ) + args = SimpleNamespace( + config=root / "reloop.toml", + pilot="original", + observer="replacement", + scenario="passenger", + server_mode="reuse", + client_mode="replace", + client_ready_timeout=1.0, + pair_ready_timeout=1.0, + fixture_timeout=1.0, + action_seconds=0.1, + between=0.0, + ) + pilot_process = mock.Mock() + observer_process = mock.Mock() + observer_process.stop.side_effect = observer_stop_side_effect + api = mock.Mock() + run_driver = ( + mock.Mock(side_effect=run_driver_side_effect) + if run_driver_side_effect is not None + else mock.Mock(return_value=(0, root / "driver.log")) + ) + patches = [ + mock.patch.object(sync_edge_probe.reloop, "load_settings", return_value=settings), + mock.patch.object(sync_edge_probe.reloop, "run_id", return_value="edge-unit"), + mock.patch.object(sync_edge_probe, "validate_layout", return_value={}), + mock.patch.object(sync_edge_probe, "file_hashes", return_value={}), + mock.patch.object(sync_edge_probe.reloop, "replace_existing_client"), + mock.patch.object(sync_edge_probe, "tcp_port_available", return_value=True), + mock.patch.object( + sync_edge_probe.reloop, "replace_existing_server", return_value=True + ), + mock.patch.object(sync_edge_probe.reloop, "prefix_pids", return_value=set()), + mock.patch.object( + sync_edge_probe.reloop, + "direct_client_launch", + return_value=(["wine", "samp.exe", "-nTest"], {}), + ), + mock.patch.object( + sync_edge_probe.reloop, + "start_process", + side_effect=[pilot_process, observer_process], + ), + mock.patch.object(sync_edge_probe, "wait_for_api", return_value=api), + mock.patch.object(sync_edge_probe, "wait_for_pair"), + mock.patch.object(sync_edge_probe, "run_driver", run_driver), + mock.patch.object(sync_edge_probe.reloop, "terminate_pids", return_value=set()), + mock.patch.object(sync_edge_probe.time, "sleep"), + ] + return settings.artifacts_root / "edge-unit", args, patches + + def test_runtime_exception_still_writes_complete_failure_artifact(self): + with tempfile.TemporaryDirectory() as directory: + artifact, args, patches = self._execute_fixture( + Path(directory), + run_driver_side_effect=sync_edge_probe.SyncEdgeError("driver failed"), + ) + with contextlib.ExitStack() as stack: + for patcher in patches: + stack.enter_context(patcher) + with self.assertRaisesRegex(sync_edge_probe.SyncEdgeError, "driver failed"): + sync_edge_probe.execute(args) + + verdict = json.loads((artifact / "verdict.json").read_text(encoding="utf-8")) + self.assertEqual(verdict["verdict"], "FAIL") + self.assertTrue(verdict["artifact_complete"]) + self.assertIn("SyncEdgeError: driver failed", verdict["runner_error"]) + + def test_teardown_error_does_not_skip_remaining_capture(self): + with tempfile.TemporaryDirectory() as directory: + artifact, args, patches = self._execute_fixture( + Path(directory), + observer_stop_side_effect=RuntimeError("observer stop failed"), + ) + with contextlib.ExitStack() as stack: + for patcher in patches: + stack.enter_context(patcher) + with self.assertRaisesRegex( + sync_edge_probe.SyncEdgeError, "observer_stop" + ): + sync_edge_probe.execute(args) + + verdict = json.loads((artifact / "verdict.json").read_text(encoding="utf-8")) + self.assertEqual(verdict["verdict"], "FAIL") + self.assertTrue(verdict["artifact_complete"]) + self.assertTrue( + any("observer_stop" in error for error in verdict["teardown_errors"]) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_sync_pair_client.py b/tools/reloop/test_sync_pair_client.py new file mode 100644 index 0000000..87e6e34 --- /dev/null +++ b/tools/reloop/test_sync_pair_client.py @@ -0,0 +1,237 @@ +import importlib.util +import math +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +MODULE_PATH = Path(__file__).with_name("sync_pair_client.py") +sys.path.insert(0, str(MODULE_PATH.parent)) +SPEC = importlib.util.spec_from_file_location("sync_pair_client", MODULE_PATH) +assert SPEC and SPEC.loader +sync_pair_client = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = sync_pair_client +SPEC.loader.exec_module(sync_pair_client) + + +class RelativeAimClient: + def __init__(self, degrees_per_mouse_count: float = 0.5): + self.heading = 0.0 + self.degrees_per_mouse_count = degrees_per_mouse_count + self.mouse_commands: list[dict[str, object]] = [] + + def command(self, command: str, **fields): + if command == "mouse": + self.mouse_commands.append(fields) + self.heading += ( + float(fields["x"]) * self.degrees_per_mouse_count + ) + return {"ok": True, "event": "mouse"} + if command == "state": + radians = math.radians(self.heading) + return { + "ok": True, + "event": "state", + "aim_front_x": math.cos(radians), + "aim_front_y": math.sin(radians), + "aim_front_z": 0.0, + "player_forward_x": math.cos(radians), + "player_forward_y": math.sin(radians), + "player_forward_z": 0.0, + } + raise AssertionError(f"unexpected command {command!r}") + + +class AngleSweepTests(unittest.TestCase): + def test_angle_delta_wraps_at_360_degrees(self): + self.assertAlmostEqual( + sync_pair_client.signed_angle_delta_degrees(5.0, 355.0), + 10.0, + ) + self.assertAlmostEqual( + sync_pair_client.signed_angle_delta_degrees(355.0, 5.0), + -10.0, + ) + + def test_missing_internal_aim_fields_rejects_trace(self): + with self.assertRaisesRegex(RuntimeError, "current control ASI"): + sync_pair_client.aim_heading_degrees({"aim_x": 1.0, "aim_y": 0.0}) + + def test_relative_input_is_calibrated_and_reaches_symmetric_targets(self): + client = RelativeAimClient() + output: list[dict[str, object]] = [] + with mock.patch.object(sync_pair_client.time, "sleep"): + left_sign, calibration_pulses = ( + sync_pair_client.calibrate_left_aim_sign( + client, + 0.0, + output, + ) + ) + left = sync_pair_client.drive_aim_to_relative_target( + client, + output, + "angles_after_left", + 0.0, + left_sign, + -sync_pair_client.ANGLE_SWEEP_MOUSE_PULSE_X, + calibration_pulses, + ) + right = sync_pair_client.drive_aim_to_relative_target( + client, + output, + "angles_after_right", + 0.0, + -left_sign, + sync_pair_client.ANGLE_SWEEP_MOUSE_PULSE_X, + ) + + self.assertEqual(left_sign, -1) + self.assertLessEqual( + left["aim_delta_from_baseline_degrees"], + -( + sync_pair_client.ANGLE_SWEEP_TARGET_DEGREES + - sync_pair_client.ANGLE_SWEEP_TOLERANCE_DEGREES + ), + ) + self.assertGreaterEqual( + right["aim_delta_from_baseline_degrees"], + ( + sync_pair_client.ANGLE_SWEEP_TARGET_DEGREES + - sync_pair_client.ANGLE_SWEEP_TOLERANCE_DEGREES + ), + ) + self.assertTrue(client.mouse_commands) + self.assertTrue( + all( + command["action"] == "move_delta" + for command in client.mouse_commands + ) + ) + self.assertEqual( + output[0]["event"], + "angles_relative_input_calibrated", + ) + + def test_unchanged_internal_aim_rejects_false_angle_trace(self): + client = RelativeAimClient(degrees_per_mouse_count=0.0) + with ( + mock.patch.object(sync_pair_client.time, "sleep"), + self.assertRaisesRegex(RuntimeError, "would not be valid"), + ): + sync_pair_client.calibrate_left_aim_sign(client, 0.0, []) + self.assertEqual(len(client.mouse_commands), 12) + + +class PassengerGQueueTests(unittest.TestCase): + def test_waits_for_streamed_onfoot_edge_setup(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + request_path = root / "sync_pair_request.txt" + results_path = root / "sync_pair_results.log" + output: list[dict[str, object]] = [] + + def emit_edge_setup(_seconds: float) -> None: + results_path.write_text( + "[sync_pair] marker=EDGE_SETUP request=123 status=PASS " + "scenario=passenger_g detail=vehicle=4 state=1 seat=-1 " + "distance=2.452 action=vk_g attempts=1\n", + encoding="utf-8", + ) + + with ( + mock.patch.object(sync_pair_client.time, "time_ns", return_value=123), + mock.patch.object( + sync_pair_client.time, + "monotonic", + side_effect=[0.0, 0.0, 0.0], + ), + mock.patch.object( + sync_pair_client.time, + "sleep", + side_effect=emit_edge_setup, + ), + ): + request_id = sync_pair_client.queue_sync_pair_scenario( + "passenger_g", + request_path, + results_path, + 1.0, + output, + ) + + self.assertEqual(request_id, 123) + self.assertEqual( + request_path.read_text(encoding="utf-8"), + "123 passenger_g\n", + ) + self.assertEqual( + output[-1]["event"], + "sync_pair_edge_setup_acknowledged", + ) + + +class PassengerGDriverTests(unittest.TestCase): + def test_presses_g_then_waits_for_verified_passenger_result(self): + client = mock.Mock() + output: list[dict[str, object]] = [] + request_path = Path("/tmp/sync_pair_request.txt") + results_path = Path("/tmp/sync_pair_results.log") + + with ( + mock.patch.object( + sync_pair_client, + "queue_sync_pair_scenario", + return_value=456, + ), + mock.patch.object( + sync_pair_client, + "wait_for_sync_pair_result", + return_value="status=PASS", + ) as wait_for_result, + mock.patch.object(sync_pair_client, "sample", return_value={}), + mock.patch.object(sync_pair_client, "capture_observer"), + mock.patch.object(sync_pair_client.time, "sleep"), + ): + sync_pair_client.drive_scenario( + client, + "passenger_g", + output, + 0.0, + 0.1, + None, + 1, + 0.0, + None, + None, + Path("/tmp/test_cmds_request.txt"), + Path("/tmp/test_cmds_results.log"), + 1, + 250, + 1.0, + request_path, + results_path, + 1.0, + ) + + self.assertEqual( + client.key.call_args_list, + [ + mock.call(sync_pair_client.VK_G, "down"), + mock.call(sync_pair_client.VK_G, "up"), + ], + ) + wait_for_result.assert_called_once_with( + "PASSENGER_ENTRY_RESULT", + "passenger_g", + 456, + results_path, + 1.0, + output, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_textdraw_selector_source_contract.py b/tools/reloop/test_textdraw_selector_source_contract.py new file mode 100644 index 0000000..acd8308 --- /dev/null +++ b/tools/reloop/test_textdraw_selector_source_contract.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Source contracts for the Original-R5 selectable TextDraw release path.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +RUNTIME_SOURCE = ROOT / "reimpl" / "src" / "runtime_bridge.c" +CONTROL_SOURCE = ROOT / "tools" / "reloop_control" / "src" / "reloop_control.c" + + +def definition_body(source: str, signature: str) -> str: + cursor = 0 + while True: + start = source.index(signature, cursor) + brace = source.find("{", start + len(signature)) + semicolon = source.find(";", start + len(signature)) + if brace >= 0 and (semicolon < 0 or brace < semicolon): + end = source.find("\nstatic ", start + len(signature)) + return source[start:] if end < 0 else source[start:end] + cursor = start + len(signature) + + +class TextDrawSelectorSourceContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.runtime = RUNTIME_SOURCE.read_text(encoding="utf-8") + cls.control = CONTROL_SOURCE.read_text(encoding="utf-8") + cls.handle_mouse = definition_body( + cls.runtime, + "static int textdraw_compat_handle_mouse(", + ) + + def test_button_up_is_not_gated_by_a_button_down_latch(self) -> None: + release = self.handle_mouse[ + self.handle_mouse.index("if (msg == WM_LBUTTONUP)") : + ] + + self.assertIn("STATIC_037:samp.dll+0x71570", release) + self.assertIn( + "SHA256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2", + release, + ) + self.assertNotIn("if (msg == WM_LBUTTONUP &&", self.handle_mouse) + self.assertNotIn( + "InterlockedExchange(&g_runtime.textdraw_mouse_down, 0) != 0", + self.handle_mouse, + ) + clear_latch = release.index( + "InterlockedExchange(&g_runtime.textdraw_mouse_down, 0);" + ) + hit_test = release.index("textdraw_compat_hit_test(") + submit = release.index("textdraw_compat_submit_click(") + consume = release.index("return 1;", submit) + self.assertLess(clear_latch, hit_test) + self.assertLess(hit_test, submit) + self.assertLess(submit, consume) + + def test_primary_button_messages_remain_consumed_without_cancel(self) -> None: + self.assertIn( + "if (msg == WM_LBUTTONDOWN || msg == WM_LBUTTONDBLCLK)", + self.handle_mouse, + ) + self.assertIn( + "InterlockedExchange(&g_runtime.textdraw_mouse_down, 1);", + self.handle_mouse, + ) + self.assertNotIn( + "textdraw_compat_clear_select_mode(", + self.handle_mouse, + ) + + def test_control_harness_can_post_an_orphan_button_up(self) -> None: + post_mouse = definition_body( + self.control, + "static int post_mouse(", + ) + left_up = post_mouse[ + post_mouse.index('strcmp(action, "left_up") == 0') : + post_mouse.index('strcmp(action, "right_down") == 0') + ] + + self.assertIn("MOUSEEVENTF_LEFTUP", left_up) + self.assertIn("WM_LBUTTONUP", left_up) + self.assertNotIn("MOUSEEVENTF_LEFTDOWN", left_up) + self.assertNotIn("WM_LBUTTONDOWN", left_up) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_udp_impairment_proxy.py b/tools/reloop/test_udp_impairment_proxy.py index e6f0916..a81e0d8 100644 --- a/tools/reloop/test_udp_impairment_proxy.py +++ b/tools/reloop/test_udp_impairment_proxy.py @@ -1,5 +1,6 @@ from __future__ import annotations +import errno import socket import threading import time @@ -44,6 +45,24 @@ def close(self) -> None: class UdpImpairmentProxyTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + probes: list[socket.socket] = [] + try: + for address in ("127.0.0.1", "127.0.0.2"): + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + probes.append(probe) + probe.bind((address, 0)) + except OSError as exc: + if exc.errno in (errno.EACCES, errno.EPERM): + raise unittest.SkipTest( + "AF_INET sockets are blocked by the execution sandbox" + ) from exc + raise + finally: + for probe in probes: + probe.close() + def run_proxy(self, proxy: UdpImpairmentProxy) -> threading.Thread: thread = threading.Thread(target=proxy.run, daemon=True) thread.start() diff --git a/tools/reloop/test_ui_surface_style_source_contract.py b/tools/reloop/test_ui_surface_style_source_contract.py new file mode 100644 index 0000000..19bbc70 --- /dev/null +++ b/tools/reloop/test_ui_surface_style_source_contract.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Source contracts for the user-requested dark glass UI surfaces.""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + + +RUNTIME_SOURCE = ( + Path(__file__).resolve().parents[2] / "reimpl" / "src" / "runtime_bridge.c" +) + + +def definition_body(source: str, signature: str) -> str: + """Return the definition beginning with *signature*, skipping prototypes.""" + + cursor = 0 + while True: + start = source.index(signature, cursor) + brace = source.find("{", start + len(signature)) + semicolon = source.find(";", start + len(signature)) + if brace >= 0 and (semicolon < 0 or brace < semicolon): + depth = 0 + for index in range(brace, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[start : index + 1] + raise AssertionError(f"unterminated function definition: {signature}") + cursor = start + len(signature) + + +def define_value(source: str, name: str) -> int: + """Resolve a literal ARGB macro or a simple alias to another macro.""" + + seen: set[str] = set() + current = name + while current not in seen: + seen.add(current) + match = re.search( + rf"^#define[ \t]+{re.escape(current)}[ \t]+(?P\S+)", + source, + re.MULTILINE, + ) + if match is None: + raise AssertionError(f"missing color macro: {current}") + value = match.group("value").rstrip("uUlL") + if re.fullmatch(r"0[xX][0-9a-fA-F]+", value): + return int(value, 16) + if re.fullmatch(r"[0-9]+", value): + return int(value, 10) + current = value + raise AssertionError(f"recursive color macro alias: {name}") + + +class UiSurfaceStyleSourceContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.source = RUNTIME_SOURCE.read_text(encoding="utf-8") + + def test_central_surface_palette_is_dark_and_semitransparent(self) -> None: + surface_names = ( + "SAMP_MODERN_UI_PANEL_COLOR", + "SAMP_MODERN_UI_HEADER_COLOR", + "SAMP_MODERN_UI_CONTROL_COLOR", + "SAMP_MODERN_UI_CONTROL_HOVER_COLOR", + "SAMP_MODERN_UI_CONTROL_PRESSED_COLOR", + ) + colors = {name: define_value(self.source, name) for name in surface_names} + + for name, color in colors.items(): + with self.subTest(name=name): + alpha = (color >> 24) & 0xFF + red = (color >> 16) & 0xFF + green = (color >> 8) & 0xFF + blue = color & 0xFF + perceived_luma = (red * 2126 + green * 7152 + blue * 722) // 10000 + self.assertGreater(alpha, 0x40) + self.assertLess(alpha, 0xFF) + # The hover state may carry a restrained blue/cyan tint, but + # every composited surface must remain visibly dark. + self.assertLessEqual(perceived_luma, 0x50) + self.assertLessEqual(max(red, green, blue), 0x80) + + self.assertEqual(len(set(colors.values())), len(colors)) + + def test_normal_hover_and_pressed_controls_are_visually_distinct(self) -> None: + normal = define_value(self.source, "SAMP_MODERN_UI_CONTROL_COLOR") + hover = define_value(self.source, "SAMP_MODERN_UI_CONTROL_HOVER_COLOR") + pressed = define_value(self.source, "SAMP_MODERN_UI_CONTROL_PRESSED_COLOR") + self.assertEqual(len({normal, hover, pressed}), 3) + + control = definition_body( + self.source, + "static void ui_compat_draw_glass_control(", + ) + self.assertIn("SAMP_MODERN_UI_CONTROL_COLOR", control) + self.assertIn("SAMP_MODERN_UI_CONTROL_HOVER_COLOR", control) + self.assertIn("SAMP_MODERN_UI_CONTROL_PRESSED_COLOR", control) + self.assertRegex(control, r"\bhover(?:ed)?\b") + self.assertRegex(control, r"\bpressed\b") + self.assertIn("ui_compat_draw_blended_rect(", control) + self.assertIn("SAMP_MODERN_UI_BORDER_COLOR", control) + + def test_blended_rect_uses_complete_alpha_state_pipeline(self) -> None: + blended = definition_body( + self.source, + "static int ui_compat_draw_blended_rect(", + ) + alpha_pipeline = definition_body( + self.source, + "static int dialog_compat_d3d_alpha_rect(", + ) + pipeline = blended + alpha_pipeline + + self.assertIn("dialog_compat_d3d_alpha_rect(", blended) + self.assertIn("SAMP_D3DRS_ALPHATESTENABLE, 0u", pipeline) + self.assertIn("SAMP_D3DRS_ALPHABLENDENABLE, 1u", pipeline) + self.assertIn("SAMP_D3DBLEND_SRCALPHA", pipeline) + self.assertIn("SAMP_D3DBLEND_INVSRCALPHA", pipeline) + self.assertIn("SAMP_D3DRS_FILLMODE, SAMP_D3DFILL_SOLID", pipeline) + self.assertIn("SAMP_D3DRS_CULLMODE, SAMP_D3DCULL_NONE", pipeline) + self.assertIn("SAMP_D3DRS_COLORWRITEENABLE", pipeline) + self.assertIn("SAMP_D3DCOLORWRITE_ALL", pipeline) + self.assertIn("SAMP_D3DRS_BLENDOP, SAMP_D3DBLENDOP_ADD", pipeline) + self.assertIn("SAMP_D3DRS_SCISSORTESTENABLE, 0u", pipeline) + self.assertIn("set_texture(device, 0u, NULL)", pipeline) + self.assertIn("SAMP_D3DTSS_COLORARG1, SAMP_D3DTA_DIFFUSE", pipeline) + self.assertIn("SAMP_D3DTSS_ALPHAARG1, SAMP_D3DTA_DIFFUSE", pipeline) + self.assertIn("apply_state_block", pipeline) + + def test_glass_panel_has_fill_header_highlight_and_border(self) -> None: + panel = definition_body( + self.source, + "static void ui_compat_draw_glass_panel(", + ) + self.assertGreaterEqual(panel.count("ui_compat_draw_blended_rect("), 2) + self.assertIn("SAMP_MODERN_UI_PANEL_COLOR", panel) + self.assertIn("SAMP_MODERN_UI_HEADER_COLOR", panel) + self.assertIn("SAMP_MODERN_UI_BORDER_COLOR", panel) + + def test_scoreboard_uses_glass_panel_and_blended_row_states(self) -> None: + scoreboard = definition_body( + self.source, + "static int scoreboard_compat_draw_d3dx_overlay(", + ) + self.assertIn("ui_compat_draw_glass_panel(", scoreboard) + self.assertIn("ui_compat_draw_blended_rect(", scoreboard) + self.assertIn("SAMP_SCOREBOARD_COLOR_HOVER", scoreboard) + self.assertIn("SAMP_MODERN_UI_BORDER_COLOR", scoreboard) + + def test_dialog_panel_and_buttons_use_glass_helpers(self) -> None: + dialog = definition_body( + self.source, + "static int dialog_compat_draw_d3dx_overlay(", + ) + button = definition_body( + self.source, + "static void dialog_compat_draw_button(", + ) + self.assertIn("ui_compat_draw_glass_panel(", dialog) + self.assertIn("ui_compat_draw_glass_control(", button) + self.assertIn("dialog_compat_point_in_rect(", button) + self.assertIn("dialog_mouse_down", button) + + def test_square_class_selection_is_the_default_glass_fallback(self) -> None: + resources = definition_body( + self.source, + "static int class_selection_compat_ensure_resources(", + ) + class_draw = definition_body( + self.source, + "static int class_selection_compat_draw_d3dx_overlay(", + ) + + env_read = resources.index( + "getenv(SAMP_CLASS_SELECTION_R5_TEXTURE_ENV)" + ) + opt_out = resources.index("if (!texture_style_enabled)", env_read) + texture_use = resources.index( + "if (g_runtime.class_selection_texture != NULL)", + opt_out, + ) + self.assertLess(env_read, opt_out) + self.assertLess(opt_out, texture_use) + self.assertIn("return 0;", resources[opt_out:texture_use]) + self.assertIn("USER_REQUESTED", resources) + + fallback_start = class_draw.index("if (!skin_drawn)") + fallback = class_draw[fallback_start:] + self.assertIn("ui_compat_draw_glass_panel(", class_draw) + self.assertIn("ui_compat_draw_glass_control(", fallback) + self.assertIn("class_selection_mouse_down", class_draw) + self.assertIn("dialog_compat_point_in_rect(", class_draw) + self.assertNotIn("sampgui.png", fallback) + + def test_class_selection_texture_colors_remain_separate_from_glass_palette( + self, + ) -> None: + normal = define_value( + self.source, + "SAMP_CLASS_SELECTION_BUTTON_NORMAL_COLOR", + ) + pressed = define_value( + self.source, + "SAMP_CLASS_SELECTION_BUTTON_PRESSED_COLOR", + ) + self.assertNotEqual(normal, pressed) + + skin = definition_body( + self.source, + "static int class_selection_compat_draw_button_skin(", + ) + self.assertIn("SAMP_CLASS_SELECTION_BUTTON_NORMAL_COLOR", skin) + self.assertIn("SAMP_CLASS_SELECTION_BUTTON_PRESSED_COLOR", skin) + self.assertIn("SAMP_CLASS_SELECTION_FILL_HOVER_COLOR", skin) + self.assertIn("SAMP_CLASS_SELECTION_FILL_PRESSED_COLOR", skin) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_windows_sync_edge_probe.py b/tools/reloop/test_windows_sync_edge_probe.py new file mode 100644 index 0000000..2c552fa --- /dev/null +++ b/tools/reloop/test_windows_sync_edge_probe.py @@ -0,0 +1,800 @@ +import contextlib +import importlib.util +import json +import struct +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + + +MODULE_PATH = Path(__file__).with_name("windows_sync_edge_probe.py") +SPEC = importlib.util.spec_from_file_location("windows_sync_edge_probe", MODULE_PATH) +assert SPEC and SPEC.loader +probe = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = probe +SPEC.loader.exec_module(probe) + + +def make_x86_pe(path: Path, machine: int = 0x014C) -> None: + value = bytearray(0x100) + value[:2] = b"MZ" + struct.pack_into(" tuple[Path, SimpleNamespace, list[object], mock.Mock]: + original_prefix = root / "original" + replacement_prefix = root / "replacement" + original = probe.reloop.ClientProfile("original", original_prefix, None, None) + replacement = probe.reloop.ClientProfile( + "replacement", replacement_prefix, None, None + ) + original.gta_root.mkdir(parents=True) + replacement.gta_root.mkdir(parents=True) + original.samp_exe.write_bytes(b"launcher") + original.samp_dll.write_bytes(b"original") + + server_root = root / "server" + (server_root / "filterscripts").mkdir(parents=True) + (server_root / "scriptfiles").mkdir() + (server_root / "filterscripts/sync_pair.amx").write_bytes(b"fixture") + settings = SimpleNamespace( + clients={"original": original, "replacement": replacement}, + artifacts_root=root / "artifacts", + server_root=server_root, + server_executable=server_root / "omp-server", + server_ready_timeout_s=1, + shutdown_timeout_s=1, + host="127.0.0.1", + port=7798, + ) + args = SimpleNamespace( + config=root / "reloop.toml", + scenario=scenario, + server_mode="reuse", + local_client_mode="replace", + windows_client_mode="replace", + windows_role=windows_role, + windows_server_host="192.168.3.181", + windows_favorite_index=3, + deploy_windows_dll=candidate, + client_ready_timeout=1.0, + pair_ready_timeout=1.0, + fixture_timeout=1.0, + action_seconds=0.1, + between=0.0, + screenshot_count=2, + screenshot_interval=0.025, + steer_during_capture=None, + windows_start_timeout=1.0, + lab_timeout=1.0, + fetch_timeout=1.0, + ) + artifact = settings.artifacts_root / "windows-edge-unit" + pilot_process = mock.Mock() + api = mock.Mock() + lab_calls: list[list[str]] = [] + candidate_hash = probe.reloop.sha256(candidate) if candidate else None + + def lab_side_effect( + artifact_dir: Path, + label: str, + arguments: list[str], + *, + timeout: float, + check: bool = True, + ): + del timeout, check + lab_calls.append(arguments) + output = "{}" + if arguments[0] == "start": + output = '{"result":{"run_id":"windows_run_1"}}' + elif arguments[0] in {"collect", "stop"}: + output = '{"result":{"run_id":"windows_run_1"}}' + elif arguments[0] == "fetch-run": + fetched = artifact_dir / "windows" / "windows_run_1" + latest = fetched / "latest_log_bytes" + latest.mkdir(parents=True) + installed_hash = candidate_hash or "installed-replacement" + (fetched / "manifest.json").write_text( + json.dumps( + { + "run_id": "windows_run_1", + "samp_sha256": installed_hash, + } + ), + encoding="utf-8", + ) + (latest / "samp_runtime.log").write_text( + ( + "replacement pilot process_attach\n" + if windows_role == "pilot" + else "\n".join( + [ + "packet-state id=210 remote_trailer seq=1", + "remote_trailer: apply seq=1 attached=1 readback=1", + "remote_edge: consume movement_seq=1 packet=210 " + "packet_seq=1 type=4 result=applied", + "", + ] + ) + ), + encoding="utf-8", + ) + return SimpleNamespace(returncode=0, stdout=output) + + def driver_side_effect(*_args, **_kwargs): + if scenario == "passenger_g": + markers = "\n".join( + [ + "marker=PASSENGER_ENTER_REQUEST request=7 status=ACTION " + "scenario=passenger_g detail=rpc=26 vehicle=4 " + "expected_vehicle=4 is_passenger=1", + "marker=PASSENGER_ENTRY_RESULT request=7 status=PASS " + "scenario=passenger_g detail=rpc=26 enter_seen=1 " + "enter_vehicle=4 is_passenger=1 vehicle=4 " + "seat=1 state=3", + "marker=PILOT_SYNC scenario=passenger_g sample=2 " + "state=3 vehicle=4", + "", + ] + ) + else: + markers = "marker=TRAILER_UPDATE count=1\n" + (server_root / "log.txt").write_text( + markers, + encoding="utf-8", + ) + return driver_returncode + + def screenshot_side_effect(destination: Path): + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(b"png") + return True, "unit" + + patches = [ + mock.patch.object(probe.reloop, "load_settings", return_value=settings), + mock.patch.object( + probe.reloop, "run_id", return_value="windows-edge-unit" + ), + mock.patch.object(probe, "validate_layout", return_value={}), + mock.patch.object(probe.reloop, "replace_existing_client"), + mock.patch.object(probe.sync_edge_probe, "tcp_port_available", return_value=True), + mock.patch.object( + probe.reloop, "replace_existing_server", return_value=True + ), + mock.patch.object(probe.reloop, "prefix_pids", return_value=set()), + mock.patch.object( + probe.reloop, + "direct_client_launch", + return_value=(["wine", "samp.exe", "127.0.0.1:7798", "-nX"], {}), + ), + mock.patch.object( + probe.reloop, "start_process", return_value=pilot_process + ), + ( + mock.patch.object( + probe, + "wait_for_api", + side_effect=AssertionError( + "local observer must not open the control API" + ), + ) + if windows_role == "pilot" + else mock.patch.object(probe, "wait_for_api", return_value=api) + ), + mock.patch.object(probe.sync_edge_probe, "wait_for_pair"), + ( + mock.patch.object( + probe, + "run_driver", + side_effect=AssertionError( + "Windows pilot must not use the host-input driver" + ), + ) + if windows_role == "pilot" + else mock.patch.object( + probe, "run_driver", side_effect=driver_side_effect + ) + ), + mock.patch.object( + probe, + "run_windows_pilot_driver", + side_effect=driver_side_effect, + ), + mock.patch.object(probe, "run_lab", side_effect=lab_side_effect), + mock.patch.object(probe.reloop, "terminate_pids", return_value=set()), + mock.patch.object(probe.sync_edge_probe, "file_hashes", return_value={}), + mock.patch.object( + probe.reloop, + "take_screenshot", + side_effect=screenshot_side_effect, + ), + mock.patch.object(probe.time, "sleep"), + ] + return artifact, args, patches, mock.Mock(side_effect=lambda: lab_calls) + + def test_without_explicit_candidate_never_validates_or_deploys(self): + with tempfile.TemporaryDirectory() as directory: + artifact, args, patches, calls_getter = self.make_fixture(Path(directory)) + with contextlib.ExitStack() as stack: + for patcher in patches: + stack.enter_context(patcher) + _path, verdict = probe.execute(args) + + commands = [call[0] for call in calls_getter.side_effect()] + self.assertNotIn("validate", commands) + self.assertNotIn("deploy", commands) + self.assertIn("collect", commands) + self.assertIn("stop", commands) + self.assertIn("fetch-run", commands) + self.assertEqual(verdict["verdict"], "TRACE_PASS_VISUAL_UNVERIFIED") + self.assertTrue((artifact / "windows/windows_run_1/manifest.json").is_file()) + + def test_explicit_candidate_is_validated_then_deployed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = root / "candidate.dll" + make_x86_pe(candidate) + _artifact, args, patches, calls_getter = self.make_fixture( + root, candidate=candidate + ) + with contextlib.ExitStack() as stack: + for patcher in patches: + stack.enter_context(patcher) + _path, verdict = probe.execute(args) + + commands = [call[0] for call in calls_getter.side_effect()] + self.assertLess(commands.index("validate"), commands.index("deploy")) + self.assertTrue(verdict["explicit_candidate_hash_matches_manifest"]) + + def test_driver_failure_still_collects_stops_and_fetches(self): + with tempfile.TemporaryDirectory() as directory: + _artifact, args, patches, calls_getter = self.make_fixture( + Path(directory), driver_returncode=7 + ) + with contextlib.ExitStack() as stack: + for patcher in patches: + stack.enter_context(patcher) + with self.assertRaisesRegex( + probe.WindowsSyncEdgeError, "driver failed" + ): + probe.execute(args) + + commands = [call[0] for call in calls_getter.side_effect()] + self.assertIn("collect", commands) + self.assertIn("stop", commands) + self.assertIn("fetch-run", commands) + + def test_windows_pilot_swaps_nicknames_without_host_input_driver(self): + with tempfile.TemporaryDirectory() as directory: + artifact, args, patches, calls_getter = self.make_fixture( + Path(directory), + scenario="passenger_g", + windows_role="pilot", + ) + with contextlib.ExitStack() as stack: + for patcher in patches: + stack.enter_context(patcher) + _path, verdict = probe.execute(args) + + start = next( + call for call in calls_getter.side_effect() + if call[0] == "start" + ) + self.assertEqual(start[5], "SyncPilot") + launch_order = json.loads( + (artifact / "launch-order.json").read_text(encoding="utf-8") + ) + self.assertIn("-nSyncObserver", launch_order["local_observer"]) + self.assertFalse(launch_order["local_observer_api_verified"]) + self.assertFalse(launch_order["local_observer_api_required"]) + self.assertTrue( + ( + artifact + / "observer/screenshots/passenger_g-after-entry.png" + ).is_file() + ) + self.assertEqual( + verdict["verdict"], + "SERVER_TRACE_PASS_OBSERVER_INTERNALS_UNAVAILABLE", + ) + self.assertEqual(verdict["windows_role"], "pilot") + self.assertEqual(verdict["observer_identity"], "original_r5") + self.assertTrue(verdict["windows_pilot_is_replacement"]) + self.assertTrue(verdict["host_observer_screenshot_captured"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/test_windows_ui_key_source_contract.py b/tools/reloop/test_windows_ui_key_source_contract.py new file mode 100644 index 0000000..5f461e2 --- /dev/null +++ b/tools/reloop/test_windows_ui_key_source_contract.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Static contracts for the bounded Windows TAB/F6/F7 lab actions.""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +REMOTE_LAB = REPO_ROOT / "tools/windows/remote_lab" +UI_KEYS = ("TAB", "F6", "F7") + + +def read(name: str) -> str: + return (REMOTE_LAB / name).read_text(encoding="utf-8") + + +class WindowsUiKeySourceContractTests(unittest.TestCase): + def test_all_external_allowlists_contain_exact_ui_keys(self) -> None: + input_script = read("Send-SampTestInput.ps1") + queue_script = read("Submit-SampTestCommand.ps1") + wrapper = read("samp_lab.sh") + + for key in UI_KEYS: + self.assertIn(f'"{key}"', input_script) + self.assertIn(f'"{key}"', queue_script) + self.assertRegex(wrapper, rf"\b{key}\b") + + self.assertIn( + 'ENTER|ESCAPE|SPACE|ALTENTER|TAB|F6|F7|UP|DOWN', + wrapper, + ) + self.assertIn( + '[ValidateSet("ENTER", "ESCAPE", "SPACE", "ALTENTER", ' + '"TAB", "F6", "F7", "UP"', + input_script, + ) + self.assertIn( + '[ValidateSet("ENTER", "ESCAPE", "SPACE", "ALTENTER", ' + '"TAB", "F6", "F7", "UP"', + queue_script, + ) + + def test_ui_actions_are_real_bounded_down_up_pairs(self) -> None: + source = read("Send-SampTestInput.ps1") + match = re.search( + r'\} elseif \(\$Key -in @\("TAB", "F6", "F7"\)\) \{' + r"(?P.*?)" + r'\} elseif \(\$Key -in @\("SPACE", "UP"', + source, + re.DOTALL, + ) + self.assertIsNotNone(match) + body = match.group("body") # type: ignore[union-attr] + + for key, virtual_key, scan_code in ( + ("TAB", "0x09", "0x0F"), + ("F6", "0x75", "0x40"), + ("F7", "0x76", "0x41"), + ): + self.assertIn(f'"{key}" {{ {virtual_key} }}', body) + self.assertIn(f'"{key}" {{ {scan_code} }}', body) + + self.assertIn( + "keybd_event($virtualKey, $scanCode, 0, [UIntPtr]::Zero)", + body, + ) + self.assertIn("try {", body) + self.assertIn("finally {", body) + self.assertIn( + "keybd_event($virtualKey, $scanCode, 0x0002, " + "[UIntPtr]::Zero)", + body, + ) + self.assertIn( + 'if ($Key -eq "TAB") { 750 } else { 100 }', + body, + ) + self.assertNotIn("SendKeys", body) + + def test_agent_routes_through_the_validated_input_script(self) -> None: + source = read("SampTestAgent.ps1") + self.assertIn('"input" {', source) + self.assertIn( + 'Join-Path $PSScriptRoot "Send-SampTestInput.ps1"', + source, + ) + self.assertIn("-Mode $mode -Key $key", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reloop/windows_sync_edge_probe.py b/tools/reloop/windows_sync_edge_probe.py new file mode 100755 index 0000000..e50a06e --- /dev/null +++ b/tools/reloop/windows_sync_edge_probe.py @@ -0,0 +1,1326 @@ +#!/usr/bin/env python3 +"""Run edge-state sync probes across one local and one Windows machine. + +By default the configured local original-R5 client is ``SyncPilot`` and the +native-Windows lab client is ``SyncObserver``. The explicit +``--windows-role=pilot`` mode is restricted to ``passenger_g``: Windows becomes +``SyncPilot``, receives the fixed lab-side VK_G action, and the sole local +original-R5 prefix becomes ``SyncObserver``. + +No Windows DLL is deployed unless ``--deploy-windows-dll PATH`` is supplied. +Even a successful run proves trace/application coverage only; screenshot +bursts remain visual evidence that must be reviewed or compared separately. +""" + +from __future__ import annotations + +import argparse +import contextlib +import json +import os +import re +import shlex +import shutil +import struct +import subprocess +import sys +import time +import traceback +from pathlib import Path +from typing import Any + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[1] +sys.path.insert(0, str(SCRIPT_DIR)) + +import reloop # noqa: E402 +import sync_edge_probe # noqa: E402 +import sync_pair_client # noqa: E402 +from control_client import VK_F8, wait_for_api # noqa: E402 + + +EDGE_SCENARIOS = sync_edge_probe.EDGE_SCENARIOS +WINDOWS_EDGE_SCENARIOS = sync_edge_probe.SUPPORTED_EDGE_SCENARIOS +CONTROL_PORT = sync_edge_probe.CONTROL_PORT +AUTOPAUSE_INI = sync_edge_probe.AUTOPAUSE_INI +LAB_WRAPPER = REPO_ROOT / "tools/windows/remote_lab/samp_lab.sh" +ORIGINAL_R5_SHA256 = ( + "b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2" +) +SAFE_LABEL = re.compile(r"^[A-Za-z0-9_.-]+$") +SAFE_HOST = re.compile(r"^[A-Za-z0-9.-]+$") +WINDOWS_ROLES = ("observer", "pilot") + + +class WindowsSyncEdgeError(RuntimeError): + """Expected operator-facing distributed probe failure.""" + + +def validate_role_scenario(windows_role: str, scenario: str) -> None: + """Keep the role reversal narrow so existing edge runs cannot change roles.""" + if windows_role not in WINDOWS_ROLES: + raise WindowsSyncEdgeError(f"unsupported Windows role: {windows_role}") + if windows_role == "pilot" and scenario != sync_edge_probe.PASSENGER_G_SCENARIO: + raise WindowsSyncEdgeError( + "--windows-role=pilot is only supported with --scenario=passenger_g" + ) + + +def topology_for_role(windows_role: str) -> str: + if windows_role == "pilot": + return "native_windows_pilot+local_original_observer" + return "local_original_pilot+native_windows_observer" + + +def validate_x86_pe(path: Path) -> dict[str, Any]: + """Validate enough of a PE header to reject a wrong-architecture DLL.""" + if not path.is_file(): + raise WindowsSyncEdgeError(f"Windows DLL candidate does not exist: {path}") + size = path.stat().st_size + if size < 0x40: + raise WindowsSyncEdgeError(f"Windows DLL candidate is too small: {path}") + with path.open("rb") as handle: + dos = handle.read(0x40) + if dos[:2] != b"MZ": + raise WindowsSyncEdgeError(f"Windows DLL candidate has no MZ header: {path}") + pe_offset = struct.unpack_from(" size - 6: + raise WindowsSyncEdgeError( + f"Windows DLL candidate has an invalid PE offset: {path}" + ) + handle.seek(pe_offset) + header = handle.read(6) + if header[:4] != b"PE\0\0": + raise WindowsSyncEdgeError(f"Windows DLL candidate has no PE signature: {path}") + machine = struct.unpack_from(" Any | None: + stripped = text.strip() + if not stripped: + return None + try: + return json.loads(stripped) + except json.JSONDecodeError: + # The SSH wrapper can prepend host diagnostics to PowerShell's + # pretty-printed, multi-line JSON document. Decode the first complete + # object/array after that banner instead of assuming one-line JSON. + decoder = json.JSONDecoder() + for offset, character in enumerate(stripped): + if character not in "[{": + continue + try: + value, _end = decoder.raw_decode(stripped, offset) + return value + except json.JSONDecodeError: + continue + return None + + +def _find_key(value: Any, key: str) -> Any | None: + if isinstance(value, dict): + if key in value: + return value[key] + for nested in value.values(): + found = _find_key(nested, key) + if found is not None: + return found + elif isinstance(value, list): + for nested in value: + found = _find_key(nested, key) + if found is not None: + return found + return None + + +def extract_run_id(text: str) -> str | None: + value = _find_key(_json_value(text), "run_id") + if not isinstance(value, str) or not SAFE_LABEL.fullmatch(value): + return None + return value + + +def extract_processes(text: str) -> list[Any] | None: + value = _find_key(_json_value(text), "processes") + return value if isinstance(value, list) else None + + +def run_lab( + artifact_dir: Path, + label: str, + arguments: list[str], + *, + timeout: float, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + """Run one allowlisted Windows-lab wrapper command and retain its output.""" + log_path = artifact_dir / "windows-control" / f"{label}.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + command = [str(LAB_WRAPPER), *arguments] + try: + completed = subprocess.run( + command, + cwd=REPO_ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=timeout, + check=False, + ) + output = completed.stdout or "" + except subprocess.TimeoutExpired as error: + output_value = error.stdout or "" + if isinstance(output_value, bytes): + output = output_value.decode("utf-8", errors="replace") + else: + output = output_value + log_path.write_text( + f"command={shlex.join(command)}\n" + f"timeout={timeout}\n" + f"{output}", + encoding="utf-8", + ) + raise WindowsSyncEdgeError( + f"Windows lab command timed out after {timeout:.1f}s: {arguments[0]}" + ) from error + log_path.write_text( + f"command={shlex.join(command)}\n" + f"returncode={completed.returncode}\n" + f"{output}", + encoding="utf-8", + ) + if check and completed.returncode: + raise WindowsSyncEdgeError( + f"Windows lab command failed with exit code {completed.returncode}: " + f"{arguments[0]}; see {log_path}" + ) + return completed + + +def validate_layout( + settings: reloop.Settings, + local_client: reloop.ClientProfile, + windows_role: str = "observer", +) -> dict[str, Any]: + local_role = "observer" if windows_role == "pilot" else "pilot" + if local_client.name != "original": + raise WindowsSyncEdgeError("the local client must be the original profile") + if not local_client.samp_exe.is_file() or not local_client.samp_dll.is_file(): + raise WindowsSyncEdgeError( + f"original GTA/SA-MP layout is incomplete: {local_client.gta_root}" + ) + if reloop.sha256(local_client.samp_dll) != ORIGINAL_R5_SHA256: + raise WindowsSyncEdgeError( + "configured local original profile does not contain the expected R5 DLL" + ) + installed_control = local_client.gta_root / reloop.CONTROL_ASI.name + if local_role == "pilot" and not installed_control.is_file(): + raise WindowsSyncEdgeError( + f"local original control ASI is missing: {installed_control}" + ) + autopause_path = local_client.gta_root / AUTOPAUSE_INI + if not sync_edge_probe.autopause_disabled(autopause_path): + raise WindowsSyncEdgeError( + f"local original requires [game] autoPause = 0 in {autopause_path}" + ) + replacement = settings.clients.get("replacement") + replacement_pids = reloop.prefix_pids(replacement.prefix) if replacement else set() + if replacement_pids: + raise WindowsSyncEdgeError( + "the local replacement prefix is active; this runner permits exactly " + f"one local prefix (pids={sorted(replacement_pids)})" + ) + layout = { + "local_role": local_role, + "local_profile": local_client.name, + "local_prefix": str(local_client.prefix), + "local_dll_sha256": reloop.sha256(local_client.samp_dll), + "local_control_sha256": ( + reloop.sha256(installed_control) + if installed_control.is_file() + else None + ), + "local_control_required": local_role == "pilot", + "built_control_sha256": ( + reloop.sha256(reloop.CONTROL_ASI) + if reloop.CONTROL_ASI.is_file() + else None + ), + "local_autopause": True, + "local_replacement_inactive": True, + "topology": topology_for_role(windows_role), + } + # Preserve the established default metadata keys while giving the swapped + # run truthful observer-prefixed aliases. + for field in ("profile", "prefix", "dll_sha256", "control_sha256", "autopause"): + layout[f"{local_role}_{field}"] = layout[f"local_{field}"] + return layout + + +def run_driver( + scenario: str, + artifact_dir: Path, + *, + fixture_timeout: float, + action_seconds: float, + screenshot_count: int, + screenshot_interval: float, + steer_during_capture: str | None, +) -> int: + output_path = artifact_dir / "driver" / f"{scenario}.json" + console_path = artifact_dir / "driver" / f"{scenario}.console.log" + output_path.parent.mkdir(parents=True, exist_ok=True) + screenshot_label = f"dist-edge-{scenario}-{int(time.time())}" + command = [ + sys.executable, + str(SCRIPT_DIR / "sync_pair_client.py"), + scenario, + "--output", + str(output_path), + "--action-seconds", + str(action_seconds), + "--observer-screenshot-label", + screenshot_label, + "--observer-screenshot-count", + str(screenshot_count), + "--observer-screenshot-interval", + str(screenshot_interval), + "--sync-pair-request-timeout", + str(fixture_timeout), + ] + if steer_during_capture is not None: + command.extend(["--steer-during-capture", steer_during_capture]) + screenshot_seconds = screenshot_count * max(0.025, screenshot_interval) + with console_path.open("wb") as handle: + completed = subprocess.run( + command, + cwd=REPO_ROOT, + stdout=handle, + stderr=subprocess.STDOUT, + timeout=fixture_timeout + action_seconds + screenshot_seconds + 60.0, + check=False, + ) + return completed.returncode + + +def run_windows_pilot_driver( + scenario: str, + artifact_dir: Path, + *, + request_path: Path, + results_path: Path, + fixture_timeout: float, + action_seconds: float, + lab_timeout: float, +) -> int: + """Queue passenger_g, inject Windows VK_G, and wait for physical seat 1.""" + if scenario != sync_edge_probe.PASSENGER_G_SCENARIO: + raise WindowsSyncEdgeError( + "the Windows-pilot driver only supports passenger_g" + ) + + output_path = artifact_dir / "driver" / f"{scenario}.json" + console_path = artifact_dir / "driver" / f"{scenario}.console.log" + output_path.parent.mkdir(parents=True, exist_ok=True) + output: list[dict[str, Any]] = [] + returncode = 0 + with console_path.open("w", encoding="utf-8") as handle: + with contextlib.redirect_stdout(handle), contextlib.redirect_stderr(handle): + try: + request_id = sync_pair_client.queue_sync_pair_scenario( + scenario, + request_path, + results_path, + fixture_timeout, + output, + ) + # EDGE_SETUP has already proved that the Windows SyncPilot is + # on foot, within R5's <4-unit gate, and has the vehicle + # streamed. This fixed allowlisted action is the only input. + run_lab( + artifact_dir, + "pilot-passenger-input", + [ + "key", + "PASSENGER", + f"dist-edge-passenger-g-{int(time.time())}", + ], + timeout=lab_timeout, + ) + sync_pair_client.wait_for_sync_pair_result( + "PASSENGER_ENTRY_RESULT", + scenario, + request_id, + results_path, + fixture_timeout, + output, + ) + time.sleep(max(action_seconds, 1.0)) + except Exception as error: + returncode = 1 + output.append( + { + "event": "windows_pilot_driver_error", + "scenario": scenario, + "error": f"{type(error).__name__}: {error}", + "host_time": time.time(), + } + ) + traceback.print_exc() + reloop.write_json( + output_path, + { + "scenario": scenario, + "windows_role": "pilot", + "input_action": "PASSENGER", + "returncode": returncode, + "events": output, + }, + ) + return returncode + + +def capture_local( + artifact_dir: Path, + server_snapshot: reloop.FileSnapshot, + result_snapshot: reloop.FileSnapshot, + local_snapshots: dict[str, reloop.FileSnapshot], + local_role: str = "pilot", +) -> str: + server_snapshot.capture_append(artifact_dir / "server.log") + result_snapshot.capture_append(artifact_dir / "sync-pair-results.log") + return sync_edge_probe.collect_logs( + artifact_dir / local_role / "client", + local_snapshots, + ) + + +def capture_host_observer_screenshot( + artifact_dir: Path, + scenario: str, + local_client: reloop.ClientProfile, +) -> dict[str, Any]: + """Capture the host Original-R5 observer after verified passenger entry.""" + destination = ( + artifact_dir + / "observer" + / "screenshots" + / f"{scenario}-after-entry.png" + ) + destination.parent.mkdir(parents=True, exist_ok=True) + ok, backend = reloop.take_screenshot(destination) + if ok: + return { + "captured": True, + "backend": backend, + "path": str(destination), + } + + # Wine exposes the window through XWayland even when GNOME denies its + # whole-desktop screenshot API. Capture that one window without changing + # focus or injecting input. + x11_backend = "x11-window:unavailable" + if shutil.which("xwininfo") and shutil.which("ffmpeg"): + try: + window_info = subprocess.run( + ["xwininfo", "-name", "GTA: San Andreas"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + timeout=3.0, + ) + window_match = re.search( + r"Window id:\s+(0x[0-9A-Fa-f]+)", + window_info.stdout, + ) + if window_info.returncode == 0 and window_match: + window_id = window_match.group(1) + capture = subprocess.run( + [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-f", + "x11grab", + "-window_id", + window_id, + "-i", + os.environ.get("DISPLAY", ":0"), + "-frames:v", + "1", + "-update", + "1", + "-y", + str(destination), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=5.0, + ) + if ( + capture.returncode == 0 + and destination.is_file() + and destination.stat().st_size > 8 + ): + return { + "captured": True, + "backend": ( + f"x11-window:{window_id} (desktop={backend})" + ), + "path": str(destination), + } + x11_backend = f"x11-window:{window_id}:exit={capture.returncode}" + else: + x11_backend = ( + f"x11-window:not-found:exit={window_info.returncode}" + ) + except (OSError, subprocess.TimeoutExpired) as error: + x11_backend = f"x11-window:{type(error).__name__}" + + # GNOME's desktop screenshot API can be denied or block under an + # unattended Wayland session even though the game is healthy. Fall back to + # Original R5's own renderer only after the request-scoped + # PASSENGER_ENTRY_RESULT has already passed. The preferred command sets + # R5's screenshot request flag only after strict PE and byte guards; F8 is + # retained for an older control ASI. Neither path can trigger or help the + # Windows pilot's earlier VK_G action. + screenshot_dirs = [ + local_client.gta_root, + local_client.gta_root / "SAMP", + local_client.gta_root / "SAMP" / "screens", + local_client.gta_root / "SAMP" / "Screenshots", + ] + prefix = getattr(local_client, "prefix", None) + users_root = Path(prefix) / "drive_c" / "users" if prefix is not None else None + if users_root is not None and users_root.is_dir(): + for user_root in users_root.iterdir(): + user_samp = ( + user_root + / "Documents" + / "GTA San Andreas User Files" + / "SAMP" + ) + screenshot_dirs.extend((user_samp, user_samp / "screens")) + + def screenshot_files() -> dict[Path, tuple[int, int]]: + files: dict[Path, tuple[int, int]] = {} + for directory in screenshot_dirs: + if not directory.is_dir(): + continue + for pattern in ("sa-mp-*.png", "samp-*.png"): + for path in directory.glob(pattern): + try: + stat = path.stat() + except OSError: + continue + files[path] = (stat.st_mtime_ns, stat.st_size) + return files + + before = screenshot_files() + newest: Path | None = None + client_backend = "original-r5-f8" + direct_request_error: str | None = None + + def wait_for_new_screenshot(timeout: float) -> Path | None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + current = screenshot_files() + changed = [ + path + for path, identity in current.items() + if identity[1] > 8 and before.get(path) != identity + ] + if changed: + return max(changed, key=lambda path: current[path][0]) + time.sleep(0.1) + return None + + try: + control = wait_for_api(timeout=5.0) + try: + try: + control.command("samp_screenshot") + client_backend = "original-r5-request-flag" + newest = wait_for_new_screenshot(3.0) + except RuntimeError as error: + # Older control-ASI builds do not expose the identity-guarded + # request flag. Retain normal input as a compatibility + # fallback while the runner is upgraded. + direct_request_error = type(error).__name__ + if newest is None: + client_backend = "original-r5-f8" + control.command("focus") + time.sleep(0.25) + # Keep F8 down across several render/Input frames. A 35 ms + # synthetic tap can be missed directly after the focus + # transition even though the control command itself succeeds. + control.key(VK_F8, "down") + time.sleep(0.15) + control.key(VK_F8, "up") + newest = wait_for_new_screenshot(2.0) + if newest is None: + # R5 normally consumes DirectInput, but keep the + # equivalent window-message edge as a final bounded + # fallback. + control.command("window_key", vk=VK_F8) + newest = wait_for_new_screenshot(3.0) + finally: + control.close() + except (OSError, TimeoutError, ConnectionError, RuntimeError, json.JSONDecodeError) as error: + return { + "captured": False, + "backend": ( + f"{backend}; {x11_backend}; " + f"original-r5-f8:{type(error).__name__}" + ), + "path": str(destination), + } + + if newest is not None: + try: + shutil.copy2(newest, destination) + except OSError as error: + return { + "captured": False, + "backend": ( + f"{backend}; {x11_backend}; " + f"original-r5-f8-copy:{type(error).__name__}" + ), + "path": str(destination), + } + return { + "captured": destination.is_file(), + "backend": ( + f"{client_backend} (desktop={backend}; x11={x11_backend}; " + f"direct_error={direct_request_error})" + ), + "path": str(destination), + "source": str(newest), + } + return { + "captured": False, + "backend": ( + f"{backend}; {x11_backend}; original-r5-f8:no-new-file" + ), + "path": str(destination), + } + + +def find_windows_manifest(root: Path, run_id: str | None) -> tuple[Path | None, dict[str, Any]]: + candidates = sorted(root.rglob("manifest.json")) if root.is_dir() else [] + for path in candidates: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if run_id is None or value.get("run_id") == run_id: + return path, value + return None, {} + + +def collect_windows_logs(root: Path) -> str: + if not root.is_dir(): + return "" + # The lab fetch also contains complete GTA-root log archives. Those can + # span many older runs (and therefore contain unrelated historical crash + # markers). The collector writes the bytes added during this run into + # latest_log_bytes; only those slices are valid parity evidence. + paths = sorted(root.rglob("latest_log_bytes/*.log")) + if not paths: + paths = sorted(root.rglob("*.log")) + chunks: list[str] = [] + for path in paths: + try: + chunks.append(path.read_text(encoding="utf-8", errors="replace")) + except OSError: + continue + return "\n".join(chunks) + + +def build_verdict( + *, + scenarios: list[str], + server_text: str, + windows_logs: str, + local_logs: str = "", + windows_manifest: dict[str, Any], + driver_returncodes: dict[str, int], + candidate: dict[str, Any] | None, + runner_error: str | None, + cleanup_errors: list[str], + local_hashes_unchanged: bool, + windows_artifact_fetched: bool, + windows_role: str = "observer", + host_observer_screenshot: dict[str, Any] | None = None, +) -> dict[str, Any]: + validate_role_scenario( + windows_role, + scenarios[0] if len(scenarios) == 1 else "all", + ) + windows_hash = windows_manifest.get("samp_sha256") + windows_is_original = windows_hash == ORIGINAL_R5_SHA256 + if windows_is_original: + windows_identity = "original_r5" + elif candidate and windows_hash == candidate.get("sha256"): + windows_identity = "explicit_candidate" + elif windows_hash: + windows_identity = "installed_non_original" + else: + windows_identity = "unknown" + + if windows_role == "pilot": + observer_logs = local_logs + observer_is_replacement = False + pilot_identity = windows_identity + observer_identity = "original_r5" + visual_evidence = ( + "The Windows input action captured the pilot after VK_G; the host " + "Original-R5 observer screenshot and logs were also retained, but " + "the image still requires manual or frame-diff review" + ) + else: + observer_logs = windows_logs + observer_is_replacement = not windows_is_original + pilot_identity = "original_r5" + observer_identity = windows_identity + visual_evidence = ( + "Windows observer screenshot bursts were captured; motion quality " + "requires manual or frame-diff review" + ) + + verdict = sync_edge_probe.evaluate_evidence( + scenarios, + server_text, + observer_logs, + observer_is_replacement, + driver_returncodes, + ) + candidate_hash_matches = ( + True + if candidate is None + else windows_hash == candidate.get("sha256") + ) + windows_crash_marker = sync_edge_probe._contains_crash(windows_logs) + local_crash_marker = sync_edge_probe._contains_crash(local_logs) + windows_pilot_is_replacement = ( + windows_role != "pilot" + or bool(windows_hash and not windows_is_original) + ) + host_observer_screenshot_captured = bool( + host_observer_screenshot + and host_observer_screenshot.get("captured") + ) + host_observer_screenshot_requirement_met = ( + windows_role != "pilot" or host_observer_screenshot_captured + ) + verdict.update( + { + "topology": topology_for_role(windows_role), + "windows_role": windows_role, + "local_role": "observer" if windows_role == "pilot" else "pilot", + "pilot_identity": pilot_identity, + "observer_identity": observer_identity, + "windows_identity": windows_identity, + "windows_samp_sha256": windows_hash, + "windows_pilot_is_replacement": windows_pilot_is_replacement, + "host_observer_screenshot": host_observer_screenshot, + "host_observer_screenshot_captured": host_observer_screenshot_captured, + "host_observer_screenshot_requirement_met": ( + host_observer_screenshot_requirement_met + ), + "explicit_candidate_hash_matches_manifest": candidate_hash_matches, + "windows_artifact_fetched": windows_artifact_fetched, + "local_hashes_unchanged": local_hashes_unchanged, + "windows_crash_marker": windows_crash_marker, + "local_crash_marker": local_crash_marker, + "pilot_crash_marker": ( + windows_crash_marker + if windows_role == "pilot" + else local_crash_marker + ), + "runner_error": runner_error, + "cleanup_errors": cleanup_errors, + "visual_parity": "TODO_VERIFY", + "visual_evidence": visual_evidence, + } + ) + if ( + runner_error + or cleanup_errors + or not local_hashes_unchanged + or not windows_artifact_fetched + or not candidate_hash_matches + or not windows_hash + or not windows_pilot_is_replacement + or not host_observer_screenshot_requirement_met + or windows_crash_marker + or local_crash_marker + ): + verdict["verdict"] = "FAIL" + return verdict + + +def execute(args: argparse.Namespace) -> tuple[Path, dict[str, Any]]: + validate_role_scenario(args.windows_role, args.scenario) + settings = reloop.load_settings(args.config.resolve()) + local_client = settings.clients["original"] + windows_role = args.windows_role + local_role = "observer" if windows_role == "pilot" else "pilot" + windows_nickname = "SyncPilot" if windows_role == "pilot" else "SyncObserver" + local_nickname = "SyncObserver" if windows_role == "pilot" else "SyncPilot" + windows_metadata_key = f"windows_{windows_role}" + local_metadata_key = f"local_{local_role}" + scenarios = list(EDGE_SCENARIOS if args.scenario == "all" else (args.scenario,)) + artifact_dir = settings.artifacts_root / reloop.run_id( + "windows-sync-edge", args.scenario + ) + artifact_dir.mkdir(parents=True, exist_ok=False) + print(f"artifact: {artifact_dir}", flush=True) + + layout = validate_layout(settings, local_client, windows_role) + if not LAB_WRAPPER.is_file(): + raise WindowsSyncEdgeError(f"Windows lab wrapper is missing: {LAB_WRAPPER}") + if not SAFE_HOST.fullmatch(args.windows_server_host): + raise WindowsSyncEdgeError( + f"invalid Windows-visible server host: {args.windows_server_host}" + ) + fixture = settings.server_root / "filterscripts/sync_pair.amx" + if not fixture.is_file(): + raise WindowsSyncEdgeError(f"sync-pair fixture is not compiled: {fixture}") + request_path = settings.server_root / "scriptfiles/sync_pair_request.txt" + if request_path.exists(): + raise WindowsSyncEdgeError(f"sync-pair request is already pending: {request_path}") + + candidate: dict[str, Any] | None = None + if args.deploy_windows_dll is not None: + candidate_path = args.deploy_windows_dll.expanduser().resolve() + candidate = validate_x86_pe(candidate_path) + + local_hashes_before = { + "samp_dll": reloop.sha256(local_client.samp_dll), + "asi": sync_edge_probe.file_hashes(local_client.gta_root), + } + metadata: dict[str, Any] = { + "run_id": artifact_dir.name, + "started_at": reloop.utc_timestamp(), + "scenarios": scenarios, + "windows_role": windows_role, + "local_role": local_role, + "topology": topology_for_role(windows_role), + "layout": layout, + "server": { + "local_host": settings.host, + "windows_visible_host": args.windows_server_host, + "port": settings.port, + "mode": args.server_mode, + }, + local_metadata_key: { + "profile": "original", + "nickname": local_nickname, + "dll_sha256": reloop.sha256(local_client.samp_dll), + }, + windows_metadata_key: { + "nickname": windows_nickname, + "favorite_index": args.windows_favorite_index, + "deploy_requested": candidate is not None, + "candidate": candidate, + }, + "local_hashes_before": local_hashes_before, + "visual_parity": "TODO_VERIFY", + } + reloop.write_json(artifact_dir / "metadata.json", metadata) + + server: reloop.ManagedProcess | None = None + local_process: reloop.ManagedProcess | None = None + server_console = artifact_dir / "server.console.log" + server_log = settings.server_root / "log.txt" + result_file = settings.server_root / "scriptfiles/sync_pair_results.log" + server_snapshot = reloop.FileSnapshot.take(server_log) + result_snapshot = reloop.FileSnapshot.take(result_file) + local_snapshots = { + name: reloop.FileSnapshot.take(local_client.gta_root / name) + for name in reloop.CLIENT_LOG_NAMES + } + local_pre_pids = reloop.prefix_pids(local_client.prefix) + windows_start_attempted = False + windows_run_id: str | None = None + driver_returncodes: dict[str, int] = {} + runner_exception: BaseException | None = None + runner_error: str | None = None + cleanup_errors: list[str] = [] + windows_fetch_root = artifact_dir / "windows" + host_observer_screenshot: dict[str, Any] | None = None + + try: + reloop.replace_existing_client( + local_client, args.local_client_mode, settings.shutdown_timeout_s + ) + if ( + local_role == "pilot" + and not sync_edge_probe.tcp_port_available("127.0.0.1", CONTROL_PORT) + ): + raise WindowsSyncEdgeError( + f"localhost control port {CONTROL_PORT} is already occupied" + ) + reused_server = reloop.replace_existing_server(settings, args.server_mode) + if not reused_server: + server = reloop.start_process( + [str(settings.server_executable)], + settings.server_root, + server_console, + "open.mp", + ) + ready = reloop.wait_for_text( + server_console, + reloop.SERVER_READY_PATTERN, + settings.server_ready_timeout_s, + server.process, + ) + if not ready: + raise WindowsSyncEdgeError( + "open.mp did not become ready; see server.console.log" + ) + + ping_result = run_lab( + artifact_dir, + "preflight-ping", + ["ping"], + timeout=args.lab_timeout, + ) + if args.windows_client_mode == "replace": + run_lab( + artifact_dir, + "preflight-stop", + ["stop"], + timeout=args.lab_timeout, + ) + else: + active_processes = extract_processes(ping_result.stdout) + if active_processes is None: + raise WindowsSyncEdgeError( + "could not verify that the Windows lab is idle in fail mode" + ) + if active_processes: + raise WindowsSyncEdgeError( + "Windows GTA/SA-MP is already active and " + "--windows-client-mode=fail was requested" + ) + if candidate is not None: + candidate_path = Path(candidate["path"]) + run_lab( + artifact_dir, + "candidate-validate", + ["validate", str(candidate_path)], + timeout=args.lab_timeout, + ) + deploy_label = f"dist-edge-{artifact_dir.name[-24:]}" + run_lab( + artifact_dir, + "candidate-deploy", + ["deploy", str(candidate_path), deploy_label], + timeout=args.lab_timeout, + ) + + windows_start_attempted = True + start_result = run_lab( + artifact_dir, + f"{windows_role}-start", + [ + "start", + f"dist_edge_{args.scenario}_{windows_role}", + "samp", + args.windows_server_host, + str(settings.port), + windows_nickname, + str(args.windows_favorite_index), + ], + timeout=args.windows_start_timeout, + ) + windows_run_id = extract_run_id(start_result.stdout) + if windows_run_id is None: + # A slow interactive launch can outlive the queue submitter's + # normal wait. The following collection executes after the start + # command in the same FIFO and returns the authoritative run ID. + discovery = run_lab( + artifact_dir, + f"{windows_role}-run-id-discovery", + ["collect"], + timeout=args.windows_start_timeout, + ) + windows_run_id = extract_run_id(discovery.stdout) + if windows_run_id is None: + raise WindowsSyncEdgeError( + f"Windows {windows_role} started without a recoverable run_id" + ) + metadata[windows_metadata_key]["run_id"] = windows_run_id + reloop.write_json(artifact_dir / "metadata.json", metadata) + + local_command, local_env = reloop.direct_client_launch( + settings, local_client, artifact_dir / f"{local_role}-launch" + ) + local_command[-1] = f"-n{local_nickname}" + local_process = reloop.start_process( + local_command, + local_client.gta_root, + artifact_dir / f"{local_role}-launcher.log", + local_nickname, + env=local_env, + ) + local_api_verified = False + if local_role == "pilot": + local_api = wait_for_api(timeout=args.client_ready_timeout) + local_api.close() + local_api_verified = True + reloop.write_json( + artifact_dir / "launch-order.json", + { + "windows_started_first": True, + f"windows_{windows_role}_started_first": True, + "windows_run_id": windows_run_id, + local_metadata_key: shlex.join(local_command), + f"{local_metadata_key}_api_verified": local_api_verified, + f"{local_metadata_key}_api_required": local_role == "pilot", + "local_replacement_launched": False, + }, + ) + sync_edge_probe.wait_for_pair( + server_console, + server_log, + server_snapshot, + args.pair_ready_timeout, + ) + + for scenario in scenarios: + if windows_role == "pilot": + returncode = run_windows_pilot_driver( + scenario, + artifact_dir, + request_path=request_path, + results_path=result_file, + fixture_timeout=args.fixture_timeout, + action_seconds=args.action_seconds, + lab_timeout=args.lab_timeout, + ) + else: + returncode = run_driver( + scenario, + artifact_dir, + fixture_timeout=args.fixture_timeout, + action_seconds=args.action_seconds, + screenshot_count=args.screenshot_count, + screenshot_interval=args.screenshot_interval, + steer_during_capture=args.steer_during_capture, + ) + driver_returncodes[scenario] = returncode + if returncode: + raise WindowsSyncEdgeError( + f"{scenario} pilot driver failed with exit code {returncode}" + ) + if windows_role == "pilot": + host_observer_screenshot = capture_host_observer_screenshot( + artifact_dir, + scenario, + local_client, + ) + if not host_observer_screenshot["captured"]: + raise WindowsSyncEdgeError( + "could not capture the host Original-R5 observer after " + f"passenger entry: {host_observer_screenshot['backend']}" + ) + time.sleep(args.between) + except BaseException as error: + runner_exception = error + runner_error = f"{type(error).__name__}: {error}" + finally: + try: + capture_local( + artifact_dir, + server_snapshot, + result_snapshot, + local_snapshots, + local_role, + ) + except Exception as error: + cleanup_errors.append( + f"pre_teardown_local_capture: {type(error).__name__}: {error}" + ) + + if windows_start_attempted: + try: + collection = run_lab( + artifact_dir, + "final-collect", + ["collect"], + timeout=args.lab_timeout, + check=False, + ) + if collection.returncode: + cleanup_errors.append( + f"windows_collect: exit={collection.returncode}" + ) + windows_run_id = windows_run_id or extract_run_id(collection.stdout) + except Exception as error: + cleanup_errors.append( + f"windows_collect: {type(error).__name__}: {error}" + ) + try: + stopped = run_lab( + artifact_dir, + "final-stop", + ["stop"], + timeout=args.lab_timeout, + check=False, + ) + if stopped.returncode: + cleanup_errors.append(f"windows_stop: exit={stopped.returncode}") + windows_run_id = windows_run_id or extract_run_id(stopped.stdout) + except Exception as error: + cleanup_errors.append( + f"windows_stop: {type(error).__name__}: {error}" + ) + + if local_process is not None: + try: + local_process.stop(settings.shutdown_timeout_s) + except Exception as error: + cleanup_errors.append( + f"{local_role}_stop: {type(error).__name__}: {error}" + ) + try: + survivors = reloop.terminate_pids( + reloop.prefix_pids(local_client.prefix) - local_pre_pids, + settings.shutdown_timeout_s, + ) + if survivors: + cleanup_errors.append( + f"{local_role}_prefix_stop: surviving_pids={sorted(survivors)}" + ) + except Exception as error: + cleanup_errors.append( + f"{local_role}_prefix_stop: {type(error).__name__}: {error}" + ) + if server is not None: + try: + server.stop(settings.shutdown_timeout_s) + except Exception as error: + cleanup_errors.append( + f"server_stop: {type(error).__name__}: {error}" + ) + + windows_artifact_fetched = False + if windows_run_id is not None: + try: + fetched = run_lab( + artifact_dir, + "final-fetch", + ["fetch-run", windows_run_id, str(windows_fetch_root)], + timeout=args.fetch_timeout, + check=False, + ) + windows_artifact_fetched = ( + fetched.returncode == 0 + and any(windows_fetch_root.rglob("manifest.json")) + ) + if not windows_artifact_fetched: + cleanup_errors.append( + f"windows_fetch: exit={fetched.returncode} manifest=missing" + ) + except Exception as error: + cleanup_errors.append( + f"windows_fetch: {type(error).__name__}: {error}" + ) + elif windows_start_attempted: + cleanup_errors.append("windows_fetch: run_id unavailable") + + try: + local_logs = capture_local( + artifact_dir, + server_snapshot, + result_snapshot, + local_snapshots, + local_role, + ) + except Exception as error: + local_logs = "" + cleanup_errors.append( + f"post_teardown_local_capture: {type(error).__name__}: {error}" + ) + + local_hashes_after = { + "samp_dll": reloop.sha256(local_client.samp_dll), + "asi": sync_edge_probe.file_hashes(local_client.gta_root), + } + local_hashes_unchanged = local_hashes_after == local_hashes_before + manifest_path, windows_manifest = find_windows_manifest( + windows_fetch_root, windows_run_id + ) + windows_logs = collect_windows_logs(windows_fetch_root) + server_text = "" + for path in (server_console, artifact_dir / "server.log"): + if path.is_file(): + server_text += path.read_text( + encoding="utf-8", errors="replace" + ) + "\n" + verdict = build_verdict( + scenarios=scenarios, + server_text=server_text, + windows_logs=windows_logs, + local_logs=local_logs, + windows_manifest=windows_manifest, + driver_returncodes=driver_returncodes, + candidate=candidate, + runner_error=runner_error, + cleanup_errors=cleanup_errors, + local_hashes_unchanged=local_hashes_unchanged, + windows_artifact_fetched=windows_artifact_fetched, + windows_role=windows_role, + host_observer_screenshot=host_observer_screenshot, + ) + metadata.update( + { + "finished_at": reloop.utc_timestamp(), + windows_metadata_key: { + **metadata[windows_metadata_key], + "run_id": windows_run_id, + "manifest_path": str(manifest_path) if manifest_path else None, + "installed_samp_sha256": windows_manifest.get("samp_sha256"), + }, + "local_hashes_after": local_hashes_after, + "host_observer_screenshot": host_observer_screenshot, + "runner_error": runner_error, + "cleanup_errors": cleanup_errors, + } + ) + reloop.write_json(artifact_dir / "metadata.json", metadata) + reloop.write_json(artifact_dir / "verdict.json", verdict) + + print(f"verdict: {verdict['verdict']}", flush=True) + if runner_exception is not None: + raise runner_exception.with_traceback(runner_exception.__traceback__) + if cleanup_errors: + raise WindowsSyncEdgeError("; ".join(cleanup_errors)) + return artifact_dir, verdict + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--config", + type=Path, + default=reloop.DEFAULT_CONFIG, + ) + parser.add_argument( + "--scenario", + choices=(*WINDOWS_EDGE_SCENARIOS, "all"), + default="all", + ) + parser.add_argument( + "--server-mode", + choices=("fail", "replace", "reuse"), + default="replace", + ) + parser.add_argument( + "--local-client-mode", + choices=("fail", "replace"), + default="replace", + ) + parser.add_argument( + "--windows-client-mode", + choices=("fail", "replace"), + default="replace", + ) + parser.add_argument( + "--windows-role", + choices=WINDOWS_ROLES, + default="observer", + help=( + "role assigned by nickname to the Windows client; pilot is an " + "explicit passenger_g-only replacement-client probe" + ), + ) + parser.add_argument("--windows-server-host", default="192.168.3.181") + parser.add_argument("--windows-favorite-index", type=int, default=3) + parser.add_argument( + "--deploy-windows-dll", + type=Path, + help=( + "explicitly validate and deploy this x86 samp.dll to Windows; " + "omitting the flag never deploys a DLL" + ), + ) + parser.add_argument("--client-ready-timeout", type=float, default=60.0) + parser.add_argument("--pair-ready-timeout", type=float, default=90.0) + parser.add_argument("--fixture-timeout", type=float, default=60.0) + parser.add_argument("--action-seconds", type=float, default=4.0) + parser.add_argument("--between", type=float, default=1.0) + parser.add_argument("--screenshot-count", type=int, default=120) + parser.add_argument("--screenshot-interval", type=float, default=0.025) + parser.add_argument( + "--steer-during-capture", + choices=("left", "right"), + help=( + "hold steering together with throttle during the car/trailer " + "screenshot burst" + ), + ) + parser.add_argument("--windows-start-timeout", type=float, default=45.0) + parser.add_argument("--lab-timeout", type=float, default=45.0) + parser.add_argument("--fetch-timeout", type=float, default=120.0) + args = parser.parse_args() + + for field in ( + "client_ready_timeout", + "pair_ready_timeout", + "fixture_timeout", + "action_seconds", + "windows_start_timeout", + "lab_timeout", + "fetch_timeout", + ): + if getattr(args, field) <= 0: + parser.error(f"--{field.replace('_', '-')} must be positive") + if args.between < 0: + parser.error("--between must not be negative") + if not 1 <= args.screenshot_count <= 120: + parser.error("--screenshot-count must be between 1 and 120") + if not 0.025 <= args.screenshot_interval <= 10.0: + parser.error("--screenshot-interval must be between 0.025 and 10 seconds") + if not 0 <= args.windows_favorite_index <= 100: + parser.error("--windows-favorite-index must be between 0 and 100") + if args.windows_role == "pilot" and args.scenario != "passenger_g": + parser.error( + "--windows-role=pilot requires --scenario=passenger_g" + ) + if args.windows_role == "pilot" and args.steer_during_capture is not None: + parser.error( + "--steer-during-capture is not valid with --windows-role=pilot" + ) + + try: + _artifact, verdict = execute(args) + except ( + WindowsSyncEdgeError, + reloop.ReLoopError, + subprocess.TimeoutExpired, + ) as error: + print(f"Windows sync-edge probe failed: {error}", file=sys.stderr) + return 2 + return 1 if verdict["verdict"] == "FAIL" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/reloop_control/README.md b/tools/reloop_control/README.md index fe20db7..d5f6d4e 100644 --- a/tools/reloop_control/README.md +++ b/tools/reloop_control/README.md @@ -5,13 +5,33 @@ original and replacement prefixes. It binds TCP only to `127.0.0.1:18737` and accepts newline-delimited JSON carrying the fixed token `reloop-local-v1`. The API exposes `ping`, `state`, `focus`, `key`, `window_key`, -`window_syskey`, `char`, and `mouse`. `window_syskey` emits the matching +`window_syskey`, `char`, `mouse`, and the narrowly guarded +`samp_screenshot` test command. `window_syskey` emits the matching `WM_SYSKEYDOWN`/`WM_SYSKEYUP` pair and is used to exercise the original R5 Alt+Enter release-trigger without depending on the host compositor. `state` samples the GTA HUD/radar/camera globals and the five bytes at `gta_sa.exe+0x141df5`; it never writes game memory. Input commands are normal Win32 window/input events so both DLLs receive the same stimulus. +The `mouse` action `move_delta` uses a bounded relative `SendInput` event +instead of moving the desktop cursor to an absolute point. `state` exposes the +three floats of GTA's `CCamera::InternalAim` front vector at +`gta_sa.exe+0x76F32C` as `aim_front_*`, plus the local ped matrix-forward vector +as `player_forward_*`. The older `aim_*` fields remain the InternalAim camera +position at `gta_sa.exe+0x76F338` for API compatibility. The angle fixture uses +the new vectors only as readback: it does not write GTA or SA-MP memory. + +`samp_screenshot` exists only to capture an unattended Original-R5 observer +under Wayland, where GNOME can deny whole-desktop screenshots and a synthetic +F8 edge can be lost during focus handoff. It requires the R5 preferred PE +ImageBase, timestamp, entry RVA, image size, and exact relocation-normalized +code guards at `samp.dll+0x755C0/+0x75751`; the actual module may be relocated +by Wine/ASLR. Only then does it atomically set the original request flag at +`samp.dll+0x12DE64`; R5's own render callback consumes the flag on the next +frame. The command rejects every other DLL identity. +Evidence: `STATIC_037`, original DLL +SHA256 `b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2`. + Build with `build_win32.sh`, deploy the resulting ASI into both GTA roots, then run `python3 tools/reloop/control_client.py scenario --output ` while the client is connected. This deliberately uses a tiny dependency-free loopback diff --git a/tools/reloop_control/src/reloop_control.c b/tools/reloop_control/src/reloop_control.c index e2b0796..3cc3211 100644 --- a/tools/reloop_control/src/reloop_control.c +++ b/tools/reloop_control/src/reloop_control.c @@ -10,6 +10,15 @@ #define CONTROL_TOKEN "reloop-local-v1" #define MAX_COMMAND 2048 +#define SAMP_R5_PREFERRED_BASE 0x10000000u +#define SAMP_R5_TIMESTAMP 0x6372c39eu +#define SAMP_R5_ENTRY_RVA 0x000cbc90u +#define SAMP_R5_IMAGE_SIZE 0x0027e000u +#define SAMP_R5_SCREENSHOT_ROUTINE_RVA 0x000755c0u +#define SAMP_R5_RENDER_SCREENSHOT_GUARD_RVA 0x00075751u +#define SAMP_R5_SCREENSHOT_REQUEST_RVA 0x0012de64u +#define SAMP_R5_EXCEPTION_HANDLER_RVA 0x000e3ac8u + static HANDLE g_stop_event; static HANDLE g_thread; static HMODULE g_module; @@ -46,6 +55,95 @@ static int readable(uintptr_t address, size_t size) { return end <= (uintptr_t)mbi.BaseAddress + mbi.RegionSize; } +static int writable(uintptr_t address, size_t size) { + MEMORY_BASIC_INFORMATION mbi; + uintptr_t end; + DWORD protection; + if (address == 0 || size == 0 || address > UINTPTR_MAX - size) return 0; + if (VirtualQuery((const void *)address, &mbi, sizeof(mbi)) != sizeof(mbi)) return 0; + end = address + size; + protection = mbi.Protect & 0xffu; + if (mbi.State != MEM_COMMIT || (mbi.Protect & PAGE_GUARD) != 0 || + end > (uintptr_t)mbi.BaseAddress + mbi.RegionSize) { + return 0; + } + return protection == PAGE_READWRITE || protection == PAGE_WRITECOPY || + protection == PAGE_EXECUTE_READWRITE || + protection == PAGE_EXECUTE_WRITECOPY; +} + +static int request_samp_r5_screenshot(void) { + uint8_t screenshot_routine_guard[] = { + 0x6a, 0xff, 0x68, 0xc8, 0x3a, 0x0e, 0x10, 0x64, + 0xa1, 0x00, 0x00, 0x00, 0x00, 0x50, 0x64, 0x89, + }; + uint8_t render_request_guard[] = { + 0xa1, 0x64, 0xde, 0x12, 0x10, 0x85, 0xc0, 0x53, 0x56, + 0x57, 0x74, 0x05, 0xe8, 0x5e, 0xfe, 0xff, 0xff, + }; + HMODULE module = GetModuleHandleA("samp.dll"); + uintptr_t base = (uintptr_t)module; + uint32_t relocated_exception_handler; + uint32_t relocated_request; + const IMAGE_DOS_HEADER *dos; + const IMAGE_NT_HEADERS32 *nt; + volatile LONG *request; + if (module == NULL || base > UINT32_MAX - SAMP_R5_IMAGE_SIZE || + !readable(base, sizeof(IMAGE_DOS_HEADER))) { + return 0; + } + dos = (const IMAGE_DOS_HEADER *)base; + if (dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew < 0x40 || + dos->e_lfanew > 0x1000 || + !readable(base + (uintptr_t)dos->e_lfanew, sizeof(IMAGE_NT_HEADERS32))) { + return 0; + } + nt = (const IMAGE_NT_HEADERS32 *)(base + (uintptr_t)dos->e_lfanew); + if (nt->Signature != IMAGE_NT_SIGNATURE || + nt->FileHeader.Machine != IMAGE_FILE_MACHINE_I386 || + nt->FileHeader.TimeDateStamp != SAMP_R5_TIMESTAMP || + nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR32_MAGIC || + nt->OptionalHeader.ImageBase != SAMP_R5_PREFERRED_BASE || + nt->OptionalHeader.AddressOfEntryPoint != SAMP_R5_ENTRY_RVA || + nt->OptionalHeader.SizeOfImage != SAMP_R5_IMAGE_SIZE) { + return 0; + } + + /* + * STATIC_037: both original guard windows contain normal PE HIGHLOW + * relocations. Compare their exact post-loader form so Wine/ASLR mappings + * remain accepted without weakening the byte guard. + */ + relocated_exception_handler = + (uint32_t)(base + SAMP_R5_EXCEPTION_HANDLER_RVA); + relocated_request = (uint32_t)(base + SAMP_R5_SCREENSHOT_REQUEST_RVA); + memcpy(screenshot_routine_guard + 3, &relocated_exception_handler, + sizeof(relocated_exception_handler)); + memcpy(render_request_guard + 1, &relocated_request, + sizeof(relocated_request)); + if ( + !readable(base + SAMP_R5_SCREENSHOT_ROUTINE_RVA, + sizeof(screenshot_routine_guard)) || + memcmp((const void *)(base + SAMP_R5_SCREENSHOT_ROUTINE_RVA), + screenshot_routine_guard, sizeof(screenshot_routine_guard)) != 0 || + !readable(base + SAMP_R5_RENDER_SCREENSHOT_GUARD_RVA, + sizeof(render_request_guard)) || + memcmp((const void *)(base + SAMP_R5_RENDER_SCREENSHOT_GUARD_RVA), + render_request_guard, sizeof(render_request_guard)) != 0 || + !writable(base + SAMP_R5_SCREENSHOT_REQUEST_RVA, sizeof(LONG))) { + return 0; + } + request = (volatile LONG *)(base + SAMP_R5_SCREENSHOT_REQUEST_RVA); + InterlockedExchange(request, 1); + log_line( + "samp_screenshot=requested flag=samp.dll+0x12de64 " + "renderer=samp.dll+0x75730 routine=samp.dll+0x755c0 " + "guard=relocation-normalized " + "sha256=b72b5dbe725f81864ca3f78bc7063bda56cc05fc7188af822fa7a754432553a2 " + "evidence=STATIC_037"); + return 1; +} + static unsigned read_u8(uintptr_t address, unsigned fallback) { return readable(address, 1) ? *(const volatile uint8_t *)address : fallback; } @@ -58,14 +156,26 @@ static float read_f32(uintptr_t address) { return readable(address, sizeof(float)) ? *(const volatile float *)address : 0.0f; } -static int read_player_position(float *x, float *y, float *z) { +static int read_player_transform(float *x, float *y, float *z, + float *forward_x, float *forward_y, + float *forward_z) { uintptr_t ped; uintptr_t matrix; if (!readable(0x00B7CD98u, sizeof(uintptr_t))) return 0; ped = *(const volatile uintptr_t *)0x00B7CD98u; if (!readable(ped + 0x14u, sizeof(uintptr_t))) return 0; matrix = *(const volatile uintptr_t *)(ped + 0x14u); - if (!readable(matrix + 0x30u, 3u * sizeof(float))) return 0; + /* + * GTA_REVERSED_REF: CMatrix stores forward at +0x10 and position at +0x30. + * The same layout is already used by the project's passive ASI probes. + */ + if (!readable(matrix + 0x10u, 3u * sizeof(float)) || + !readable(matrix + 0x30u, 3u * sizeof(float))) { + return 0; + } + *forward_x = *(const volatile float *)(matrix + 0x10u); + *forward_y = *(const volatile float *)(matrix + 0x14u); + *forward_z = *(const volatile float *)(matrix + 0x18u); *x = *(const volatile float *)(matrix + 0x30u); *y = *(const volatile float *)(matrix + 0x34u); *z = *(const volatile float *)(matrix + 0x38u); @@ -115,7 +225,7 @@ static void send_json(SOCKET client, const char *text) { } static void send_state(SOCKET client) { - char response[1024]; + char response[1536]; char input_bytes[16] = "unreadable"; HWND hwnd = game_window(); RECT rect = {0}; @@ -123,7 +233,12 @@ static void send_state(SOCKET client) { CURSORINFO cursor_info; unsigned char *input = (unsigned char *)(uintptr_t)0x00541DF5u; float player_x = 0.0f, player_y = 0.0f, player_z = 0.0f; - int player_position_valid = read_player_position(&player_x, &player_y, &player_z); + float player_forward_x = 0.0f, player_forward_y = 0.0f; + float player_forward_z = 0.0f; + int player_transform_valid = + read_player_transform(&player_x, &player_y, &player_z, + &player_forward_x, &player_forward_y, + &player_forward_z); memset(&cursor_info, 0, sizeof(cursor_info)); cursor_info.cbSize = sizeof(cursor_info); GetClientRect(hwnd, &rect); @@ -139,17 +254,24 @@ static void send_state(SOCKET client) { "\"client_w\":%ld,\"client_h\":%ld,\"cursor_x\":%ld,\"cursor_y\":%ld," "\"cursor_showing\":%s," "\"hud\":%u,\"radar_blank\":%u,\"camera_mode\":%u,\"camera_mode2\":%u," - "\"camera_use_mouse\":%u,\"aim_x\":%.6f,\"aim_y\":%.6f,\"aim_z\":%.6f," + "\"camera_use_mouse\":%u," + "\"aim_front_x\":%.6f,\"aim_front_y\":%.6f,\"aim_front_z\":%.6f," + "\"aim_x\":%.6f,\"aim_y\":%.6f,\"aim_z\":%.6f," "\"player_position_valid\":%s,\"player_x\":%.6f,\"player_y\":%.6f,\"player_z\":%.6f," + "\"player_forward_x\":%.6f,\"player_forward_y\":%.6f,\"player_forward_z\":%.6f," "\"tab_down\":%s,\"t_down\":%s,\"w_down\":%s,\"input_call\":\"%s\"}", (unsigned long)(uintptr_t)hwnd, GetForegroundWindow() == hwnd ? "true" : "false", rect.right - rect.left, rect.bottom - rect.top, cursor.x, cursor.y, (cursor_info.flags & CURSOR_SHOWING) != 0 ? "true" : "false", read_u8(0x00BA6769u, 255), read_u8(0x00BAA3FBu, 255), read_u8(0x00B6F1A8u, 255), read_u16(0x00B6F858u, 65535), - read_u8(0x00B6EC2Eu, 255), read_f32(0x00B6F338u), + read_u8(0x00B6EC2Eu, 255), + read_f32(0x00B6F32Cu), read_f32(0x00B6F330u), + read_f32(0x00B6F334u), read_f32(0x00B6F338u), read_f32(0x00B6F33Cu), read_f32(0x00B6F340u), - player_position_valid ? "true" : "false", player_x, player_y, player_z, + player_transform_valid ? "true" : "false", + player_x, player_y, player_z, + player_forward_x, player_forward_y, player_forward_z, (GetAsyncKeyState(VK_TAB) & 0x8000) ? "true" : "false", (GetAsyncKeyState('T') & 0x8000) ? "true" : "false", (GetAsyncKeyState('W') & 0x8000) ? "true" : "false", input_bytes); @@ -162,9 +284,20 @@ static void post_key(HWND hwnd, int vk, int down) { keybd_event((BYTE)vk, (BYTE)scan, down ? 0 : KEYEVENTF_KEYUP, 0); } -static void post_mouse(HWND hwnd, int x, int y, const char *action) { +static int post_mouse(HWND hwnd, int x, int y, const char *action) { POINT screen = {x, y}; LPARAM point = MAKELPARAM((short)x, (short)y); + if (strcmp(action, "move_delta") == 0) { + INPUT input; + if (x < -2048 || x > 2048 || y < -2048 || y > 2048) return 0; + memset(&input, 0, sizeof(input)); + input.type = INPUT_MOUSE; + input.mi.dx = (LONG)x; + input.mi.dy = (LONG)y; + input.mi.dwFlags = MOUSEEVENTF_MOVE; + return SendInput(1, &input, sizeof(input)) == 1; + } + ClientToScreen(hwnd, &screen); SetCursorPos(screen.x, screen.y); PostMessageA(hwnd, WM_MOUSEMOVE, 0, point); @@ -191,9 +324,8 @@ static void post_mouse(HWND hwnd, int x, int y, const char *action) { PostMessageA(hwnd, WM_RBUTTONUP, 0, point); PostMessageA(hwnd, WM_LBUTTONDBLCLK, MK_LBUTTON, point); PostMessageA(hwnd, WM_LBUTTONUP, 0, point); - } else if (strcmp(action, "move_delta") == 0) { - SetCursorPos(screen.x + x, screen.y + y); } + return 1; } static void handle_command(SOCKET client, const char *line) { @@ -243,10 +375,23 @@ static void handle_command(SOCKET client, const char *line) { int code = json_int(line, "code", 0); PostMessageA(hwnd, WM_CHAR, (WPARAM)code, 1); send_json(client, "{\"ok\":true,\"event\":\"char\"}"); + } else if (strcmp(command, "samp_screenshot") == 0) { + if (request_samp_r5_screenshot()) { + send_json(client, + "{\"ok\":true,\"event\":\"samp_screenshot\"," + "\"identity\":\"original_r5\"," + "\"request_flag_rva\":\"0x0012de64\"}"); + } else { + send_json(client, + "{\"ok\":false,\"error\":\"unsupported_samp_identity\"}"); + } } else if (strcmp(command, "mouse") == 0) { if (!json_string(line, "action", action, sizeof(action))) strcpy(action, "move"); - post_mouse(hwnd, json_int(line, "x", 0), json_int(line, "y", 0), action); - send_json(client, "{\"ok\":true,\"event\":\"mouse\"}"); + if (post_mouse(hwnd, json_int(line, "x", 0), json_int(line, "y", 0), action)) { + send_json(client, "{\"ok\":true,\"event\":\"mouse\"}"); + } else { + send_json(client, "{\"ok\":false,\"error\":\"mouse_injection_failed\"}"); + } } else { send_json(client, "{\"ok\":false,\"error\":\"unknown_cmd\"}"); } diff --git a/tools/tests/fixtures/runtime_trace/critical_fail.log b/tools/tests/fixtures/runtime_trace/critical_fail.log new file mode 100644 index 0000000..eb7e7b2 --- /dev/null +++ b/tools/tests/fixtures/runtime_trace/critical_fail.log @@ -0,0 +1,19 @@ +0001:trace:loaddll:build_module Loaded L"C:\\GTA San Andreas\\samp.dll" at 02380000: native +0002:trace:module:process_attach (L"samp.dll",00000000) - START +0003:Call PE DLL (proc=023811F0,module=02380000 L"samp.dll",reason=PROCESS_ATTACH,res=00000000) +0004:trace:module:process_attach (L"samp.dll",00000000) - END +0005:trace:loaddll:build_module Loaded L"C:\\windows\\system32\\WS2_32.dll" at 76000000: builtin +0006:trace:loaddll:build_module Loaded L"C:\\windows\\system32\\WSOCK32.dll" at 76100000: builtin +0007:Call ws2_32.WSAStartup(0202,0012) +0008:Call ws2_32.gethostbyname("server.invalid") +0009:Call ws2_32.inet_addr("127.0.0.1") +0010:Call ws2_32.inet_ntoa(0100007f) +0011:Call ws2_32.sendto(00000100,00000200,32,0,00000300,16) +0012:Call user32.CallWindowProcA(00000100,00000200,0,0,0) +0013:Call user32.SetWindowLongA(00000100,-4,00000200) +0014:Call user32.CreateWindowExA(0,"STATIC","fixture",0,0,0,1,1,0,0,0,0) +0015:Connecting to 127.0.0.1:7777... +0016:Connected to 127.0.0.1:7777. +0017:Call ws2_32.WSACleanup() +0018:Call PE DLL (proc=023811F0,module=02380000 L"samp.dll",reason=PROCESS_DETACH,res=00000001) +0019:trace:module:MODULE_InitDLL (02380000 L"samp.dll",PROCESS_DETACH,00000001) - END diff --git a/tools/tests/fixtures/runtime_trace/pass.log b/tools/tests/fixtures/runtime_trace/pass.log new file mode 100644 index 0000000..baf8003 --- /dev/null +++ b/tools/tests/fixtures/runtime_trace/pass.log @@ -0,0 +1,20 @@ +0001:trace:loaddll:build_module Loaded L"C:\\GTA San Andreas\\samp.dll" at 02380000: native +0002:trace:module:process_attach (L"samp.dll",00000000) - START +0003:Call PE DLL (proc=023811F0,module=02380000 L"samp.dll",reason=PROCESS_ATTACH,res=00000000) +0004:trace:module:process_attach (L"samp.dll",00000000) - END +0005:trace:loaddll:build_module Loaded L"C:\\windows\\system32\\WS2_32.dll" at 76000000: builtin +0006:trace:loaddll:build_module Loaded L"C:\\windows\\system32\\WSOCK32.dll" at 76100000: builtin +0007:Call ws2_32.WSAStartup(0202,0012) +0008:Call ws2_32.gethostbyname("server.invalid") +0009:Call ws2_32.inet_addr("127.0.0.1") +0010:Call ws2_32.inet_ntoa(0100007f) +0011:Call ws2_32.sendto(00000100,00000200,32,0,00000300,16) +0012:Call ws2_32.recvfrom(00000100,00000200,32,0,00000300,00000400) +0013:Call user32.CallWindowProcA(00000100,00000200,0,0,0) +0014:Call user32.SetWindowLongA(00000100,-4,00000200) +0015:Call user32.CreateWindowExA(0,"STATIC","fixture",0,0,0,1,1,0,0,0,0) +0016:Connecting to 127.0.0.1:7777... +0017:Connected to 127.0.0.1:7777. +0018:Call ws2_32.WSACleanup() +0019:Call PE DLL (proc=023811F0,module=02380000 L"samp.dll",reason=PROCESS_DETACH,res=00000001) +0020:trace:module:MODULE_InitDLL (02380000 L"samp.dll",PROCESS_DETACH,00000001) - END diff --git a/tools/tests/fixtures/runtime_trace/shifted_base.log b/tools/tests/fixtures/runtime_trace/shifted_base.log new file mode 100644 index 0000000..07e1ab7 --- /dev/null +++ b/tools/tests/fixtures/runtime_trace/shifted_base.log @@ -0,0 +1,20 @@ +0001:trace:loaddll:build_module Loaded L"C:\\GTA San Andreas\\samp.dll" at 055A0000: native +0002:trace:module:process_attach (L"samp.dll",00000000) - START +0003:Call PE DLL (proc=055A11F0,module=055A0000 L"samp.dll",reason=PROCESS_ATTACH,res=00000000) +0004:trace:module:process_attach (L"samp.dll",00000000) - END +0005:trace:loaddll:build_module Loaded L"C:\\windows\\system32\\WS2_32.dll" at 76000000: builtin +0006:trace:loaddll:build_module Loaded L"C:\\windows\\system32\\WSOCK32.dll" at 76100000: builtin +0007:Call ws2_32.WSAStartup(0202,0012) +0008:Call ws2_32.gethostbyname("server.invalid") +0009:Call ws2_32.inet_addr("127.0.0.1") +0010:Call ws2_32.inet_ntoa(0100007f) +0011:Call ws2_32.sendto(00000100,00000200,32,0,00000300,16) +0012:Call ws2_32.recvfrom(00000100,00000200,32,0,00000300,00000400) +0013:Call user32.CallWindowProcA(00000100,00000200,0,0,0) +0014:Call user32.SetWindowLongA(00000100,-4,00000200) +0015:Call user32.CreateWindowExA(0,"STATIC","fixture",0,0,0,1,1,0,0,0,0) +0016:Connecting to 127.0.0.1:7777... +0017:Connected to 127.0.0.1:7777. +0018:Call ws2_32.WSACleanup() +0019:Call PE DLL (proc=055A11F0,module=055A0000 L"samp.dll",reason=PROCESS_DETACH,res=00000001) +0020:trace:module:MODULE_InitDLL (055A0000 L"samp.dll",PROCESS_DETACH,00000001) - END diff --git a/tools/tests/test_compare_runtime_traces.sh b/tools/tests/test_compare_runtime_traces.sh new file mode 100755 index 0000000..ad86ff7 --- /dev/null +++ b/tools/tests/test_compare_runtime_traces.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +COMPARE_SCRIPT="${REPO_DIR}/tools/compare_runtime_traces.sh" +FIXTURE_DIR="${SCRIPT_DIR}/fixtures/runtime_trace" +TMP_DIR="$(mktemp -d /tmp/samp-runtime-trace-test.XXXXXX)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +run_expect_status() { + local expected="$1" + local label="$2" + shift 2 + local output_file="${TMP_DIR}/${label}.out" + local status=0 + + "$COMPARE_SCRIPT" "$@" >"$output_file" 2>&1 || status=$? + if [[ "$status" -ne "$expected" ]]; then + sed -n '1,160p' "$output_file" >&2 + fail "$label returned $status, expected $expected" + fi +} + +PASS_TRACE="${FIXTURE_DIR}/pass.log" +FAIL_TRACE="${FIXTURE_DIR}/critical_fail.log" +SHIFTED_TRACE="${FIXTURE_DIR}/shifted_base.log" + +run_expect_status 0 pass \ + "$PASS_TRACE" "$PASS_TRACE" "${TMP_DIR}/pass-report" +rg -q '^ABI checks FAIL: 0$' "${TMP_DIR}/pass.out" || fail "pass report contains a FAIL" +rg -q '^Result: PASS$' "${TMP_DIR}/pass.out" || fail "pass result marker missing" + +run_expect_status 5 critical-fail \ + "$PASS_TRACE" "$FAIL_TRACE" "${TMP_DIR}/fail-report" +rg -q $'^critical\tnet_recvfrom\t.*\tFAIL\t' \ + "${TMP_DIR}/fail-report/check_report.tsv" || fail "recvfrom critical failure missing" +rg -q '^Result: FAIL$' "${TMP_DIR}/critical-fail.out" || fail "failure result marker missing" + +run_expect_status 0 report-only \ + --report-only "$PASS_TRACE" "$FAIL_TRACE" "${TMP_DIR}/report-only-report" +rg -Fqx 'Result: FAIL (report-only; exit status suppressed)' \ + "${TMP_DIR}/report-only.out" || fail "report-only result marker missing" + +run_expect_status 0 shifted-base \ + "$PASS_TRACE" "$SHIFTED_TRACE" "${TMP_DIR}/shifted-report" +rg -q '^055A0000$' \ + "${TMP_DIR}/shifted-report/candidate_trim/samp_module_base.txt" || fail "shifted base was not detected" +rg -q $'^critical\tsamp_process_attach_call\t.*\tPASS\t' \ + "${TMP_DIR}/shifted-report/check_report.tsv" || fail "shifted attach call was not matched" +rg -q $'^critical\tsamp_process_detach_call\t.*\tPASS\t' \ + "${TMP_DIR}/shifted-report/check_report.tsv" || fail "shifted detach call was not matched" +rg -Fqx $'1\tPE_DLL_REASON.PROCESS_ATTACH' \ + "${TMP_DIR}/shifted-report/candidate_trim/call_api_counts.tsv" || fail "shifted attach API count missing" +rg -Fqx $'1\tPE_DLL_REASON.PROCESS_DETACH' \ + "${TMP_DIR}/shifted-report/candidate_trim/call_api_counts.tsv" || fail "shifted detach API count missing" + +echo "PASS: compare_runtime_traces fixtures" diff --git a/tools/trim_wine_trace.sh b/tools/trim_wine_trace.sh index 3aad032..201cf0f 100755 --- a/tools/trim_wine_trace.sh +++ b/tools/trim_wine_trace.sh @@ -27,6 +27,26 @@ SAMP_WINDOW_FILE="${OUT_DIR}/samp_lifecycle_window.log" TOP_EVENTS_FILE="${OUT_DIR}/top_events.log" TEXTDRAW_FILE="${OUT_DIR}/textdraw_focus.log" SUMMARY_FILE="${OUT_DIR}/SUMMARY.txt" +SAMP_MODULE_BASE_FILE="${OUT_DIR}/samp_module_base.txt" + +# PROBE_TRACE: Wine's build_module line binds the loaded samp.dll path to its +# runtime base. Keep the fallback name-bound if that marker is absent; callers +# still treat the missing native-load marker as a failed check. +SAMP_LOAD_LINE="$( + rg -m1 -i \ + 'trace:loaddll:build_module Loaded L".*samp\.dll" at [[:xdigit:]]+: native' \ + "$IN_FILE" 2>/dev/null || true +)" +SAMP_MODULE_BASE="" +if [[ "$SAMP_LOAD_LINE" =~ [[:space:]]at[[:space:]]([[:xdigit:]]+):[[:space:]][Nn][Aa][Tt][Ii][Vv][Ee] ]]; then + SAMP_MODULE_BASE="${BASH_REMATCH[1]^^}" +fi +printf '%s\n' "$SAMP_MODULE_BASE" >"$SAMP_MODULE_BASE_FILE" + +SAMP_MODULE_PATTERN='[[:xdigit:]]+' +if [[ -n "$SAMP_MODULE_BASE" ]]; then + SAMP_MODULE_PATTERN="$SAMP_MODULE_BASE" +fi # Broad high-signal focus view (lifecycle + module load + key SA-MP DLLs + net status text). rg -n -i \ @@ -44,6 +64,8 @@ rg -n -i \ # Relay often appears as "Call ..."/"Ret ..." lines; capture these explicitly. rg -n -i \ + -e "Call PE DLL \\(proc=.*module=${SAMP_MODULE_PATTERN} L\"samp\\.dll\",reason=" \ + -e "Ret PE DLL \\(proc=.*module=${SAMP_MODULE_PATTERN} L\"samp\\.dll\",reason=" \ -e 'Call .*?\.(WSAStartup|WSACleanup|socket|connect|bind|sendto|recvfrom|send|recv|gethostbyname|inet_addr|inet_ntoa|select|ioctlsocket)' \ -e 'Call .*?\.(BASS_|Direct3DCreate9|D3DX|CreateWindowEx|SetWindowLong|CallWindowProc)' \ -e 'Ret .*?\.(WSAStartup|WSACleanup|socket|connect|bind|sendto|recvfrom|send|recv|gethostbyname|inet_addr|inet_ntoa|select|ioctlsocket)' \ @@ -52,8 +74,8 @@ rg -n -i \ # Smaller call stream focused on SA-MP lifecycle + known ABI-relevant modules/APIs. rg -n -i \ - -e 'Call PE DLL \(proc=.*module=02380000 L"samp\.dll",reason=' \ - -e 'Ret PE DLL \(proc=.*module=02380000 L"samp\.dll",reason=' \ + -e "Call PE DLL \\(proc=.*module=${SAMP_MODULE_PATTERN} L\"samp\\.dll\",reason=" \ + -e "Ret PE DLL \\(proc=.*module=${SAMP_MODULE_PATTERN} L\"samp\\.dll\",reason=" \ -e 'Call (WSOCK32|WS2_32|BASS|WINMM|D3DX9_25|D3D9|USER32|KERNEL32)\.' \ -e 'Ret (WSOCK32|WS2_32|BASS|WINMM|D3DX9_25|D3D9|USER32|KERNEL32)\.' \ -e 'Call .*?\.(connect|sendto|recvfrom|gethostbyname|inet_addr|inet_ntoa|WSAStartup|WSACleanup)' \ @@ -61,14 +83,16 @@ rg -n -i \ "$CALL_FILE" >"$CALL_REDUCED_FILE" || true # API frequency map from reduced call stream. -awk ' +awk -v samp_module_base="$SAMP_MODULE_BASE" ' match($0, /(Call|Ret ) ([A-Za-z0-9_]+)\.([A-Za-z0-9_@]+)/, m) { key = m[2] "." m[3] cnt[key]++ } - match($0, /(Call|Ret ) PE DLL \(proc=.*module=02380000 L"samp\.dll",reason=([A-Z_]+)/, p) { - key = "PE_DLL_REASON." p[2] - cnt[key]++ + match($0, /(Call|Ret ) PE DLL \(proc=.*module=([[:xdigit:]]+) L"samp\.dll",reason=([A-Z_]+)/, p) { + if (samp_module_base == "" || toupper(p[2]) == samp_module_base) { + key = "PE_DLL_REASON." p[3] + cnt[key]++ + } } END { for (k in cnt) print cnt[k] "\t" k @@ -90,18 +114,18 @@ awk -F'trace:' 'NF>1 { print $2 }' "$FOCUS_FILE" \ # Extract only the samp.dll lifetime window. START_LINE="$(rg -n 'process_attach \(L"samp\.dll"' "$IN_FILE" | head -n1 | cut -d: -f1 || true)" -END_LINE="$(rg -n 'MODULE_InitDLL \(02380000 L"samp\.dll",PROCESS_DETACH' "$IN_FILE" | head -n1 | cut -d: -f1 || true)" +END_LINE="$(rg -n -i "MODULE_InitDLL \\(${SAMP_MODULE_PATTERN} L\"samp\\.dll\",PROCESS_DETACH" "$IN_FILE" | head -n1 | cut -d: -f1 || true)" if [[ -n "${START_LINE:-}" && -n "${END_LINE:-}" && "$END_LINE" -ge "$START_LINE" ]]; then sed -n "${START_LINE},${END_LINE}p" "$IN_FILE" >"$SAMP_WINDOW_FILE" else - rg -n -i 'samp\.dll|MODULE_InitDLL \(02380000' "$IN_FILE" >"$SAMP_WINDOW_FILE" || true + rg -n -i "samp\\.dll|MODULE_InitDLL \\(${SAMP_MODULE_PATTERN}" "$IN_FILE" >"$SAMP_WINDOW_FILE" || true fi # Small deterministic event list for quick checks in CI or manual review. rg -n -i \ -e 'process_attach \(L"samp\.dll"' \ - -e 'MODULE_InitDLL \(02380000 L"samp\.dll",PROCESS_ATTACH' \ - -e 'MODULE_InitDLL \(02380000 L"samp\.dll",PROCESS_DETACH' \ + -e "MODULE_InitDLL \\(${SAMP_MODULE_PATTERN} L\"samp\\.dll\",PROCESS_ATTACH" \ + -e "MODULE_InitDLL \\(${SAMP_MODULE_PATTERN} L\"samp\\.dll\",PROCESS_DETACH" \ -e 'Loaded L".*samp\.dll"' \ -e 'Loaded L".*d3dx9_25\.dll"' \ -e 'Loaded L".*WSOCK32\.dll"' \ @@ -122,6 +146,7 @@ rg -n -i \ { echo "input: $IN_FILE" + echo "samp module base: ${SAMP_MODULE_BASE:-not detected}" wc -l "$IN_FILE" echo for f in \ @@ -132,6 +157,7 @@ rg -n -i \ call_api_counts.tsv \ loaddll_relevant.log \ focus_event_counts.tsv \ + samp_module_base.txt \ samp_lifecycle_window.log \ top_events.log \ textdraw_focus.log; do diff --git a/tools/windows/remote_lab/Common.ps1 b/tools/windows/remote_lab/Common.ps1 index d830f17..47b77ff 100644 --- a/tools/windows/remote_lab/Common.ps1 +++ b/tools/windows/remote_lab/Common.ps1 @@ -57,6 +57,54 @@ function Get-SampLabHash { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } +function Get-SampLabAutoPauseState { + param([Parameter(Mandatory = $true)][string]$GameDir) + + $path = Join-Path $GameDir "III.VC.SA.WindowedMode.ini" + $value = $null + $section = "" + if (Test-Path -LiteralPath $path) { + foreach ($rawLine in Get-Content -LiteralPath $path -ErrorAction Stop) { + $line = $rawLine.Trim() + if (-not $line -or $line.StartsWith(";") -or $line.StartsWith("#")) { + continue + } + if ($line.StartsWith("[") -and $line.EndsWith("]")) { + $section = $line.Substring(1, $line.Length - 2).Trim().ToLowerInvariant() + continue + } + if ($section -ne "game" -or -not $line.Contains("=")) { + continue + } + $parts = $line -split "=", 2 + if ($parts[0].Trim().ToLowerInvariant() -ne "autopause") { + continue + } + $value = (($parts[1] -split "[;#]", 2)[0]).Trim() + } + } + return [pscustomobject]@{ + path = $path + exists = Test-Path -LiteralPath $path + value = $value + disabled = $value -eq "0" + } +} + +function Get-SampLabProbeFlags { + param([Parameter(Mandatory = $true)][string]$GameDir) + + if (-not (Test-Path -LiteralPath $GameDir)) { + return @() + } + return @( + Get-ChildItem -LiteralPath $GameDir -Filter "samp_probe_*.flag" -File ` + -ErrorAction SilentlyContinue | + Sort-Object Name | + Select-Object -ExpandProperty Name + ) +} + function Get-SampLabCurrentRun { param([string]$Root = "C:\samp-test") diff --git a/tools/windows/remote_lab/README.md b/tools/windows/remote_lab/README.md index 6a30ecc..2d02221 100644 --- a/tools/windows/remote_lab/README.md +++ b/tools/windows/remote_lab/README.md @@ -43,6 +43,9 @@ From the Linux workspace, the passwordless wrapper uses tools/windows/remote_lab/samp_lab.sh ping tools/windows/remote_lab/samp_lab.sh screenshot-burst combat-m4 60 50 tools/windows/remote_lab/samp_lab.sh key ENTER language_english +tools/windows/remote_lab/samp_lab.sh key TAB scoreboard-hold +tools/windows/remote_lab/samp_lab.sh key F6 chat-edge +tools/windows/remote_lab/samp_lab.sh key F7 chat-mode-edge tools/windows/remote_lab/samp_lab.sh key MENUTEST create-menu-golden tools/windows/remote_lab/samp_lab.sh key TPASSWORD password-dialog-golden tools/windows/remote_lab/samp_lab.sh key TPASSWORDVALUE password-dialog-type @@ -57,6 +60,7 @@ tools/windows/remote_lab/samp_lab.sh stop tools/windows/remote_lab/samp_lab.sh probe-profile actor-heavy tools/windows/remote_lab/samp_lab.sh overlay-profile shadow tools/windows/remote_lab/samp_lab.sh overlay-kill on +tools/windows/remote_lab/samp_lab.sh autopause-disable tools/windows/remote_lab/samp_lab.sh favorite-port 3 192.168.3.181 7778 7798 tools/windows/remote_lab/samp_lab.sh favorite-endpoint 3 192.168.200.149 7798 192.168.3.181 7798 tools/windows/remote_lab/samp_lab.sh fetch-run RUN_ID /tmp/samp-runs @@ -115,15 +119,74 @@ are written under `C:\samp-test\screenshots\bursts`. The completed command JSON also contains the manifest path, per-frame timestamps, measured capture times, paths, and hashes. +`TAB`, `F6`, and `F7` are fixed bounded actions, not a general key-injection +interface. The shell wrapper and both PowerShell parameter boundaries +allowlist them explicitly. `Send-SampTestInput.ps1` sends matching Win32 +key-down/key-up events in a `try`/`finally`: TAB is held for 750 ms, while F6 +and F7 are held for 100 ms. The target must be the single foreground GTA +window. These actions do not automate ESC or the GTA pause menu. + Module inventories use Toolhelp with both `TH32CS_SNAPMODULE` and `TH32CS_SNAPMODULE32`; this lets the 64-bit PowerShell agent enumerate the 32-bit GTA process, including `samp.dll` and loaded ASIs. +The `ping` response also reports the configured GTA root, GTA/SA-MP/probe/ +control hashes, active managed probe flag files, and the parsed +`III.VC.SA.WindowedMode.ini` auto-pause state. The same state and hashes are +persisted in the run manifest, allowing distributed runners to require +`[game] autoPause = 0` without changing the behavior of unrelated manual lab +starts. + `probe-profile` removes only the probe's fixed, known flag-file allowlist and then enables the selected named profile. It refuses to change flags while a GTA/SA-MP process is running; arbitrary flag names and arbitrary commands are not accepted. +The `trailer-r5` probe profile enables only the timing-minimized original-R5 +Packet-210 ring trace. The probe deliberately skips its normal Winsock/IAT and +unrelated code-hook sets in this profile. + +The separate `vehicle-lifecycle` profile enables only the original-R5 +`VehiclePool::New` and `CPlayerPed::PutDirectlyInVehicle` lifecycle ring +trace. It records bounded pre/post pool, GTA vehicle, driver, passenger-seat, +status, and flag snapshots without file I/O on either hooked thread. It also +skips the normal Winsock/IAT, render, trailer, and unrelated code-hook sets. + +The `aim-bullet-jetpack` profile enables only the original-R5 aim-context, +remote key, shot/fire-dispatch, and jetpack-wrapper ring trace. Its eight +hooks are all-or-nothing, exact-R5/GTA-identity and entry/tail-byte guarded, +normalize the two guarded R5 HIGHLOW operands to the actual module base, and +write only bounded pre/post state into memory on the hooked thread. GTA +remains fixed-base guarded. The worker later emits the ped matrix, aim +buffers, weapon/task state, shot geometry, frame, thread, and caller RVA. + +The `death-cleanup` profile enables only the original-R5 local Process, +Spawn, class-selection, GMX-reset, connection-loss, and `CNetGame` destructor +ring trace. Its six hooks are all-or-nothing and exact-R5/GTA-identity, +R5-relocation-normalized, fixed-GTA-base, entry-byte, and return-tail guarded. +Only the documented HIGHLOW operand in the Spawn entry is rebased; every +surrounding byte remains exact. Hook threads capture bounded local player, +ped/task, UI/camera, pool-count, representative entity, and +RemoveBuilding-counter snapshots; the worker performs the file logging. + +The `pickup-r5` profile enables only the original-R5 +`CPickupPool::PickedUp`/`CPickupPool::Process` ring trace plus the existing +bounded outgoing RakClient RPC observer for RPC 131/97. It records pickup +handles, raw GTA indices, notification timers, dropped/player metadata, types, +process cadence, and pre/post state without hook-thread file I/O. + +The `ui-latches-r5` profile enables only the original-R5 chat-mode, +chat-open/close, scoreboard-show/hide, cursor-mode/restore, menu-query, and +remote-player Process ring trace. Its nine hooks are all-or-nothing and +exact-R5/GTA-identity, relocation-normalized for R5, fixed-GTA-base, +entry/tail, cursor-return, and AFK-transition-byte guarded. Hook threads never +inject input or write files. +TAB/chat/cursor calls capture bounded pre/post state, menu records are +edge-only, and remote AFK records use a bounded tracker with a one-second +heartbeat. The worker logs raw frontend/input-gate/cursor values and Win32 +focus/capture observations; fields not directly proven by R5 static analysis +remain explicitly `TODO_VERIFY`. + `overlay-profile` selects only `bypass`, `shadow`, or `replace` for `samp_re.asi` and refuses changes while GTA/SA-MP is running. The interactive starter validates the managed profile again, sets the fixed diff --git a/tools/windows/remote_lab/SampTestAgent.ps1 b/tools/windows/remote_lab/SampTestAgent.ps1 index e128eeb..b5b141e 100644 --- a/tools/windows/remote_lab/SampTestAgent.ps1 +++ b/tools/windows/remote_lab/SampTestAgent.ps1 @@ -30,11 +30,20 @@ function Invoke-AgentCommand { $action = ([string]$Command.action).ToLowerInvariant() switch ($action) { "ping" { + $config = Get-SampLabConfig -Root $Root + $gameDir = [string]$config.game_dir return [pscustomobject]@{ action = $action user = [Environment]::UserDomainName + "\" + [Environment]::UserName session_id = [Diagnostics.Process]::GetCurrentProcess().SessionId processes = @(Get-SampLabProcesses) + game_dir = $gameDir + gta_sha256 = Get-SampLabHash -Path (Join-Path $gameDir "gta_sa.exe") + samp_sha256 = Get-SampLabHash -Path (Join-Path $gameDir "samp.dll") + samp_probe_sha256 = Get-SampLabHash -Path (Join-Path $gameDir "samp_probe.asi") + reloop_control_sha256 = Get-SampLabHash -Path (Join-Path $gameDir "reloop_control.asi") + autopause = Get-SampLabAutoPauseState -GameDir $gameDir + probe_flags = @(Get-SampLabProbeFlags -GameDir $gameDir) } } "screenshot" { diff --git a/tools/windows/remote_lab/Send-SampTestInput.ps1 b/tools/windows/remote_lab/Send-SampTestInput.ps1 index c69fc40..f74202c 100644 --- a/tools/windows/remote_lab/Send-SampTestInput.ps1 +++ b/tools/windows/remote_lab/Send-SampTestInput.ps1 @@ -1,7 +1,7 @@ param( [string]$Root = "C:\samp-test", [ValidateSet("key", "click")][string]$Mode = "key", - [ValidateSet("ENTER", "ESCAPE", "SPACE", "ALTENTER", "UP", "DOWN", "LEFT", "RIGHT", "FIRE", "GAS", "GASFIRE", "STEERLEFT", "STEERRIGHT", "BRAKE", "HANDBRAKE", "HORN", "MODE", "CLASS", "KILL", "QUIT", "MENUTEST", "TPASSWORD", "TPASSWORDVALUE", "SFA", "LVA", "AA", "ACTORS", "ACTORSOFF", "RPC175EDGE", "RPC175EDGEOFF", "RPC175RAW", "RPC176RAW", "RPC178EDGE", "RPC178EDGEOFF", "RPCLEGACYRAW", "RPCLEGACYDRUNKON", "RPCLEGACYDRUNKOFF", "SYNCFOOT", "SYNCCAR", "SYNCRUSTLER", "SYNCSTOP")][string]$Key = "ENTER", + [ValidateSet("ENTER", "ESCAPE", "SPACE", "ALTENTER", "TAB", "F6", "F7", "UP", "DOWN", "LEFT", "RIGHT", "FIRE", "GAS", "GASFIRE", "PASSENGER", "STEERLEFT", "STEERRIGHT", "BRAKE", "HANDBRAKE", "HORN", "MODE", "CLASS", "KILL", "QUIT", "MENUTEST", "TPASSWORD", "TPASSWORDVALUE", "SFA", "LVA", "AA", "ACTORS", "ACTORSOFF", "RPC175EDGE", "RPC175EDGEOFF", "RPC175RAW", "RPC176RAW", "RPC178EDGE", "RPC178EDGEOFF", "RPCLEGACYRAW", "RPCLEGACYDRUNKON", "RPCLEGACYDRUNKOFF", "SYNCFOOT", "SYNCCAR", "SYNCRUSTLER", "SYNCSTOP")][string]$Key = "ENTER", [int]$X = 0, [int]$Y = 0, [string]$Label = "input" @@ -103,8 +103,9 @@ if ($Mode -eq "key") { [SampTestWindowInput]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) [SampTestKeyboardInput]::keybd_event(0x57, 0, 0x0002, [UIntPtr]::Zero) } - } elseif ($Key -in @("STEERLEFT", "STEERRIGHT", "BRAKE", "HANDBRAKE", "HORN")) { + } elseif ($Key -in @("PASSENGER", "STEERLEFT", "STEERRIGHT", "BRAKE", "HANDBRAKE", "HORN")) { [byte]$virtualKey = switch ($Key) { + "PASSENGER" { 0x47 } "STEERLEFT" { 0x41 } "STEERRIGHT" { 0x44 } "BRAKE" { 0x53 } @@ -113,7 +114,11 @@ if ($Mode -eq "key") { } [SampTestKeyboardInput]::keybd_event($virtualKey, 0, 0, [UIntPtr]::Zero) try { - Start-Sleep -Milliseconds 750 + # STATIC_037: + # CLocalPlayer::Process consumes the passenger control on the + # first pressed frame. Keep G down across several DirectInput + # samples without turning the test into a long held-key repeat. + Start-Sleep -Milliseconds $(if ($Key -eq "PASSENGER") { 100 } else { 750 }) } finally { [SampTestKeyboardInput]::keybd_event($virtualKey, 0, 0x0002, [UIntPtr]::Zero) } @@ -134,6 +139,29 @@ if ($Mode -eq "key") { } finally { [SampTestKeyboardInput]::keybd_event(0x12, 0, 0x0002, [UIntPtr]::Zero) } + } elseif ($Key -in @("TAB", "F6", "F7")) { + # STATIC_037 + TODO_VERIFY: + # The focused R5 UI-latch probe observes the scoreboard, chat-open, + # chat-display-cycle and cursor transition callees. Emit a real, + # bounded key-down/key-up pair so DirectInput and WndProc both see the + # same edge. TAB remains held long enough for a concurrent screenshot + # burst to catch the visible scoreboard. + [byte]$virtualKey = switch ($Key) { + "TAB" { 0x09 } + "F6" { 0x75 } + "F7" { 0x76 } + } + [byte]$scanCode = switch ($Key) { + "TAB" { 0x0F } + "F6" { 0x40 } + "F7" { 0x41 } + } + [SampTestKeyboardInput]::keybd_event($virtualKey, $scanCode, 0, [UIntPtr]::Zero) + try { + Start-Sleep -Milliseconds $(if ($Key -eq "TAB") { 750 } else { 100 }) + } finally { + [SampTestKeyboardInput]::keybd_event($virtualKey, $scanCode, 0x0002, [UIntPtr]::Zero) + } } elseif ($Key -in @("SPACE", "UP", "DOWN", "LEFT", "RIGHT")) { # PROBE_TRACE: # WScript.SendKeys emits gameplay/menu keys too briefly for every diff --git a/tools/windows/remote_lab/Set-SampAutoPause.ps1 b/tools/windows/remote_lab/Set-SampAutoPause.ps1 new file mode 100644 index 0000000..0b2c21c --- /dev/null +++ b/tools/windows/remote_lab/Set-SampAutoPause.ps1 @@ -0,0 +1,87 @@ +param([string]$Root = "C:\samp-test") + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. (Join-Path $PSScriptRoot "Common.ps1") + +$processes = @(Get-SampLabProcesses) +if ($processes.Count -ne 0) { + throw "Refusing to edit WindowedMode configuration while GTA/SA-MP is active." +} + +$config = Get-SampLabConfig -Root $Root +$gameDir = [string]$config.game_dir +$path = Join-Path $gameDir "III.VC.SA.WindowedMode.ini" +$encoding = [Text.Encoding]::Default +$lines = if (Test-Path -LiteralPath $path) { + @([IO.File]::ReadAllLines($path, $encoding)) +} else { + @() +} +$before = Get-SampLabAutoPauseState -GameDir $gameDir +$section = "" +$gameSectionSeen = $false +$autoPauseSeen = $false +$updated = [Collections.Generic.List[string]]::new() + +foreach ($rawLine in $lines) { + $line = $rawLine.Trim() + if ($line.StartsWith("[") -and $line.EndsWith("]")) { + if ($section -eq "game" -and -not $autoPauseSeen) { + $updated.Add("autoPause = 0") + $autoPauseSeen = $true + } + $section = $line.Substring(1, $line.Length - 2).Trim().ToLowerInvariant() + if ($section -eq "game") { + $gameSectionSeen = $true + } + $updated.Add($rawLine) + continue + } + if ($section -eq "game" -and $line.Contains("=")) { + $parts = $line -split "=", 2 + if ($parts[0].Trim().ToLowerInvariant() -eq "autopause") { + $updated.Add("autoPause = 0") + $autoPauseSeen = $true + continue + } + } + $updated.Add($rawLine) +} + +if ($section -eq "game" -and -not $autoPauseSeen) { + $updated.Add("autoPause = 0") + $autoPauseSeen = $true +} +if (-not $gameSectionSeen) { + if ($updated.Count -ne 0 -and $updated[$updated.Count - 1] -ne "") { + $updated.Add("") + } + $updated.Add("[game]") + $updated.Add("autoPause = 0") +} + +$backup = $null +if (Test-Path -LiteralPath $path) { + $backupName = "III.VC.SA.WindowedMode.{0}.ini" -f ( + Get-Date -Format "yyyyMMdd_HHmmss" + ) + $backup = Join-Path (Join-Path $Root "backups") $backupName + Copy-Item -LiteralPath $path -Destination $backup -Force +} + +$temporary = "$path.tmp.$PID" +[IO.File]::WriteAllLines($temporary, $updated, $encoding) +Move-Item -LiteralPath $temporary -Destination $path -Force +$after = Get-SampLabAutoPauseState -GameDir $gameDir +if ($after.disabled -ne $true) { + throw "Failed to enforce [game] autoPause = 0 in $path" +} + +[pscustomobject]@{ + path = $path + backup = $backup + before = $before + after = $after +} | ConvertTo-Json -Depth 5 diff --git a/tools/windows/remote_lab/Set-SampProbeProfile.ps1 b/tools/windows/remote_lab/Set-SampProbeProfile.ps1 index 7edab41..96694c9 100644 --- a/tools/windows/remote_lab/Set-SampProbeProfile.ps1 +++ b/tools/windows/remote_lab/Set-SampProbeProfile.ps1 @@ -12,7 +12,13 @@ param( "actor", "actor-heavy", "rpc-gap", - "dialog-menu" + "dialog-menu", + "trailer-r5", + "vehicle-lifecycle", + "aim-bullet-jetpack", + "death-cleanup", + "pickup-r5", + "ui-latches-r5" )] [string]$Profile = "passive" ) @@ -49,7 +55,14 @@ $knownFlags = @( "samp_probe_actor_hooks.flag", "samp_probe_actor_heavy.flag", "samp_probe_rpc_gap_hooks.flag", - "samp_probe_dialog_menu_rpc_hooks.flag" + "samp_probe_dialog_menu_rpc_hooks.flag", + "samp_probe_trailer_sync_hooks.flag", + "samp_probe_trailer_physics_hooks.flag", + "samp_probe_vehicle_lifecycle_hooks.flag", + "samp_probe_aim_bullet_jetpack_hooks.flag", + "samp_probe_death_cleanup_hooks.flag", + "samp_probe_pickup_hooks.flag", + "samp_probe_ui_latches_hooks.flag" ) $profileFlags = @{ @@ -68,6 +81,25 @@ $profileFlags = @{ "actor-heavy" = @("samp_probe_actor_heavy.flag") "rpc-gap" = @("samp_probe_rpc_gap_hooks.flag") "dialog-menu" = @("samp_probe_dialog_menu_rpc_hooks.flag") + "trailer-r5" = @( + "samp_probe_trailer_sync_hooks.flag", + "samp_probe_trailer_physics_hooks.flag" + ) + "vehicle-lifecycle" = @( + "samp_probe_vehicle_lifecycle_hooks.flag" + ) + "aim-bullet-jetpack" = @( + "samp_probe_aim_bullet_jetpack_hooks.flag" + ) + "death-cleanup" = @( + "samp_probe_death_cleanup_hooks.flag" + ) + "pickup-r5" = @( + "samp_probe_pickup_hooks.flag" + ) + "ui-latches-r5" = @( + "samp_probe_ui_latches_hooks.flag" + ) } $removed = @() diff --git a/tools/windows/remote_lab/Start-SampTest.ps1 b/tools/windows/remote_lab/Start-SampTest.ps1 index a1d2f4f..d100aea 100644 --- a/tools/windows/remote_lab/Start-SampTest.ps1 +++ b/tools/windows/remote_lab/Start-SampTest.ps1 @@ -25,6 +25,7 @@ $runDir = Join-Path $Root ("runs\" + $runId) New-SampLabDirectory -Path $runDir $gameDir = [string]$config.game_dir +$autoPauseState = Get-SampLabAutoPauseState -GameDir $gameDir $documentsDir = [Environment]::GetFolderPath([Environment+SpecialFolder]::MyDocuments) $logDir = Join-Path (Join-Path $documentsDir "SA-MP Logs") $runId New-SampLabDirectory -Path $logDir @@ -163,6 +164,10 @@ $manifest = [ordered]@{ favorite_index = if ($FavoriteIndex -ge 0) { $FavoriteIndex } else { $null } gta_sha256 = Get-SampLabHash -Path (Join-Path $gameDir "gta_sa.exe") samp_sha256 = Get-SampLabHash -Path (Join-Path $gameDir "samp.dll") + samp_probe_sha256 = Get-SampLabHash -Path (Join-Path $gameDir "samp_probe.asi") + reloop_control_sha256 = Get-SampLabHash -Path (Join-Path $gameDir "reloop_control.asi") + autopause = $autoPauseState + probe_flags = @(Get-SampLabProbeFlags -GameDir $gameDir) samp_re = [ordered]@{ profile = $sampReMode mode = $sampReMode diff --git a/tools/windows/remote_lab/Submit-SampTestCommand.ps1 b/tools/windows/remote_lab/Submit-SampTestCommand.ps1 index 38a8ed0..1445a96 100644 --- a/tools/windows/remote_lab/Submit-SampTestCommand.ps1 +++ b/tools/windows/remote_lab/Submit-SampTestCommand.ps1 @@ -9,7 +9,7 @@ param( [string]$Nickname = "WinDebug", [int]$FavoriteIndex = -1, [ValidateSet("key", "click")][string]$InputMode = "key", - [ValidateSet("ENTER", "ESCAPE", "SPACE", "ALTENTER", "UP", "DOWN", "LEFT", "RIGHT", "FIRE", "GAS", "GASFIRE", "STEERLEFT", "STEERRIGHT", "BRAKE", "HANDBRAKE", "HORN", "MODE", "CLASS", "KILL", "QUIT", "MENUTEST", "TPASSWORD", "TPASSWORDVALUE", "SFA", "LVA", "AA", "ACTORS", "ACTORSOFF", "RPC175EDGE", "RPC175EDGEOFF", "RPC175RAW", "RPC176RAW", "RPC178EDGE", "RPC178EDGEOFF", "RPCLEGACYRAW", "RPCLEGACYDRUNKON", "RPCLEGACYDRUNKOFF", "SYNCFOOT", "SYNCCAR", "SYNCRUSTLER", "SYNCSTOP")][string]$InputKey = "ENTER", + [ValidateSet("ENTER", "ESCAPE", "SPACE", "ALTENTER", "TAB", "F6", "F7", "UP", "DOWN", "LEFT", "RIGHT", "FIRE", "GAS", "GASFIRE", "PASSENGER", "STEERLEFT", "STEERRIGHT", "BRAKE", "HANDBRAKE", "HORN", "MODE", "CLASS", "KILL", "QUIT", "MENUTEST", "TPASSWORD", "TPASSWORDVALUE", "SFA", "LVA", "AA", "ACTORS", "ACTORSOFF", "RPC175EDGE", "RPC175EDGEOFF", "RPC175RAW", "RPC176RAW", "RPC178EDGE", "RPC178EDGEOFF", "RPCLEGACYRAW", "RPCLEGACYDRUNKON", "RPCLEGACYDRUNKOFF", "SYNCFOOT", "SYNCCAR", "SYNCRUSTLER", "SYNCSTOP")][string]$InputKey = "ENTER", [int]$InputX = 0, [int]$InputY = 0, [string]$Label = "manual", diff --git a/tools/windows/remote_lab/samp_lab.sh b/tools/windows/remote_lab/samp_lab.sh index f476083..aef5be9 100755 --- a/tools/windows/remote_lab/samp_lab.sh +++ b/tools/windows/remote_lab/samp_lab.sh @@ -14,15 +14,16 @@ usage() { " $0 ping" \ " $0 screenshot [label]" \ " $0 screenshot-burst